mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #71780 from NousResearch/bb/multitab-perf
perf(desktop): make multitab streaming sessions fast
This commit is contained in:
commit
be00a7176c
6 changed files with 303 additions and 4 deletions
|
|
@ -4,6 +4,7 @@
|
|||
import coldStart from './cold-start.mjs'
|
||||
import firstToken from './first-token.mjs'
|
||||
import keystroke from './keystroke.mjs'
|
||||
import multitab from './multitab.mjs'
|
||||
import profileSwitch from './profile-switch.mjs'
|
||||
import sessionSwitch from './session-switch.mjs'
|
||||
import stream from './stream.mjs'
|
||||
|
|
@ -14,6 +15,7 @@ export const SCENARIOS = {
|
|||
[stream.name]: stream,
|
||||
[keystroke.name]: keystroke,
|
||||
[transcript.name]: transcript,
|
||||
[multitab.name]: multitab,
|
||||
[coldStart.name]: coldStart,
|
||||
[firstToken.name]: firstToken,
|
||||
[submit.name]: submit,
|
||||
|
|
|
|||
246
apps/desktop/scripts/perf/scenarios/multitab.mjs
Normal file
246
apps/desktop/scripts/perf/scenarios/multitab.mjs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// Multi-tab working sessions: N session tiles stacked as tabs in the main
|
||||
// zone, EVERY tab mounted (keep-alive), all streaming concurrently — the
|
||||
// "5 tabs doing PR review" workload. Measures frame pacing + longtasks while
|
||||
// the whole stack streams, which is where multitab renderers crawl.
|
||||
//
|
||||
// Drives the real pipeline synthetically (no backend, no credits):
|
||||
// publishSessionState per session per flush — exactly what the gateway's
|
||||
// delta flush does — via the __HERMES_SESSION_TILES__ hook.
|
||||
//
|
||||
// node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
||||
// Same recorder pattern as stream.mjs (generation-guarded rAF + longtasks).
|
||||
const RECORDERS = `
|
||||
(() => {
|
||||
window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1
|
||||
const ftGen = window.__FT_GEN__
|
||||
window.__FT__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return
|
||||
const now = performance.now()
|
||||
window.__FT__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__LT__ = { entries: [], stop: false }
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
if (window.__LT__.stop) return
|
||||
for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime })
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__LT__.po = po
|
||||
} catch {}
|
||||
return 'armed'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__FT__.stop = true
|
||||
window.__LT__.stop = true
|
||||
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
|
||||
return JSON.stringify({ frames: window.__FT__.times, longtasks: window.__LT__.entries })
|
||||
})()
|
||||
`
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles stacked into the main zone,
|
||||
* bind fake runtime ids, and seed each with a realistic transcript. */
|
||||
const setup = (tiles, seedTurns, streamSeed) => `
|
||||
(() => {
|
||||
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: 'Review question ' + i + ': does the diff in module ' + i + ' handle the error path?' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: [
|
||||
'## Finding ' + i, '',
|
||||
'The handler swallows the rejection. Key points for hunk \\\`' + i + '\\\`:', '',
|
||||
'- The catch block drops the original error.',
|
||||
'- Retries are unbounded — see [the loop](https://example.com/loop).', '',
|
||||
'\\\`\\\`\\\`ts',
|
||||
'async function retry' + i + '(fn: () => Promise<void>) {',
|
||||
' for (;;) { try { return await fn() } catch {} }',
|
||||
'}',
|
||||
'\\\`\\\`\\\`', '',
|
||||
'| path | covered |', '|---|---|', '| happy | yes |', '| error | no |', ''
|
||||
].join('\\n') }] }
|
||||
])
|
||||
|
||||
const state = (sid, rid) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// Streaming tail the driver grows (--code seeds an open fence).
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] })
|
||||
return {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
window.__MT__ = { ids: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'perf-tile-' + n
|
||||
const rid = 'perf-rt-' + n
|
||||
window.__MT__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, state(sid, rid))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
// Activate every tab once so keep-alive mounts the full stack (lazy mount:
|
||||
// a never-activated tab stays unmounted, which would understate the cost).
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Page-side driver: grow every tile's streaming tail by `chunk` each
|
||||
* `intervalMs`, through the same publish path the gateway flush uses. */
|
||||
const drive = (chunk, intervalMs, totalTokens) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
const states = hook.states()
|
||||
for (const { rid } of window.__MT__.ids) {
|
||||
const prev = states[rid]
|
||||
if (!prev) continue
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
})
|
||||
hook.publish(rid, { ...prev, messages })
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs})
|
||||
else window.__MT__.done = true
|
||||
}
|
||||
window.__MT__.timer = setTimeout(tick, ${intervalMs})
|
||||
return 'driving'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__MT__) {
|
||||
clearTimeout(window.__MT__.timer)
|
||||
for (const { sid, rid } of window.__MT__.ids) {
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, {
|
||||
...window.__HERMES_SESSION_TILES__.states()[rid], busy: false, streamId: null
|
||||
})
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__MT__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'multitab',
|
||||
tier: 'ci',
|
||||
description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
// --code: every tile grows ONE giant fenced code block with no settle
|
||||
// boundaries — what a coding agent streams. The block re-parses and
|
||||
// re-renders fully every flush (block memoization can't settle it), the
|
||||
// documented worst case and the "5 tabs all coding" crawl.
|
||||
const chunk = opts.code
|
||||
? ' const value = await resolve(ctx, { retry: true }) // step\n'
|
||||
: (opts.chunk ?? 'A streamed review sentence with **bold**, `code`, and ordinary prose.\n\n')
|
||||
const streamSeed = opts.code ? '```ts\n' : ''
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`)
|
||||
}
|
||||
|
||||
// Mount every tab (keep-alive mounts on first activation), then settle.
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`perf-tile-${n}`))
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
await cdp.eval(RECORDERS)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens))
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Drop the first 500ms (recorder install + settle).
|
||||
const frames = []
|
||||
let acc = 0
|
||||
|
||||
for (const f of data.frames) {
|
||||
acc += f
|
||||
|
||||
if (acc >= 500) {
|
||||
frames.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const ltDurations = data.longtasks.map(e => e.duration)
|
||||
const windowS = frames.reduce((a, b) => a + b, 0) / 1000
|
||||
// The felt numbers: sustained fps over the window, and the fps of the
|
||||
// worst 1-second slice (a 333ms frame IS "3fps" even if the average looks
|
||||
// fine). Worst slice = max summed frame time in any sliding 1s window.
|
||||
const avgFps = windowS ? frames.length / windowS : 0
|
||||
let worstFps = avgFps
|
||||
|
||||
for (let i = 0, j = 0, sum = 0; j < frames.length; j++) {
|
||||
sum += frames[j]
|
||||
|
||||
while (sum > 1000) {
|
||||
sum -= frames[i++]
|
||||
}
|
||||
|
||||
// Only a window that actually spans ~1s counts; short prefixes don't.
|
||||
if (sum >= 900) {
|
||||
worstFps = Math.min(worstFps, ((j - i + 1) / sum) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
longtasks_n: data.longtasks.length,
|
||||
longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10,
|
||||
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
|
||||
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
|
||||
slow_frames_33: frames.filter(f => f > 33).length
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
code: Boolean(opts.code),
|
||||
windowS: Math.round(windowS * 10) / 10,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
worstSecondFps: Math.round(worstFps * 10) / 10,
|
||||
frameHistogram: frameHistogram(frames)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo } from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -174,6 +176,30 @@ interface ChatRuntimeBoundaryProps {
|
|||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/**
|
||||
* The view's $messages, live only while this surface is the VISIBLE tab.
|
||||
*
|
||||
* Keep-alive keeps every ever-active tab MOUNTED (tree-group.tsx), so without
|
||||
* this gate a hidden tab re-renders its entire thread on every streaming
|
||||
* delta flush (~30×/s) — five busy tabs quintuple the per-token render cost
|
||||
* and the app crawls. Hidden tabs freeze their transcript instead (status
|
||||
* dots stay live through the separate status atoms) and catch up in one
|
||||
* commit on reveal — the subscribe fires immediately with the current value.
|
||||
*/
|
||||
function useMessagesWhileVisible($messages: ReadableAtom<ChatMessage[]>): ChatMessage[] {
|
||||
const visible = usePaneVisible()
|
||||
const [messages, setMessages] = useState(() => $messages.get())
|
||||
|
||||
// nanostores types the listener value ReadonlyIfObject; the store publishes
|
||||
// a fresh array per flush, so the cast is safe and avoids a per-token clone.
|
||||
useEffect(
|
||||
() => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined),
|
||||
[$messages, visible]
|
||||
)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the $messages subscription and the assistant-ui external-store runtime.
|
||||
*
|
||||
|
|
@ -193,7 +219,7 @@ function ChatRuntimeBoundary({
|
|||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore(useSessionView().$messages)
|
||||
const storeMessages = useMessagesWhileVisible(useSessionView().$messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const runtimeMessageRepository = useRuntimeMessageRepository(messages)
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,17 @@ export const ThreadTimeline: FC = () => {
|
|||
const compute = () => {
|
||||
raf = 0
|
||||
|
||||
// Pinned to the bottom (the entire streaming steady-state): the active
|
||||
// prompt is simply the last one. Skipping the walk matters — it reads a
|
||||
// rect per user message per scroll frame, and interleaved with React's
|
||||
// streaming style writes each read forces a full reflow (the single
|
||||
// hottest frame in the multitab profile).
|
||||
if (viewport.dataset.following === 'true') {
|
||||
setActiveIndex(prev => (prev === entries.length - 1 ? prev : entries.length - 1))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const top = viewport.getBoundingClientRect().top
|
||||
|
||||
const offsets = entries.map(entry => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
* has to skip hidden panes, or it silently answers with the wrong tab.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/** Marks a mounted-but-hidden pane layer (an inactive tab in a stack). */
|
||||
export const PANE_HIDDEN_ATTR = 'data-pane-hidden'
|
||||
|
||||
|
|
@ -18,6 +20,14 @@ const HIDDEN_PANE = `[${PANE_HIDDEN_ATTR}]`
|
|||
/** Spread onto a kept pane layer so the lookups below can skip it. */
|
||||
export const hiddenPaneProps = (hidden: boolean): Record<string, string> => (hidden ? { [PANE_HIDDEN_ATTR]: '' } : {})
|
||||
|
||||
/** React face of the same policy: the pane layer provides its visibility so a
|
||||
* kept-alive surface can gate hot subscriptions (streaming re-renders) off
|
||||
* while it's an inactive tab. Default TRUE — surfaces outside a tab stack
|
||||
* (secondary windows, plain routes) are always visible. */
|
||||
export const PaneVisibleContext = createContext(true)
|
||||
|
||||
export const usePaneVisible = (): boolean => useContext(PaneVisibleContext)
|
||||
|
||||
/** `querySelectorAll` minus anything inside an inactive tab. */
|
||||
export const queryAllVisible = <T extends HTMLElement>(selector: string, root: ParentNode = document): T[] =>
|
||||
[...root.querySelectorAll<T>(selector)].filter(el => !el.closest(HIDDEN_PANE))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
import { useWindowControlsOverlap } from '../../geometry'
|
||||
import { hiddenPaneProps } from '../../pane-visibility'
|
||||
import { hiddenPaneProps, PaneVisibleContext } from '../../pane-visibility'
|
||||
import type { DropPosition, GroupNode, RootEdge } from '../model'
|
||||
import { adjacentGroup } from '../model'
|
||||
import {
|
||||
|
|
@ -544,7 +544,11 @@ export function TreeGroup({
|
|||
{...hiddenPaneProps(!isActive)}
|
||||
>
|
||||
{pane?.render ? (
|
||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
||||
// Visibility flows to the pane so a kept-alive chat surface
|
||||
// can gate its hot (per-token) subscriptions while hidden.
|
||||
<PaneVisibleContext.Provider value={isActive}>
|
||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
||||
</PaneVisibleContext.Provider>
|
||||
) : (
|
||||
isActive && (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue