perf: add full-app interaction benchmark and UI architecture plan
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// Full-App interaction benchmark supervisor (PERF-11).
|
||||
// Starts a loopback fake OpenAI-compatible model on 127.0.0.1:11434 (the
|
||||
// fresh-profile default model endpoint), launches the current production
|
||||
// build in an isolated profile through tests/support/app-perf-driver.mjs,
|
||||
// enforces a total deadline and prints a summary of the driver report.
|
||||
const { spawn } = require('node:child_process')
|
||||
const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
const modelPort = 11434
|
||||
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || 600_000)
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000)
|
||||
const chunkChars = Number(process.env.GB_PERF_CHUNK_CHARS || 12)
|
||||
const streamCharsPerSecond = Number(process.env.GB_PERF_STREAM_CPS || 2_000)
|
||||
const tickIntervalMs = 10
|
||||
|
||||
function buildAnswer(targetBytes) {
|
||||
const blocks = [
|
||||
'## Section {n}\n\nThis paragraph explains the {n}th step of the benchmark answer with **bold**, `inline code`, and a [link](https://example.com/{n}).\n\n',
|
||||
'- First item for block {n}\n- Second item with *emphasis*\n - Nested item {n}\n- Third item\n\n',
|
||||
'```ts\nexport function step{n}(input: number): number {\n const value = input * {n}\n return value + 1\n}\n```\n\n',
|
||||
'| Column | Value {n} | Notes |\n| --- | --- | --- |\n| alpha | {n} | first |\n| beta | {n}1 | second |\n\n',
|
||||
// Single-line display math is common in model output. GB_PERF_CORPUS=multiline-math
|
||||
// puts the delimiters on their own lines instead.
|
||||
process.env.GB_PERF_CORPUS === 'multiline-math'
|
||||
? 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\n\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}\n$$\n\n'
|
||||
: 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}$$\n\n',
|
||||
'> Quoted note {n}: keep the streaming renderer honest with mixed blocks.\n\n'
|
||||
]
|
||||
let text = ''
|
||||
let n = 1
|
||||
while (Buffer.byteLength(text) < targetBytes) {
|
||||
text += blocks[n % blocks.length].replaceAll('{n}', String(n))
|
||||
n += 1
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const answer = buildAnswer(streamBytes)
|
||||
const server = createServer((request, response) => {
|
||||
const started = Date.now()
|
||||
const entry = { method: request.method, path: request.url, startedAt: started }
|
||||
ledger.push(entry)
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
let body = ''
|
||||
request.on('data', chunk => { body += chunk })
|
||||
request.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* counted below */ }
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const last = messages.at(-1)
|
||||
const lastText = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '')
|
||||
const benchmark = lastText.includes('perf-stream')
|
||||
entry.kind = benchmark ? 'benchmark-stream' : 'auxiliary'
|
||||
entry.stream = parsed.stream === true
|
||||
const content = benchmark ? answer : 'Benchmark title'
|
||||
if (!entry.stream) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: 'perf', object: 'chat.completion', model: 'qwen3',
|
||||
choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }
|
||||
}))
|
||||
entry.finishedAt = Date.now()
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
||||
const send = payload => response.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const delta = text => send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
|
||||
let offset = 0
|
||||
let chunks = 0
|
||||
const finish = () => {
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [], usage: { prompt_tokens: 10, completion_tokens: Math.ceil(content.length / 4), total_tokens: 10 + Math.ceil(content.length / 4) } })
|
||||
response.write('data: [DONE]\n\n')
|
||||
response.end()
|
||||
entry.finishedAt = Date.now()
|
||||
entry.chunks = chunks
|
||||
entry.bytes = Buffer.byteLength(content)
|
||||
}
|
||||
if (!benchmark) { delta(content); finish(); return }
|
||||
// Emit at a fixed character rate, split into small SSE events like a
|
||||
// fast token stream. Pacing is time-based so coarse timers do not
|
||||
// change throughput.
|
||||
const streamStart = Date.now()
|
||||
const tick = () => {
|
||||
if (response.destroyed) { entry.aborted = true; return }
|
||||
const due = Math.min(content.length, Math.ceil(((Date.now() - streamStart) / 1000) * streamCharsPerSecond))
|
||||
while (offset < due) {
|
||||
delta(content.slice(offset, offset + chunkChars))
|
||||
offset += chunkChars
|
||||
chunks += 1
|
||||
}
|
||||
if (offset >= content.length) { finish(); return }
|
||||
setTimeout(tick, tickIntervalMs)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(modelPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
function format(value, digits = 1) {
|
||||
return value === null || value === undefined || Number.isNaN(value) ? '-' : Number(value).toFixed(digits)
|
||||
}
|
||||
|
||||
function printSummary(report) {
|
||||
console.log(`\nGoodBuddy full-App benchmark (${report.status})`)
|
||||
console.log(`source ${report.source?.commit ?? '?'}${report.source?.dirty ? ' (dirty)' : ''}, Electron ${report.environment?.electron}, DPR ${report.environment?.devicePixelRatio}, ${report.environment?.cpu}`)
|
||||
if (report.startup) {
|
||||
console.log(`startup: window ${format(report.startup.windowCreatedMs, 0)} ms, ready-to-show ${format(report.startup.readyToShowMs, 0)} ms, composer ready ${format(report.startup.composerReadyMs, 0)} ms`)
|
||||
}
|
||||
const rows = (report.scenarios ?? []).map(s => ({
|
||||
scenario: s.id,
|
||||
ms: format(s.durationMs, 0),
|
||||
longTasks: `${s.renderer?.longTasks?.count ?? '-'} / ${format(s.renderer?.longTasks?.totalMs, 0)} / ${format(s.renderer?.longTasks?.maxMs, 0)}`,
|
||||
'frame p50/p95/max': `${format(s.renderer?.frames?.p50)} / ${format(s.renderer?.frames?.p95)} / ${format(s.renderer?.frames?.max, 0)}`,
|
||||
'jank%': format(s.renderer?.frames?.jankRatio * 100),
|
||||
'latency p50/p95/max': s.latency ? `${format(s.latency.p50)} / ${format(s.latency.p95)} / ${format(s.latency.max)}` : '-',
|
||||
'mainLag p99/max': `${format(s.main?.eventLoopDelay?.p99)} / ${format(s.main?.eventLoopDelay?.max)}`,
|
||||
'cpu% main/renderer/gpu': `${format(s.processes?.cpu?.Browser, 0)} / ${format(s.processes?.cpu?.Tab, 0)} / ${format(s.processes?.cpu?.GPU, 0)}`,
|
||||
domNodes: s.renderer?.domNodes ?? '-',
|
||||
heapMB: format(s.renderer?.heapUsedMB)
|
||||
}))
|
||||
if (rows.length) console.table(rows)
|
||||
if (report.error) console.log(`error: ${report.error}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const required = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']
|
||||
const missing = required.filter(file => !existsSync(join(root, file)))
|
||||
if (missing.length) throw new Error(`Missing production build (${missing.join(', ')}); run npm run build:bundle first`)
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-app-perf-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port and retry`, { cause: error })
|
||||
}
|
||||
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace')
|
||||
mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true })
|
||||
const started = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, totalTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', (code, signal) => resolvePromise({ code, signal })))
|
||||
clearTimeout(timer)
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
const resultPath = join(outputDirectory, 'result.json')
|
||||
let report
|
||||
if (existsSync(resultPath)) report = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
else report = { status: 'failed', error: timedOut ? 'Supervisor timeout before report' : 'Driver exited without a report' }
|
||||
report.supervisor = { elapsedMs: Date.now() - started, timeoutMs: totalTimeoutMs, timedOut, exitCode: exit.code, signal: exit.signal }
|
||||
report.corpus = { kind: process.env.GB_PERF_CORPUS || 'single-line-math', bytes: streamBytes, charsPerSecond: streamCharsPerSecond, charsPerEvent: chunkChars }
|
||||
report.modelRequests = {
|
||||
boundary: 'loopback fake model only; no external requests',
|
||||
total: ledger.length,
|
||||
benchmarkStreams: ledger.filter(entry => entry.kind === 'benchmark-stream').length,
|
||||
auxiliary: ledger.filter(entry => entry.kind === 'auxiliary').length,
|
||||
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt }) => ({ method, path, kind, stream, chunks, bytes, aborted, durationMs: finishedAt ? finishedAt - startedAt : null }))
|
||||
}
|
||||
if (timedOut || exit.code !== 0) report.status = 'failed'
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
printSummary(report)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
if (!keepProfile) rmSync(join(runDirectory, 'profile'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -70,6 +70,7 @@
|
||||
| 2 | 通道、更新和真实 Runtime | `FUN-02`、`FUN-03`、`FUN-05`、`QA-01`、`QA-02` | 对应平台测试配置可用 | 文件办公闭环、更新下载闭环和真实 Runtime E2E 均通过 |
|
||||
| 3A | 性能基线 | `PERF-01`、`PERF-03`、`PERF-05`、`PERF-07`、`PERF-09` | 固定测试数据和设备 | 形成可重复基线、目标阈值和原始结果 |
|
||||
| 3B | 已测量性能优化 | `PERF-02`、`PERF-04`、`PERF-06`、`PERF-08`、`PERF-10` | 对应 3A 指标确认存在瓶颈 | 指标改善且功能、取消、恢复语义不回退 |
|
||||
| 3C | 架构级界面性能改造 | `PERF-11` 至 `PERF-17` | `PERF-11` 完整 App 基线已产生 | 第 7.1 节五条架构规则成立,并由基准和 lint 持续守护 |
|
||||
| 4 | 增强能力 | `FUN-06` 至 `FUN-09` | 核心闭环稳定;首个电脑控制平台已选定 | 每项形成独立、完整、可真实使用的工作流 |
|
||||
|
||||
## 5. 发布与交付
|
||||
@@ -390,6 +391,159 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
|
||||
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
|
||||
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
|
||||
|
||||
### 7.1 架构级界面性能改造(批次 3C)
|
||||
|
||||
2026-10-02 的架构评审确认,界面卡顿的主要来源是架构组织方式,而不是 Electron 或 GPU:
|
||||
|
||||
- **Renderer 单组件状态:** `App.tsx` 约 11k 行,`App()` 内有 123 个 `useState`、69 个
|
||||
`useEffect`。会话、消息、草稿、任务和设置都在其中,输入和每次流式 flush 都会重新执行
|
||||
整个 App 函数体;多个大面板未 memo,隐藏的 keep-alive 视图也会参与渲染。
|
||||
- **Main 同步存储:** 全部 SQLite 访问使用 Main 线程的 `DatabaseSync`,包括全表向量扫描、
|
||||
会话列表与搜索、Story Graph 读取;PDF/Office 解析和附件同步文件 IO 也在 Main。
|
||||
- **全量回写:** 活动记录在任何变化后 250 ms 全量 `replace`;会话每 500 ms 定时保存;
|
||||
`*Changed` 通知不带内容,Renderer 再重新查询完整列表。
|
||||
|
||||
改造完成后须同时满足以下规则,并由工具守护,避免回退:
|
||||
|
||||
1. Main 只负责窗口、生命周期、权限和消息路由,不执行数据库查询、文档解析或批量计算。
|
||||
2. Renderer 状态按领域拆成外部 store,组件通过 selector 订阅;`App.tsx` 只保留布局壳。
|
||||
3. 流式文本和终端输出走专用通道,只更新正在显示的行或面板,按帧合并提交。
|
||||
4. 数据只有一个权威来源,跨进程只传增量;不再全量回写,也不再“通知后重新查全量”。
|
||||
5. 消息、会话、笔记、文件树等长列表虚拟化,DOM 数量不随数据量增长。
|
||||
|
||||
实施方式:先建立 `PERF-11` 基线,再沿 Renderer(`PERF-12`→`PERF-13`→`PERF-14`)和
|
||||
数据进程(`PERF-15`→`PERF-16`)两条线并行推进,最后由 `PERF-17` 固化守护。每一步完成后
|
||||
重新运行 `PERF-11` 场景并记录对比;不换技术栈,不一次性重写 `App.tsx`。
|
||||
|
||||
### PERF-11 完整 App 交互基准
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中
|
||||
- **目标:** 在当前生产构建、隔离 profile 和本地假模型下,可重复测量用户能感知的卡顿。
|
||||
- **运行:** `npm run build:bundle` 后执行 `npm run perf:app`;结果写入
|
||||
`GB_PERF_OUTPUT` 或系统临时目录,不含用户内容,不发出外部模型请求。
|
||||
- **场景:** 空闲基线;Composer 连续输入;切换会话;打开长会话;大量会话列表下的流式
|
||||
回答;流式回答期间同时输入。
|
||||
- **指标:** Renderer Long Tasks(>50 ms)数量与总时长、按键到下一帧延迟、帧间隔 p95 与
|
||||
掉帧比例、React commit 无法直接取得时以 DOM mutation 批次近似、Main event-loop 延迟
|
||||
(`monitorEventLoopDelay`)、Main 与 Renderer 进程 CPU 和内存、Renderer JS heap。
|
||||
- **验收:** 同一设备多次运行结果可比较;报告记录源码版本、环境、数据规模和原始样本摘要。
|
||||
|
||||
#### 实现
|
||||
|
||||
- `build/run-app-perf.cjs`:独立 supervisor。在 `127.0.0.1:11434`(新 profile 默认模型地址)
|
||||
启动 OpenAI 兼容假模型,清除 `ELECTRON_RUN_AS_NODE` 和模型环境变量,设置整轮期限(默认
|
||||
600 s),汇总报告并删除隔离 profile。端口被占用时直接失败。
|
||||
- `tests/support/app-perf-driver.mjs`:在导入 `out/main/index.js` 前设置 `appPath`、
|
||||
`userData`、`sessionData` 和日志目录,然后由生产 Main 创建窗口、注册 IPC 并加载真实
|
||||
Preload/Renderer。全部交互使用 `sendInputEvent` 原生输入;数据通过生产
|
||||
`conversations.saveLocal` IPC 写入,再 reload 走正常启动加载。
|
||||
- Renderer 探针只观察:`longtask`、`long-animation-frame`、Event Timing、rAF 帧间隔、
|
||||
keydown 到下一帧、MutationObserver 计数、DOM 节点数和精确 JS heap。Main 用
|
||||
`monitorEventLoopDelay`,进程用 `app.getAppMetrics()`。
|
||||
- 默认规模:300 个会话 × 20 条消息,另加一个 2,000 条消息的长会话;输入 120 个字符,
|
||||
按键间隔 60 ms;流式回答约 40 KB 混合 Markdown(标题、列表、代码、表格、行内/块级公式、
|
||||
引用),每个 SSE 事件 12 个字符,2,000 字符/秒。可用 `GB_PERF_*` 环境变量调整,
|
||||
见脚本开头。
|
||||
- `GB_PERF_PROFILE=1` 为流式场景采集 Renderer CPU profile 并汇总自身耗时热点;剖析有开销,
|
||||
只用于归因,不作为基线数据。
|
||||
|
||||
#### 2026-10-02 基线
|
||||
|
||||
源码 `8280158`(工作区含其他未提交改动),Windows x64,Intel Core Ultra X7 358H ×16,
|
||||
32 GB,Electron 43.2.0 / Chromium 150,DPR 1,内容区 1280×800,GPU 合成与光栅化均启用。
|
||||
连续运行 3 次,假模型请求每次 2 次,外部模型请求 0 次。帧间隔基线为 8.3 ms(120 Hz)。
|
||||
|
||||
| 场景 | Long Tasks 次数 / 总时长 | 帧间隔 p95 | 掉帧比例 | 交互延迟 p50 / p95 | Main lag 最大 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 空闲(新 profile / 有历史) | 0 / 0 | 8.5 ms | 0% | — | 17–21 ms |
|
||||
| 输入 120 字(新 profile) | 0 / 0 | 8.5 ms | ≤0.2% | 按键→帧 4.8–5.1 / 8.3–8.5 ms | ≤17 ms |
|
||||
| 输入 120 字(有历史) | 0 / 0 | 8.5 ms | 0% | 按键→帧 6.5–6.7 / 8.4–8.7 ms | ≤18 ms |
|
||||
| 首次切换会话 ×15 | 1 / 77–81 ms | 8.6 ms | 3.7–4.5% | 点击→显示 49–50 / 102–105 ms | 29–32 ms |
|
||||
| 再次切换(缓存命中)×10 | 0 / 0 | 8.6 ms | 3.3–4.1% | 点击→显示 52–53 / 56–59 ms | 19–22 ms |
|
||||
| 打开 2,000 条消息会话 | 1 / 94–102 ms | 8.5 ms | 1.6% | 点击→显示 134–144 ms | ≤21 ms |
|
||||
| 滚动长会话 3 s | 0 / 0 | 8.5 ms | 0% | — | ≤17 ms |
|
||||
| **流式 40 KB 回答** | **126–137 / 8.6–9.3 s** | **83 ms** | **45–48%** | — | 30–36 ms |
|
||||
| **流式期间输入 100 字** | **128–182 / 8.8–11.9 s** | **83 ms** | **50–52%** | 按键→帧 12.5–13.5 / 35–36 ms | 29–144 ms |
|
||||
|
||||
其他观察:
|
||||
|
||||
- 启动:窗口创建约 0.58 s,`ready-to-show` 约 1.29 s,Composer 可用约 1.42 s;带 300 个
|
||||
会话 reload 到侧栏出现约 0.19 s。
|
||||
- 流式回答约 20 s,Long Tasks 占记录时间约 40%;按四等分统计为 `0 / 2.5 / 4.7 / 4.6 s`,
|
||||
随回答变长而增加,说明每次提交的成本与已生成长度成正比。
|
||||
- 有历史时侧栏一次渲染全部 302 行,DOM 约 4,400 节点;访问过若干会话后约 1.6 万;两次
|
||||
流式后约 4 万,JS heap 最高约 210 MB(keep-alive 缓存持续增长)。
|
||||
- 长会话只渲染最近 80 条,打开耗时主要是一次约 100 ms 的 Long Task。
|
||||
- KaTeX 的 `data:` 字体被 CSP 拒绝(缺少 `font-src`),每轮 8 条控制台错误,字体回退。
|
||||
- `app.getAppMetrics()` 的 CPU 百分比在 Windows 上按全部逻辑核归一,只作相对比较。
|
||||
|
||||
**归因(剖析运行):** 流式期间 Renderer 自身 CPU 约 74% 在应用 bundle 中,热点是
|
||||
`hast-util-from-dom` 的 `parseFragment`/`fromDom`(rehype-katex 把 KaTeX HTML 再解析成
|
||||
hast)、micromark 分词、hast→React 转换和 React reconcile。对照实验确认根因:默认语料的
|
||||
块级公式写在一行(`$$...$$`),命中 `MarkdownRenderer.tsx` 的 `unsafeSegmentLine`,
|
||||
`splitMarkdownSegments` 退回整篇单段,**每次 flush 都重新解析整篇回答并重新渲染全部公式**。
|
||||
仅把 `$$` 改成独占一行(`GB_PERF_CORPUS=multiline-math`)后,同一流式场景 Long Tasks
|
||||
从约 130 次 / 9 s 降到 1 次 / 0.15 s,帧间隔 p95 从 83 ms 回到 8.5 ms,流式期间按键→帧
|
||||
p95 从约 36 ms 降到 15.5 ms。
|
||||
|
||||
**结论与对后续批次的影响:**
|
||||
|
||||
1. 当前最严重、用户最易感知的卡顿是**流式长回答**,且由 Markdown 分段失效后的整篇重解析
|
||||
主导;输入、切换、滚动和空闲在本机均未出现明显问题。`PERF-12` 应优先处理:让分段在
|
||||
单行 `$$`、HTML 行、链接定义等情况下也能安全工作或局部回退,并对已完成段缓存渲染结果。
|
||||
2. 假模型 2,000 字符/秒高于常见真实模型输出速度,但成本随长度线性增长,真实速度下回答
|
||||
足够长时同样会出现。
|
||||
3. 会话首次切换 p95 约 100 ms、打开长会话约 140 ms,可在 `PERF-13`/`PERF-14` 中改善,
|
||||
优先级低于第 1 项。
|
||||
4. 本机为高性能设备;低端设备上的绝对值会更差,后续应补一台低配 Windows 设备基线。
|
||||
|
||||
### PERF-12 流式与输入快速止血
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **范围:** 流式 delta 在 Renderer 按 `requestAnimationFrame` 合并后提交,并以 transition
|
||||
降低优先级;缓存 Markdown 预处理结果;`RightAssistantSidebar`、`ProjectSwitcher` 和
|
||||
keep-alive 路由使用 memo 并稳定回调;滚动跟随只在需要时执行。
|
||||
- **验收:** `PERF-11` 流式与输入场景的 Long Tasks 和输入延迟下降;事件顺序、停止、重试、
|
||||
工具与 Subagent 块不回退。该项吸收 `PERF-04` 的范围。
|
||||
|
||||
### PERF-13 Renderer 领域 Store 迁移
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **顺序:** 流式缓冲 → Composer 草稿 → 会话与消息 → 任务与通知 → 设置及其余界面状态。
|
||||
- **每个领域的完成步骤:** 建立 store 并把 IPC 订阅移入 store;调用方改为 selector;删除
|
||||
App 中对应 state/ref 同步代码;运行测试与 `PERF-11`。
|
||||
- **验收:** 输入和流式更新不再重新渲染 App 根组件;`App.tsx` 最终降到布局壳规模。
|
||||
|
||||
### PERF-14 长列表虚拟化
|
||||
|
||||
- **优先级 / 状态:** P2 / 待开始
|
||||
- **实施条件:** `PERF-13` 完成会话与消息迁移,行组件 props 已稳定。
|
||||
- **范围:** 消息时间线和会话侧栏优先;保留滚动位置、跳转、展开状态和“加载更早消息”语义。
|
||||
- **验收:** 5,000 条消息和 1,000 个会话场景下 DOM 节点数有界,滚动与切换指标改善。
|
||||
|
||||
### PERF-15 存储接口异步化
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步,
|
||||
调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。
|
||||
- **验收:** 行为与测试不变;`PERF-11` Main event-loop 延迟不变差。
|
||||
|
||||
### PERF-16 数据进程
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **实施条件:** `PERF-15` 完成,调���方已全部异步。
|
||||
- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph、
|
||||
活动记录及文档解析;Main 只转发;保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。
|
||||
- **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、
|
||||
崩溃恢复和迁移语义不回退。
|
||||
|
||||
### PERF-17 架构守护
|
||||
|
||||
- **优先级 / 状态:** P2 / 待开始
|
||||
- **范围:** lint 禁止 `src/main` 使用同步文件 API 和在数据进程外引用 `DatabaseSync`;禁止
|
||||
组件直接订阅 `window.goodbuddy.*.on*`;限制 `App.tsx` 规模;`PERF-11` 关键指标设置回归阈值。
|
||||
- **验收:** 违反规则的改动在 lint 或基准阶段失败。
|
||||
|
||||
## 8. 体验与可访问性
|
||||
|
||||
### UX-01 统一 Ask/Execute 文案
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"perf:app": "node build/run-app-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",
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
// Full-App interaction benchmark driver (PERF-11). Launched by
|
||||
// build/run-app-perf.cjs. Runs inside the Electron main process: isolates the
|
||||
// profile, imports the current production Main, then drives the real window
|
||||
// with native input events and records renderer, Main and process metrics.
|
||||
import { app } from 'electron'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { cpus, totalmem } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
|
||||
import console from 'node:console'
|
||||
import process from 'node:process'
|
||||
import { clearTimeout, setTimeout } from 'node:timers'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const directory = process.env.GB_PERF_DIRECTORY
|
||||
const artifacts = process.env.GB_PERF_ARTIFACTS
|
||||
const root = process.env.GB_PERF_ROOT
|
||||
if (!directory || !artifacts || !root) throw new Error('Run through build/run-app-perf.cjs')
|
||||
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
||||
const config = {
|
||||
seedConversations: Number(process.env.GB_PERF_SEED_CONVERSATIONS || 300),
|
||||
seedMessagesPerConversation: Number(process.env.GB_PERF_SEED_MESSAGES || 20),
|
||||
longConversationMessages: Number(process.env.GB_PERF_LONG_MESSAGES || 2_000),
|
||||
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),
|
||||
contentWidth: 1280,
|
||||
contentHeight: 800
|
||||
}
|
||||
|
||||
// Isolation must happen before the production Main module loads: it takes
|
||||
// the single-instance lock and opens storage from userData at import time.
|
||||
app.setAppPath(root)
|
||||
app.setName(packageJson.name)
|
||||
app.setVersion(packageJson.version)
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.setPath('sessionData', join(directory, 'session'))
|
||||
app.setAppLogsPath(join(directory, 'logs'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
|
||||
app.commandLine.appendSwitch('enable-precise-memory-info')
|
||||
mkdirSync(artifacts, { recursive: true })
|
||||
|
||||
const driverStart = performance.now()
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
kind: 'goodbuddy-full-app-benchmark',
|
||||
status: 'running',
|
||||
source: sourceInfo(),
|
||||
environment: null,
|
||||
config,
|
||||
startup: {},
|
||||
scenarios: [],
|
||||
rendererErrors: [],
|
||||
pending: 'startup'
|
||||
}
|
||||
const errors = report.rendererErrors
|
||||
const mainDelay = monitorEventLoopDelay({ resolution: 10 })
|
||||
mainDelay.enable()
|
||||
let win
|
||||
|
||||
function sourceInfo() {
|
||||
try {
|
||||
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
|
||||
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().split('\n').filter(Boolean).length
|
||||
return { commit, dirty: dirty > 0, dirtyFiles: dirty, version: packageJson.version }
|
||||
} catch {
|
||||
return { commit: null, dirty: null, version: packageJson.version }
|
||||
}
|
||||
}
|
||||
|
||||
function writeReport() {
|
||||
writeFileSync(join(artifacts, 'result.json'), JSON.stringify(report, null, 2))
|
||||
}
|
||||
|
||||
const deadline = setTimeout(() => {
|
||||
report.status = 'failed'
|
||||
report.error = `Driver timeout during ${report.pending}`
|
||||
writeReport()
|
||||
app.exit(1)
|
||||
}, Number(process.env.GB_PERF_DRIVER_TIMEOUT_MS || 540_000))
|
||||
|
||||
app.on('browser-window-created', (_event, created) => {
|
||||
if (win) return
|
||||
win = created
|
||||
report.startup.windowCreatedMs = performance.now() - driverStart
|
||||
created.once('ready-to-show', () => { report.startup.readyToShowMs = performance.now() - driverStart })
|
||||
created.webContents.on('console-message', event => {
|
||||
if (event.level !== 'error') return
|
||||
const text = String(event.message)
|
||||
// CSP has no font-src, so KaTeX data: fonts are refused. Count, don't list.
|
||||
if (text.startsWith("Loading the font 'data:font/")) { report.cspBlockedDataFonts = (report.cspBlockedDataFonts ?? 0) + 1; return }
|
||||
errors.push(text.slice(0, 500))
|
||||
})
|
||||
created.webContents.on('render-process-gone', (_e, details) => errors.push(`render-process-gone ${JSON.stringify(details)}`))
|
||||
})
|
||||
|
||||
await import(pathToFileURL(join(root, 'out', 'main', 'index.js')).href)
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const js = code => win.webContents.executeJavaScript(code, true)
|
||||
async function waitFor(code, timeoutMs = 30_000, label = code) {
|
||||
const started = performance.now()
|
||||
while (performance.now() - started < timeoutMs) {
|
||||
try { if (await js(code)) return performance.now() - started } catch { /* page may be reloading */ }
|
||||
await sleep(20)
|
||||
}
|
||||
throw new Error(`Timeout waiting for ${label}`)
|
||||
}
|
||||
const settle = () => js('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))')
|
||||
|
||||
async function focusWindow() {
|
||||
win.show()
|
||||
win.focus()
|
||||
win.webContents.focus()
|
||||
await waitFor('document.hasFocus()', 5_000, 'window focus')
|
||||
}
|
||||
|
||||
async function clickSelector(selector, { scroll = true } = {}) {
|
||||
await focusWindow()
|
||||
const point = await js(`(() => {
|
||||
const e = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!e) return null;
|
||||
${scroll ? "e.scrollIntoView({ block: 'center' });" : ''}
|
||||
const r = e.getBoundingClientRect();
|
||||
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
|
||||
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
|
||||
})()`)
|
||||
if (!point) throw new Error(`Missing element ${selector}`)
|
||||
if (!point.hit) throw new Error(`Element occluded ${selector}`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
}
|
||||
|
||||
async function typeText(text) {
|
||||
for (const character of text) {
|
||||
const keyCode = character === ' ' ? 'Space' : character
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
|
||||
win.webContents.sendInputEvent({ type: 'char', keyCode: character })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
|
||||
await sleep(config.keyIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return null
|
||||
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 summarize(values) {
|
||||
if (!values.length) return null
|
||||
return { count: values.length, p50: percentile(values, 50), p95: percentile(values, 95), max: Math.max(...values) }
|
||||
}
|
||||
|
||||
// Installed into the renderer after every load. It only observes; it does not
|
||||
// change application state.
|
||||
const probeSource = `(() => {
|
||||
if (window.__gbPerf) return true;
|
||||
const s = { recording: false, startedAt: 0, frames: [], lastFrame: 0, longTasks: [], loafs: [], events: [], latencies: [], mutations: 0, pointerDown: 0 };
|
||||
const frame = t => { if (s.recording && s.lastFrame) s.frames.push(t - s.lastFrame); s.lastFrame = t; requestAnimationFrame(frame); };
|
||||
requestAnimationFrame(frame);
|
||||
const observe = (type, sink, options = {}) => { try { new PerformanceObserver(list => { if (s.recording) for (const e of list.getEntries()) sink(e); }).observe({ type, ...options }); } catch {} };
|
||||
observe('longtask', e => s.longTasks.push({ at: e.startTime - s.startedAt, duration: e.duration }));
|
||||
observe('long-animation-frame', e => s.loafs.push({ duration: e.duration, blocking: e.blockingDuration }));
|
||||
observe('event', e => { if (e.name === 'keydown' || e.name === 'keypress' || e.name === 'input' || e.name === 'pointerdown' || e.name === 'click' || e.name === 'mousedown') s.events.push({ name: e.name, duration: e.duration }); }, { durationThreshold: 16 });
|
||||
document.addEventListener('keydown', e => { if (!s.recording) return; const t = e.timeStamp; requestAnimationFrame(() => s.latencies.push(performance.now() - t)); }, true);
|
||||
document.addEventListener('mousedown', e => { s.pointerDown = e.timeStamp; }, true);
|
||||
new MutationObserver(() => { if (s.recording) s.mutations += 1; }).observe(document.documentElement, { subtree: true, childList: true, characterData: true });
|
||||
window.__gbPerf = {
|
||||
start() { Object.assign(s, { recording: true, startedAt: performance.now(), frames: [], lastFrame: 0, longTasks: [], loafs: [], events: [], latencies: [], mutations: 0 }); return true; },
|
||||
stop() {
|
||||
s.recording = false;
|
||||
return { elapsed: performance.now() - s.startedAt, frames: s.frames, longTasks: s.longTasks, loafs: s.loafs, events: s.events, latencies: s.latencies, mutations: s.mutations,
|
||||
domNodes: document.getElementsByTagName('*').length, heapUsedMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null };
|
||||
},
|
||||
// Resolves with the time from the last native mousedown until the given
|
||||
// conversation is the active pane with rendered messages, checked at frame
|
||||
// boundaries. (CSP forbids eval, so the condition is fixed here.)
|
||||
paneShownSinceMouseDown(conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = performance.now();
|
||||
const check = () => {
|
||||
const pane = document.querySelector('.chat-history-pane[data-active=true]');
|
||||
if (pane && pane.dataset.conversationId === conversationId && pane.querySelector('article.message')) resolve(performance.now() - s.pointerDown);
|
||||
else if (performance.now() - started > 30000) reject(new Error('timeout pane ' + conversationId));
|
||||
else requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
};
|
||||
return true;
|
||||
})()`
|
||||
|
||||
function processSnapshot() {
|
||||
const byType = {}
|
||||
for (const metric of app.getAppMetrics()) {
|
||||
const key = metric.type
|
||||
const entry = byType[key] ?? (byType[key] = { cpu: 0, workingSetMB: 0, count: 0 })
|
||||
entry.cpu += metric.cpu.percentCPUUsage
|
||||
entry.workingSetMB += metric.memory.workingSetSize / 1024
|
||||
entry.count += 1
|
||||
}
|
||||
return byType
|
||||
}
|
||||
|
||||
let frameBaseline = null
|
||||
async function measure(id, description, body) {
|
||||
report.pending = id
|
||||
writeReport()
|
||||
await js(probeSource)
|
||||
processSnapshot() // resets per-process CPU accounting
|
||||
mainDelay.reset()
|
||||
await js('window.__gbPerf.start()')
|
||||
const started = performance.now()
|
||||
const extra = (await body()) ?? {}
|
||||
const durationMs = performance.now() - started
|
||||
const raw = await js('window.__gbPerf.stop()')
|
||||
const processes = processSnapshot()
|
||||
const frames = raw.frames
|
||||
const baseline = frameBaseline ?? percentile(frames, 50) ?? 16.7
|
||||
const longTaskDurations = raw.longTasks.map(task => task.duration)
|
||||
const longTaskTotal = longTaskDurations.reduce((a, b) => a + b, 0)
|
||||
// Long-task time per quarter of the recorded window shows whether cost grows over the scenario.
|
||||
const quarters = [0, 0, 0, 0]
|
||||
for (const task of raw.longTasks) quarters[Math.min(3, Math.max(0, Math.floor((task.at / raw.elapsed) * 4)))] += task.duration
|
||||
const scenario = {
|
||||
id,
|
||||
description,
|
||||
durationMs,
|
||||
renderer: {
|
||||
longTasks: { count: raw.longTasks.length, totalMs: longTaskTotal, maxMs: longTaskDurations.length ? Math.max(...longTaskDurations) : 0, shareOfDuration: longTaskTotal / raw.elapsed, msByQuarter: quarters.map(Math.round) },
|
||||
longAnimationFrames: { count: raw.loafs.length, blockingMs: raw.loafs.reduce((a, b) => a + b.blocking, 0), maxMs: raw.loafs.length ? Math.max(...raw.loafs.map(l => l.duration)) : 0 },
|
||||
frames: { count: frames.length, ...summarize(frames), baselineMs: baseline, jankRatio: frames.length ? frames.filter(f => f > baseline * 1.5).length / frames.length : null },
|
||||
slowEvents: { countOver16ms: raw.events.length, maxMs: raw.events.length ? Math.max(...raw.events.map(e => e.duration)) : 0, byName: raw.events.reduce((acc, e) => ({ ...acc, [e.name]: (acc[e.name] ?? 0) + 1 }), {}) },
|
||||
domMutationBatches: raw.mutations,
|
||||
domNodes: raw.domNodes,
|
||||
heapUsedMB: raw.heapUsedMB
|
||||
},
|
||||
latency: raw.latencies.length ? summarize(raw.latencies) : extra.latency ?? null,
|
||||
main: {
|
||||
eventLoopDelay: {
|
||||
p50: mainDelay.percentile(50) / 1e6,
|
||||
p99: mainDelay.percentile(99) / 1e6,
|
||||
max: mainDelay.max / 1e6,
|
||||
mean: mainDelay.mean / 1e6
|
||||
}
|
||||
},
|
||||
processes: {
|
||||
cpu: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, value.cpu])),
|
||||
workingSetMB: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, Math.round(value.workingSetMB)]))
|
||||
},
|
||||
...extra.fields
|
||||
}
|
||||
delete scenario.latencyExtra
|
||||
report.scenarios.push(scenario)
|
||||
writeReport()
|
||||
console.log(`[perf] ${id}: ${Math.round(durationMs)} ms, long tasks ${raw.longTasks.length} (${Math.round(longTaskTotal)} ms), frame p95 ${scenario.renderer.frames.p95?.toFixed(1)} ms, main lag max ${scenario.main.eventLoopDelay.max.toFixed(1)} ms`)
|
||||
return scenario
|
||||
}
|
||||
|
||||
// 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()
|
||||
const debuggerApi = win.webContents.debugger
|
||||
if (!debuggerApi.isAttached()) debuggerApi.attach('1.3')
|
||||
await debuggerApi.sendCommand('Profiler.enable')
|
||||
await debuggerApi.sendCommand('Profiler.setSamplingInterval', { interval: 200 })
|
||||
await debuggerApi.sendCommand('Profiler.start')
|
||||
try {
|
||||
return await body()
|
||||
} finally {
|
||||
const { profile } = await debuggerApi.sendCommand('Profiler.stop')
|
||||
writeFileSync(join(artifacts, `${name}.cpuprofile`), JSON.stringify(profile))
|
||||
report.profiles = [...(report.profiles ?? []), { scenario: name, hotspots: profileHotspots(profile) }]
|
||||
}
|
||||
}
|
||||
|
||||
// Self time per function and per script, without source text.
|
||||
function profileHotspots(profile) {
|
||||
const byId = new Map(profile.nodes.map(node => [node.id, node]))
|
||||
const self = new Map()
|
||||
const deltas = profile.timeDeltas
|
||||
for (let index = 0; index < profile.samples.length; index += 1) {
|
||||
const id = profile.samples[index]
|
||||
self.set(id, (self.get(id) ?? 0) + (deltas[index] ?? 0))
|
||||
}
|
||||
const functions = new Map()
|
||||
const scripts = new Map()
|
||||
let total = 0
|
||||
for (const [id, micros] of self) {
|
||||
const frame = byId.get(id).callFrame
|
||||
total += micros
|
||||
const file = frame.url ? frame.url.split('/').pop() : `(${frame.functionName || 'native'})`
|
||||
const key = `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}`
|
||||
functions.set(key, (functions.get(key) ?? 0) + micros)
|
||||
scripts.set(file, (scripts.get(file) ?? 0) + micros)
|
||||
}
|
||||
const top = (map, count) => [...map].sort((a, b) => b[1] - a[1]).slice(0, count).map(([key, micros]) => ({ key, ms: Math.round(micros / 1000), share: Math.round((micros / total) * 1000) / 10 }))
|
||||
return { totalMs: Math.round(total / 1000), functions: top(functions, 30), scripts: top(scripts, 10) }
|
||||
}
|
||||
|
||||
async function screenshot(name) {
|
||||
writeFileSync(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
|
||||
const composer = '.composer__input textarea'
|
||||
const sendButton = '.composer__submit-actions button.send-button:not(.send-button--stop)'
|
||||
const stopButton = 'button.send-button--stop'
|
||||
|
||||
async function dismissStartupDialogs({ expect = true } = {}) {
|
||||
// The fresh-profile release notes dialog opens after an async IPC read.
|
||||
if (expect) await waitFor("!!document.querySelector('section.release-notes-dialog')", 6_000, 'release notes').catch(() => {})
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const open = await js("!!document.querySelector('section.release-notes-dialog')")
|
||||
if (!open) return
|
||||
await clickSelector('.release-notes-dialog__header button.icon-button', { scroll: false })
|
||||
await waitFor("!document.querySelector('section.release-notes-dialog')", 5_000, 'release notes closed')
|
||||
}
|
||||
}
|
||||
|
||||
function message(role, index, createdAt) {
|
||||
const content = role === 'user'
|
||||
? `Question ${index}: how should the benchmark handle item ${index}?`
|
||||
: `## Answer ${index}\n\nThe benchmark answer for item ${index} contains **formatting**, \`inline code\`, and a list:\n\n- point one\n- point two\n\n\`\`\`ts\nconst value${index} = ${index}\n\`\`\`\n\nInline math $x_${index} = ${index}^2$.`
|
||||
return { id: randomUUID(), role, content, createdAt, state: 'complete' }
|
||||
}
|
||||
|
||||
function conversation(projectId, title, messageCount, updatedAt) {
|
||||
const messages = []
|
||||
for (let index = 0; index < messageCount; index += 1) {
|
||||
messages.push(message(index % 2 === 0 ? 'user' : 'assistant', index, updatedAt - (messageCount - index) * 1000))
|
||||
}
|
||||
return { header: { id: randomUUID(), projectId, title, updatedAt, knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, messages }
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const projectId = await js(`window.goodbuddy.projects.list(false).then(list => list.find(p => p.kind === 'user' && p.executionSpace.kind === 'local')?.id)`)
|
||||
if (!projectId) throw new Error('No local project to seed')
|
||||
const now = Date.now()
|
||||
const items = []
|
||||
for (let index = 0; index < config.seedConversations; index += 1) {
|
||||
items.push(conversation(projectId, `Perf conversation ${String(index + 1).padStart(4, '0')}`, config.seedMessagesPerConversation, now - (index + 1) * 60_000))
|
||||
}
|
||||
const long = conversation(projectId, 'Perf long conversation', config.longConversationMessages, now - 30_000)
|
||||
const started = performance.now()
|
||||
for (let offset = 0; offset < items.length; offset += 100) {
|
||||
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify(items.slice(offset, offset + 100))}).then(() => true)`)
|
||||
}
|
||||
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify([long])}).then(() => true)`)
|
||||
return { seedMs: performance.now() - started, titles: items.map(item => item.header.title), longTitle: long.header.title, longId: long.header.id, ids: items.map(item => item.header.id) }
|
||||
}
|
||||
|
||||
const rowSelectorByTitle = title => `[...document.querySelectorAll('button.conversation-item')].find(b => b.querySelector('.conversation-item__title')?.textContent.includes(${JSON.stringify(title)}))`
|
||||
|
||||
async function clickConversation(title) {
|
||||
await focusWindow()
|
||||
const point = await js(`(() => {
|
||||
const e = ${rowSelectorByTitle(title)};
|
||||
if (!e) return null;
|
||||
e.scrollIntoView({ block: 'center' });
|
||||
const r = e.getBoundingClientRect();
|
||||
const x = Math.round(r.x + Math.min(r.width / 2, 80)), y = Math.round(r.y + r.height / 2);
|
||||
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
|
||||
})()`)
|
||||
if (!point?.hit) throw new Error(`Conversation row not clickable: ${title}`)
|
||||
await settle()
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
|
||||
}
|
||||
|
||||
async function streamOnce({ typeDuring = 0 } = {}) {
|
||||
await clickSelector('button.new-chat', { scroll: false })
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)})`, 10_000, 'composer after new chat')
|
||||
await clickSelector(composer, { scroll: false })
|
||||
await typeText('perf-stream benchmark request')
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(sendButton)}) && !document.querySelector(${JSON.stringify(sendButton)}).disabled`, 15_000, 'send enabled')
|
||||
await clickSelector(sendButton, { scroll: false })
|
||||
const sendAt = performance.now()
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(stopButton)})`, 15_000, 'stream started')
|
||||
const startedMs = performance.now() - sendAt
|
||||
// Renderer samples cover only the streaming period, not prompt typing.
|
||||
await js('window.__gbPerf.start()')
|
||||
let typedDuringStream = 0
|
||||
if (typeDuring > 0) {
|
||||
await clickSelector(composer, { scroll: false })
|
||||
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(typeDuring / 26)).slice(0, typeDuring))
|
||||
typedDuringStream = typeDuring
|
||||
}
|
||||
const stillStreaming = await js(`!!document.querySelector(${JSON.stringify(stopButton)})`)
|
||||
await waitFor(`!document.querySelector(${JSON.stringify(stopButton)})`, 180_000, 'stream finished')
|
||||
const assistantChars = await js(`(() => { const list = document.querySelectorAll('.chat-history-pane[data-active=true] article.message--assistant'); return list.length ? list[list.length - 1].textContent.length : 0; })()`)
|
||||
return { fields: { streamStartMs: startedMs, streamTotalMs: performance.now() - sendAt, assistantRenderedChars: assistantChars, typedDuringStream, typingEndedBeforeStreamFinished: stillStreaming } }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scenarios
|
||||
|
||||
// Electron emits `ready` only after this ESM entry finishes evaluating, so
|
||||
// the scenario run must not be awaited at module top level.
|
||||
void app.whenReady().then(run)
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
for (let i = 0; i < 600 && !win; i += 1) await sleep(100)
|
||||
if (!win) throw new Error('Main window was not created')
|
||||
win.setContentSize(config.contentWidth, config.contentHeight)
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && !!document.querySelector('button.new-chat')`, 60_000, 'composer ready')
|
||||
report.startup.composerReadyMs = performance.now() - driverStart
|
||||
await dismissStartupDialogs()
|
||||
await js('document.fonts.ready.then(() => true)')
|
||||
const gpu = app.getGPUFeatureStatus()
|
||||
const [innerWidth, innerHeight, devicePixelRatio] = await js('[innerWidth, innerHeight, devicePixelRatio]')
|
||||
report.environment = {
|
||||
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 }
|
||||
}
|
||||
await sleep(1_500)
|
||||
|
||||
const idle = await measure('idle', 'Fresh profile, no interaction for 3 s', async () => { await sleep(3_000) })
|
||||
frameBaseline = idle.renderer.frames.p50 ?? 16.7
|
||||
|
||||
await measure('typing-small', `Type ${config.typedCharacters} characters into the composer (fresh profile)`, async () => {
|
||||
await clickSelector(composer, { scroll: false })
|
||||
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(config.typedCharacters / 26)).slice(0, config.typedCharacters))
|
||||
await settle()
|
||||
})
|
||||
// Clear the draft so later sends start from an empty composer.
|
||||
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; })()`)
|
||||
|
||||
report.pending = 'seed'
|
||||
const seeded = await seed()
|
||||
report.seed = { conversations: config.seedConversations, messagesPerConversation: config.seedMessagesPerConversation, longConversationMessages: config.longConversationMessages, seedMs: seeded.seedMs }
|
||||
|
||||
// Reload so the App loads the seeded history through its normal startup path.
|
||||
const reloadStart = performance.now()
|
||||
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 dismissStartupDialogs({ expect: false })
|
||||
await sleep(1_500)
|
||||
report.seed.sidebarRows = await js("document.querySelectorAll('button.conversation-item').length")
|
||||
await screenshot('seeded')
|
||||
|
||||
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 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 () => {
|
||||
const samples = []
|
||||
for (let index = 0; index < config.switches; index += 1) {
|
||||
const target = index * 3 + 1
|
||||
await clickConversation(seeded.titles[target])
|
||||
samples.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
|
||||
await sleep(250)
|
||||
}
|
||||
return { latency: summarize(samples), fields: { switchSamplesMs: samples } }
|
||||
})
|
||||
|
||||
await measure('switch-conversations-warm', 'Revisit the same conversations (keep-alive cache warm)', async () => {
|
||||
const samples = []
|
||||
for (let index = 0; index < Math.min(config.switches, 10); index += 1) {
|
||||
const target = index * 3 + 1
|
||||
await clickConversation(seeded.titles[target])
|
||||
samples.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
|
||||
await sleep(250)
|
||||
}
|
||||
return { latency: summarize(samples), fields: { switchSamplesMs: samples } }
|
||||
})
|
||||
|
||||
await measure('open-long-conversation', `Open a conversation with ${config.longConversationMessages} messages`, async () => {
|
||||
await clickConversation(seeded.longTitle)
|
||||
const openMs = await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.longId)})`)
|
||||
await sleep(1_000)
|
||||
const renderedMessages = await js("document.querySelectorAll('.chat-history-pane[data-active=true] article.message').length")
|
||||
return { latency: { count: 1, p50: openMs, p95: openMs, max: openMs }, fields: { openMs, renderedMessages } }
|
||||
})
|
||||
|
||||
await measure('scroll-long-conversation', 'Wheel-scroll up through the long conversation for 3 s', async () => {
|
||||
const point = await js(`(() => { const e = document.querySelector('.chat-history-pane[data-active=true] #chat-message-list') || document.querySelector('.chat-history-pane[data-active=true]'); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`)
|
||||
const scroller = `(() => { const p = document.querySelector('.chat-history-pane[data-active=true]'); const c = p && [p, ...p.querySelectorAll('*')].find(e => e.scrollHeight > e.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)); return c ? c.scrollTop : null; })()`;
|
||||
const before = await js(scroller)
|
||||
const end = performance.now() + 3_000
|
||||
while (performance.now() < end) {
|
||||
win.webContents.sendInputEvent({ type: 'mouseWheel', x: point.x, y: point.y, deltaX: 0, deltaY: 120 })
|
||||
await sleep(16)
|
||||
}
|
||||
await sleep(300)
|
||||
const after = await js(scroller)
|
||||
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } }
|
||||
})
|
||||
|
||||
await screenshot('long-conversation')
|
||||
|
||||
await measure('stream-seeded', 'New conversation, stream a long Markdown answer from the loopback model (seeded history)', () => withRendererProfile('stream-seeded', () => streamOnce()))
|
||||
await screenshot('streamed')
|
||||
|
||||
await measure('stream-and-type', 'Stream a long answer while typing into the composer; latency and frames cover the typing period', () => streamOnce({ typeDuring: 100 }))
|
||||
|
||||
report.status = errors.length ? 'passed-with-renderer-errors' : 'passed'
|
||||
report.pending = null
|
||||
report.startup.driverTotalMs = performance.now() - driverStart
|
||||
writeReport()
|
||||
clearTimeout(deadline)
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
report.status = 'failed'
|
||||
report.error = error instanceof Error ? error.stack : String(error)
|
||||
try { await screenshot('failure') } catch { /* window may be gone */ }
|
||||
writeReport()
|
||||
clearTimeout(deadline)
|
||||
app.exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user