bench(desktop): measure representative (warm-cache) cold start (#67733)

Profiling the boot answered "is there a real cold-start win?": no wasteful
hotspot — the renderer does only ~tens of ms of work at mount, no heavy library
(shiki/mermaid/katex/d3/motion) initializes at startup; the rest is Electron
runtime + waiting, near the Electron floor.

It also exposed that the cold-start number was pessimistic: a fresh
--user-data-dir per run means a COLD V8 code cache and worst-case bundle
recompile every launch. Real users reuse their profile. Measured delta:
  fresh (cold cache):  spawn→interactive ~1.48s
  reused (warm cache): ~1.0s
So representative launch is ~1.0s; only first-launch-after-install pays ~+400ms.

- coldStartSamples() reuses one profile (run 0 warms the cache, discarded;
  runs 1..N are warm samples), stepping ports + pausing so the single-instance
  lock releases. `--cold-fresh` measures the first-launch worst case.
- Re-baselined cold-start with the representative warm numbers.

Net: nothing high-ROI left to optimize. The only lever is shipping a pre-warmed
V8 code cache to make first launch match warm (~400ms, once per update) — real
packaging complexity for a marginal win, deliberately not pursued.
This commit is contained in:
brooklyn! 2026-07-19 19:21:45 -04:00 committed by GitHub
parent b6ae910d8c
commit 8142331616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 71 additions and 21 deletions

View file

@ -1,9 +1,9 @@
{
"_meta": {
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start uses a fresh unique-port spawn per run and its marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances are loose for cross-machine/disk variance.",
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
"platform": "darwin-arm64",
"node": "v24.11.0",
"updated": "2026-07-19T22:59:21.605Z"
"updated": "2026-07-19T23:16:01.227Z"
},
"scenarios": {
"stream": {
@ -49,11 +49,11 @@
"tolAbs": 150
},
"metrics": {
"spawn_to_cdp_ms": 1098,
"spawn_to_driver_ms": 1482,
"dom_interactive_ms": 794,
"dom_content_loaded_ms": 1057,
"nav_to_read_ms": 1209
"spawn_to_cdp_ms": 606,
"spawn_to_driver_ms": 984,
"dom_interactive_ms": 324,
"dom_content_loaded_ms": 574,
"nav_to_read_ms": 721
}
}
}

View file

@ -326,6 +326,65 @@ export async function startIsolatedInstance({
}
}
// Representative cold-start sampling. A fresh --user-data-dir means a COLD V8
// code cache and worst-case bundle recompile every run (~+400ms measured); real
// users reuse their profile, so a warm cache is the representative case. We reuse
// ONE profile across runs: run 0 warms the cache (discarded), runs 1..N are the
// warm samples. Each run steps the port so a just-killed instance can't be
// re-attached, and we pause between runs so the single-instance lock releases.
export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, prod = false, warm = true } = {}) {
const pickNumeric = timings => Object.fromEntries(Object.entries(timings).filter(([, v]) => typeof v === 'number'))
const samples = []
if (warm) {
// Shared profile across runs: run 0 warms the V8 code cache (discarded),
// runs 1..N are the representative warm samples.
const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-'))
const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-'))
seedConfigFrom(join(homedir(), '.hermes'), home)
try {
for (let i = 0; i <= runs; i++) {
const inst = await startIsolatedInstance({
port: port + i,
devPort: devPort + i,
prod,
coldStart: true,
hermesHome: home,
userDataDir,
seedConfig: false
})
if (i > 0) {
samples.push(pickNumeric(inst.timings))
}
inst.teardown()
await sleep(2500) // let the single-instance lock release before reuse
}
} finally {
for (const dir of [home, userDataDir]) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// best-effort
}
}
}
} else {
// Worst case: a fresh profile per run → cold code cache every launch
// (first-launch-after-install). startIsolatedInstance makes+removes its dirs.
for (let i = 0; i < runs; i++) {
const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true })
samples.push(pickNumeric(inst.timings))
inst.teardown()
await sleep(2500)
}
}
return samples
}
// Read First Contentful Paint + time-to-composer from the renderer, relative to
// its navigation start (the process-spawn deltas live in `timings`).
async function readBootMarks(cdp) {

View file

@ -29,7 +29,7 @@ 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 { attach, buildProdRenderer, coldStartSamples, startIsolatedInstance } from './lib/launch.mjs'
import { cpuProfileTopSelf, median } from './lib/stats.mjs'
import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs'
@ -149,20 +149,11 @@ async function main() {
process.exit(2)
}
const perRun = []
// Representative WARM-cache samples (see coldStartSamples). Pass --cold-fresh
// to instead measure the worst-case first-launch (cold code cache).
const perRun = await coldStartSamples({ runs, port, devPort, prod, warm: !('cold-fresh' in flags) })
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 })
record('cold-start', 'cold', medianMetrics(perRun), { runs, warm: !('cold-fresh' in flags) })
}
// Steady-state scenarios share one persistent connection.