hermes-agent/apps/desktop/scripts/profile-under-stream.mjs
Brooklyn Nicholson 88e7d7537c perf(desktop): cut FadeText forced layouts during streaming
The slowest user-felt path is typing into the composer while the
assistant is streaming. Profile (scripts/profile-under-stream.mjs):

  FadeText measureOverflow self time:  35.8 ms → 18.1 ms  (-50%)
  total active CPU during 7s window:   ~150 ms → ~50 ms

Two changes in src/components/ui/fade-text.tsx:

1. Drop the `useEffect([children])` that re-ran `measureOverflow`
   (reads scrollWidth + clientWidth — forced layout) on every parent
   re-render. `useResizeObserver` already fires the same callback on
   mount and whenever the host span's box size changes; that covers
   the only case where overflow state can legitimately change. The
   previous explicit useEffect was a forced-layout flush on every
   parent render, which during streaming meant every token tick.

2. Wrap the component in `memo` with a custom comparator that
   short-circuits the entire render when scalar string `children` and
   the className/fadeWidth/style props are unchanged. The hot path
   was tool-fallback's title chips being re-rendered by parent
   streaming updates even though their text was stable; memo+
   comparator skips that.

Also adds two harness scripts under apps/desktop/scripts/:
  - latency-under-stream.mjs (key→paint latency while a turn streams)
  - profile-under-stream.mjs (CPU profile while a turn streams)

Updates profile-typing-lag.md with the streaming numbers and confirms
the Enter→paint submit path is already fast (≤320ms on the populated
session; the 2s "stall after Enter" the user noticed once was a
one-time cold-start, not reproducible at the UI layer).

I'd guess the felt jank in real use is fast-burst typing during a
long-form streaming reply (code blocks + markdown lists multiply the
per-token render cost). The CPU savings here scale linearly with
token volume.
2026-05-21 16:09:44 -05:00

170 lines
6 KiB
JavaScript

#!/usr/bin/env node
// Capture a CPU profile while the assistant is streaming AND the user is
// typing into the composer. This is the scenario most likely to feel laggy
// in real use: follow-up typing while a prior turn is still streaming in.
//
// Output: /tmp/hermes-stream-type.cpuprofile
import { writeFileSync } from 'node:fs'
const args = Object.fromEntries(
process.argv.slice(2).flatMap(s => {
const m = s.match(/^--([^=]+)(?:=(.*))?$/)
return m ? [[m[1], m[2] ?? true]] : []
})
)
const PORT = Number(args.port ?? 9222)
const OUT = String(args.out ?? `/tmp/hermes-stream-type-${Date.now()}`)
const CHARS = Number(args.chars ?? 100)
const CPS = Number(args.cps ?? 20)
async function pickRenderer() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
return list.find(t => t.type === 'page' && t.url.startsWith('http'))
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
let id = 0
const pending = new Map()
ws.addEventListener('open', () =>
resolve({
send(method, params = {}) {
const myId = ++id
ws.send(JSON.stringify({ id: myId, method, params }))
return new Promise((res, rej) => pending.set(myId, { res, rej }))
},
close: () => ws.close()
})
)
ws.addEventListener('error', reject)
ws.addEventListener('message', ev => {
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
if (m.id != null) {
const p = pending.get(m.id)
if (!p) return
pending.delete(m.id)
m.error ? p.rej(new Error(m.error.message)) : p.res(m.result)
}
})
})
}
async function evalP(cdp, expr) {
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
return r.result.value
}
async function main() {
const tgt = await pickRenderer()
console.log('target', tgt.url)
const cdp = await connect(tgt.webSocketDebuggerUrl)
await cdp.send('Runtime.enable')
await cdp.send('Profiler.enable')
// Submit a meaty prompt
await evalP(
cdp,
`(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
el.focus()
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
})()`
)
const prompt = 'explain GPU memory bandwidth and the roofline model in detail with at least 6 paragraphs, no code'
for (const c of prompt) {
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
await new Promise(r => setTimeout(r, 6))
}
await new Promise(r => setTimeout(r, 200))
await cdp.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
})
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
// Wait for stream to begin
console.log('waiting for assistant…')
let streaming = false
for (let i = 0; i < 100; i++) {
const c = await evalP(
cdp,
`document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length`
)
if (c > 0) { streaming = true; break }
await new Promise(r => setTimeout(r, 100))
}
if (!streaming) {
console.error('no assistant message appeared')
cdp.close()
return
}
// Wait for stream to produce some tokens
await new Promise(r => setTimeout(r, 800))
// Refocus, start profiler, type while streaming
await evalP(
cdp,
`(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
el.focus()
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
})()`
)
await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
await cdp.send('Profiler.start')
const text = 'follow-up typing during streaming feels laggy when tokens flood in '.repeat(4).slice(0, CHARS)
const intervalMs = Math.max(1, Math.round(1000 / CPS))
const t0 = Date.now()
for (let i = 0; i < text.length; i++) {
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] })
const expected = t0 + (i + 1) * intervalMs
const wait = expected - Date.now()
if (wait > 0) await new Promise(r => setTimeout(r, wait))
}
await new Promise(r => setTimeout(r, 500))
const { profile } = await cdp.send('Profiler.stop')
writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile))
console.log(`cpuprofile → ${OUT}.cpuprofile`)
// Quick top-self summary
const total = (profile.endTime - profile.startTime) / 1000
const intMs = total / Math.max(1, profile.samples?.length ?? 1)
const counts = new Map()
for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1)
const rows = profile.nodes
.map(n => ({ id: n.id, fn: n.callFrame.functionName || '(anon)', url: n.callFrame.url || '', line: n.callFrame.lineNumber, self: counts.get(n.id) ?? 0 }))
.sort((a, b) => b.self - a.self)
.slice(0, 25)
console.log(`\n=== ${total.toFixed(0)}ms wall, ${profile.samples?.length ?? 0} samples (${intMs.toFixed(2)}ms each) ===`)
for (const r of rows) {
if (r.self === 0) break
const url = r.url.replace(/^.*\/src\//, 'src/').replace(/\?.*$/, '').slice(0, 70)
console.log(` ${(r.self * intMs).toFixed(1).padStart(7)}ms (${String(r.self).padStart(4)} samp) ${r.fn.padEnd(45)} ${url}:${r.line}`)
}
// Cancel stream
await evalP(
cdp,
`(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' }
}
return 'no-stop'
})()`
).then(r => console.log('cancel:', r))
cdp.close()
}
main().catch(e => {
console.error('fatal:', e.stack ?? e.message)
process.exit(1)
})