perf: add CPU-throttled, memory-growth and knowledge retrieval benchmarks

perf:app gains GB_PERF_CPU_THROTTLE (CDP renderer throttling) and a keep-alive memory scenario with post-GC heap samples; perf:knowledge times synchronous FTS, vector and hybrid search at 5k/20k/50k chunks. Results and conclusions recorded in the performance plan (PERF-05, PERF-09, PERF-12).
This commit is contained in:
mesalogo
2026-10-02 15:51:33 +08:00
parent 5dc65457d9
commit 1e10935d22
5 changed files with 241 additions and 10 deletions
+5
View File
@@ -140,6 +140,11 @@ function printSummary(report) {
heapMB: format(s.renderer?.heapUsedMB)
}))
if (rows.length) console.table(rows)
if (report.memoryGrowth) {
console.log(`memory growth (${report.memoryGrowth.description}):`)
console.table(report.memoryGrowth.samples)
}
if (report.environment?.rendererCpuThrottle > 1) console.log(`renderer CPU throttled ${report.environment.rendererCpuThrottle}x`)
if (report.error) console.log(`error: ${report.error}`)
}
+96
View File
@@ -0,0 +1,96 @@
// Knowledge retrieval benchmark (PERF-05). Bundles the production
// KnowledgeDatabase with esbuild, seeds synthetic libraries of increasing size
// in a temporary SQLite file and times the synchronous search calls that the
// Electron Main process runs inline during retrieval. Because these calls are
// synchronous, their duration is the time Main is blocked per query.
const { buildSync } = require('esbuild')
const { createHash } = require('node:crypto')
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const { performance } = require('node:perf_hooks')
const root = resolve(__dirname, '..')
const sizes = (process.env.GB_KPERF_SIZES || '5000,20000,50000').split(',').map(Number)
const dimensions = Number(process.env.GB_KPERF_DIMENSIONS || 384)
const chunksPerDocument = 100
const queries = Number(process.env.GB_KPERF_QUERIES || 15)
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-knowledge-perf-'))
const bundle = join(directory, 'knowledge-database.cjs')
buildSync({
entryPoints: [join(root, 'src/main/knowledge/knowledge-database.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
outfile: bundle,
logLevel: 'error'
})
const { KnowledgeDatabase } = require(bundle)
// Deterministic pseudo-random generator so runs are comparable.
let seed = 42
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ')
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
const vector = () => Array.from({ length: dimensions }, () => random() * 2 - 1)
const checksum = value => createHash('sha256').update(value).digest('hex')
function percentile(values, p) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
}
function time(body) {
const samples = []
body() // warm statement cache and page cache
for (let index = 0; index < queries; index += 1) {
const started = performance.now()
body()
samples.push(performance.now() - started)
}
return { p50: round(percentile(samples, 50)), p95: round(percentile(samples, 95)), max: round(Math.max(...samples)) }
}
const round = value => Math.round(value * 10) / 10
const results = []
try {
for (const size of sizes) {
const path = join(directory, `knowledge-${size}.sqlite`)
const database = new KnowledgeDatabase(path)
database.initialize()
const library = database.createKnowledgeBase({ name: `Perf ${size}`, storageMode: 'reference' })
const sqlite = database.database
const seedStarted = performance.now()
for (let documentIndex = 0; documentIndex < size / chunksPerDocument; documentIndex += 1) {
const source = database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: `C:\\perf\\doc-${documentIndex}.md`, displayName: `doc-${documentIndex}.md`, status: 'ready' })
const chunks = Array.from({ length: chunksPerDocument }, (_, ordinal) => ({ id: `d${documentIndex}-c${ordinal}`, ordinal, content: sentence(80), location: `line ${ordinal + 1}` }))
const document = database.upsertDocument({ knowledgeBaseId: library.id, sourceId: source.id, externalId: `doc-${documentIndex}`, title: `doc-${documentIndex}`, sourceLocation: source.location }, chunks)
const stored = sqlite.prepare("SELECT id, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent'").all(document.id)
database.replaceDocumentEmbeddings(document.id, 'perf', 'perf-model', stored.map(row => ({ chunkId: row.id, contentChecksum: checksum(row.index_content), vector: vector() })))
}
const seedMs = performance.now() - seedStarted
database.close()
// Reopen so timings start from a fresh connection, like an app restart.
const reopened = new KnowledgeDatabase(path)
reopened.initialize()
const query = vector()
const entry = {
chunks: size,
dimensions,
seedSeconds: round(seedMs / 1000),
ftsMs: time(() => reopened.search({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40 })),
vectorMs: time(() => reopened.vectorSearch({ knowledgeBaseId: library.id, provider: 'perf', model: 'perf-model', vector: query, limit: 40 })),
hybridMs: time(() => reopened.hybridSearchWithDiagnostics({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40, provider: 'perf', model: 'perf-model', vector: query, graphEnabled: false, candidateMultiplier: 1 }))
}
reopened.close()
results.push(entry)
console.log(`[kperf] ${size} chunks: fts p95 ${entry.ftsMs.p95} ms, vector p95 ${entry.vectorMs.p95} ms, hybrid p95 ${entry.hybridMs.p95} ms (seed ${entry.seedSeconds} s)`)
}
const output = process.env.GB_KPERF_OUTPUT
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', node: process.version, results }, null, 2))
console.table(results.map(r => ({ chunks: r.chunks, 'fts p50/p95': `${r.ftsMs.p50} / ${r.ftsMs.p95}`, 'vector p50/p95': `${r.vectorMs.p50} / ${r.vectorMs.p95}`, 'hybrid p50/p95': `${r.hybridMs.p50} / ${r.hybridMs.p95}` })))
} finally {
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}
@@ -332,11 +332,30 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-05 向量检索规模基准
- **优先级 / 状态:** P1 / 待开始
- **优先级 / 状态:** P1 / 耗时基线已完成,取消响应与峰值内存待测
- **场景:** 1k、10k、50k chunks 的全文、向量和混合检索。
- **指标:** 总耗时、`vectorScannedCount`、Main event-loop lag、取消响应和峰值内存。
- **验收:** 固定语料和查询集产生可比较结果,并保留检索质量指标。
#### 2026-10-02 耗时基线
`npm run perf:knowledge`(`build/run-knowledge-perf.cjs`)用 esbuild 打包生产
`KnowledgeDatabase`,在临时 SQLite 中写入确定性随机语料(每文档 100 chunks,每 chunk 80 词,
384 维向量,与内置嵌入模型一致),重开连接后对每个规模各测 15 次 `search`、`vectorSearch`
和 `hybridSearchWithDiagnostics`(图谱关闭)。这些调用是同步的,在 App 中由 Main 线程直接
执行,所以单次耗时就是 Main 被阻塞的时长。同一台设备(Node 24.18),p95:
| chunks | 全文 | 向量 | 混合 |
| --- | --- | --- | --- |
| 5,000 | 14 ms | 54 ms | 93 ms |
| 20,000 | 59 ms | 173 ms | 219 ms |
| 50,000 | 130 ms | 396 ms | 520 ms |
向量扫描与 chunk 数线性相关,FTS 也随规模增长。按 50 ms 预算,约 5k chunks 起混合检索就会
使 Main 可感知地卡顿;5 万 chunks 时每次检索约冻结 Main 0.5 s,期间全部 IPC(包括流式输出、
终端和界面操作)排队。结论:`PERF-06` 的实施条件已满足;检索应先移出 Main(`PERF-06`,
随后并入 `PERF-16`)。取消响应和峰值内存尚未测量。
### PERF-06 向量计算移出 Main
- **优先级 / 状态:** P1 / 待开始
@@ -366,11 +385,29 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-09 Keep-alive 压力测试
- **优先级 / 状态:** P2 / 待开始
- **优先级 / 状态:** P2 / 文本会话部分已完成,图片与工具记录待补
- **场景:** 连续打开 12 个会话,每个会话包含 80 条富 Markdown、图片和工具记录。
- **指标:** DOM 节点、Renderer heap、GC pause 和流式 commit。
- **验收:** 明确隐藏会话缓存是否构成实际内存或渲染问题。
#### 2026-10-02 文本会话测量
`npm run perf:app` 在流式场景之后连续打开 24 个未访问过的种子会话(每个 20 条富 Markdown),
每 4 次记录挂载的面板数、DOM 节点数和强制 GC 后的 JS heap(CDP
`HeapProfiler.collectGarbage` + `Runtime.getHeapUsage`)。
| 已访问 | 挂载面板 | DOM 节点 | GC 后 heap |
| --- | --- | --- | --- |
| 0(两次流式之后) | 12 | 39,657 | 54.6 MB |
| 4 / 8 | 12 | 39,656 | 54.6–55.1 MB |
| 12 | 12 | 15,884 | 27.4 MB |
| 16 / 20 / 24 | 12 | 15,884 | 27.3–27.9 MB |
缓存上限 12 个面板有效:两条长流式回答被挤出缓存后,DOM 和 heap 回落并保持不变,后半段
增长为 0。此前观察到的“4 万节点、210 MB”是两条 40 KB 流式回答仍在缓存中时的瞬时值
(未 GC),不是持续泄漏。结论:文本会话场景下 keep-alive 不构成内存问题,`PERF-10` 不需要
调整缓存数量或有效期;含图片和工具记录的会话尚未测。
### PERF-10 Keep-alive 轻量优化
- **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成
@@ -444,8 +481,10 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
按键间隔 60 ms;流式回答约 40 KB 混合 Markdown(标题、列表、代码、表格、行内/块级公式、
引用),每个 SSE 事件 12 个字符,2,000 字符/秒。可用 `GB_PERF_*` 环境变量调整,
见脚本开头。
- `GB_PERF_PROFILE=1` 为流式场景采集 Renderer CPU profile 并汇总自身耗时热点;剖析有开销,
只用于归因,不作为基线数据。
- `GB_PERF_PROFILE=1` 为有历史输入和流式场景采集 Renderer CPU profile 并汇总自身耗时
热点;剖析有开销,只用于归因,不作为基线数据。
- `GB_PERF_CPU_THROTTLE=<倍数>` 用 CDP 降低 Renderer CPU 速度模拟低端设备;
`GB_PERF_MEMORY_VISITS`(默认 24,0 跳过)控制 `PERF-09` 内存场景。
#### 2026-10-02 基线
@@ -534,6 +573,38 @@ p95 从约 36 ms 降到 15.5 ms。
剩余开销属于 App 级重渲染,留给 `PERF-12` 后续项和 `PERF-13`。
- **剩余:** 流式 delta 按帧合并、大面板 memo、滚动跟随优化仍待实施。
#### 2026-10-02 低端设备模拟(Renderer CPU 4× 降速)
`GB_PERF_CPU_THROTTLE=4 npm run perf:app` 通过 CDP `Emulation.setCPUThrottlingRate` 把
Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用),源码 `5dc6545`
(含 `PERF-12` 第一步),同一设备。不降速一栏为同一轮构建的对照。
| 场景 | 不降速 | 4× 降速 |
| --- | --- | --- |
| 输入 120 字(新 profile)按键→帧 p95 | 8.7 ms | 42 ms,掉帧 21% |
| **输入 120 字(有 300 个会话)** | 0 Long Task,p95 9 ms | **37 次 / 3.2 s,p95 355 ms,最大 483 ms** |
| **首次切换会话 ×15** 点击→显示 p50 / p95 | 52 / 117 ms | **434 / 922 ms** |
| 再次切换(缓存命中)p50 / p95 | 55 / 66 ms | 482 / 534 ms |
| 打开 2,000 条消息会话 | 140 ms | 1.2 s |
| 滚动长会话 | 帧 p95 8.5 ms | 帧 p95 17 ms,正常 |
| **流式 40 KB** | 1 次 / 0.17 s,帧 p95 17 ms | **156 次 / 16.7 s,帧 p95 183 ms,掉帧 100%** |
| **流式期间输入** 按键→帧 p50 / p95 | 11.5 / 17 ms | **575 / 1,513 ms** |
高性能机上不可见的问题,在降速后全部暴露:
- 新 profile 输入只到 42 ms,有 300 个会话后升到 355 ms,差异来自**每次按键重新渲染整个
App 和完整侧栏**。降速剖析:`resizeComposerTextarea`(每次输入同步读 `scrollHeight` 强制
布局)约 8%;i18next `formatLanguageCode`/`translate` 约 12%(`Intl.getCanonicalLocales`
每次调用 `t()` 都执行,侧栏 302 行各自多次调用);其余是 App 与侧栏行的 React 渲染。
- 流式期间约 18% 是 `ChatHistoryPane` 每次 flush 的滚动跟随 layout effect(读布局后
`scrollTo`),i18next 约 8%,Markdown 解析与 React reconcile 其余。
- 缓存命中切换也约 0.5 s,说明慢的不是挂载新面板,而是切换触发的 App 级整体重渲染。
**结论:** 低端设备上输入、切换和流式都会明显卡顿,主因是 App 级重渲染和侧栏全量渲染,
证实 `PERF-13`(领域 store)和 `PERF-14`(侧栏虚拟化)有必要。`PERF-12` 剩余项应先做三处
低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、
滚动跟随按帧合并且只在贴底时执行。
### PERF-13 Renderer 领域 Store 迁移
- **优先级 / 状态:** P1 / 待开始
@@ -559,7 +630,7 @@ p95 从约 36 ms 降到 15.5 ms。
### PERF-16 数据进程
- **优先级 / 状态:** P1 / 待开始
- **实施条件:** `PERF-15` 完成,调���方已全部异步。
- **实施条件:** `PERF-15` 完成,调用方已全部异步。
- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph、
活动记录及文档解析;Main 只转发;保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。
- **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、
+1
View File
@@ -26,6 +26,7 @@
"test": "vitest run",
"test:watch": "vitest",
"perf:app": "node build/run-app-perf.cjs",
"perf:knowledge": "node build/run-knowledge-perf.cjs",
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
"build": "npm run typecheck && npm run build:bundle",
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs",
+63 -5
View File
@@ -27,6 +27,9 @@ const config = {
typedCharacters: Number(process.env.GB_PERF_TYPED_CHARACTERS || 120),
keyIntervalMs: Number(process.env.GB_PERF_KEY_INTERVAL_MS || 60),
switches: Number(process.env.GB_PERF_SWITCHES || 15),
memoryVisits: Number(process.env.GB_PERF_MEMORY_VISITS || 24),
// Chromium CPU throttling of the renderer only (Main is not slowed down).
cpuThrottle: Number(process.env.GB_PERF_CPU_THROTTLE || 1),
contentWidth: 1280,
contentHeight: 800
}
@@ -267,10 +270,24 @@ async function measure(id, description, body) {
// Optional renderer CPU profile (GB_PERF_PROFILE=1). Profiling adds overhead,
// so profiled runs are for attribution only, not for baseline numbers.
const profiling = process.env.GB_PERF_PROFILE === '1'
async function withRendererProfile(name, body) {
if (!profiling) return body()
function cdp() {
const debuggerApi = win.webContents.debugger
if (!debuggerApi.isAttached()) debuggerApi.attach('1.3')
return debuggerApi
}
// Throttling is per page session, so it is reapplied after every reload.
async function applyCpuThrottle() {
if (config.cpuThrottle > 1) await cdp().sendCommand('Emulation.setCPUThrottlingRate', { rate: config.cpuThrottle })
}
// Heap after a full GC, so retained memory is not hidden by GC timing.
async function retainedHeapMB() {
await cdp().sendCommand('HeapProfiler.collectGarbage')
const { usedSize } = await cdp().sendCommand('Runtime.getHeapUsage')
return usedSize / 1048576
}
async function withRendererProfile(name, body) {
if (!profiling) return body()
const debuggerApi = cdp()
await debuggerApi.sendCommand('Profiler.enable')
await debuggerApi.sendCommand('Profiler.setSamplingInterval', { interval: 200 })
await debuggerApi.sendCommand('Profiler.start')
@@ -401,6 +418,42 @@ async function streamOnce({ typeDuring = 0 } = {}) {
return { fields: { streamStartMs: startedMs, streamTotalMs: performance.now() - sendAt, assistantRenderedChars: assistantChars, typedDuringStream, typingEndedBeforeStreamFinished: stillStreaming } }
}
// PERF-09: visit many distinct conversations and check whether DOM size and
// retained heap stay bounded by the keep-alive cache. Sampling forces a GC,
// so it is recorded outside measure() and is not mixed with frame metrics.
async function memoryGrowth(seeded) {
report.pending = 'memory-growth'
writeReport()
const sample = async visits => ({
visits,
mountedPanes: await js("document.querySelectorAll('.chat-history-pane').length"),
domNodes: await js("document.getElementsByTagName('*').length"),
retainedHeapMB: Math.round(await retainedHeapMB() * 10) / 10
})
const samples = [await sample(0)]
const openMs = []
for (let index = 0; index < config.memoryVisits; index += 1) {
const target = index * 3 + 2
await clickConversation(seeded.titles[target])
openMs.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
await sleep(200)
if ((index + 1) % 4 === 0) samples.push(await sample(index + 1))
}
const first = samples[0]
const last = samples[samples.length - 1]
const half = samples[Math.floor(samples.length / 2)]
report.memoryGrowth = {
description: `Visit ${config.memoryVisits} distinct seeded conversations; DOM and post-GC heap every 4 visits`,
samples,
openLatency: summarize(openMs),
// Growth in the second half shows whether the cache bound actually holds.
secondHalfGrowth: { domNodes: last.domNodes - half.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - half.retainedHeapMB) * 10) / 10 },
totalGrowth: { domNodes: last.domNodes - first.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - first.retainedHeapMB) * 10) / 10 }
}
writeReport()
console.log(`[perf] memory-growth: panes ${first.mountedPanes}->${last.mountedPanes}, DOM ${first.domNodes}->${last.domNodes}, heap ${first.retainedHeapMB}->${last.retainedHeapMB} MB (second half +${report.memoryGrowth.secondHalfGrowth.retainedHeapMB} MB)`)
}
// -------------------------------------------------------------- scenarios
// Electron emits `ready` only after this ESM entry finishes evaluating, so
@@ -422,8 +475,10 @@ try {
platform: process.platform, arch: process.arch, electron: process.versions.electron, chrome: process.versions.chrome,
cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824),
innerWidth, innerHeight, devicePixelRatio,
gpu: { gpu_compositing: gpu.gpu_compositing, rasterization: gpu.rasterization, webgl: gpu.webgl, webgl2: gpu.webgl2, video_decode: gpu.video_decode }
gpu: { gpu_compositing: gpu.gpu_compositing, rasterization: gpu.rasterization, webgl: gpu.webgl, webgl2: gpu.webgl2, video_decode: gpu.video_decode },
rendererCpuThrottle: config.cpuThrottle
}
await applyCpuThrottle()
await sleep(1_500)
const idle = await measure('idle', 'Fresh profile, no interaction for 3 s', async () => { await sleep(3_000) })
@@ -446,6 +501,7 @@ try {
win.webContents.reload()
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && document.querySelectorAll('button.conversation-item').length >= ${Math.min(50, config.seedConversations)}`, 60_000, 'seeded sidebar')
report.startup.reloadWithSeedMs = performance.now() - reloadStart
await applyCpuThrottle()
await dismissStartupDialogs({ expect: false })
await sleep(1_500)
report.seed.sidebarRows = await js("document.querySelectorAll('button.conversation-item').length")
@@ -453,11 +509,11 @@ try {
await measure('idle-seeded', `Seeded history (${config.seedConversations} conversations), no interaction for 3 s`, async () => { await sleep(3_000) })
await measure('typing-seeded', `Type ${config.typedCharacters} characters with seeded history`, async () => {
await measure('typing-seeded', `Type ${config.typedCharacters} characters with seeded history`, () => withRendererProfile('typing-seeded', async () => {
await clickSelector(composer, { scroll: false })
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(config.typedCharacters / 26)).slice(0, config.typedCharacters))
await settle()
})
}))
await js(`(() => { const t = document.querySelector(${JSON.stringify(composer)}); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`)
await measure('switch-conversations', `Click ${config.switches} different seeded conversations (${config.seedMessagesPerConversation} messages each), first visit`, async () => {
@@ -511,6 +567,8 @@ try {
await measure('stream-and-type', 'Stream a long answer while typing into the composer; latency and frames cover the typing period', () => streamOnce({ typeDuring: 100 }))
if (config.memoryVisits > 0) await memoryGrowth(seeded)
report.status = errors.length ? 'passed-with-renderer-errors' : 'passed'
report.pending = null
report.startup.driverTotalMs = performance.now() - driverStart