hermes-agent/apps/desktop/scripts/perf/run.mjs
brooklyn! b6ae910d8c
bench(desktop): trustworthy cold-start measurement (code-splitting is not the lever) (#67720)
* bench(desktop): measure the full picture — prod build, cold-start, first-token

Stop drip-feeding scenarios: extend the harness to cover the latencies that
actually dominate perceived speed, and measure them on a REAL production build.

- --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1,
  off in normal builds) and launch it from dist/. Measures minified React, so
  numbers are representative shipped figures instead of ~3x-inflated dev ones.
- cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a
  fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms.
- first-token scenario (backend tier): Enter → first assistant token painted —
  the TTFT latency an agent app is uniquely judged on.
- run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates
  ci+cold tiers against the baseline.

Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all
green. Representative numbers:
  cold-start  spawn→interactive ~1.6s, FCP ~0.5s
  stream      frame p95 22ms, 1 longtask
  keystroke   p50 2ms, p95 8.7ms
  transcript  mount 145ms, 82ms longtask (400-msg open)

The prod build also settled the open question from the dev numbers: the
transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not
actionable. Measurement did its job.

* bench(desktop): trustworthy cold-start measurement (code-splitting is NOT the lever)

Investigated code-splitting the ~22MB renderer bundle to cut cold start. It is
the wrong fix on both counts:

1. Intentional design: vite.config disables codeSplitting because Shiki emits
   thousands of dynamic chunks and electron-builder OOMs scanning them — a
   packaging/installer constraint, not an oversight.
2. The data says it wouldn't help. Fixing the cold-start measurement to be
   trustworthy and reading the boot composition (prod build):
     spawn → interactive ~1.5s
     renderer nav → DOMInteractive ~0.8s, → DOMContentLoaded ~1.06s
   so the whole 22MB bundle EVAL is only ~0.27s (DCL − DOMInteractive) of the
   ~1.5s. The dominant costs are Electron/window startup and React app mount —
   neither touched by splitting.

The measurement fixes (the real content of this PR — no app change, since the
optimization was rejected):
- Drop HERMES_DESKTOP_BOOT_FAKE from spawned instances — it injected artificial
  per-phase boot-overlay sleeps that inflated cold-start (and slowed every run).
- Unique debug/dev port per cold-start run — a just-killed instance can hold
  :9222 briefly, so reusing it made CDP attach to the DYING instance and report
  garbage (spawn_to_cdp of ~4ms). Stepping the port per run fixes the race.
- Richer boot marks (dom_interactive, dom_content_loaded, main-script size) so
  cold-start composition is visible, not just a single number.
- Forward all numeric boot marks from the cold-start loop.
- Re-baseline cold-start with the clean numbers.

A real cold-start win would target Electron startup / app-mount (e.g. V8 code
cache, deferred non-critical mount) — a future pass, now that it's measurable.
2026-07-19 18:13:04 -05:00

230 lines
8.2 KiB
JavaScript

