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:
Brooklyn Nicholson 2026-04-26 16:45:53 -05:00
parent 71eee26640
commit cd7a200e6c
4 changed files with 114 additions and 0 deletions

View file

@ -21,6 +21,11 @@ export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.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 { stringWidth } from './ink/stringWidth.js'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'

View file

@ -67,6 +67,54 @@ export function resetScrollHint(): void {
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 {
return scrollHint
}
@ -927,6 +975,27 @@ function renderNodeToOutput(
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
// is false the full path renders a next.screen that doesn't match
// the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as