hermes-agent/apps/desktop/scripts/diag-switch-trace.mjs
Brooklyn Nicholson 2c867b05ce perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins
Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:

  style recalc 2736ms | script 1027ms | layout 89ms
  top callsite: pin @ fallback.tsx — 927ms

Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).

Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.

Measured on the live app, same drag, before -> after:
  fps      11.5 -> 59-60
  p95      101ms -> 18ms
  slow>33  60/60 -> 1/60

Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
  boundaries + row identity) and WEIGHT (part counts — budget only), and
  memoize groups + row JSX. A streamed part-append re-rendered every
  turn's boundary via its resetKey prop; explain() measured 540-865
  wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
  ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
  measured cost of the last one (33ms floor, 250ms cap), so multi-stream
  load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
  wrappers, committing the store ONCE on release (fixed-zone sides get
  flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
  with changed-hook indices, diag-real-loop/key-latency/switch-trace
  probes that drive the real app over CDP.

Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
2026-07-27 01:07:48 -05:00

74 lines
2.4 KiB
JavaScript

// What happens during a SLOW session switch? Click a heavy row with tracing
// on, dump the style/layout/script split plus top callsites.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const CLICK_HEAVIEST = `
(() => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return 'need rows'
// Alternate between the first two rows so every run actually switches.
const current = location.hash
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
target.click()
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
})()
`
const events = []
let complete = false
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
try {
await cdp.send('Runtime.enable')
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline'] }
})
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
await cdp.send('Tracing.end')
for (let w = 0; !complete && w < 10000; w += 200) {
await sleep(200)
}
const totals = new Map()
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
if (e.name === 'FunctionCall') {
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
console.log('\nTOP CALLSITES:')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}