mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
perf(tui): instrument scroll fast-path decline reasons
Adds scrollFastPathStats counters to render-node-to-output.ts: captures every time a ScrollBox's DECSTBM scroll hint is generated, records whether the fast path took it (blit+shift from prevScreen) or declined, and why. Exposed through hermes-ink's public exports and snapshotted on every FrameEvent so the profiler harness can correlate decline reasons with the actual patch/renderer cost per frame. This is pure observation — no behaviour change. Preparing for the virtual-history rewrite: the hypothesis was that our topSpacer/ bottomSpacer scheme disqualifies every scroll via heightDelta mismatch, but the data shows the fast path is actually taken on most scrolls (19/23 over a 6s PageUp hold through 1100 messages) — the remaining steady-state renderer cost is Yoga tree traversal, not the per-frame full redraw I initially suspected. Declines that do happen correlate with React commits that changed the mounted range mid-scroll (heightDelta=±3 to ±35). Those are the rarer cases the virtualization rewrite still needs to address. No test diffs — instrumentation-only. Build verified: `tsc --noEmit` plus the full `npm run build` compiler post-pass pass cleanly.
This commit is contained in:
parent
71eee26640
commit
cd7a200e6c
4 changed files with 114 additions and 0 deletions
|
|
@ -21,6 +21,11 @@ export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||||
export { default as measureElement } from './ink/measure-element.js'
|
export { default as measureElement } from './ink/measure-element.js'
|
||||||
|
export {
|
||||||
|
resetScrollFastPathStats,
|
||||||
|
scrollFastPathStats,
|
||||||
|
type ScrollFastPathStats
|
||||||
|
} from './ink/render-node-to-output.js'
|
||||||
export { createRoot, default as render, renderSync } from './ink/root.js'
|
export { createRoot, default as render, renderSync } from './ink/root.js'
|
||||||
export { stringWidth } from './ink/stringWidth.js'
|
export { stringWidth } from './ink/stringWidth.js'
|
||||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,54 @@ export function resetScrollHint(): void {
|
||||||
absoluteRectsCur = []
|
absoluteRectsCur = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast-path diagnostics. Bumped from the ScrollBox fast-path branch
|
||||||
|
// whenever a scroll hint was captured. Reveals why a fast path was
|
||||||
|
// declined (heightDelta mismatch, no prevScreen, etc.) so we can chase
|
||||||
|
// the last mile of PageUp/wheel latency. Zero cost when no reader —
|
||||||
|
// it's all integer bumps. Exposed as a counter object so external
|
||||||
|
// probes can snapshot + diff.
|
||||||
|
export type ScrollFastPathStats = {
|
||||||
|
captured: number
|
||||||
|
taken: number
|
||||||
|
declined: {
|
||||||
|
noPrevScreen: number
|
||||||
|
heightDeltaMismatch: number
|
||||||
|
noHint: number
|
||||||
|
other: number
|
||||||
|
}
|
||||||
|
lastDeclineReason?: string
|
||||||
|
lastHeightDelta?: number
|
||||||
|
lastHintDelta?: number
|
||||||
|
lastScrollHeight?: number
|
||||||
|
lastPrevHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollFastPathStats: ScrollFastPathStats = {
|
||||||
|
captured: 0,
|
||||||
|
taken: 0,
|
||||||
|
declined: {
|
||||||
|
noPrevScreen: 0,
|
||||||
|
heightDeltaMismatch: 0,
|
||||||
|
noHint: 0,
|
||||||
|
other: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetScrollFastPathStats(): void {
|
||||||
|
scrollFastPathStats.captured = 0
|
||||||
|
scrollFastPathStats.taken = 0
|
||||||
|
scrollFastPathStats.declined.noPrevScreen = 0
|
||||||
|
scrollFastPathStats.declined.heightDeltaMismatch = 0
|
||||||
|
scrollFastPathStats.declined.noHint = 0
|
||||||
|
scrollFastPathStats.declined.other = 0
|
||||||
|
scrollFastPathStats.lastDeclineReason = undefined
|
||||||
|
scrollFastPathStats.lastHeightDelta = undefined
|
||||||
|
scrollFastPathStats.lastHintDelta = undefined
|
||||||
|
scrollFastPathStats.lastScrollHeight = undefined
|
||||||
|
scrollFastPathStats.lastPrevHeight = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getScrollHint(): ScrollHint | null {
|
export function getScrollHint(): ScrollHint | null {
|
||||||
return scrollHint
|
return scrollHint
|
||||||
}
|
}
|
||||||
|
|
@ -927,6 +975,27 @@ function renderNodeToOutput(
|
||||||
|
|
||||||
const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta)
|
const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta)
|
||||||
|
|
||||||
|
// Diagnostics (opt-in via scrollFastPathStats reader). Only
|
||||||
|
// counts when a hint was captured — cases where nothing scrolled
|
||||||
|
// (hint === null) are not declines, just idle frames.
|
||||||
|
if (hint) {
|
||||||
|
scrollFastPathStats.captured++
|
||||||
|
scrollFastPathStats.lastHintDelta = hint.delta
|
||||||
|
scrollFastPathStats.lastScrollHeight = scrollHeight
|
||||||
|
scrollFastPathStats.lastPrevHeight = prevHeight
|
||||||
|
scrollFastPathStats.lastHeightDelta = heightDelta
|
||||||
|
|
||||||
|
if (!safeForFastPath) {
|
||||||
|
scrollFastPathStats.declined.heightDeltaMismatch++
|
||||||
|
scrollFastPathStats.lastDeclineReason = `heightDelta=${heightDelta} hintDelta=${hint.delta}`
|
||||||
|
} else if (!prevScreen) {
|
||||||
|
scrollFastPathStats.declined.noPrevScreen++
|
||||||
|
scrollFastPathStats.lastDeclineReason = 'noPrevScreen'
|
||||||
|
} else {
|
||||||
|
scrollFastPathStats.taken++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// scrollHint is set above when hint is captured. If safeForFastPath
|
// scrollHint is set above when hint is captured. If safeForFastPath
|
||||||
// is false the full path renders a next.screen that doesn't match
|
// is false the full path renders a next.screen that doesn't match
|
||||||
// the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as
|
// the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import { homedir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
import type { FrameEvent } from '@hermes/ink'
|
import type { FrameEvent } from '@hermes/ink'
|
||||||
|
import { scrollFastPathStats } from '@hermes/ink'
|
||||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
|
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
|
||||||
|
|
@ -122,8 +123,29 @@ export const logFrameEvent = ENABLED
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the fast-path counters each frame. Cumulative values —
|
||||||
|
// consumers diff pairs to get per-frame deltas. Written verbatim
|
||||||
|
// so we can also see "last*" fields (which decline reason fired,
|
||||||
|
// and what the height math looked like).
|
||||||
|
const fastPath = {
|
||||||
|
captured: scrollFastPathStats.captured,
|
||||||
|
taken: scrollFastPathStats.taken,
|
||||||
|
declined: {
|
||||||
|
heightDeltaMismatch: scrollFastPathStats.declined.heightDeltaMismatch,
|
||||||
|
noHint: scrollFastPathStats.declined.noHint,
|
||||||
|
noPrevScreen: scrollFastPathStats.declined.noPrevScreen,
|
||||||
|
other: scrollFastPathStats.declined.other
|
||||||
|
},
|
||||||
|
lastDeclineReason: scrollFastPathStats.lastDeclineReason,
|
||||||
|
lastHeightDelta: scrollFastPathStats.lastHeightDelta,
|
||||||
|
lastHintDelta: scrollFastPathStats.lastHintDelta,
|
||||||
|
lastPrevHeight: scrollFastPathStats.lastPrevHeight,
|
||||||
|
lastScrollHeight: scrollFastPathStats.lastScrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
writeRow({
|
writeRow({
|
||||||
durationMs: round2(event.durationMs),
|
durationMs: round2(event.durationMs),
|
||||||
|
fastPath,
|
||||||
flickers: event.flickers.length ? event.flickers : undefined,
|
flickers: event.flickers.length ? event.flickers : undefined,
|
||||||
phases: event.phases
|
phases: event.phases
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
18
ui-tui/src/types/hermes-ink.d.ts
vendored
18
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -101,6 +101,24 @@ declare module '@hermes/ink' {
|
||||||
export const TextInput: React.ComponentType<any>
|
export const TextInput: React.ComponentType<any>
|
||||||
export const stringWidth: (s: string) => number
|
export const stringWidth: (s: string) => number
|
||||||
|
|
||||||
|
export type ScrollFastPathStats = {
|
||||||
|
captured: number
|
||||||
|
taken: number
|
||||||
|
declined: {
|
||||||
|
noPrevScreen: number
|
||||||
|
heightDeltaMismatch: number
|
||||||
|
noHint: number
|
||||||
|
other: number
|
||||||
|
}
|
||||||
|
lastDeclineReason?: string
|
||||||
|
lastHeightDelta?: number
|
||||||
|
lastHintDelta?: number
|
||||||
|
lastScrollHeight?: number
|
||||||
|
lastPrevHeight?: number
|
||||||
|
}
|
||||||
|
export const scrollFastPathStats: ScrollFastPathStats
|
||||||
|
export function resetScrollFastPathStats(): void
|
||||||
|
|
||||||
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
||||||
|
|
||||||
export function useApp(): { readonly exit: (error?: Error) => void }
|
export function useApp(): { readonly exit: (error?: Error) => void }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue