diff --git a/ui-tui/scripts/bench-native-history.tsx b/ui-tui/scripts/bench-native-history.tsx new file mode 100644 index 0000000000..1d8a2dd7b2 --- /dev/null +++ b/ui-tui/scripts/bench-native-history.tsx @@ -0,0 +1,257 @@ +// Compare the native primary-buffer transcript with the virtualized ScrollBox path. +// +// Run from ui-tui: +// npx tsx scripts/bench-native-history.tsx --warmups=1 --samples=3 --items=100,1000,10000 +// +// Native terminal scrolling is handled by the terminal emulator after rows are +// written, so it has no app-side scroll operation to time. This measures the +// work that differs: initial history mount, a long-history rerender, memory, +// and terminal output. The virtual path also reports its app-side scroll cost. + +import { PassThrough } from 'stream' + +import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink' +import React, { useLayoutEffect, useRef } from 'react' + +import { useVirtualHistory } from '../src/hooks/useVirtualHistory.js' + +const DEFAULT_WORKLOADS = [100, 1_000, 10_000] +const DEFAULT_WARMUPS = 1 +const DEFAULT_SAMPLES = 5 +const COLUMNS = 100 +const ROWS = 30 + +interface Item { + height: number + key: string + text: string +} + +interface Sample { + heapDeltaBytes: number | null + mountMs: number + rerenderMs: number + scrollMs: number | null + terminalBytes: number + terminalWrites: number +} + +interface HarnessProps { + items: readonly Item[] + expose?: React.MutableRefObject + scrollRef?: React.MutableRefObject +} + +class CountingStream extends PassThrough { + columns = COLUMNS + rows = ROWS + isTTY = false + bytes = 0 + writes = 0 + + override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.bytes += Buffer.byteLength(chunk) + this.writes++ + callback() + } +} + +const immediate = () => new Promise(resolve => setImmediate(resolve)) + +async function settle(frames = 4) { + for (let frame = 0; frame < frames; frame++) { + await immediate() + } +} + +function makeItems(count: number): Item[] { + return Array.from({ length: count }, (_, index) => ({ + height: 1 + ((index * 17) % 4), + key: `row-${index}`, + text: `row ${index} ${'history '.repeat(2 + (index % 5))}` + })) +} + +function NativeHarness({ items }: HarnessProps) { + return ( + + {items.map(item => ( + + {item.text} + + ))} + + ) +} + +function VirtualHarness({ expose, items, scrollRef }: HarnessProps) { + const virtual = useVirtualHistory(scrollRef!, items, COLUMNS, { + coldStartCount: 30, + estimateHeight: index => items[index]?.height ?? 1, + maxMounted: 120, + overscan: 20 + }) + + useLayoutEffect(() => { + expose!.current = scrollRef!.current + }, [expose, scrollRef]) + + return ( + + + {virtual.topSpacer > 0 ? : null} + {items.slice(virtual.start, virtual.end).map(item => ( + + {item.text} + + ))} + {virtual.bottomSpacer > 0 ? : null} + + + ) +} + +async function runSample(mode: 'native' | 'virtual', itemCount: number): Promise { + const stdout = new CountingStream() + const stderr = new CountingStream() + const stdin = new PassThrough() + const scrollRef = { current: null as ScrollBoxHandle | null } + const exposedScrollRef = { current: null as ScrollBoxHandle | null } + let items = makeItems(itemCount) + const heapBefore = process.memoryUsage?.().heapUsed ?? null + const mountStart = performance.now() + const instance = renderSync( + mode === 'native' ? ( + + ) : ( + + ), + { + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + } + ) + await settle() + const mountMs = performance.now() - mountStart + + const rerenderItems = items.map((item, index) => + index === items.length - 1 ? { ...item, text: `${item.text} rerender` } : item + ) + const rerenderStart = performance.now() + instance.rerender( + mode === 'native' ? ( + + ) : ( + + ) + ) + await settle() + const rerenderMs = performance.now() - rerenderStart + items = rerenderItems + + let scrollMs: number | null = null + if (mode === 'virtual' && exposedScrollRef.current) { + const scrollStart = performance.now() + exposedScrollRef.current.scrollTo(Math.max(0, itemCount - ROWS)) + await settle(8) + scrollMs = performance.now() - scrollStart + } + + const heapAfter = process.memoryUsage?.().heapUsed ?? null + const sample = { + heapDeltaBytes: heapBefore === null || heapAfter === null ? null : heapAfter - heapBefore, + mountMs, + rerenderMs, + scrollMs, + terminalBytes: stdout.bytes, + terminalWrites: stdout.writes + } + + instance.unmount() + instance.cleanup() + stdin.destroy() + stdout.destroy() + stderr.destroy() + return sample +} + +function distribution(values: number[]) { + const sorted = [...values].sort((a, b) => a - b) + const percentile = (p: number) => + sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))] ?? 0 + return { + max: sorted.at(-1) ?? 0, + mean: sorted.reduce((sum, value) => sum + value, 0) / Math.max(1, sorted.length), + min: sorted[0] ?? 0, + p50: percentile(0.5), + p95: percentile(0.95) + } +} + +function numericArg(name: string, fallback: number) { + const raw = process.argv + .slice(2) + .find(arg => arg.startsWith(`--${name}=`)) + ?.split('=', 2)[1] + const parsed = Number(raw) + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function workloadsArg() { + const raw = process.argv + .slice(2) + .find(arg => arg.startsWith('--items=')) + ?.split('=', 2)[1] + if (!raw) return DEFAULT_WORKLOADS + const parsed = raw.split(',').map(Number) + if (parsed.some(value => !Number.isSafeInteger(value) || value <= 0)) { + throw new Error(`invalid --items workload list: ${raw}`) + } + return parsed +} + +function summarize(samples: Sample[]) { + return { + heapDeltaBytes: distribution(samples.flatMap(sample => sample.heapDeltaBytes ?? [])), + mountMs: distribution(samples.map(sample => sample.mountMs)), + rerenderMs: distribution(samples.map(sample => sample.rerenderMs)), + scrollMs: distribution(samples.flatMap(sample => sample.scrollMs ?? [])), + terminalBytes: distribution(samples.map(sample => sample.terminalBytes)), + terminalWrites: distribution(samples.map(sample => sample.terminalWrites)) + } +} + +async function main() { + const workloads = workloadsArg() + const warmups = numericArg('warmups', DEFAULT_WARMUPS) + const sampleCount = numericArg('samples', DEFAULT_SAMPLES) + const results = [] + + for (const itemCount of workloads) { + for (let warmup = 0; warmup < warmups; warmup++) { + await runSample('virtual', itemCount) + await runSample('native', itemCount) + } + + const virtual: Sample[] = [] + const native: Sample[] = [] + for (let sample = 0; sample < sampleCount; sample++) { + virtual.push(await runSample('virtual', itemCount)) + native.push(await runSample('native', itemCount)) + } + + results.push({ + itemCount, + native: { samples: native, summary: summarize(native) }, + virtual: { samples: virtual, summary: summarize(virtual) } + }) + } + + process.stdout.write( + `${JSON.stringify({ config: { columns: COLUMNS, rows: ROWS, samples: sampleCount, warmups }, results, workloads }, null, 2)}\n` + ) +} + +await main()