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
+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