hermes-agent/apps/desktop/scripts/diag-real-loop.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

149 lines
6.5 KiB
JavaScript

// The real-app perf loop: drive HER hgui instance (real profile, real
// sessions) through the three interactions that matter — session switch,
// sidebar drag, composer typing — and report honest single-clock numbers.
//
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
//
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
// switching is measured as the user feels it: click -> transcript painted.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const SWITCHES = Number(arg('switches', 6))
const { cdp, teardown } = await attach({ port })
// ---------------------------------------------------------------------------
// Session switch: click a sidebar session row, await the transcript settling.
// Measures click -> first paint of the new transcript AND click -> settled
// (two rAFs with no further DOM mutation in the thread viewport).
// ---------------------------------------------------------------------------
const SWITCH = swaps => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
const results = []
for (let i = 0; i < ${swaps}; i++) {
const row = rows[i % Math.min(rows.length, 4)]
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
const t0 = performance.now()
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
row.click()
// First paint: next two rAFs after the click.
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
const firstPaint = performance.now() - t0
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
let lastMutation = performance.now()
const target = viewport() ?? document.body
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(target, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 120) break
}
mo.disconnect()
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
await new Promise(r => setTimeout(r, 250))
}
return JSON.stringify(results)
})()
`
// ---------------------------------------------------------------------------
// Drag the first visible sash, single-clock frames.
// ---------------------------------------------------------------------------
const DRAG = `
(async () => {
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
if (!handle) return JSON.stringify({ error: 'no sash' })
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const frames = []
let last = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 2 : -2)
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
}
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length
})
})()
`
// ---------------------------------------------------------------------------
// Type into the composer, single-clock frames (one mark per keystroke frame).
// ---------------------------------------------------------------------------
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const frames = []
let last = performance.now()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
await new Promise(r => setTimeout(r, 20))
}
// Clear what we typed.
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const moving = frames
const total = moving.reduce((a, b) => a + b, 0)
const sorted = [...moving].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: moving.filter(f => f > 33).length
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
console.log(await cdp.eval(SWITCH(SWITCHES)))
await sleep(500)
console.log('\n== SIDEBAR DRAG ==')
console.log(await cdp.eval(DRAG))
await sleep(500)
console.log('\n== COMPOSER TYPING ==')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}