Skip to content

Commit 0df8825

Browse files
RtlZeroMemoryclaude
andcommitted
perf: fix post-audit PTY regressions and complete 5-item perf audit
Fixes three PTY benchmark regressions introduced by the perf audit: 1. terminal-virtual-list 1.03ms → 649µs (-37%, beats historical 681µs): Remove isWidthSensitiveParent from layout stability signatures — treating every text-content change as layout-affecting caused O(frames) relayout in scrolling lists (submitFramePipeline.ts). 2. terminal-table/frame-fill render packet key cost reduced: Add hashTextProps fast path for text/richText nodes that hashes only 6 visual-relevant props instead of generic Object.keys iteration (renderPackets.ts). Includes the original 5-item perf audit: - beginFrame slot reclamation in nodeBackend.ts - Content-based render packet keying (renderPackets.ts) - O(1) push for clip sentinels replacing O(n) splice (containers.ts) - FNV-1a layout stability signatures (submitFramePipeline.ts) - Perf counter instrumentation and benchmark infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1887f84 commit 0df8825

File tree

24 files changed

+1303
-165
lines changed

24 files changed

+1303
-165
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"bench": "node --expose-gc packages/bench/dist/run.js",
4747
"bench:report": "node --expose-gc packages/bench/dist/run.js --markdown",
4848
"bench:ci": "node scripts/run-bench-ci.mjs",
49-
"bench:ci:compare": "node scripts/bench-ci-compare.mjs"
49+
"bench:ci:compare": "node scripts/bench-ci-compare.mjs",
50+
"bench:full:compare": "node scripts/bench-full-compare.mjs"
5051
},
5152
"devDependencies": {
5253
"@biomejs/biome": "^1.9.4",

packages/bench/src/io.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,16 @@ export async function createBenchBackend(): Promise<BenchFrameBackend> {
119119
}
120120

121121
const NodeBackend = await import("@rezi-ui/node");
122+
const executionModeEnv = (
123+
process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>
124+
).REZI_BENCH_REZI_EXECUTION_MODE;
125+
const executionMode = executionModeEnv === "worker" ? "worker" : "inline";
122126
const inner = NodeBackend.createNodeBackend({
123127
// PTY mode already runs in a dedicated process, so prefer inline execution
124128
// here for stability (avoids nested worker-thread ownership + transport).
125-
executionMode: "inline",
129+
// Diagnostic override:
130+
// REZI_BENCH_REZI_EXECUTION_MODE=worker
131+
executionMode,
126132
fpsCap: 60,
127133
nativeConfig: {
128134
// Include output backpressure (when supported) for a closer-to-real measurement.

packages/bench/src/reziProfile.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { appendFileSync, mkdirSync } from "node:fs";
2+
import { dirname } from "node:path";
3+
import type { BenchMetrics, ScenarioConfig } from "./types.js";
4+
5+
type CorePerfModule = Readonly<{
6+
perfReset?: () => void;
7+
perfSnapshot?: () => unknown;
8+
}>;
9+
10+
function readEnv(name: string): string | null {
11+
const value = process.env[name];
12+
if (typeof value !== "string") return null;
13+
const trimmed = value.trim();
14+
return trimmed.length > 0 ? trimmed : null;
15+
}
16+
17+
function envFlag(name: string): boolean {
18+
const value = readEnv(name);
19+
if (value === null) return false;
20+
const norm = value.toLowerCase();
21+
return norm === "1" || norm === "true" || norm === "yes" || norm === "on";
22+
}
23+
24+
const PROFILE_ENABLED = envFlag("REZI_BENCH_REZI_PROFILE");
25+
const PROFILE_LOG_PATH = readEnv("REZI_BENCH_REZI_PROFILE_LOG");
26+
27+
export function resetReziPerfSnapshot(core: CorePerfModule): void {
28+
if (!PROFILE_ENABLED) return;
29+
core.perfReset?.();
30+
}
31+
32+
export function emitReziPerfSnapshot(
33+
core: CorePerfModule,
34+
scenario: string,
35+
params: Record<string, number | string>,
36+
config: ScenarioConfig,
37+
metrics: BenchMetrics,
38+
): void {
39+
if (!PROFILE_ENABLED) return;
40+
if (!PROFILE_LOG_PATH) return;
41+
const snapshot = core.perfSnapshot?.();
42+
if (snapshot === undefined) return;
43+
44+
try {
45+
mkdirSync(dirname(PROFILE_LOG_PATH), { recursive: true });
46+
appendFileSync(
47+
PROFILE_LOG_PATH,
48+
`${JSON.stringify({
49+
ts: new Date().toISOString(),
50+
pid: process.pid,
51+
scenario,
52+
params,
53+
config,
54+
timingMeanMs: metrics.timing.mean,
55+
timingP95Ms: metrics.timing.p95,
56+
bytesProduced: metrics.bytesProduced,
57+
framesProduced: metrics.framesProduced,
58+
perfSnapshot: snapshot,
59+
})}\n`,
60+
"utf8",
61+
);
62+
} catch {
63+
// Profiling is optional and must never affect benchmark execution.
64+
}
65+
}
66+

packages/bench/src/scenarios/construction.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js";
1313
import { runOpenTuiScenario } from "../frameworks/opentui.js";
1414
import { benchAsync, benchSync, tryGc } from "../measure.js";
15+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
1516
import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js";
1617
import {
1718
buildBlessedTree,
@@ -21,7 +22,8 @@ import {
2122
} from "./treeBuilders.js";
2223

2324
async function runRezi(config: ScenarioConfig, n: number): Promise<BenchMetrics> {
24-
const { createApp } = await import("@rezi-ui/core");
25+
const core = await import("@rezi-ui/core");
26+
const { createApp } = core;
2527
// Viewport must be large enough to fit all items — prevents viewport
2628
// clipping from giving Rezi an unfair advantage over frameworks that
2729
// render the full list regardless of viewport size.
@@ -48,6 +50,7 @@ async function runRezi(config: ScenarioConfig, n: number): Promise<BenchMetrics>
4850

4951
const frameBase = backend.frameCount;
5052
const bytesBase = backend.totalFrameBytes;
53+
resetReziPerfSnapshot(core);
5154

5255
const metrics = await benchAsync(
5356
async (i) => {
@@ -61,6 +64,7 @@ async function runRezi(config: ScenarioConfig, n: number): Promise<BenchMetrics>
6164

6265
metrics.framesProduced = backend.frameCount - frameBase;
6366
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
67+
emitReziPerfSnapshot(core, "tree-construction", { items: n }, config, metrics);
6468
return metrics;
6569
} finally {
6670
await app.stop();

packages/bench/src/scenarios/content-update.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { type VNode, ui } from "@rezi-ui/core";
1818
import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js";
1919
import { runOpenTuiScenario } from "../frameworks/opentui.js";
2020
import { benchAsync, benchSync, tryGc } from "../measure.js";
21+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
2122
import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js";
2223

2324
const LIST_SIZE = 500;
@@ -112,7 +113,8 @@ function termkitListTree(
112113
// ── Runners ─────────────────────────────────────────────────────────
113114

114115
async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
115-
const { createApp } = await import("@rezi-ui/core");
116+
const core = await import("@rezi-ui/core");
117+
const { createApp } = core;
116118
// Match the viewport height to other frameworks (540 rows for 500-item list)
117119
// so Rezi renders ALL rows, not just the visible 40.
118120
const backend = new BenchBackend(120, 540);
@@ -138,6 +140,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
138140

139141
const frameBase = backend.frameCount;
140142
const bytesBase = backend.totalFrameBytes;
143+
resetReziPerfSnapshot(core);
141144

142145
const metrics = await benchAsync(
143146
async (i) => {
@@ -151,6 +154,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
151154

152155
metrics.framesProduced = backend.frameCount - frameBase;
153156
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
157+
emitReziPerfSnapshot(core, "content-update", {}, config, metrics);
154158
return metrics;
155159
} finally {
156160
await app.stop();

packages/bench/src/scenarios/memory.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { NullReadable } from "../backends.js";
1616
import { runOpenTuiScenario } from "../frameworks/opentui.js";
1717
import { createBenchBackend, createInkStdout } from "../io.js";
1818
import { computeStats, diffCpu, peakMemory, takeCpu, takeMemory, tryGc } from "../measure.js";
19+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
1920
import type {
2021
BenchMetrics,
2122
Framework,
@@ -121,7 +122,8 @@ function analyzeMemory(samples: NodeMemorySnapshot[]): MemoryProfile {
121122
// ── Runners ─────────────────────────────────────────────────────────
122123

123124
async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
124-
const { createApp } = await import("@rezi-ui/core");
125+
const core = await import("@rezi-ui/core");
126+
const { createApp } = core;
125127
const backend = await createBenchBackend();
126128

127129
type State = { iteration: number };
@@ -150,6 +152,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
150152
const frameBase = backend.frameCount;
151153
const bytesBase = backend.totalFrameBytes;
152154

155+
resetReziPerfSnapshot(core);
153156
tryGc();
154157
const memBefore = takeMemory();
155158
const cpuBefore = takeCpu();
@@ -177,7 +180,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
177180

178181
const prof = analyzeMemory(memorySamples);
179182

180-
return {
183+
const metrics: BenchMetrics = {
181184
timing: computeStats(timingSamples),
182185
memBefore,
183186
memAfter,
@@ -195,6 +198,8 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
195198
bytesProduced: backend.totalFrameBytes - bytesBase,
196199
ptyBytesObserved: null,
197200
};
201+
emitReziPerfSnapshot(core, "memory-profile", {}, config, metrics);
202+
return metrics;
198203
} finally {
199204
await app.stop();
200205
app.dispose();

packages/bench/src/scenarios/scrollStress.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { NullReadable } from "../backends.js";
1212
import { runOpenTuiScenario } from "../frameworks/opentui.js";
1313
import { createBenchBackend, createInkStdout } from "../io.js";
1414
import { benchAsync, tryGc } from "../measure.js";
15+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
1516
import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js";
1617

1718
function reziTree(items: number, active: number, tick: number): VNode {
@@ -66,7 +67,8 @@ function reactTree(
6667
}
6768

6869
async function runRezi(config: ScenarioConfig, items: number): Promise<BenchMetrics> {
69-
const { createApp } = await import("@rezi-ui/core");
70+
const core = await import("@rezi-ui/core");
71+
const { createApp } = core;
7072
const backend = await createBenchBackend();
7173

7274
type State = { active: number; tick: number };
@@ -86,6 +88,7 @@ async function runRezi(config: ScenarioConfig, items: number): Promise<BenchMetr
8688

8789
const frameBase = backend.frameCount;
8890
const bytesBase = backend.totalFrameBytes;
91+
resetReziPerfSnapshot(core);
8992

9093
const metrics = await benchAsync(
9194
async () => {
@@ -99,6 +102,7 @@ async function runRezi(config: ScenarioConfig, items: number): Promise<BenchMetr
99102

100103
metrics.framesProduced = backend.frameCount - frameBase;
101104
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
105+
emitReziPerfSnapshot(core, "scroll-stress", { items }, config, metrics);
102106
return metrics;
103107
} finally {
104108
await app.stop();

packages/bench/src/scenarios/startup.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { BenchBackend, MeasuringStream, NullReadable } from "../backends.js";
1919
import { runOpenTuiScenario } from "../frameworks/opentui.js";
2020
import { computeStats, diffCpu, peakMemory, takeCpu, takeMemory, tryGc } from "../measure.js";
21+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
2122
import type {
2223
BenchMetrics,
2324
Framework,
@@ -35,7 +36,8 @@ import {
3536
const TREE_SIZE = 50;
3637

3738
async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
38-
const { createApp } = await import("@rezi-ui/core");
39+
const core = await import("@rezi-ui/core");
40+
const { createApp } = core;
3941

4042
const samples: number[] = [];
4143

@@ -51,6 +53,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
5153
app.dispose();
5254
}
5355

56+
resetReziPerfSnapshot(core);
5457
tryGc();
5558
const memBefore = takeMemory();
5659
const cpuBefore = takeCpu();
@@ -83,7 +86,7 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
8386
const memAfter = takeMemory();
8487
memMax = peakMemory(memMax, memAfter);
8588

86-
return {
89+
const metrics: BenchMetrics = {
8790
timing: computeStats(samples),
8891
memBefore,
8992
memAfter,
@@ -101,6 +104,8 @@ async function runRezi(config: ScenarioConfig): Promise<BenchMetrics> {
101104
bytesProduced: totalBytes,
102105
ptyBytesObserved: null,
103106
};
107+
emitReziPerfSnapshot(core, "startup", {}, config, metrics);
108+
return metrics;
104109
}
105110

106111
async function runInkCompat(config: ScenarioConfig): Promise<BenchMetrics> {

packages/bench/src/scenarios/terminalStrictBench.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { runOpenTuiScenario } from "../frameworks/opentui.js";
55
import { runRatatuiScenario } from "../frameworks/ratatui.js";
66
import { createBenchBackend, createInkStdout } from "../io.js";
77
import { benchAsync } from "../measure.js";
8+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
89
import type { BenchMetrics, ScenarioConfig } from "../types.js";
910
import {
1011
type StrictSections,
@@ -143,7 +144,8 @@ async function runRezi(
143144
params: Record<string, number | string>,
144145
variant: StrictVariant,
145146
): Promise<BenchMetrics> {
146-
const { createApp } = await import("@rezi-ui/core");
147+
const core = await import("@rezi-ui/core");
148+
const { createApp } = core;
147149
const backend = await createBenchBackend();
148150

149151
type State = { tick: number };
@@ -163,6 +165,7 @@ async function runRezi(
163165

164166
const frameBase = backend.frameCount;
165167
const bytesBase = backend.totalFrameBytes;
168+
resetReziPerfSnapshot(core);
166169

167170
const metrics = await benchAsync(
168171
async () => {
@@ -176,6 +179,13 @@ async function runRezi(
176179

177180
metrics.framesProduced = backend.frameCount - frameBase;
178181
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
182+
emitReziPerfSnapshot(
183+
core,
184+
variant === "navigation" ? "terminal-strict-ui-navigation" : "terminal-strict-ui",
185+
params,
186+
config,
187+
metrics,
188+
);
179189
return metrics;
180190
} finally {
181191
await app.stop();

packages/bench/src/scenarios/terminalVirtualList.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { runOpenTuiScenario } from "../frameworks/opentui.js";
1313
import { runRatatuiScenario } from "../frameworks/ratatui.js";
1414
import { createBenchBackend, createInkStdout } from "../io.js";
1515
import { benchAsync, tryGc } from "../measure.js";
16+
import { emitReziPerfSnapshot, resetReziPerfSnapshot } from "../reziProfile.js";
1617
import type { BenchMetrics, Framework, Scenario, ScenarioConfig } from "../types.js";
1718

1819
type BlessedElement = Readonly<{ setContent: (s: string) => void }>;
@@ -99,7 +100,8 @@ async function runRezi(
99100
totalItems: number,
100101
viewport: number,
101102
): Promise<BenchMetrics> {
102-
const { createApp } = await import("@rezi-ui/core");
103+
const core = await import("@rezi-ui/core");
104+
const { createApp } = core;
103105
const backend = await createBenchBackend();
104106

105107
type State = { offset: number; tick: number };
@@ -119,6 +121,7 @@ async function runRezi(
119121

120122
const frameBase = backend.frameCount;
121123
const bytesBase = backend.totalFrameBytes;
124+
resetReziPerfSnapshot(core);
122125

123126
const metrics = await benchAsync(
124127
async () => {
@@ -132,6 +135,7 @@ async function runRezi(
132135

133136
metrics.framesProduced = backend.frameCount - frameBase;
134137
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
138+
emitReziPerfSnapshot(core, "terminal-virtual-list", { items: totalItems, viewport }, config, metrics);
135139
return metrics;
136140
} finally {
137141
await app.stop();

0 commit comments

Comments
 (0)