// Desktop perf harness entrypoint.
//
// node scripts/perf/run.mjs [scenarios...] [flags]
//
// Default (no scenarios): runs the CI suite (stream, keystroke, transcript)
// against a renderer on :9222 and diffs the committed baseline.
//
// Flags:
// --spawn launch a fully isolated instance (own user-data-dir +
// HERMES_HOME + debug port) instead of attaching
// --port <n> CDP port to attach to (default 9222)
// --dev-port <n> vite dev-server port to match / spawn (default 5174)
// --runs <n> repeat each scenario n times, report the median (default 1)
// --cpuprofile [dir] also record a V8 CPU profile per scenario (top-30 self time)
// --update-baseline overwrite baseline.json with this run's numbers
// --json <path> write the full results JSON here
// --tier <ci|backend> run all scenarios of a tier
// ...scenario opts e.g. --tokens 600, --turns 400, --real, --a <sid> --b <sid>, --profile <name>
//
// Examples:
// npm run perf # attach to :9222, run CI suite, gate on baseline
// npm run perf -- --spawn # isolated instance, no running app needed
// npm run perf -- stream --cpuprofile --tokens 800
// npm run perf -- --update-baseline
import { writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { withCpuProfile } from './lib/cdp.mjs'
import { compareScenario, loadBaseline, updateBaseline } from './lib/baseline.mjs'
import { attach, buildProdRenderer, startIsolatedInstance } from './lib/launch.mjs'
import { cpuProfileTopSelf, median } from './lib/stats.mjs'
import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const BASELINE_PATH = join(HERE, 'baseline.json')
function parseArgs(argv) {
const positional = []
const flags = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg.startsWith('--')) {
const [key, inlineValue] = arg.slice(2).split(/=(.*)/s)
const next = argv[i + 1]
if (inlineValue !== undefined) {
flags[key] = inlineValue
} else if (next === undefined || next.startsWith('--')) {
flags[key] = true
} else {
flags[key] = next
i++
}
} else {
positional.push(arg)
}
}
return { positional, flags }
}
function medianMetrics(runs) {
const keys = new Set(runs.flatMap(r => Object.keys(r)))
const out = {}
for (const key of keys) {
const values = runs.map(r => r[key]).filter(v => typeof v === 'number')
out[key] = values.length ? Math.round(median(values) * 10) / 10 : runs[0][key]
}
return out
}
function printMetrics(name, metrics, comparison) {
console.log(`\n${name}`)
const byMetric = new Map((comparison?.rows ?? []).map(r => [r.metric, r]))
for (const [metric, value] of Object.entries(metrics)) {
const row = byMetric.get(metric)
if (!row || row.baseline === null) {
console.log(` ${metric.padEnd(26)} ${String(value).padStart(9)}${row ? ' (new)' : ''}`)
} else {
const tag = row.status === 'REGRESSED' ? ' ✗ REGRESSED' : ' ✓'
const delta = row.deltaPct === null ? '' : ` (${row.deltaPct > 0 ? '+' : ''}${row.deltaPct}%)`
console.log(
` ${metric.padEnd(26)} ${String(value).padStart(9)} vs ${String(row.baseline).padStart(9)}${delta}${tag}`
)
}
}
}
async function main() {
const { positional, flags } = parseArgs(process.argv.slice(2))
let names = positional
if (!names.length) {
names = flags.tier ? Object.values(SCENARIOS).filter(s => s.tier === flags.tier).map(s => s.name) : CI_SCENARIOS
}
const unknown = names.filter(n => !SCENARIOS[n])
if (unknown.length) {
console.error(`unknown scenario(s): ${unknown.join(', ')}\nknown: ${Object.keys(SCENARIOS).join(', ')}`)
process.exit(2)
}
const runs = Number(flags.runs ?? 1)
const port = Number(flags.port ?? 9222)
const devPort = Number(flags['dev-port'] ?? 5174)
const prod = 'prod' in flags
const cpuProfile = 'cpuprofile' in flags
const cpuProfileDir = typeof flags.cpuprofile === 'string' ? flags.cpuprofile : HERE
const coldNames = names.filter(n => SCENARIOS[n].tier === 'cold')
const liveNames = names.filter(n => SCENARIOS[n].tier !== 'cold')
// ci + cold metrics are stable enough to gate against the baseline; backend
// scenarios vary too much with the live environment, so they're report-only.
const GATED = new Set(['ci', 'cold'])
const baseline = loadBaseline(BASELINE_PATH)
const results = []
let regressed = false
const record = (name, tier, metrics, detail) => {
const comparison = GATED.has(tier) ? compareScenario(name, metrics, baseline) : null
regressed = regressed || Boolean(comparison?.regressed)
results.push({ name, tier, metrics, detail })
printMetrics(name, metrics, comparison)
}
if (prod) {
if (!flags.spawn) {
console.error('--prod requires --spawn (it builds and launches an isolated production renderer)')
process.exit(2)
}
console.log('[perf] building production renderer with the probe (VITE_PERF_PROBE=1)…')
await buildProdRenderer()
}
// Cold start measures the launch itself → a fresh spawn per run.
if (coldNames.length) {
if (!flags.spawn) {
console.error('cold-start requires --spawn (it measures a fresh launch)')
process.exit(2)
}
const perRun = []
for (let i = 0; i < runs; i++) {
// Unique debug + dev port per run: a just-killed instance can keep :9222
// held for a beat, and reusing it makes the next CDP.connect attach to the
// dying instance (garbage timings). Stepping the port sidesteps the race.
const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true })
// Forward every numeric boot mark; only the baseline keys are gated, the
// rest (dom_interactive, main_script_kb, …) are reported for composition.
perRun.push(Object.fromEntries(Object.entries(inst.timings).filter(([, v]) => typeof v === 'number')))
inst.teardown()
}
record('cold-start', 'cold', medianMetrics(perRun), { runs })
}
// Steady-state scenarios share one persistent connection.
if (liveNames.length) {
const connection = flags.spawn
? await startIsolatedInstance({ port, devPort, prod })
: await attach({ port, match: prod ? undefined : String(devPort) })
const { cdp, teardown } = connection
try {
for (const name of liveNames) {
const scenario = SCENARIOS[name]
const perRun = []
let detail = null
for (let i = 0; i < runs; i++) {
if (cpuProfile && i === 0) {
const { result, profile } = await withCpuProfile(cdp, () => scenario.run(cdp, flags))
const out = join(cpuProfileDir, `${name}-${Date.now()}.cpuprofile`)
writeFileSync(out, JSON.stringify(profile))
console.log(`\n[cpuprofile] wrote ${out}`)
console.log('[cpuprofile] top self-time (ms):')
for (const r of cpuProfileTopSelf(profile, 15)) {
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(38)} ${r.url}:${r.line}`)
}
perRun.push(result.metrics)
detail = result.detail
} else {
const result = await scenario.run(cdp, flags)
perRun.push(result.metrics)
detail = result.detail
}
}
record(name, scenario.tier, medianMetrics(perRun), detail)
}
} finally {
teardown()
}
}
if (flags.json) {
writeFileSync(resolve(String(flags.json)), `${JSON.stringify({ timestamp: new Date().toISOString(), results }, null, 2)}\n`)
console.log(`\nwrote ${flags.json}`)
}
if (flags['update-baseline']) {
updateBaseline(BASELINE_PATH, results.filter(r => GATED.has(r.tier)))
console.log(`\nupdated ${BASELINE_PATH}`)
return
}
if (regressed) {
console.error('\n✗ perf regression vs baseline (see REGRESSED rows above)')
process.exit(1)
}
console.log('\n✓ no perf regressions')
}
main().catch(err => {
console.error('\nperf harness failed:', err.stack ?? err.message)
process.exit(1)
})