Merge pull request #72245 from NousResearch/bb/desktop-idle-churn

perf(desktop): stop the whole transcript re-rendering on sash drag
This commit is contained in:
brooklyn! 2026-07-26 18:36:00 -05:00 committed by GitHub
commit 2ec84c5d25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 475 additions and 32 deletions

View file

@ -0,0 +1,158 @@
// Who re-renders the transcript during a sash drag?
//
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
// recording (a) render attribution and (b) every nanostores atom that notifies
// during the gesture. The idle-cost scenario proved the transcript re-renders
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
// (identical counts at 0px and 60px displacement) — so this names the store
// that actually fires.
//
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const TILES = 5
const TURNS = 20
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
])
window.__D__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'diag-tile-' + n
const rid = 'diag-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__D__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Drag, recording renders AND atom notifications together.
const DRAG = `
(async () => {
const rc = window.__RENDER_COUNTS__
const ac = window.__ATOM_CHURN__
rc.start(); ac.start()
const handle = document.querySelector('[role="separator"]')
if (!handle) { rc.stop(); ac.stop(); 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 opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
rc.stop(); ac.stop()
const all = rc.report(400)
const named = n => all.find(r => r.name === n) || null
return JSON.stringify({
moved: Math.round(x - x0),
commits: rc.commits(),
renders: rc.report(14),
// The suspects: who at the TOP of the transcript tree re-rendered?
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
atoms: ac.report(30)
})
})()
`
const CLEANUP = `
(() => {
if (window.__D__) {
for (const { sid, rid } of window.__D__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__D__ = null
}
return 'cleaned'
})()
`
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`diag-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const data = JSON.parse(await cdp.eval(DRAG))
await cdp.eval(CLEANUP)
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
console.log('RENDERS during drag:')
for (const r of data.renders) {
console.log(
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
)
}
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
for (const { name, hit } of data.chain) {
console.log(
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
)
}
console.log('\nATOMS that notified during drag:')
for (const a of data.atoms) {
console.log(
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
)
}
} finally {
teardown?.()
}

View file

@ -0,0 +1,264 @@
// What does the app cost while a turn is running but NOTHING is arriving?
//
// The user-visible symptom: with a thread spinning, resizing the sidebar or
// typing in the composer feels slow. That is not streaming cost — the stream
// is idle. It is the app re-rendering on its own, competing with the
// interaction for the main thread.
//
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
// - idle_commits_per_s the renderer's self-inflicted commit rate
// - drag_fps fps while dragging the sidebar splitter
// - type_fps fps while typing in the composer
//
// A perfectly idle app scores 0 idle commits and pins both interactions at the
// display's refresh rate. Every idle commit is main-thread time stolen from an
// interaction the user can feel.
//
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
import { sleep } from '../lib/cdp.mjs'
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
* `render-churn`, but the driver never runs the turn just stays open. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
])
window.__IDLE__ = { ids: [] }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'idle-tile-' + n
const rid = 'idle-rt-' + n
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
// An OPEN assistant message: the turn is running, but no tokens arrive.
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working on it.' }] })
window.__IDLE__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Measure the app's self-inflicted commit rate with nothing happening. */
const idleCost = seconds => `
(async () => {
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
await new Promise(r => setTimeout(r, ${seconds} * 1000))
const elapsed = (performance.now() - t0) / 1000
rc.stop()
return JSON.stringify({
elapsed,
commits: rc.commits(),
top: rc.report(12),
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
})
})()
`
/** Record frame pacing across an interaction driven from the page.
*
* `record` MUST be false for any fps number you intend to believe: the render
* counter walks the whole fiber tree on every commit, so recording during a
* gesture measures the instrumentation as much as the app. Attribution and
* timing therefore run as two separate passes the observer effect here is
* large enough to hide a 15x render reduction behind an unchanged fps. */
const withFrames = (body, record = false) => `
(async () => {
const rc = window.__RENDER_COUNTS__
${record ? 'rc.start()' : ''}
const frames = []
let last = performance.now()
let stop = false
const tick = () => {
if (stop) return
const now = performance.now()
frames.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
${body}
stop = true
${record ? 'rc.stop()' : ''}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
return JSON.stringify({
fps: total ? (frames.length / total) * 1000 : 0,
p95: pct(0.95),
worst: sorted.length ? sorted[sorted.length - 1] : 0,
slow33: frames.filter(f => f > 33).length,
n: frames.length,
commits: ${record ? 'rc.commits()' : '0'},
top: ${record ? 'rc.report(10)' : '[]'}
})
})()
`
/** Drag the sidebar splitter the resize symptom.
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
* and reports how far it actually moved so a drag that silently did nothing
* shows up as `dragMoved: 0` instead of a confident wrong number. */
const DRAG = withFrames(`
const handle = document.querySelector('[role="separator"]')
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
window.__DRAG_MOVED__ = 0
if (handle) {
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
// Out 60px then back — a real gesture, with a net displacement at the peak.
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.__DRAG_MOVED__ = Math.round(x - x0)
for (let i = 0; i < 30; i++) {
x -= 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
/** Type into the composer — the keystroke symptom. */
const TYPE = withFrames(`
const el = document.querySelector('[contenteditable="true"], textarea')
if (el) {
el.focus()
for (let i = 0; i < 40; i++) {
const ch = 'performance testing '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
if (el.tagName === 'TEXTAREA') {
el.value += ch
el.dispatchEvent(new Event('input', { bubbles: true }))
} else {
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 => setTimeout(r, 25))
}
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
const CLEANUP = `
(() => {
if (window.__IDLE__) {
for (const { sid, rid } of window.__IDLE__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__IDLE__ = null
}
window.__RENDER_COUNTS__.clear()
return 'cleaned'
})()
`
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
export default {
name: 'idle-cost',
tier: 'ci',
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const seconds = Number(opts.seconds ?? 6)
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
}
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`idle-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
const drag = JSON.parse(await cdp.eval(DRAG))
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
const type = JSON.parse(await cdp.eval(TYPE))
await cdp.eval(CLEANUP)
if (dragTarget === 'none') {
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
}
return {
metrics: {
// Commits per second with a turn open and nothing arriving. Should be 0.
idle_commits_per_s: round(idle.commits / idle.elapsed),
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
// Interaction smoothness while that churn competes for the main thread.
// Reported as a deficit from 60fps so "lower is better" matches the
// baseline gate's direction.
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
drag_slow_frames: drag.slow33,
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
type_slow_frames: type.slow33
},
detail: {
tiles,
dragTarget,
dragMoved,
idleSeconds: round(idle.elapsed),
dragFps: round(drag.fps),
dragP95: round(drag.p95),
dragWorst: round(drag.worst),
typeFps: round(type.fps),
typeP95: round(type.p95),
typeWorst: round(type.worst),
// Components whose OWN state changed with no prop change: the roots.
idleOwners: idle.owners,
idleTop: idle.top,
dragCommits: drag.commits,
dragTop: drag.top,
typeCommits: type.commits,
typeTop: type.top
}
}
}
}

View file

@ -3,6 +3,7 @@
import coldStart from './cold-start.mjs'
import firstToken from './first-token.mjs'
import idleCost from './idle-cost.mjs'
import keystroke from './keystroke.mjs'
import multitab from './multitab.mjs'
import profileSwitch from './profile-switch.mjs'
@ -20,6 +21,7 @@ export const SCENARIOS = {
[transcript.name]: transcript,
[multitab.name]: multitab,
[renderChurn.name]: renderChurn,
[idleCost.name]: idleCost,
[coldStart.name]: coldStart,
[firstToken.name]: firstToken,
[submit.name]: submit,

View file

@ -89,7 +89,11 @@ function ZoneMenu({
/** False for the zone hosting the uncloseable workspace collapsing the
* MAIN pane strands the app behind a strip. */
minimizable?: boolean
directions: ZoneMenuDirection[]
/** Called when the menu renders, not on every zone re-render: resolving the
* neighbor zones has to read the layout tree, and subscribing every zone to
* it made a sash drag re-render every mounted pane. Same lazy shape as
* `closable`. */
directions: () => ZoneMenuDirection[]
headerHidden?: boolean
minimized?: boolean
nodeId: string
@ -100,7 +104,7 @@ function ZoneMenu({
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{directions.map(direction => (
{directions().map(direction => (
<ContextMenuItem key={direction.side} onSelect={direction.run}>
{direction.label}
</ContextMenuItem>
@ -255,30 +259,43 @@ export function TreeGroup({
// - a single pane -> "Move <dir>": join the zone visually adjacent on that
// side (splitting here would only make an invisible empty zone). Sides
// with no visible neighbor are omitted entirely.
const tree = useStore($layoutTree)
// NOT `useStore($layoutTree)`: this subscribes every zone — and therefore
// every mounted pane and its whole transcript — to the entire layout tree.
// A sash drag rewrites the tree once per frame, so dragging the sidebar
// re-rendered all five tiles' message lists on every pointermove (measured:
// TreeGroup 180 renders cascading into ChatView/Thread/TileChat at ~4.5s
// each, holding the drag at ~3fps).
//
// The tree is only read to build the zone context menu's move/split
// directions, which are consumed when the menu OPENS — so read it at that
// moment with `.get()` instead of subscribing to every intermediate frame.
const menuDirections = (): ZoneMenuDirection[] => {
if (shown.length > 1) {
return DIRECTION_ORDER.map(side => ({
side,
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
}))
}
const menuDirections: ZoneMenuDirection[] =
shown.length > 1
? DIRECTION_ORDER.map(side => ({
const tree = $layoutTree.get()
return DIRECTION_ORDER.flatMap(side => {
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
if (!neighbor || neighbor.id === node.id) {
return []
}
return [
{
side,
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
}))
: DIRECTION_ORDER.flatMap(side => {
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
if (!neighbor || neighbor.id === node.id) {
return []
}
return [
{
side,
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
}
]
})
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
}
]
})
}
// Close targets the right-clicked chip (falling back to the active pane);
// only panes that declare `uncloseable` (the main workspace) are exempt.

View file

@ -9,8 +9,15 @@ import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
* new string, so the stock splitter pays that O(full-text) cost ~30×/s on
* long replies. Two caches remove it:
*
* 1. Exact-string LRU a message that REMOUNTS with unchanged text
* (virtualizer scroll, session switch) reuses its parse outright.
* 1. Exact-string cache the same text always yields the SAME ARRAY. This is
* identity, not just cost: `parseMarkdownIntoBlocks` builds a fresh array
* every call, and Streamdown mirrors the block list into `useState`, so a
* new array identity for unchanged text makes every Streamdown re-render
* itself and re-render every Block under it. Short messages used to skip
* the cache on the theory that re-lexing them was cheap the lex is, but
* the churn it caused was not (measured: ~105 self-renders of Streamdown
* across five idle tiles in six seconds, cascading into 800 Block renders
* with nothing streaming). Every length is cached now.
* 2. Streaming-append cache when the new text starts with a recently parsed
* text (the token-append case), the previous parse's blocks are reused up
* to a settled boundary and only the suffix is lexed. The boundary drops
@ -28,8 +35,7 @@ import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
* full lex, i.e. exactly the previous behavior.
*/
const EXACT_CACHE_MAX = 64
const EXACT_CACHE_MIN_LENGTH = 1024
const EXACT_CACHE_MAX = 256
const exactCache = new Map<string, string[]>()
// Streaming messages grow monotonically, and only a handful stream at once
@ -109,10 +115,6 @@ function lexIncrementally(text: string): null | string[] {
}
export function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < EXACT_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = exactCache.get(markdown)
if (hit) {