hermes-agent-features/ui-tui/src/lib/perfPane.tsx
Brooklyn Nicholson 71eee26640 perf(tui): full-pipeline instrumentation + profiling harness
Extends HERMES_DEV_PERF to capture the complete render pipeline, not
just React commits. Adds scripts/profile-tui.py to drive repeatable
hold-PageUp stress tests against a real long session.

perfPane.tsx:
  Wires ink's onFrame callback (already plumbed through the fork) into
  the same perf.log as the React.Profiler samples. Captures per-phase
  timing (yoga calculateLayout, renderNodeToOutput, screen diff, patch
  optimize, stdout write) plus yoga counters (visited/measured/cache-
  Hits/live) and patch counts per frame.  Events are tagged
  {src: 'react'|'frame'} so jq can split them.  logFrameEvent is
  undefined when HERMES_DEV_PERF is unset, so ink doesn't even attach
  the callback.

entry.tsx:
  Passes logFrameEvent into render().

types/hermes-ink.d.ts:
  Declares FrameEvent + onFrame on RenderOptions so the ui-tui side
  type-checks against the plumbed-through ink option.

scripts/profile-tui.py:
  New harness. Launches the built TUI under a PTY with the longest
  session in state.db resumed, holds PageUp/PageDown/etc at a
  configurable Hz for N seconds, then parses perf.log and prints
  per-phase p50/p95/p99/max plus yoga-counter summaries. Zero deps
  beyond stdlib. Exit 2 if nothing was captured (wiring broken).

Initial findings (1106-msg session, 6s PageUp hold at 30Hz):
  - Steady state: 10 fps; renderer phase p99=63ms, write p99=0.2ms
  - 4/107 heavy frames (>=16ms), all dominated by renderNodeToOutput
  - One pathological 97ms frame with yoga measuring 70,415 text cells
    and Yoga visiting 225k nodes — the cold-unmeasured-region hit
  - Ink's scroll fast-path (DECSTBM blit from prevScreen) is
    disqualified because our spacer-based virtual history doesn't
    keep heightDelta in sync with scroll.delta, so every PageUp step
    falls through to a full 2000-4800 patch re-render instead of ~40
2026-04-26 16:36:25 -05:00

151 lines
5.2 KiB
TypeScript

// Perf instrumentation for the full render pipeline.
//
// Two sources of timing:
// 1. React.Profiler wrapper (PerfPane) → per-pane commit times. Shows
// which subtree is reconciling and for how long.
// 2. Ink onFrame callback (logFrameEvent) → per-frame pipeline phases:
// yoga (calculateLayout), renderer (DOM → screen buffer), diff
// (prev vs current screen → patches), optimize (patch merge/dedupe),
// write (serialize → ANSI → stdout), plus yoga counters (visited,
// measured, cacheHits, live). Shows where the time goes BELOW React.
//
// Both sources gate on HERMES_DEV_PERF=1 and dump JSON-lines to the same
// log (default ~/.hermes/perf.log, override via HERMES_DEV_PERF_LOG).
// Events are tagged { src: 'react' | 'frame' } so jq can split them.
//
// Threshold HERMES_DEV_PERF_MS (default 2ms) skips sub-millisecond idle
// frames. For the 2fps-during-PageUp investigation, set
// HERMES_DEV_PERF_MS=0 to capture everything, then filter with jq.
//
// Zero cost when the env var is unset: PerfPane returns children
// directly (no Profiler fiber), logFrameEvent is a noop on the onFrame
// callback — the ink instance isn't given the callback at all.
//
// Usage:
// # entry.tsx wires logFrameEvent into render()
// import { logFrameEvent, PerfPane } from './lib/perfPane.js'
// render(<App/>, { onFrame: logFrameEvent })
//
// Analysis helpers (once you've captured a session):
// tail -f ~/.hermes/perf.log | jq -c 'select(.src=="frame" and .durationMs > 16)'
// # p50/p99 per phase across frame events:
// jq -s '[.[] | select(.src=="frame")] |
// {n: length,
// dur_p50: (sort_by(.durationMs) | .[length/2|floor].durationMs),
// dur_p99: (sort_by(.durationMs) | .[length*0.99|floor].durationMs),
// yoga_p99: (sort_by(.phases.yoga) | .[length*0.99|floor].phases.yoga),
// write_p99: (sort_by(.phases.write) | .[length*0.99|floor].phases.write),
// diff_p99: (sort_by(.phases.diff) | .[length*0.99|floor].phases.diff),
// patches_p99: (sort_by(.phases.patches) | .[length*0.99|floor].phases.patches)}' \
// ~/.hermes/perf.log
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { FrameEvent } from '@hermes/ink'
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
let initialized = false
const ensureLogDir = () => {
if (initialized) {
return
}
initialized = true
try {
mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch {
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.)
// the appendFileSync calls below will throw silently and we drop the
// sample. Perf logging should never crash the TUI.
}
}
const writeRow = (row: Record<string, unknown>) => {
ensureLogDir()
try {
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
} catch {
// Same rationale as ensureLogDir — never crash the UI to log a sample.
}
}
const round2 = (n: number) => Math.round(n * 100) / 100
const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => {
if (actualMs < THRESHOLD_MS) {
return
}
writeRow({
actualMs: round2(actualMs),
baseMs: round2(baseMs),
commitMs: round2(commitTime),
id,
phase,
src: 'react',
startMs: round2(startTime),
ts: Date.now()
})
}
export function PerfPane({ children, id }: { children: ReactNode; id: string }) {
if (!ENABLED) {
return children
}
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
)
}
/**
* Ink onFrame handler. Captures the FULL render pipeline: yoga calculateLayout,
* DOM → screen buffer, screen diff, patch optimize, and stdout write.
*
* Returns `undefined` when disabled so `render()` doesn't attach the callback —
* ink only pays the timing cost when the callback is truthy.
*/
export const logFrameEvent = ENABLED
? (event: FrameEvent) => {
if (event.durationMs < THRESHOLD_MS) {
return
}
writeRow({
durationMs: round2(event.durationMs),
flickers: event.flickers.length ? event.flickers : undefined,
phases: event.phases
? {
commit: round2(event.phases.commit),
diff: round2(event.phases.diff),
optimize: round2(event.phases.optimize),
patches: event.phases.patches,
renderer: round2(event.phases.renderer),
write: round2(event.phases.write),
yoga: round2(event.phases.yoga),
yogaCacheHits: event.phases.yogaCacheHits,
yogaLive: event.phases.yogaLive,
yogaMeasured: event.phases.yogaMeasured,
yogaVisited: event.phases.yogaVisited
}
: undefined,
src: 'frame',
ts: Date.now()
})
}
: undefined
export const PERF_ENABLED = ENABLED
export const PERF_LOG_PATH = LOG_PATH