mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
perf(desktop): mount tooltips lazily, and measure the idle cost
Adds an `idle-cost` scenario for the symptom Brooklyn reported: with a thread spinning, resizing the sidebar feels slow. It holds N tiles busy, pushes NO tokens, and measures the renderer's self-inflicted commit rate plus fps while dragging the splitter and while typing. It reproduces immediately. Five busy tiles, nothing streaming: idle commits 17.7/sec (should be 0 — nothing is happening) drag 1.4 fps p95 812ms, worst frame 1.9s typing 61 fps (fine — this is specific to resize) Attributing the drag window showed 105,385 TooltipProvider renders and ~15s of component time across a 60-frame gesture. Cause: `Tip` mounts a full Radix provider + Tooltip per call site, and there are ~107 of them. Radix's Tooltip holds real state and Popper subscribes to layout, so an unrelated interaction re-rendered all of them. Mounts the machinery lazily instead, on first hover/focus. Tooltip churn drops ~4x (105k -> 26k) and drag doubles to 3fps. Note `defaultOpen` on the armed Tooltip is load-bearing: the pointerenter that armed it has already fired, so Radix never sees it and the tip mounts silently closed. A test caught exactly that, and now guards it. 3fps is still bad — the remaining cost is the whole transcript re-rendering per resize frame (MessagePrimitive.Parts 12,600 renders / 10.5s, Block/Ct 24,300 each, all 100% wasted). Separate fix.
This commit is contained in:
parent
0054c7edcb
commit
4798994dce
4 changed files with 336 additions and 1 deletions
238
apps/desktop/scripts/perf/scenarios/idle-cost.mjs
Normal file
238
apps/desktop/scripts/perf/scenarios/idle-cost.mjs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
// 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, plus WHAT
|
||||||
|
* rendered during it — a slow interaction is almost never the handler, it is
|
||||||
|
* whatever re-renders underneath while the handler runs. */
|
||||||
|
const withFrames = body => `
|
||||||
|
(async () => {
|
||||||
|
const rc = window.__RENDER_COUNTS__
|
||||||
|
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
|
||||||
|
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: rc.commits(),
|
||||||
|
top: rc.report(10)
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
/** Drag the sidebar splitter back and forth — the resize symptom. */
|
||||||
|
const DRAG = withFrames(`
|
||||||
|
const handle = document.querySelector('[data-slot="pane-resize-handle"], [role="separator"], .resize-handle')
|
||||||
|
if (handle) {
|
||||||
|
const box = handle.getBoundingClientRect()
|
||||||
|
const y = box.top + box.height / 2
|
||||||
|
let x = box.left + box.width / 2
|
||||||
|
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
x += (i % 2 === 0 ? 3 : -3)
|
||||||
|
window.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
|
||||||
|
await new Promise(r => requestAnimationFrame(r))
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
|
||||||
|
} 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 type = JSON.parse(await cdp.eval(TYPE))
|
||||||
|
|
||||||
|
await cdp.eval(CLEANUP)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import coldStart from './cold-start.mjs'
|
import coldStart from './cold-start.mjs'
|
||||||
import firstToken from './first-token.mjs'
|
import firstToken from './first-token.mjs'
|
||||||
|
import idleCost from './idle-cost.mjs'
|
||||||
import keystroke from './keystroke.mjs'
|
import keystroke from './keystroke.mjs'
|
||||||
import multitab from './multitab.mjs'
|
import multitab from './multitab.mjs'
|
||||||
import profileSwitch from './profile-switch.mjs'
|
import profileSwitch from './profile-switch.mjs'
|
||||||
|
|
@ -20,6 +21,7 @@ export const SCENARIOS = {
|
||||||
[transcript.name]: transcript,
|
[transcript.name]: transcript,
|
||||||
[multitab.name]: multitab,
|
[multitab.name]: multitab,
|
||||||
[renderChurn.name]: renderChurn,
|
[renderChurn.name]: renderChurn,
|
||||||
|
[idleCost.name]: idleCost,
|
||||||
[coldStart.name]: coldStart,
|
[coldStart.name]: coldStart,
|
||||||
[firstToken.name]: firstToken,
|
[firstToken.name]: firstToken,
|
||||||
[submit.name]: submit,
|
[submit.name]: submit,
|
||||||
|
|
|
||||||
56
apps/desktop/src/components/ui/tooltip.lazy.test.tsx
Normal file
56
apps/desktop/src/components/ui/tooltip.lazy.test.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { Tip } from './tooltip'
|
||||||
|
|
||||||
|
// `Tip` mounts Radix lazily (on first hover/focus) because ~107 eager
|
||||||
|
// providers dominated unrelated interactions — dragging the sidebar splitter
|
||||||
|
// measured 105k TooltipProvider renders across a 60-frame gesture. The saving
|
||||||
|
// is only worth anything if the tip still actually appears, so this asserts
|
||||||
|
// the behavior the optimization has to preserve, not its implementation.
|
||||||
|
|
||||||
|
describe('Tip lazy mount', () => {
|
||||||
|
it('shows the tooltip on hover even though Radix mounts only once armed', async () => {
|
||||||
|
render(
|
||||||
|
<Tip label="Reveal in sidebar">
|
||||||
|
<button type="button">target</button>
|
||||||
|
</Tip>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole('button', { name: 'target' })
|
||||||
|
|
||||||
|
expect(screen.queryByText('Reveal in sidebar')).toBeNull()
|
||||||
|
|
||||||
|
// Arms the lazy wrapper, then drives Radix's own trigger handlers for the
|
||||||
|
// same hover — the pointer is still inside, so the tip must open.
|
||||||
|
fireEvent.pointerEnter(trigger)
|
||||||
|
fireEvent.pointerMove(trigger)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('Reveal in sidebar').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the child untouched when there is no label', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Tip label={undefined}>
|
||||||
|
<button type="button">bare</button>
|
||||||
|
</Tip>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
|
||||||
|
expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mount Radix until the pointer arrives', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Tip label="Later">
|
||||||
|
<button type="button">idle</button>
|
||||||
|
</Tip>
|
||||||
|
)
|
||||||
|
|
||||||
|
// The idle wrapper is present; no Radix trigger has been created.
|
||||||
|
expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeTruthy()
|
||||||
|
expect(container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -108,14 +108,53 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
|
||||||
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
||||||
// anywhere without a provider ancestor. Renders the child untouched when label
|
// anywhere without a provider ancestor. Renders the child untouched when label
|
||||||
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
|
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
|
||||||
|
//
|
||||||
|
// Perf: the Radix machinery is mounted LAZILY, on first hover/focus. `Tip` has
|
||||||
|
// ~107 call sites, and Radix's `Tooltip` holds real state (trigger element,
|
||||||
|
// content id, open) while `Popper` subscribes to layout — so eagerly mounting
|
||||||
|
// all of them made every one of those subtrees re-render during unrelated
|
||||||
|
// interactions. Measured while dragging the sidebar splitter with five busy
|
||||||
|
// tiles: 105,385 TooltipProvider renders and ~15s of component time across a
|
||||||
|
// 60-frame gesture, which is what dropped the drag to ~1.5fps. Until the
|
||||||
|
// pointer actually arrives, a Tip is just its child plus one wrapper.
|
||||||
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
||||||
|
// Once armed, stay armed: unmounting the machinery on pointer-out would
|
||||||
|
// close the tip mid-hover and re-pay the mount on every re-entry.
|
||||||
|
const [armed, setArmed] = React.useState<'focus' | 'pointer' | null>(null)
|
||||||
|
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!armed) {
|
||||||
|
// `display: contents` so this wrapper never affects layout — the child
|
||||||
|
// participates in its parent's box exactly as it did before.
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="tooltip-idle"
|
||||||
|
onFocus={event => {
|
||||||
|
suppressNonKeyboardFocusOpen(event)
|
||||||
|
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
setArmed('focus')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => setArmed('pointer')}
|
||||||
|
style={{ display: 'contents' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `defaultOpen` is required, not incidental: the pointerenter/focus that
|
||||||
|
// armed this has ALREADY fired, so Radix's own trigger handlers never see it
|
||||||
|
// and the tip would mount silently closed. Arming IS the hover, so open with
|
||||||
|
// it — Radix owns every subsequent open/close from here (pointerleave, blur,
|
||||||
|
// escape, pointerdown) exactly as before.
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
|
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
|
||||||
<Tooltip disableHoverableContent>
|
<Tooltip defaultOpen disableHoverableContent>
|
||||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
<TooltipContent {...props}>{label}</TooltipContent>
|
<TooltipContent {...props}>{label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue