diff --git a/bench/render.mjs b/bench/render.mjs index f1636faf22b..1c7a2509706 100644 --- a/bench/render.mjs +++ b/bench/render.mjs @@ -1,9 +1,12 @@ #!/usr/bin/env node -// Report renderer — reads bench/results/*.json and emits ONE self-contained -// bench/report.html (inline SVG, zero CDN/network) plus PNG exports of each -// chart to bench/report-assets/ (rasterized with the resvg-js available at -// ~/.claude/skills/tmux-pane-screenshot/scripts/node_modules — see README). +// Report renderer — reads bench/results/*.json + bench/session-distribution.json +// and emits ONE self-contained bench/report.html (inline SVG, zero CDN/network) +// plus PNG exports of each chart to bench/report-assets/ (rasterized with the +// resvg-js available at ~/.claude/skills/tmux-pane-screenshot/scripts/node_modules). // Real data only: cells with no results render as "not run". +// +// Audience note: the report is written for a 60-second skim — verdicts first, +// plain language everywhere, precise stats terms kept in parentheses. import { createRequire } from 'node:module' import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -15,6 +18,7 @@ const here = dirname(fileURLToPath(import.meta.url)) const RESULTS_DIR = join(here, 'results') const ASSETS_DIR = join(here, 'report-assets') const OUT_HTML = join(here, 'report.html') +const DIST_FILE = join(here, 'session-distribution.json') const CAP_MB = 2048 @@ -39,6 +43,14 @@ function loadResults() { return out } +function loadDistribution() { + try { + return JSON.parse(readFileSync(DIST_FILE, 'utf8')) + } catch { + return null + } +} + // ── stats ─────────────────────────────────────────────────────────────── const quantile = (sorted, q) => { if (!sorted.length) return null @@ -47,7 +59,8 @@ const quantile = (sorted, q) => { const hi = Math.ceil(pos) return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) } -const median = xs => quantile(xs.slice().sort((a, b) => a - b), 0.5) +const pq = (xs, q) => quantile(xs.slice().sort((a, b) => a - b), q) +const median = xs => pq(xs, 0.5) const iqr = xs => { const s = xs.slice().sort((a, b) => a - b) return [quantile(s, 0.25), quantile(s, 0.75)] @@ -56,7 +69,7 @@ const fmt = (x, d = 1) => (x === null || x === undefined || Number.isNaN(x) ? ' const fmtMedIqr = (xs, d = 1) => { if (!xs.length) return 'not run' const [lo, hi] = iqr(xs) - return `${fmt(median(xs), d)} [${fmt(lo, d)}–${fmt(hi, d)}]` + return `${fmt(median(xs), d)} [middle half: ${fmt(lo, d)}–${fmt(hi, d)}]` } // least-squares slope of rss_mb vs msgs over points @@ -101,13 +114,14 @@ function runPlateau(run) { // ── SVG primitives ────────────────────────────────────────────────────── const COLORS = { ink: '#e06c75', 'otui-capped': '#61afef', 'otui-uncapped': '#56b6c2', other: '#c678dd' } +const NICE = { ink: 'Ink', 'otui-capped': 'OpenTUI', 'otui-uncapped': 'OpenTUI (no cap)' } const esc = s => String(s).replace(/&/g, '&').replace(//g, '>') -function chart({ title, w = 860, h = 440, xLabel, yLabel, xMax, yMax, series, capLine, markers = [], note }) { - const padL = 64 - const padR = 16 - const padT = 40 - const padB = 64 +function chart({ title, w = 1040, h = 500, xLabel, yLabel, xMax, yMax, series, capLine, markers = [], note }) { + const padL = 72 + const padR = 18 + const padT = 48 + const padB = 70 const pw = w - padL - padR const ph = h - padT - padB const X = x => padL + (x / xMax) * pw @@ -115,7 +129,7 @@ function chart({ title, w = 860, h = 440, xLabel, yLabel, xMax, yMax, series, ca const parts = [] parts.push(``) parts.push(``) - parts.push(`${esc(title)}`) + parts.push(`${esc(title)}`) // grid + axes const xticks = 6 const yticks = 5 @@ -123,73 +137,73 @@ function chart({ title, w = 860, h = 440, xLabel, yLabel, xMax, yMax, series, ca const xv = (xMax / xticks) * i const x = X(xv) parts.push(``) - parts.push(`${Math.round(xv)}`) + parts.push(`${Math.round(xv)}`) } for (let i = 0; i <= yticks; i++) { const yv = (yMax / yticks) * i const y = Y(yv) parts.push(``) - parts.push(`${Math.round(yv)}`) + parts.push(`${Math.round(yv)}`) } - parts.push(`${esc(xLabel)}`) + parts.push(`${esc(xLabel)}`) parts.push( - `${esc(yLabel)}` + `${esc(yLabel)}` ) if (capLine != null && capLine <= yMax) { parts.push( `` ) - parts.push(`cap ${capLine} MB`) + parts.push(`hard memory cap ${capLine} MB`) } - let legendY = padT + 8 + let legendY = padT + 10 for (const s of series) { if (!s.points.length) continue const d = s.points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${X(Math.min(x, xMax)).toFixed(1)},${Y(Math.min(y, yMax)).toFixed(1)}`).join(' ') parts.push(``) if (s.label) { - parts.push(``) - parts.push(`${esc(s.label)}`) - legendY += 16 + parts.push(``) + parts.push(`${esc(s.label)}`) + legendY += 18 } } for (const m of markers) { const x = X(Math.min(m.x, xMax)) const y = Y(Math.min(m.y, yMax)) - parts.push(`×`) - if (m.label) parts.push(`${esc(m.label)}`) + parts.push(`×`) + if (m.label) parts.push(`${esc(m.label)}`) } - if (note) parts.push(`${esc(note)}`) + if (note) parts.push(`${esc(note)}`) parts.push('') return parts.join('\n') } -function barChart({ title, w = 860, h = 360, groups, yLabel, note }) { +function barChart({ title, w = 1040, h = 420, groups, yLabel, note, barWidth = 56 }) { // groups: [{label, bars: [{name, value, lo, hi, color}]}] - const padL = 64 - const padR = 16 - const padT = 40 - const padB = 64 + const padL = 72 + const padR = 18 + const padT = 48 + const padB = 74 const pw = w - padL - padR const ph = h - padT - padB const vals = groups.flatMap(g => g.bars.map(b => b.hi ?? b.value)).filter(v => v != null) if (!vals.length) return null - const yMax = Math.max(...vals) * 1.15 + const yMax = Math.max(...vals) * 1.18 const Y = y => padT + ph - (y / yMax) * ph const parts = [] parts.push(``) parts.push(``) - parts.push(`${esc(title)}`) + parts.push(`${esc(title)}`) for (let i = 0; i <= 5; i++) { const yv = (yMax / 5) * i parts.push(``) - parts.push(`${yv >= 100 ? Math.round(yv) : yv.toFixed(1)}`) + parts.push(`${yv >= 100 ? Math.round(yv) : yv.toFixed(1)}`) } parts.push( - `${esc(yLabel)}` + `${esc(yLabel)}` ) const gw = pw / groups.length groups.forEach((g, gi) => { - const bw = Math.min(46, (gw - 24) / Math.max(1, g.bars.length)) + const bw = Math.min(barWidth, (gw - 28) / Math.max(1, g.bars.length)) g.bars.forEach((b, bi) => { if (b.value == null) return const x = padL + gi * gw + gw / 2 - (g.bars.length * bw) / 2 + bi * bw @@ -198,16 +212,123 @@ function barChart({ title, w = 860, h = 360, groups, yLabel, note }) { const cx = x + bw / 2 parts.push(``) } - parts.push(`${b.value >= 100 ? Math.round(b.value) : b.value.toFixed(1)}`) - parts.push(`${esc(b.name)}`) + parts.push(`${b.value >= 100 ? Math.round(b.value) : b.value.toFixed(1)}`) + parts.push(`${esc(b.name)}`) }) - parts.push(`${esc(g.label)}`) + parts.push(`${esc(g.label)}`) }) - if (note) parts.push(`${esc(note)}`) + if (note) parts.push(`${esc(note)}`) parts.push('') return parts.join('\n') } +// categorical histogram with percentile markers (uneven buckets, equal-width bars) +function histChart({ title, buckets, percentiles, w = 1040, h = 460, yLabel, note }) { + const bs = buckets.filter(b => b.count > 0 || b.hi != null) + while (bs.length && bs[bs.length - 1].count === 0) bs.pop() + if (!bs.length) return null + const padL = 72 + const padR = 18 + const padT = 96 // room for staggered percentile labels + const padB = 70 + const pw = w - padL - padR + const ph = h - padT - padB + const maxC = Math.max(...bs.map(b => b.count)) + const yMax = maxC * 1.1 + const Y = y => padT + ph - (y / yMax) * ph + const bw = pw / bs.length + const parts = [] + parts.push(``) + parts.push(``) + parts.push(`${esc(title)}`) + for (let i = 0; i <= 5; i++) { + const yv = (yMax / 5) * i + parts.push(``) + parts.push(`${Math.round(yv)}`) + } + parts.push( + `${esc(yLabel)}` + ) + bs.forEach((b, i) => { + const x = padL + i * bw + parts.push(``) + parts.push(`${b.count}`) + const lbl = b.hi == null ? `${b.lo}+` : `${b.lo}–${b.hi}` + parts.push(`${esc(lbl)}`) + }) + parts.push(`messages in the session`) + // percentile markers: position within the containing bucket (linear within bucket) + percentiles.forEach((p, i) => { + let bi = bs.findIndex(b => p.v >= b.lo && (b.hi == null || p.v < b.hi)) + if (bi < 0) bi = bs.length - 1 + const b = bs[bi] + const frac = b.hi == null ? 0.5 : (p.v - b.lo) / (b.hi - b.lo) + const x = padL + (bi + frac) * bw + const labelY = 46 + (i % 3) * 16 + const flip = x > w - 160 + parts.push(``) + parts.push(`${esc(p.label)}`) + }) + if (note) parts.push(`${esc(note)}`) + parts.push('') + return parts.join('\n') +} + +// ── aggregate stats used by verdicts + sections ───────────────────────── +function aggregate(results) { + const A = {} + const cell = (name, cfg) => + results.filter(r => r.meta.cell === name && (cfg == null || r.meta.config === cfg) && !r.meta.instrumented) + + // memory peaks (VmHWM = process peak RSS) per cell/config + A.memPeak = {} + for (const c of ['mem100', 'mem300', 'mem2000', 'mem3000']) { + A.memPeak[c] = {} + for (const cfg of ['ink', 'otui-capped', 'otui-uncapped']) { + const v = cell(c, cfg).filter(r => r.meta.mode === 'mem').map(r => r.summary.vmhwm_kb).filter(Boolean).map(k => k / 1024) + if (v.length) A.memPeak[c][cfg] = { med: median(v), n: v.length, all: v } + } + } + // mem3000 plateaus + A.plateau3000 = {} + for (const cfg of ['ink', 'otui-capped', 'otui-uncapped']) { + const v = cell('mem3000', cfg).filter(r => r.meta.mode === 'mem').map(runPlateau).filter(x => x != null) + if (v.length) A.plateau3000[cfg] = { med: median(v), all: v } + } + + // scroll latencies pooled per config + A.scroll = {} + for (const r of results.filter(r => r.meta.cell.startsWith('scroll') && r.summary.scroll_latencies_ms?.length)) { + ;(A.scroll[r.meta.config] ??= []).push(...r.summary.scroll_latencies_ms) + } + + // echo + A.echo = {} + for (const r of results.filter(r => r.meta.cell === 'echo' && r.summary.echo)) A.echo[r.meta.config] = r.summary.echo + + // pipeline (cpu + frame pacing) + A.pipeline = {} + for (const r of results.filter(r => r.meta.cell === 'pipeline' && r.summary.pipeline)) { + A.pipeline[r.meta.config] = { cpu: r.summary.pipeline.cpu_s, fp: r.summary.frame_pacing, bytes: r.summary.pipeline.bytes_total, msgs: r.summary.msgs_streamed } + } + + // chaos: scenario × config + A.chaos = {} + for (const r of results.filter(r => r.meta.cell === 'chaos' && r.summary.chaos)) { + const sc = r.summary.chaos.scenario + ;(A.chaos[sc] ??= {})[r.meta.config] = r.summary.chaos + } + + // startup + A.startup = {} + for (const r of results.filter(r => r.meta.cell === 'startup')) { + const c = (A.startup[r.meta.config] ??= { fb: [], sc: [] }) + if (r.summary.first_byte_ms != null) c.fb.push(r.summary.first_byte_ms) + if (r.summary.session_create_ms != null) c.sc.push(r.summary.session_create_ms) + } + return A +} + // ── chart builders ────────────────────────────────────────────────────── function rssChart(results) { const runs = results.filter( @@ -226,26 +347,42 @@ function rssChart(results) { yMax = Math.max(yMax, ...pts.map(p => p[1])) const color = COLORS[r.meta.config] ?? COLORS.other const key = r.meta.config - series.push({ points: pts, color, width: r.meta.cell.startsWith('slope') ? 2.5 : 1.5, opacity: 0.8, label: seen.has(key) ? null : `${key}` }) + series.push({ + points: pts, + color, + width: r.meta.cell.startsWith('slope') ? 2.5 : 1.5, + opacity: 0.8, + label: seen.has(key) ? null : `${NICE[key] ?? key}` + }) seen.add(key) if (r.summary.cap_hit) { const last = pts[pts.length - 1] - markers.push({ x: last[0], y: last[1], label: `OOM @${last[0]}`, color: '#e5c07b' }) + markers.push({ x: last[0], y: last[1], label: `out of memory @${last[0]}`, color: '#e5c07b' }) } else if (r.summary.result === 'died' || r.summary.result === 'crashed_after_stream') { const last = pts[pts.length - 1] markers.push({ x: last[0], y: last[1], label: `crash @${last[0]}`, color: '#e06c75' }) } } + // de-duplicate stacked markers (several repeats crash at the same boundary) + const seenMarks = new Set() + const dedup = markers.filter(m => { + const k = `${m.label}:${Math.round(m.x / 100)}` + if (seenMarks.has(k)) return false + seenMarks.add(k) + return true + }) + markers.length = 0 + markers.push(...dedup) return chart({ - title: 'RSS vs fixture messages (clean memory runs, all reps + slope runs)', - xLabel: 'fixture messages (rowsPerTurn accounting)', - yLabel: 'RSS (MB)', + title: 'Memory used as the conversation grows (stress runs, every repeat shown)', + xLabel: 'messages streamed into the session', + yLabel: 'memory (MB)', xMax: Math.max(xMax, 1000), yMax: yMax * 1.08, series, capLine: CAP_MB, markers, - note: '2GB cgroup cap (systemd-run, MemorySwapMax=0). × = cgroup OOM kill. Samples every 100 msgs from /proc/PID/smaps_rollup.' + note: '2GB hard cap (systemd cgroup). × = killed for running out of memory. Crash marks on older OpenTUI runs are the exit-7 bug, fixed in the latest runs.' }) } @@ -258,7 +395,6 @@ function nodesChart(results) { for (const r of runs) { let pts = [] if (r.node_samples?.length) { - // Ink fd-3 sampler: align wall-clock node samples to msg boundaries. const t0 = Date.parse(r.meta.utc) const bounds = r.samples.filter(s => s.kind === 'boundary' && s.msgs != null) pts = r.node_samples.map(ns => { @@ -267,17 +403,16 @@ function nodesChart(results) { for (const b of bounds) if (b.t_ms <= el) msgs = b.msgs return [msgs, ns.yoga] }) - // collapse to last sample per msg count const byMsg = new Map() for (const [m, y] of pts) byMsg.set(m, y) pts = [...byMsg.entries()].sort((a, b) => a[0] - b[0]) - series.push({ points: pts, color: COLORS.ink, label: 'ink live yoga nodes (fd-3 sampler)' }) + series.push({ points: pts, color: COLORS.ink, label: 'Ink live layout nodes' }) } else if (r.samples.some(s => s.renderables != null)) { pts = r.samples.filter(s => s.renderables != null).map(s => [s.msgs, s.renderables]) series.push({ points: pts, color: COLORS[r.meta.config] ?? COLORS.other, - label: `${r.meta.config} renderables (headless walk)` + label: `${NICE[r.meta.config] ?? r.meta.config} renderables` }) } for (const [x, y] of pts) { @@ -287,13 +422,13 @@ function nodesChart(results) { } if (!series.length) return null return chart({ - title: 'Mounted node count vs messages (instrumented runs — mechanism witness, never headlined)', - xLabel: 'fixture messages', + title: 'Live UI nodes during the 3,000-msg marathon (diagnostic run)', + xLabel: 'messages streamed into the session', yLabel: 'live nodes', xMax: Math.max(xMax, 100), yMax: yMax * 1.1, series, - note: 'instrumented:true. Ink: HERMES_TUI_MEMSAMPLE_FD walk of the forked reconciler root. OpenTUI: scripts/mem-bench.tsx renderable walk (headless, diagnostic-only).' + note: 'Diagnostic instrumented runs only — never used for the headline numbers.' }) } @@ -311,16 +446,16 @@ function scrollCdfChart(results) { const s = lats.slice().sort((a, b) => a - b) xMax = Math.max(xMax, quantile(s, 0.995)) const pts = s.map((v, i) => [v, ((i + 1) / s.length) * 100]) - series.push({ points: pts, color: COLORS[config] ?? COLORS.other, label: `${config} (n=${s.length})` }) + series.push({ points: pts, color: COLORS[config] ?? COLORS.other, label: `${NICE[config] ?? config} (${s.length} scrolls)` }) } return chart({ - title: `Scroll latency CDF — SGR wheel 30Hz×15s on ${transcriptMsgs}-msg transcript (all reps pooled)`, - xLabel: 'write → first output byte (ms)', - yLabel: 'percentile (%)', + title: `What fraction of scroll responses finished within X ms (${transcriptMsgs}-msg transcript)`, + xLabel: 'time from scroll input to first screen response (ms)', + yLabel: '% of scroll responses at least this fast', xMax: Math.max(1, xMax), yMax: 100, series, - note: 'Latency = PTY write of the wheel event to the next PTY output chunk.' + note: 'Mouse wheel fired 30×/s for 15s, three repeats pooled. Higher curve further left = more responses answered fast.' }) } @@ -334,17 +469,18 @@ function startupChart(results) { if (r.summary.session_create_ms != null) c.sc.push(r.summary.session_create_ms) } const groups = Object.entries(byConfig).map(([config, v]) => ({ - label: config, + label: NICE[config] ?? config, bars: [ - { name: 'first byte', value: median(v.fb), lo: iqr(v.fb)[0], hi: iqr(v.fb)[1], color: COLORS[config] ?? COLORS.other }, - { name: 'session.create', value: median(v.sc), lo: iqr(v.sc)[0], hi: iqr(v.sc)[1], color: '#98c379' } + { name: 'first paint', value: median(v.fb), lo: iqr(v.fb)[0], hi: iqr(v.fb)[1], color: COLORS[config] ?? COLORS.other }, + { name: 'session ready', value: median(v.sc), lo: iqr(v.sc)[0], hi: iqr(v.sc)[1], color: '#98c379' } ] })) return barChart({ - title: 'Startup (fake gateway) — median of 10 reps, whiskers = IQR', - yLabel: 'ms from spawn', + title: 'Startup: time until something is on screen, and until the session is ready', + yLabel: 'ms after launch (lower = faster)', groups, - note: 'first byte = first PTY output; session.create = the UI reaches its session bootstrap RPC.' + barWidth: 110, + note: 'Typical of 10 launches (median); whisker = middle half of runs. "first paint" = first byte drawn to the terminal.' }) } @@ -359,34 +495,367 @@ function ptyRateChart(results) { const secs = (done.t_ms - start) / 1000 const c = (byConfig[r.meta.config] ??= { rate: [], cpu: [] }) c.rate.push(done.pty_bytes / secs / 1024) - // CPU ms per event over the streaming window const sb = r.samples.filter(s => s.kind === 'boundary') if (sb.length >= 2) { const first = sb[0] const last = sb[sb.length - 1] const ticks = last.utime_ticks + last.stime_ticks - first.utime_ticks - first.stime_ticks const events = (last.events ?? 0) - (first.events ?? 0) - if (events > 0) c.cpu.push((ticks * 10) / events) // 100Hz ticks → ms + if (events > 0) c.cpu.push((ticks * 10) / events) } } const groups = Object.entries(byConfig).map(([config, v]) => ({ - label: config, + label: NICE[config] ?? config, bars: [ - { name: 'PTY KiB/s', value: median(v.rate), lo: iqr(v.rate)[0], hi: iqr(v.rate)[1], color: COLORS[config] ?? COLORS.other }, - { name: 'CPU ms/event', value: median(v.cpu), lo: iqr(v.cpu)[0], hi: iqr(v.cpu)[1], color: '#d19a66' } + { name: 'KiB/s', value: median(v.rate), lo: iqr(v.rate)[0], hi: iqr(v.rate)[1], color: COLORS[config] ?? COLORS.other }, + { name: 'ms/event', value: median(v.cpu), lo: iqr(v.cpu)[0], hi: iqr(v.cpu)[1], color: '#d19a66' } ] })) return barChart({ - title: 'Paced streaming (30 ev/s): PTY output rate + CPU per event — median±IQR over reps', - yLabel: 'KiB/s · ms/event', + title: 'Streaming at 30 events/s: terminal output volume and CPU cost per event', + yLabel: 'output KiB/s · CPU ms per event', groups, - note: 'CPU from /proc/PID/stat utime+stime deltas across the stream window (UI process only).' + barWidth: 100, + note: 'Typical of 3 runs (median); whisker = middle half. CPU is the UI process only, measured over the stream.' }) } +function sessionHistChart(dist) { + if (!dist?.tui_cli?.histogram) return null + const p = dist.tui_cli + return histChart({ + title: `How long the user's real terminal sessions actually are (${p.n} sessions)`, + buckets: p.histogram, + yLabel: 'number of sessions', + percentiles: [ + { v: p.p50, label: `half are ≤${p.p50} (p50)` }, + { v: p.p75, label: `75% ≤${p.p75}` }, + { v: p.p90, label: `90% ≤${p.p90}` }, + { v: p.p95, label: `95% ≤${p.p95}` }, + { v: p.p99, label: `99% ≤${p.p99} (p99)` } + ], + note: `Every TUI/CLI session in the real session DB (${dist.db ?? 'state.db'}); message counts per session.` + }) +} + +function memRealChart(A) { + const groups = [] + for (const [cellName, label] of [ + ['mem100', '100 msgs (heavy-ish day)'], + ['mem300', '300 msgs (top 5% of sessions)'], + ['mem2000', '2,000 msgs (longest real sessions)'] + ]) { + const m = A.memPeak[cellName] + if (!m) continue + const bars = [] + if (m.ink) bars.push({ name: 'Ink', value: m.ink.med, color: COLORS.ink }) + if (m['otui-capped']) bars.push({ name: 'OpenTUI', value: m['otui-capped'].med, color: COLORS['otui-capped'] }) + if (bars.length) groups.push({ label, bars }) + } + if (!groups.length) return null + return barChart({ + title: 'Peak memory at real session sizes — Ink vs OpenTUI', + yLabel: 'peak memory (MB)', + groups, + note: 'Peak resident memory of the UI process (VmHWM), typical of 2 repeats (median).' + }) +} + +function frameRateChart(A) { + const groups = [] + const fpsBars = [] + const gapBars = [] + for (const cfg of ['ink', 'otui-capped']) { + const fp = A.pipeline[cfg]?.fp + if (!fp) continue + fpsBars.push({ name: NICE[cfg], value: fp.fps_avg, color: COLORS[cfg] }) + gapBars.push({ name: NICE[cfg], value: fp.interframe_ms_p95, color: COLORS[cfg] }) + } + if (!fpsBars.length) return null + groups.push({ label: 'screen updates per second (higher = smoother)', bars: fpsBars }) + return barChart({ + title: 'Frame smoothness while text streams in', + yLabel: 'frames per second', + groups, + barWidth: 90, + note: '800-message stream at 30 events/s; a frame = a burst of terminal output separated by a ≥4ms gap.' + }) +} + +function frameGapChart(A) { + const mk = key => { + const bars = [] + for (const cfg of ['ink', 'otui-capped']) { + const fp = A.pipeline[cfg]?.fp + if (fp) bars.push({ name: NICE[cfg], value: fp[key], color: COLORS[cfg] }) + } + return bars + } + const typical = mk('interframe_ms_p50') + const worst = mk('interframe_ms_p95') + if (!worst.length) return null + return barChart({ + title: 'Pauses between screen updates while streaming (lower = steadier)', + yLabel: 'gap between frames (ms)', + groups: [ + { label: 'typical gap (p50)', bars: typical }, + { label: 'slowest 1 in 20 gaps (p95)', bars: worst } + ], + barWidth: 90, + note: 'Same 800-message stream. The right-hand pair is the stutter you actually notice.' + }) +} + +function pipelineCpuChart(A) { + const groups = [] + for (const cfg of ['ink', 'otui-capped']) { + const c = A.pipeline[cfg]?.cpu + if (!c) continue + groups.push({ + label: NICE[cfg], + bars: [ + { name: 'UI', value: c.ui, color: COLORS[cfg] }, + { name: 'gateway', value: c.gateway, color: '#98c379' }, + { name: 'tmux', value: c.tmux_server, color: '#d19a66' } + ] + }) + } + if (!groups.length) return null + return barChart({ + title: 'Total CPU burned streaming the same 800-message conversation', + yLabel: 'CPU seconds', + groups, + note: 'Whole pipeline measured inside tmux: UI process + gateway + the tmux server that has to parse the UI’s output.' + }) +} + +// ── verdicts ──────────────────────────────────────────────────────────── +function buildVerdicts(A, dist, results) { + const r1 = x => (x == null ? null : Math.round(x)) + const rows = [] + const add = (dim, winner, headline, detail) => rows.push({ dim, winner, headline, detail }) + + // memory typical + { + const d100 = A.memPeak.mem100?.['otui-capped'] && A.memPeak.mem100?.ink ? A.memPeak.mem100['otui-capped'].med - A.memPeak.mem100.ink.med : null + const d300 = A.memPeak.mem300?.['otui-capped'] && A.memPeak.mem300?.ink ? A.memPeak.mem300['otui-capped'].med - A.memPeak.mem300.ink.med : null + if (d100 != null || d300 != null) { + add( + 'Memory — typical real sessions (20–300 msgs)', + 'ink', + 'Ink wins, modestly', + `OpenTUI uses ~${r1(d100)}–${r1(d300)}MB more (${r1(A.memPeak.mem100.ink.med)} vs ${r1(A.memPeak.mem100['otui-capped'].med)}MB at 100 msgs; ${r1(A.memPeak.mem300.ink.med)} vs ${r1(A.memPeak.mem300['otui-capped'].med)}MB at 300).` + ) + } + } + // memory p99 tail + { + const i = A.memPeak.mem2000?.ink?.med + const o = A.memPeak.mem2000?.['otui-capped']?.med + if (i != null && o != null) { + add( + 'Memory — longest real sessions (~2,000 msgs, the longest 1 in 100 = p99)', + 'ink', + 'Ink wins big', + `${r1(i)}MB vs ${r1(o)}MB peak — ${(o / i).toFixed(1)}× more. Sessions this long really happen (6 of them in the DB).` + ) + } + } + // memory stress + { + const ip = A.plateau3000?.ink?.med + const op = A.plateau3000?.['otui-capped']?.med + const opk = A.memPeak.mem3000?.['otui-capped']?.med + if (ip != null && op != null) { + add( + 'Memory — 3,000-msg stress marathon (beyond any real session)', + 'ink', + 'Ink wins', + `Ink levels off near ${r1(ip)}MB; OpenTUI climbs to ~${r1(op)}MB (peak ~${r1(opk)}MB), and its syntax styling degrades past ~1,400 rows. Stress test only — past the longest real session.` + ) + } + } + // scroll + { + const i = A.scroll.ink + const oc = A.scroll['otui-capped'] + const ou = A.scroll['otui-uncapped'] + if (i?.length && oc?.length) { + const perRep = cfg => + results + .filter(r => r.meta.cell.startsWith('scroll') && r.meta.config === cfg && r.summary.scroll_latencies_ms?.length) + .map(r => pq(r.summary.scroll_latencies_ms, 0.99)) + const iReps = perRep('ink') + const oReps = [...perRep('otui-capped'), ...perRep('otui-uncapped')] + const op99s = [pq(oc, 0.99), ou?.length ? pq(ou, 0.99) : null].filter(x => x != null) + add( + 'Scroll responsiveness on a long transcript', + 'otui', + 'OpenTUI wins decisively', + `Slowest 1-in-100 scroll responses (p99): ${r1(Math.min(...op99s))}–${r1(Math.max(...op99s))}ms vs ${r1(Math.min(...iReps))}–${r1(Math.max(...iReps))}ms across repeats. Typical scrolls are ~2ms on both — the difference is the stutters.` + ) + } + } + // frame smoothness + { + const fi = A.pipeline.ink?.fp + const fo = A.pipeline['otui-capped']?.fp + if (fi && fo) { + add( + 'Frame smoothness while streaming', + 'otui', + 'OpenTUI wins', + `${fo.fps_avg.toFixed(0)} vs ${fi.fps_avg.toFixed(0)} screen updates/s, and its worst pauses between updates are half as long (${r1(fo.interframe_ms_p95)}ms vs ${r1(fi.interframe_ms_p95)}ms, slowest 1 in 20).` + ) + } + } + // echo + { + const ei = A.echo.ink + const eo = A.echo['otui-capped'] + if (ei && eo) { + add('Typing echo (keystroke → it appears)', 'tie', 'Tie', `Both answer a keystroke in ${r1(ei.echo_ms.p50)}–${r1(eo.echo_ms.p50)}ms — under any human threshold.`) + add( + 'Submit → first reply paint', + 'ink', + 'Ink wins', + `${r1(ei.submit_first_token_paint_ms)}ms vs ${r1(eo.submit_first_token_paint_ms)}ms from pressing Enter to the first reply text on screen.` + ) + } + } + // CPU + { + const ci = A.pipeline.ink?.cpu + const co = A.pipeline['otui-capped']?.cpu + if (ci && co) { + add( + 'CPU, including the terminal-emulator (tmux) side', + 'tie', + 'Tie', + `~80 CPU-seconds either way for the same 800-message stream (${ci.total.toFixed(0)} vs ${co.total.toFixed(0)}s total); the tmux leg is ~0.4s for both.` + ) + } + } + // chaos + { + const scen = ['gw-kill-stream', 'gw-kill-tool', 'gw-stop'] + const iT = scen.map(s => A.chaos[s]?.ink?.time_to_respawn_ms).filter(x => x != null) + const oT = scen.map(s => A.chaos[s]?.['otui-capped']?.time_to_respawn_ms).filter(x => x != null) + if (iT.length && oT.length) { + add( + 'Crash recovery (gateway shot mid-stream)', + 'tie', + 'Tie', + `Both auto-respawn the killed gateway and end with the full transcript intact and zero orphan processes. Ink respawns in ~${r1(median(iT))}ms, OpenTUI in ~${(median(oT) / 1000).toFixed(1)}s.` + ) + } + } + // startup + { + const si = A.startup.ink + const so = A.startup['otui-capped'] + if (si?.fb.length && so?.fb.length) { + add( + 'Startup', + 'ink', + 'Ink wins, modestly', + `First paint ${r1(median(si.fb))}ms vs ${r1(median(so.fb))}ms. Both feel instant; OpenTUI actually reaches “session ready” slightly sooner (${r1(median(so.sc))} vs ${r1(median(si.sc))}ms).` + ) + } + } + return rows +} + +function verdictTable(rows) { + if (!rows.length) return '

No results yet.

' + const cellFor = r => { + const cls = r.winner === 'ink' ? 'win-ink' : r.winner === 'otui' ? 'win-otui' : 'win-tie' + return `${esc(r.headline)}` + } + return ` + + ${rows.map(r => `${cellFor(r)}`).join('\n')} +
dimensionwinnerthe numbers
${r.dim}${r.detail}
+

  red = Ink (current UI) wins  ·    green = OpenTUI (new engine) wins  ·    grey = tie

` +} + // ── tables ────────────────────────────────────────────────────────────── +function memMediansTable(A, dist) { + const rowsDef = [ + ['mem100', '100 msgs', 'a heavier-than-usual day (typical session is ~20 msgs)'], + ['mem300', '300 msgs', 'top ~5% of real sessions'], + ['mem2000', '2,000 msgs', 'the longest sessions that actually occur (~1 in 100, p99)'] + ] + const rows = [] + for (const [c, label, gloss] of rowsDef) { + const m = A.memPeak[c] + if (!m?.ink || !m['otui-capped']) continue + const i = m.ink.med + const o = m['otui-capped'].med + rows.push( + `${label}${gloss}${Math.round(i)} MB${Math.round(o)} MBOpenTUI +${Math.round(o - i)} MB (${(o / i).toFixed(1)}×)` + ) + } + if (!rows.length) return '

memory-at-size cells: not run

' + return `${rows.join('\n')}
session sizewhat that means in practiceInk peakOpenTUI peakdifference
` +} + +function chaosTable(A) { + const scen = Object.keys(A.chaos) + if (!scen.length) return '

chaos cells: not run

' + const DESC = { + 'gw-kill-stream': 'shot the gateway (kill -9) while reply text was streaming', + 'gw-kill-tool': 'shot the gateway (kill -9) in the middle of a tool call', + 'gw-stop': 'froze the gateway for 30 seconds mid-session, then let the UI recover', + 'pty-eof': 'closed the terminal out from under the UI (should exit cleanly, leave nothing behind)', + 'resize-storm': 'resized the window 30 times in 3 seconds' + } + const ORDER = ['gw-kill-stream', 'gw-kill-tool', 'gw-stop', 'resize-storm', 'pty-eof'] + const ok = 'yes' + const no = 'no' + const cellFor = c => { + if (!c) return 'not run' + const bits = [] + if (c.scenario === 'pty-eof') { + bits.push(`exited cleanly: ${c.ui_exited_after_eof ? ok : no}`) + bits.push(`gateway cleaned up: ${c.gateway_reaped ? ok : no} (${c.gateway_reaped_ms}ms)`) + } else if (c.scenario === 'resize-storm') { + bits.push(`survived: ${c.ui_survived ? ok : no}`) + bits.push(`transcript intact: ${c.transcript_preserved ? ok : no}`) + } else { + bits.push(`UI survived: ${c.ui_survived ? ok : no}`) + if (c.gateway_respawned != null) + bits.push(`gateway respawned: ${c.gateway_respawned ? ok : no} (${c.time_to_respawn_ms >= 1000 ? (c.time_to_respawn_ms / 1000).toFixed(1) + 's' : c.time_to_respawn_ms + 'ms'})`) + if (c.stream_resumed != null) bits.push(`stream resumed: ${c.stream_resumed ? ok : no}`) + bits.push(`transcript intact: ${c.transcript_preserved ? ok : no}`) + } + bits.push(`orphan processes: ${(c.orphans ?? []).length === 0 ? 'none' : `${c.orphans.length}`}`) + return `${bits.join('
')}` + } + const rows = ORDER.filter(s => A.chaos[s]).map( + s => `${esc(s)}
${esc(DESC[s] ?? '')}${cellFor(A.chaos[s].ink)}${cellFor(A.chaos[s]['otui-capped'])}` + ) + return `${rows.join('\n')}
what we didInkOpenTUI
` +} + +function echoTable(A) { + const ei = A.echo.ink + const eo = A.echo['otui-capped'] + if (!ei && !eo) return '

echo cells: not run

' + const row = (cfg, e) => + e + ? `${NICE[cfg]} + ${fmt(e.echo_ms.p50, 0)} ms${fmt(e.echo_ms.p95, 1)} ms + ${fmt(e.submit_first_token_paint_ms, 0)} ms${e.keystrokes_matched}/${e.keystrokes_sent}` + : '' + return ` + + ${row('ink', ei)}${row('otui-capped', eo)} +
UIkeystroke echo, typical (p50)keystroke echo, slowest 1 in 20 (p95)Enter → first reply text on screenkeystrokes verified
` +} + function matrixTable(results) { - const memRuns = results.filter(r => r.meta.cell.startsWith('mem') && r.meta.mode === 'mem' && !r.meta.instrumented) + const memRuns = results.filter(r => r.meta.cell === 'mem3000' && r.meta.mode === 'mem' && !r.meta.instrumented) const slopeRuns = results.filter(r => r.meta.cell.startsWith('slope')) const scrollRuns = results.filter(r => r.meta.cell.startsWith('scroll')) const cpuRuns = results.filter(r => r.meta.cell.startsWith('cpu')) @@ -412,7 +881,7 @@ function matrixTable(results) { } const capHits = [...mem, ...slopeRuns.filter(r => r.meta.config === config)].filter(r => r.summary.cap_hit) rows.push(` - ${config} + ${NICE[config] ?? config} ${fmtMedIqr(slopes, 2)} ${slope10k.length ? fmt(median(slope10k), 2) : 'not run'} ${fmtMedIqr(plateaus, 0)} @@ -423,20 +892,25 @@ function matrixTable(results) { `) } return ` - + ${rows.join('\n')}
configslope MB/1k msgs
(3000-msg runs)
slope MB/1k
(10k run)
plateau RSS MB
(final quartile)
VmHWM MBscroll p50/p90/p99 msCPU ms/event
(paced)
cap hits (2GB)
configmemory growth
MB per 1k msgs
(3,000-msg runs)
memory growth
MB per 1k
(10,000-msg run)
settled memory MB
(plateau, end of run)
peak memory MBscroll ms — typical /
slowest 1 in 10 /
slowest 1 in 100
(p50/p90/p99)
CPU ms per event
(paced stream)
killed by 2GB cap?
` } function survivalTable(results) { const runs = results.filter(r => r.meta.cell.startsWith('e3')) - if (!runs.length) return '

E3 (memory-constrained Docker survival): not run.

' + if (!runs.length) return '

Low-memory survival (Docker): not run.

' + const fmtLimit = v => { + const n = Number(v) + if (Number.isFinite(n) && n > 1e6) return `${Math.round(n / 1073741824 * 10) / 10} GB` + return String(v ?? '?') + } const rows = runs.map( - r => `${esc(r.meta.cell)}${esc(r.meta.config)}${esc(String(r.meta.memory_max ?? r.meta.container_memory ?? '?'))} + r => `${esc(r.meta.cell)}${esc(NICE[r.meta.config] ?? r.meta.config)}${esc(fmtLimit(r.meta.memory_max ?? r.meta.container_memory))} ${r.summary.result}${r.summary.at_messages ?? r.summary.msgs_streamed ?? '—'} ${fmt((r.summary.vmhwm_kb ?? 0) / 1024, 0)} MB${esc(r.summary.cap_hit_basis ?? '—')}` ) - return `${rows.join('')}
cellconfiglimitresultmsgs survivedVmHWMbasis
` + return `${rows.join('')}
cellUImemory limitresultmsgs survivedpeak memorybasis
` } function gateTable(results) { @@ -446,19 +920,19 @@ function gateTable(results) { for (const r of runs) (byConfig[r.meta.config] ??= []).push(r.summary.digest) const rows = Object.entries(byConfig).map(([c, ds]) => { const ok = ds.length >= 2 && ds.every(d => d && d === ds[0]) - return `${esc(c)}${ds.map(d => (d ?? '∅').slice(0, 16)).join(' · ')}${ok ? 'PASS' : 'FAIL'}` + return `${esc(NICE[c] ?? c)}${ds.map(d => (d ?? '∅').slice(0, 16)).join(' · ')}${ok ? 'PASS' : 'FAIL'}` }) - return `${rows.join('')}
configreplay digestsgate
` + return `${rows.join('')}
UIreplay fingerprints (same input must give same screen)gate
` } function drainTable(results) { const bad = results.filter(r => r.summary && r.summary.drain_ok === false) - if (!bad.length) return '

PTY drain assertion: no run exceeded the 10ms event-loop starvation budget.

' + if (!bad.length) return '

Harness self-check: no run was distorted by the test rig itself (event loop never starved >10ms).

' const rows = bad.map( r => `${esc(r._file)}${fmt(r.summary.drain_max_loop_lag_ms, 0)} ms${r.summary.drain_lag_violations}` ) - return `

⚠ drain assertion violations (runs kept, flagged):

- ${rows.join('')}
runmax loop lagviolations >10ms
` + return `

⚠ some runs were flagged: the test rig itself lagged (results kept, but read with care):

+ ${rows.join('')}
runmax rig laglags >10ms
` } // ── PNG export ────────────────────────────────────────────────────────── @@ -478,73 +952,148 @@ function exportPng(name, svg) { // ── main ──────────────────────────────────────────────────────────────── const results = loadResults() +const dist = loadDistribution() +const A = aggregate(results) mkdirSync(ASSETS_DIR, { recursive: true }) -const charts = [ - ['rss-vs-msgs', rssChart(results)], - ['node-count', nodesChart(results)], - ['scroll-cdf', scrollCdfChart(results)], - ['startup', startupChart(results)], - ['pty-rate', ptyRateChart(results)] -] +const charts = { + 'session-histogram': sessionHistChart(dist), + 'mem-real-workloads': memRealChart(A), + 'scroll-cdf': scrollCdfChart(results), + 'frame-rate': frameRateChart(A), + 'frame-gaps': frameGapChart(A), + 'pipeline-cpu': pipelineCpuChart(A), + 'pty-rate': ptyRateChart(results), + startup: startupChart(results), + 'rss-vs-msgs': rssChart(results), + 'node-count': nodesChart(results) +} const pngs = [] -for (const [name, svg] of charts) { +for (const [name, svg] of Object.entries(charts)) { if (svg && exportPng(name, svg)) pngs.push(`${name}.png`) } +const fig = (name, caption) => + charts[name] + ? `
${charts[name]}
${caption}
` + : `

${esc(name)}: not run

` + +const verdicts = buildVerdicts(A, dist, results) + const metaRuns = results.length ? `${results.length} result files · sha ${esc(results[0].meta.sha ?? '?')} · node ${esc(results.find(r => r.meta.node_version)?.meta.node_version ?? '?')}` : 'no results yet' +const p99real = dist?.tui_cli?.p99 ?? '~2,000' + const html = ` Hermes TUI bench — Ink vs OpenTUI -

Hermes TUI benchmark — Ink (ui-tui) vs OpenTUI (ui-opentui)

-

${metaRuns} · generated ${new Date().toISOString()}
-Methodology: docs/plans/opentui-bench-suite.md. Real binaries over a real node-pty PTY (120×40, -xterm-256color), fake gateway via HERMES_PYTHON (zero UI changes), external /proc sampling on -100-msg boundaries, 2GB cgroup-v2 caps via systemd-run --user --scope. Median±IQR throughout; -instrumented node-count runs are flagged and never headlined.

+

Hermes TUI benchmark — Ink (current UI) vs OpenTUI (new engine)

+

Both UIs were run as real binaries in a real terminal, fed the exact same scripted conversations by a fake +gateway, and measured from outside the process. Every number below is the typical of repeated runs (median) +unless said otherwise. ${metaRuns} · generated ${new Date().toISOString()}

-

Determinism gate

-${gateTable(results)} +

The verdict — who won what

+${verdictTable(verdicts)} +

One-line summary: OpenTUI is the smoother UI (scrolling, streaming) and +Ink is the lighter one (memory, first paint). Everything else is a wash — including reliability, +where both recover from a killed gateway with the transcript intact.

-

Headline: RSS vs messages

-${charts[0][1] ?? '

not run

'} +

Memory at real workloads — what sessions actually look like

+

The memory debate was framed around 200–300-message sessions. The real session database says that band is +the top 5–10%: the typical session is ~${dist?.tui_cli?.p50 ?? 20} messages, 90% stay under +${dist?.tui_cli?.p90 ?? 182}, and the longest real sessions reach ~${p99real} messages (the longest +1 in 100 — p99) — and at that tail the memory gap widens to ${A.memPeak.mem2000?.ink && A.memPeak.mem2000?.['otui-capped'] ? (A.memPeak.mem2000['otui-capped'].med / A.memPeak.mem2000.ink.med).toFixed(1) : '~2.9'}×.

+${fig('session-histogram', 'Takeaway: real sessions are short — half end within ~20 messages; the 200–300-msg sizes the debate assumed are actually the top 5–10% of sessions.')} +${fig('mem-real-workloads', 'Takeaway: at everyday sizes the gap is a modest 60–90MB; at the rare-but-real 2,000-msg session it becomes 234MB vs 671MB — 2.9× — which is where Ink genuinely wins.')} +${memMediansTable(A, dist)} -

Result matrix (median ± IQR)

-${matrixTable(results)} +

Scroll responsiveness — where OpenTUI wins

+${fig('scroll-cdf', 'Takeaway: both feel identical on a typical scroll (~2ms), but Ink’s slowest 1-in-100 responses (p99) take 82–101ms — visible hitches — while OpenTUI stays under ~17ms.')} -

Mechanism: mounted node count (instrumented)

-${charts[1][1] ?? '

not run

'} +

Frame smoothness while streaming — where OpenTUI wins

+${fig('frame-rate', 'Takeaway: while a long reply streams in, OpenTUI repaints ~22×/s vs Ink’s ~16×/s — text appears noticeably more fluid.')} +${fig('frame-gaps', 'Takeaway: typical pauses are similar, but Ink’s worst stutters between repaints (slowest 1 in 20, p95) are twice as long — 209ms vs 103ms.')} -

Scroll latency

-${charts[2][1] ?? '

not run

'} +

Typing echo & first reply paint

+${echoTable(A)} +

Keystroke echo is a tie — 1–2ms on both, far below anything a human can perceive. The one real difference: +after pressing Enter, Ink paints the first reply text in ${A.echo.ink?.submit_first_token_paint_ms ?? '—'}ms +vs OpenTUI's ${A.echo['otui-capped']?.submit_first_token_paint_ms ?? '—'}ms. (Single run per UI.)

+ +

CPU cost of streaming — including the terminal's side of the work

+${fig('pipeline-cpu', 'Takeaway: a tie — same 800-message stream costs ~80 CPU-seconds on either UI, and the terminal emulator’s share (tmux) is a rounding error (~0.4s) for both.')} +

The hypothesis that Ink's bigger output stream costs meaningfully more CPU in the terminal emulator +did not hold at this workload: Ink did push more bytes (${A.pipeline.ink ? (A.pipeline.ink.bytes / 1048576).toFixed(1) : '—'}MB +vs ${A.pipeline['otui-capped'] ? (A.pipeline['otui-capped'].bytes / 1048576).toFixed(1) : '—'}MB), but the tmux +server burned ~0.4 CPU-seconds either way.

+${fig('pty-rate', 'Takeaway: per streamed event the CPU cost is in the same ballpark for all configs — neither UI is the cheap one on CPU.')} + +

Crash recovery — we shot the gateway mid-stream and watched what happened

+

Each scenario kills, freezes, or yanks something out from under the UI on a live session, then checks: +did the UI survive, did the gateway come back, did the stream resume, is the final transcript identical +to an undisturbed run, and is anything left running afterwards?

+${chaosTable(A)} +

Result: a tie. Both UIs auto-respawn a killed gateway and finish with a byte-identical final +transcript and zero orphan processes. Ink respawns faster (~35–87ms vs ~1.0s), but both are well within +"didn't lose anything".

Startup

-${charts[3][1] ?? '

not run

'} +${fig('startup', 'Takeaway: Ink gets pixels on screen first (~67ms vs ~127ms); OpenTUI finishes its session bootstrap slightly sooner (~176ms vs ~202ms). Both feel instant.')} -

Streaming CPU / PTY throughput

-${charts[4][1] ?? '

not run

'} +

Stress appendix — beyond real usage

+

Everything below streams 3,000–10,000 messages into one session — past the longest +session ever recorded in the real database (~${p99real} msgs). It shows where the engines break, not what +daily use feels like.

+${fig('rss-vs-msgs', 'Takeaway: Ink stays flat (~250MB) no matter how long the marathon runs; OpenTUI climbs toward ~870MB and only levels off thanks to its rolling row cap. Neither hits the 2GB kill line.')} -

E3 survival (memory-constrained Docker)

+

Stress-run numbers (3,000-msg marathons + one 10,000-msg run)

+${matrixTable(results)} +

Reading guide: "typical" = median; the bracketed range is the middle half of runs +(interquartile range). "slowest 1 in 100" = p99. OpenTUI's syntax styling visibly degrades past ~1,400 +rendered rows in these marathons (style-handle exhaustion fallback); older OpenTUI runs also show a +post-stream crash that was fixed before the latest runs.

+ +${fig('node-count', 'Takeaway: Ink keeps a bounded few hundred nodes mounted no matter how long the transcript gets — consistent with its flat memory line above. (No OpenTUI node-walk run in this result set.)')} + +

Low-memory survival (1GB Docker container)

${survivalTable(results)} -

Run health

+

Run health — can you trust these numbers?

+

Determinism gate: each UI replayed the same input twice and must produce a pixel-identical final screen +(same fingerprint). If this fails, none of the comparisons above are meaningful.

+${gateTable(results)} ${drainTable(results)} +

Methodology: docs/plans/opentui-bench-suite.md. Real binaries on a real PTY +(120×40), fake gateway via HERMES_PYTHON (zero UI changes), outside-the-process /proc sampling, +2GB cgroup caps via systemd. Instrumented diagnostic runs are flagged and never headlined.

` diff --git a/bench/report-assets/frame-gaps.png b/bench/report-assets/frame-gaps.png new file mode 100644 index 00000000000..84ac08a7f1f Binary files /dev/null and b/bench/report-assets/frame-gaps.png differ diff --git a/bench/report-assets/frame-rate.png b/bench/report-assets/frame-rate.png new file mode 100644 index 00000000000..735f27e6da5 Binary files /dev/null and b/bench/report-assets/frame-rate.png differ diff --git a/bench/report-assets/mem-real-workloads.png b/bench/report-assets/mem-real-workloads.png new file mode 100644 index 00000000000..7f39334e4a0 Binary files /dev/null and b/bench/report-assets/mem-real-workloads.png differ diff --git a/bench/report-assets/node-count.png b/bench/report-assets/node-count.png index 7fa8045663a..a7ed6423447 100644 Binary files a/bench/report-assets/node-count.png and b/bench/report-assets/node-count.png differ diff --git a/bench/report-assets/pipeline-cpu.png b/bench/report-assets/pipeline-cpu.png new file mode 100644 index 00000000000..25255d270f0 Binary files /dev/null and b/bench/report-assets/pipeline-cpu.png differ diff --git a/bench/report-assets/pty-rate.png b/bench/report-assets/pty-rate.png index 5b9131ac14f..3dd59a28f68 100644 Binary files a/bench/report-assets/pty-rate.png and b/bench/report-assets/pty-rate.png differ diff --git a/bench/report-assets/rss-vs-msgs.png b/bench/report-assets/rss-vs-msgs.png index bf4ab317583..8e62d856694 100644 Binary files a/bench/report-assets/rss-vs-msgs.png and b/bench/report-assets/rss-vs-msgs.png differ diff --git a/bench/report-assets/scroll-cdf.png b/bench/report-assets/scroll-cdf.png index e716291f865..7626541d3f4 100644 Binary files a/bench/report-assets/scroll-cdf.png and b/bench/report-assets/scroll-cdf.png differ diff --git a/bench/report-assets/session-histogram.png b/bench/report-assets/session-histogram.png new file mode 100644 index 00000000000..1940d4d02eb Binary files /dev/null and b/bench/report-assets/session-histogram.png differ diff --git a/bench/report-assets/startup.png b/bench/report-assets/startup.png index bb430e7d084..a59c40a8c6f 100644 Binary files a/bench/report-assets/startup.png and b/bench/report-assets/startup.png differ diff --git a/bench/report.html b/bench/report.html index 311b102b3a1..585c1b61d56 100644 --- a/bench/report.html +++ b/bench/report.html @@ -2,311 +2,583 @@ Hermes TUI bench — Ink vs OpenTUI -

Hermes TUI benchmark — Ink (ui-tui) vs OpenTUI (ui-opentui)

-

61 result files · sha 50e34713b · node v26.3.0 · generated 2026-06-10T22:39:01.273Z
-Methodology: docs/plans/opentui-bench-suite.md. Real binaries over a real node-pty PTY (120×40, -xterm-256color), fake gateway via HERMES_PYTHON (zero UI changes), external /proc sampling on -100-msg boundaries, 2GB cgroup-v2 caps via systemd-run --user --scope. Median±IQR throughout; -instrumented node-count runs are flagged and never headlined.

+

Hermes TUI benchmark — Ink (current UI) vs OpenTUI (new engine)

+

Both UIs were run as real binaries in a real terminal, fed the exact same scripted conversations by a fake +gateway, and measured from outside the process. Every number below is the typical of repeated runs (median) +unless said otherwise. 103 result files · sha 50e34713b · node v26.3.0 · generated 2026-06-11T04:02:33.335Z

-

Determinism gate

-
configreplay digestsgate
ink7775bee02e57da2b · 7775bee02e57da2bPASS
otui-cappedd5e9558583159eac · d5e9558583159eacPASS
+

The verdict — who won what

+ + + + + + + + + + + + +
dimensionwinnerthe numbers
Memory — typical real sessions (20–300 msgs)Ink wins, modestlyOpenTUI uses ~59–88MB more (163 vs 222MB at 100 msgs; 180 vs 268MB at 300).
Memory — longest real sessions (~2,000 msgs, the longest 1 in 100 = p99)Ink wins big234MB vs 671MB peak — 2.9× more. Sessions this long really happen (6 of them in the DB).
Memory — 3,000-msg stress marathon (beyond any real session)Ink winsInk levels off near 249MB; OpenTUI climbs to ~767MB (peak ~875MB), and its syntax styling degrades past ~1,400 rows. Stress test only — past the longest real session.
Scroll responsiveness on a long transcriptOpenTUI wins decisivelySlowest 1-in-100 scroll responses (p99): 14–17ms vs 83–101ms across repeats. Typical scrolls are ~2ms on both — the difference is the stutters.
Frame smoothness while streamingOpenTUI wins22 vs 16 screen updates/s, and its worst pauses between updates are half as long (103ms vs 209ms, slowest 1 in 20).
Typing echo (keystroke → it appears)TieBoth answer a keystroke in 1–2ms — under any human threshold.
Submit → first reply paintInk wins44ms vs 107ms from pressing Enter to the first reply text on screen.
CPU, including the terminal-emulator (tmux) sideTie~80 CPU-seconds either way for the same 800-message stream (83 vs 80s total); the tmux leg is ~0.4s for both.
Crash recovery (gateway shot mid-stream)TieBoth auto-respawn the killed gateway and end with the full transcript intact and zero orphan processes. Ink respawns in ~81ms, OpenTUI in ~1.0s.
StartupInk wins, modestlyFirst paint 67ms vs 127ms. Both feel instant; OpenTUI actually reaches “session ready” slightly sooner (176 vs 202ms).
+

  red = Ink (current UI) wins  ·    green = OpenTUI (new engine) wins  ·    grey = tie

+

One-line summary: OpenTUI is the smoother UI (scrolling, streaming) and +Ink is the lighter one (memory, first paint). Everything else is a wash — including reliability, +where both recover from a killed gateway with the transcript intact.

-

Headline: RSS vs messages

- - -RSS vs fixture messages (clean memory runs, all reps + slope runs) - -0 - -1667 - -3333 - -5000 - -6667 - -8333 - -10000 - -0 - -464 - -929 - -1393 - -1858 - -2322 -fixture messages (rowsPerTurn accounting) -RSS (MB) - -cap 2048 MB - - -otui-capped - - -otui-uncapped - - - - -ink - - - - - - - - - - - -× -crash @3000 -× -crash @3000 -× -crash @3000 -× -crash @2901 -× -crash @3000 -× -crash @3000 -× -crash @3000 -2GB cgroup cap (systemd-run, MemorySwapMax=0). × = cgroup OOM kill. Samples every 100 msgs from /proc/PID/smaps_rollup. - +

Memory at real workloads — what sessions actually look like

+

The memory debate was framed around 200–300-message sessions. The real session database says that band is +the top 5–10%: the typical session is ~20 messages, 90% stay under +182, and the longest real sessions reach ~1941 messages (the longest +1 in 100 — p99) — and at that tail the memory gap widens to 2.9×.

+
+ +How long the user's real terminal sessions actually are (444 sessions) + +0 + +37 + +74 + +111 + +148 + +185 +number of sessions + +168 +0–10 + +78 +10–25 + +78 +25–50 + +47 +50–100 + +30 +100–200 + +14 +200–300 + +18 +300–500 + +5 +500–1000 + +6 +1000–3000 +messages in the session + +half are ≤20 (p50) + +75% ≤53 + +90% ≤182 + +95% ≤340 + +99% ≤1941 (p99) +Every TUI/CLI session in the real session DB (/home/daimon/.hermes/state.db); message counts per session. +
Takeaway: real sessions are short — half end within ~20 messages; the 200–300-msg sizes the debate assumed are actually the top 5–10% of sessions.
+
+ +Peak memory at real session sizes — Ink vs OpenTUI + +0.0 + +158 + +317 + +475 + +633 + +791 +peak memory (MB) + +163 +Ink + +222 +OpenTUI +100 msgs (heavy-ish day) + +180 +Ink + +268 +OpenTUI +300 msgs (top 5% of sessions) + +234 +Ink + +671 +OpenTUI +2,000 msgs (longest real sessions) +Peak resident memory of the UI process (VmHWM), typical of 2 repeats (median). +
Takeaway: at everyday sizes the gap is a modest 60–90MB; at the rare-but-real 2,000-msg session it becomes 234MB vs 671MB — 2.9× — which is where Ink genuinely wins.
+ + +
session sizewhat that means in practiceInk peakOpenTUI peakdifference
100 msgsa heavier-than-usual day (typical session is ~20 msgs)163 MB222 MBOpenTUI +59 MB (1.4×)
300 msgstop ~5% of real sessions180 MB268 MBOpenTUI +88 MB (1.5×)
2,000 msgsthe longest sessions that actually occur (~1 in 100, p99)234 MB671 MBOpenTUI +437 MB (2.9×)
-

Result matrix (median ± IQR)

+

Scroll responsiveness — where OpenTUI wins

+
+ +What fraction of scroll responses finished within X ms (2000-msg transcript) + +0 + +19 + +37 + +56 + +74 + +93 + +111 + +0 + +20 + +40 + +60 + +80 + +100 +time from scroll input to first screen response (ms) +% of scroll responses at least this fast + + +Ink (1350 scrolls) + + +OpenTUI (1350 scrolls) + + +OpenTUI (no cap) (1350 scrolls) +Mouse wheel fired 30×/s for 15s, three repeats pooled. Higher curve further left = more responses answered fast. +
Takeaway: both feel identical on a typical scroll (~2ms), but Ink’s slowest 1-in-100 responses (p99) take 82–101ms — visible hitches — while OpenTUI stays under ~17ms.
+ +

Frame smoothness while streaming — where OpenTUI wins

+
+ +Frame smoothness while text streams in + +0.0 + +5.3 + +10.5 + +15.8 + +21.1 + +26.3 +frames per second + +15.8 +Ink + +22.3 +OpenTUI +screen updates per second (higher = smoother) +800-message stream at 30 events/s; a frame = a burst of terminal output separated by a ≥4ms gap. +
Takeaway: while a long reply streams in, OpenTUI repaints ~22×/s vs Ink’s ~16×/s — text appears noticeably more fluid.
+
+ +Pauses between screen updates while streaming (lower = steadier) + +0.0 + +49.3 + +98.6 + +148 + +197 + +247 +gap between frames (ms) + +42.0 +Ink + +39.0 +OpenTUI +typical gap (p50) + +209 +Ink + +103 +OpenTUI +slowest 1 in 20 gaps (p95) +Same 800-message stream. The right-hand pair is the stutter you actually notice. +
Takeaway: typical pauses are similar, but Ink’s worst stutters between repaints (slowest 1 in 20, p95) are twice as long — 209ms vs 103ms.
+ +

Typing echo & first reply paint

- + + + + + + +
configslope MB/1k msgs
(3000-msg runs)
slope MB/1k
(10k run)
plateau RSS MB
(final quartile)
VmHWM MBscroll p50/p90/p99 msCPU ms/event
(paced)
cap hits (2GB)
UIkeystroke echo, typical (p50)keystroke echo, slowest 1 in 20 (p95)Enter → first reply text on screenkeystrokes verified
Ink1 ms1.5 ms44 ms30/30
OpenTUI2 ms3.5 ms107 ms30/30
+

Keystroke echo is a tie — 1–2ms on both, far below anything a human can perceive. The one real difference: +after pressing Enter, Ink paints the first reply text in 44ms +vs OpenTUI's 107ms. (Single run per UI.)

+ +

CPU cost of streaming — including the terminal's side of the work

+
+ +Total CPU burned streaming the same 800-message conversation + +0.0 + +19.5 + +38.9 + +58.4 + +77.8 + +97.3 +CPU seconds + +82.4 +UI + +0.5 +gateway + +0.4 +tmux +Ink + +79.1 +UI + +0.3 +gateway + +0.4 +tmux +OpenTUI +Whole pipeline measured inside tmux: UI process + gateway + the tmux server that has to parse the UI’s output. +
Takeaway: a tie — same 800-message stream costs ~80 CPU-seconds on either UI, and the terminal emulator’s share (tmux) is a rounding error (~0.4s) for both.
+

The hypothesis that Ink's bigger output stream costs meaningfully more CPU in the terminal emulator +did not hold at this workload: Ink did push more bytes (3.8MB +vs 3.3MB), but the tmux +server burned ~0.4 CPU-seconds either way.

+
+ +Streaming at 30 events/s: terminal output volume and CPU cost per event + +0.0 + +13.9 + +27.9 + +41.8 + +55.7 + +69.7 +output KiB/s · CPU ms per event + + +58.7 +KiB/s + + +30.1 +ms/event +OpenTUI (no cap) + + +58.9 +KiB/s + + +30.0 +ms/event +OpenTUI + + +44.0 +KiB/s + + +31.0 +ms/event +Ink +Typical of 3 runs (median); whisker = middle half. CPU is the UI process only, measured over the stream. +
Takeaway: per streamed event the CPU cost is in the same ballpark for all configs — neither UI is the cheap one on CPU.
+ +

Crash recovery — we shot the gateway mid-stream and watched what happened

+

Each scenario kills, freezes, or yanks something out from under the UI on a live session, then checks: +did the UI survive, did the gateway come back, did the stream resume, is the final transcript identical +to an undisturbed run, and is anything left running afterwards?

+ + + + +
what we didInkOpenTUI
gw-kill-stream
shot the gateway (kill -9) while reply text was streaming
UI survived: yes
gateway respawned: yes (87ms)
stream resumed: yes
transcript intact: yes
orphan processes: none
UI survived: yes
gateway respawned: yes (1.0s)
stream resumed: yes
transcript intact: yes
orphan processes: none
gw-kill-tool
shot the gateway (kill -9) in the middle of a tool call
UI survived: yes
gateway respawned: yes (81ms)
stream resumed: yes
transcript intact: yes
orphan processes: none
UI survived: yes
gateway respawned: yes (1.0s)
stream resumed: yes
transcript intact: yes
orphan processes: none
gw-stop
froze the gateway for 30 seconds mid-session, then let the UI recover
UI survived: yes
gateway respawned: yes (35ms)
stream resumed: yes
transcript intact: yes
orphan processes: none
UI survived: yes
gateway respawned: yes (1.1s)
stream resumed: yes
transcript intact: yes
orphan processes: none
resize-storm
resized the window 30 times in 3 seconds
survived: yes
transcript intact: yes
orphan processes: none
survived: yes
transcript intact: yes
orphan processes: none
pty-eof
closed the terminal out from under the UI (should exit cleanly, leave nothing behind)
exited cleanly: yes
gateway cleaned up: yes (100ms)
orphan processes: none
exited cleanly: yes
gateway cleaned up: yes (101ms)
orphan processes: none
+

Result: a tie. Both UIs auto-respawn a killed gateway and finish with a byte-identical final +transcript and zero orphan processes. Ink respawns faster (~35–87ms vs ~1.0s), but both are well within +"didn't lose anything".

+ +

Startup

+
+ +Startup: time until something is on screen, and until the session is ready + +0.0 + +47.7 + +95.3 + +143 + +191 + +238 +ms after launch (lower = faster) + + +66.5 +first paint + + +202 +session ready +Ink + + +127 +first paint + + +176 +session ready +OpenTUI +Typical of 10 launches (median); whisker = middle half of runs. "first paint" = first byte drawn to the terminal. +
Takeaway: Ink gets pixels on screen first (~67ms vs ~127ms); OpenTUI finishes its session bootstrap slightly sooner (~176ms vs ~202ms). Both feel instant.
+ +

Stress appendix — beyond real usage

+

Everything below streams 3,000–10,000 messages into one session — past the longest +session ever recorded in the real database (~1941 msgs). It shows where the engines break, not what +daily use feels like.

+
+ +Memory used as the conversation grows (stress runs, every repeat shown) + +0 + +1667 + +3333 + +5000 + +6667 + +8333 + +10000 + +0 + +464 + +929 + +1393 + +1858 + +2322 +messages streamed into the session +memory (MB) + +hard memory cap 2048 MB + + +OpenTUI + + +OpenTUI (no cap) + + + + +Ink + + + + + + + + + + + + + + + + + + + + + + + +× +crash @3000 +× +crash @2901 +2GB hard cap (systemd cgroup). × = killed for running out of memory. Crash marks on older OpenTUI runs are the exit-7 bug, fixed in the latest runs. +
Takeaway: Ink stays flat (~250MB) no matter how long the marathon runs; OpenTUI climbs toward ~870MB and only levels off thanks to its rolling row cap. Neither hits the 2GB kill line.
+ +

Stress-run numbers (3,000-msg marathons + one 10,000-msg run)

+ + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - +
configmemory growth
MB per 1k msgs
(3,000-msg runs)
memory growth
MB per 1k
(10,000-msg run)
settled memory MB
(plateau, end of run)
peak memory MBscroll ms — typical /
slowest 1 in 10 /
slowest 1 in 100
(p50/p90/p99)
CPU ms per event
(paced stream)
killed by 2GB cap?
ink25.48 [23.89–27.74]Ink25.48 [middle half: 23.89–27.74] 5.88249 [247–250]257 [256–258]249 [middle half: 247–250]257 [middle half: 256–258] 2.0 / 36.1 / 98.531.04 [30.99–31.23]31.04 [middle half: 30.99–31.23] none
otui-capped254.74 [250.81–265.46]OpenTUI254.74 [middle half: 250.81–265.46] not run767 [741–799]875 [868–879]767 [middle half: 741–799]875 [middle half: 868–879] 2.0 / 3.0 / 17.030.00 [30.00–30.05]30.00 [middle half: 30.00–30.05] none
otui-uncapped297.65 [283.88–311.91]OpenTUI (no cap)297.65 [middle half: 283.88–311.91] 173.76807 [796–813]890 [874–893]807 [middle half: 796–813]890 [middle half: 874–893] 2.0 / 3.0 / 14.030.09 [30.09–30.10]30.09 [middle half: 30.09–30.10] none
+

Reading guide: "typical" = median; the bracketed range is the middle half of runs +(interquartile range). "slowest 1 in 100" = p99. OpenTUI's syntax styling visibly degrades past ~1,400 +rendered rows in these marathons (style-handle exhaustion fallback); older OpenTUI runs also show a +post-stream crash that was fixed before the latest runs.

-

Mechanism: mounted node count (instrumented)

- - -Mounted node count vs messages (instrumented runs — mechanism witness, never headlined) - -0 - -500 - -1000 - -1500 - -2000 - -2500 - -3000 - -0 - -84 - -169 - -253 - -337 - -421 -fixture messages -live nodes - - -ink live yoga nodes (fd-3 sampler) -instrumented:true. Ink: HERMES_TUI_MEMSAMPLE_FD walk of the forked reconciler root. OpenTUI: scripts/mem-bench.tsx renderable walk (headless, diagnostic-only). - +
+ +Live UI nodes during the 3,000-msg marathon (diagnostic run) + +0 + +500 + +1000 + +1500 + +2000 + +2500 + +3000 + +0 + +84 + +169 + +253 + +337 + +421 +messages streamed into the session +live nodes + + +Ink live layout nodes +Diagnostic instrumented runs only — never used for the headline numbers. +
Takeaway: Ink keeps a bounded few hundred nodes mounted no matter how long the transcript gets — consistent with its flat memory line above. (No OpenTUI node-walk run in this result set.)
-

Scroll latency

- - -Scroll latency CDF — SGR wheel 30Hz×15s on 2000-msg transcript (all reps pooled) - -0 - -19 - -37 - -56 - -74 - -93 - -111 - -0 - -20 - -40 - -60 - -80 - -100 -write → first output byte (ms) -percentile (%) - - -ink (n=1350) - - -otui-capped (n=1350) - - -otui-uncapped (n=1350) -Latency = PTY write of the wheel event to the next PTY output chunk. - - -

Startup

- - -Startup (fake gateway) — median of 10 reps, whiskers = IQR - -0.0 - -46.5 - -92.9 - -139 - -186 - -232 -ms from spawn - - -66.5 -first byte - - -202 -session.create -ink - - -127 -first byte - - -176 -session.create -otui-capped -first byte = first PTY output; session.create = the UI reaches its session bootstrap RPC. - - -

Streaming CPU / PTY throughput

- - -Paced streaming (30 ev/s): PTY output rate + CPU per event — median±IQR over reps - -0.0 - -13.6 - -27.2 - -40.7 - -54.3 - -67.9 -KiB/s · ms/event - - -58.7 -PTY KiB/s - - -30.1 -CPU ms/event -otui-uncapped - - -58.9 -PTY KiB/s - - -30.0 -CPU ms/event -otui-capped - - -44.0 -PTY KiB/s - - -31.0 -CPU ms/event -ink -CPU from /proc/PID/stat utime+stime deltas across the stream window (UI process only). - - -

E3 survival (memory-constrained Docker)

- +

Low-memory survival (1GB Docker container)

+
cellconfiglimitresultmsgs survivedVmHWMbasis
e3lite-1gink1073741824
- +
cellUImemory limitresultmsgs survivedpeak memorybasis
e3lite-1gInk1 GB completed10000223 MB
e3lite-1gotui-capped1073741824223 MB
e3lite-1gOpenTUI1 GB died3000 849 MB
-

Run health

-

⚠ drain assertion violations (runs kept, flagged):

-
runmax loop lagviolations >10ms
2026-06-10T2058-50e3471-mem3000-opentui-otui-capped-r0.json18 ms2
2026-06-10T2058-50e3471-mem3000-opentui-otui-uncapped-r0.json22 ms3
2026-06-10T2059-14ee1a5-mem3000-opentui-otui-uncapped-r1.json33 ms3
2026-06-10T2100-197d499-mem3000-opentui-otui-capped-r2.json22 ms3
2026-06-10T2100-197d499-mem3000-opentui-otui-uncapped-r2.json19 ms2
2026-06-10T2107-197d499-cpu800-opentui-otui-uncapped-r0.json11 ms1
2026-06-10T2108-197d499-cpu800-opentui-otui-capped-r0.json15 ms1
2026-06-10T2116-197d499-cpu800-opentui-otui-capped-r1.json11 ms1
2026-06-10T2124-197d499-scroll2000-opentui-otui-capped-r0.json15 ms2
2026-06-10T2125-197d499-scroll2000-opentui-otui-uncapped-r0.json26 ms2
2026-06-10T2126-197d499-scroll2000-opentui-otui-capped-r1.json23 ms2
2026-06-10T2126-197d499-scroll2000-opentui-otui-uncapped-r1.json21 ms2
2026-06-10T2127-197d499-scroll2000-opentui-otui-uncapped-r2.json15 ms2
2026-06-10T2128-197d499-scroll2000-opentui-otui-capped-r2.json20 ms2
2026-06-10T2146-197d499-slope10000-opentui-otui-uncapped-r0.json25 ms4
2026-06-10T2147-e3-e3lite-1g-opentui-otui-capped-r0.json14 ms3
2026-06-10T2236-a939c9a-mem3000-opentui-otui-capped-r0.json24 ms4
2026-06-10T2237-a939c9a-mem3000-opentui-otui-uncapped-r0.json14 ms2
2026-06-10T2237-a939c9a-slope10000-opentui-otui-uncapped-r0.json69 ms17
+

Run health — can you trust these numbers?

+

Determinism gate: each UI replayed the same input twice and must produce a pixel-identical final screen +(same fingerprint). If this fails, none of the comparisons above are meaningful.

+
UIreplay fingerprints (same input must give same screen)gate
Ink7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2b · 7775bee02e57da2bPASS
OpenTUId5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eac · d5e9558583159eacPASS
+

⚠ some runs were flagged: the test rig itself lagged (results kept, but read with care):

+
runmax rig laglags >10ms
2026-06-10T2058-50e3471-mem3000-opentui-otui-capped-r0.json18 ms2
2026-06-10T2058-50e3471-mem3000-opentui-otui-uncapped-r0.json22 ms3
2026-06-10T2059-14ee1a5-mem3000-opentui-otui-uncapped-r1.json33 ms3
2026-06-10T2100-197d499-mem3000-opentui-otui-capped-r2.json22 ms3
2026-06-10T2100-197d499-mem3000-opentui-otui-uncapped-r2.json19 ms2
2026-06-10T2107-197d499-cpu800-opentui-otui-uncapped-r0.json11 ms1
2026-06-10T2108-197d499-cpu800-opentui-otui-capped-r0.json15 ms1
2026-06-10T2116-197d499-cpu800-opentui-otui-capped-r1.json11 ms1
2026-06-10T2124-197d499-scroll2000-opentui-otui-capped-r0.json15 ms2
2026-06-10T2125-197d499-scroll2000-opentui-otui-uncapped-r0.json26 ms2
2026-06-10T2126-197d499-scroll2000-opentui-otui-capped-r1.json23 ms2
2026-06-10T2126-197d499-scroll2000-opentui-otui-uncapped-r1.json21 ms2
2026-06-10T2127-197d499-scroll2000-opentui-otui-uncapped-r2.json15 ms2
2026-06-10T2128-197d499-scroll2000-opentui-otui-capped-r2.json20 ms2
2026-06-10T2146-197d499-slope10000-opentui-otui-uncapped-r0.json25 ms4
2026-06-10T2147-e3-e3lite-1g-opentui-otui-capped-r0.json14 ms3
2026-06-10T2236-a939c9a-mem3000-opentui-otui-capped-r0.json24 ms4
2026-06-10T2237-a939c9a-mem3000-opentui-otui-uncapped-r0.json14 ms2
2026-06-10T2237-a939c9a-slope10000-opentui-otui-uncapped-r0.json69 ms17
2026-06-11T0240-805e080-mem2000-opentui-otui-capped-r0.json25 ms2
2026-06-11T0325-cbe703c-chaos-ink-ink-rgw-stop.json13 ms1
+

Methodology: docs/plans/opentui-bench-suite.md. Real binaries on a real PTY +(120×40), fake gateway via HERMES_PYTHON (zero UI changes), outside-the-process /proc sampling, +2GB cgroup caps via systemd. Instrumented diagnostic runs are flagged and never headlined.