Skip to content

Commit 2fc2308

Browse files
authored
perf: benchmark and optimize large split review streams (#76)
* test: benchmark large split review streams * test: flush benchmark highlight work * Baseline note-enabled large split-stream scroll benchmark on PR branch before optimization; current 18-tick note-enabled scroll cost is 16406.23ms and still shows act warnings from deferred highlight work.\n\nResult: {"status":"keep","note_scroll_ticks_ms":16406.23} * Scope visible agent notes to the selected hunk and keep placeholder windowing active for the rest of the review stream; note-enabled 18-tick scroll drops from 16406.23ms to 647.48ms while the interaction test now follows notes as focus moves.\n\nResult: {"status":"keep","note_scroll_ticks_ms":647.48} * Count visible inline note rows in split-mode section metrics so placeholder windowing keeps accurate heights for the selected note-bearing file; note-enabled 18-tick scroll drops from 647.48ms to 231.25ms and the section-height helper now has note-row coverage.\n\nResult: {"status":"keep","note_scroll_ticks_ms":231.25} * Validation rerun after note-height-aware windowing confirms the win: note-enabled 18-tick scroll improves further to 197.32ms, though the benchmark still occasionally surfaces deferred Pierre highlight act warnings that should be cleaned up separately.\n\nResult: {"status":"keep","note_scroll_ticks_ms":197.32} * Baseline after restoring viewport-scoped visible notes rather than selected-hunk-only notes; under the broader visible-note policy the current 18-tick large-stream scroll cost is 2009.15ms.\n\nResult: {"status":"keep","note_scroll_ticks_ms":2009.15} * Cache note-aware section metrics by visible note set so viewport-scoped visible notes reuse placeholder height plans across scroll ticks; under the restored always-visible note policy, the 18-tick large-stream scroll cost drops from 2009.15ms to 301.54ms while viewport-visible notes stay on screen.\n\nResult: {"status":"keep","note_scroll_ticks_ms":301.54} * chore: remove autoresearch log
1 parent d4b80f4 commit 2fc2308

18 files changed

Lines changed: 536 additions & 117 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ tmp
4040
.hunk/latest.json
4141
.hunk/config.toml
4242
.pi/
43+
autoresearch.jsonl
44+
autoresearch.ideas.md

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ bun run build:npm
5757
bun run check:pack
5858
```
5959

60+
Benchmark scripts live in [`benchmarks/`](benchmarks/README.md).
61+
62+
Common runs:
63+
64+
```bash
65+
bun run bench:bootstrap-load
66+
bun run bench:highlight-prefetch
67+
bun run bench:large-stream
68+
bun run bench:large-stream-profile
69+
```
70+
6071
Build and smoke-test the prebuilt npm packages for the current host:
6172

6273
```bash

benchmarks/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Benchmarks
2+
3+
Benchmark scripts, shared fixtures, and local result artifacts live here.
4+
5+
## Scripts
6+
7+
- `bootstrap-load.ts` — measures bootstrap and git-loader cost on a synthetic large repo
8+
- `highlight-prefetch.ts` — measures selected-file highlight startup and adjacent prefetch readiness
9+
- `large-stream.ts` — measures large split-stream first-frame and scroll cost, including note-enabled cases
10+
- `large-stream-profile.ts` — profiles the main pure planning stages behind the large split-stream benchmark
11+
- `large-stream-fixture.ts` — shared synthetic diff fixture used by the large-stream benchmarks
12+
13+
## Running
14+
15+
From the project root:
16+
17+
```bash
18+
bun run bench:bootstrap-load
19+
bun run bench:highlight-prefetch
20+
bun run bench:large-stream
21+
bun run bench:large-stream-profile
22+
```
23+
24+
## Results
25+
26+
Use `benchmarks/results/` for local benchmark output, notes, or captured runs.
27+
28+
The folder stays in the repo so the convention is discoverable, but local result files inside it are ignored by default.
File renamed without changes.

benchmarks/large-stream-fixture.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { parseDiffFromFile } from "@pierre/diffs";
2+
import type { AppBootstrap, DiffFile } from "../src/core/types";
3+
4+
export const DEFAULT_FILE_COUNT = 180;
5+
export const DEFAULT_LINES_PER_FILE = 120;
6+
export const DEFAULT_NOTES_PER_FILE = 2;
7+
8+
interface LargeSplitStreamFixtureOptions {
9+
fileCount?: number;
10+
linesPerFile?: number;
11+
notesPerFile?: number;
12+
}
13+
14+
function createAgentAnnotations(index: number, notesPerFile: number) {
15+
if (notesPerFile <= 0) {
16+
return [];
17+
}
18+
19+
return Array.from({ length: notesPerFile }, (_, noteIndex) => {
20+
const startLine = 40 + noteIndex * 12;
21+
const endLine = startLine + 5;
22+
return {
23+
id: `note:${index}:${noteIndex}`,
24+
newRange: [startLine, endLine] as [number, number],
25+
summary: `Explain the split-mode refactor in file ${index}, hunk note ${noteIndex + 1}.`,
26+
rationale:
27+
"Synthetic benchmark note to exercise inline note placement, guide rows, and note-enabled full-stream rendering.",
28+
};
29+
});
30+
}
31+
32+
export function createLargeSplitDiffFile(
33+
index: number,
34+
{
35+
linesPerFile = DEFAULT_LINES_PER_FILE,
36+
notesPerFile = 0,
37+
}: Omit<LargeSplitStreamFixtureOptions, "fileCount"> = {},
38+
): DiffFile {
39+
const path = `src/stream${index}.ts`;
40+
const before = Array.from({ length: linesPerFile }, (_, lineIndex) => {
41+
const line = lineIndex + 1;
42+
return `export function stream${index}_${line}(value: number) { return value + ${line}; }\n`;
43+
}).join("");
44+
45+
const after = Array.from({ length: linesPerFile }, (_, lineIndex) => {
46+
const line = lineIndex + 1;
47+
if (lineIndex >= 36 && lineIndex < 84) {
48+
return `export function stream${index}_${line}(value: number) { return value * ${line} + ${index}; }\n`;
49+
}
50+
51+
return `export function stream${index}_${line}(value: number) { return value + ${line}; }\n`;
52+
}).join("");
53+
54+
const metadata = parseDiffFromFile(
55+
{
56+
name: path,
57+
contents: before,
58+
cacheKey: `stream:${index}:before:${linesPerFile}`,
59+
},
60+
{
61+
name: path,
62+
contents: after,
63+
cacheKey: `stream:${index}:after:${linesPerFile}`,
64+
},
65+
{ context: 3 },
66+
true,
67+
);
68+
69+
const annotations = createAgentAnnotations(index, notesPerFile);
70+
71+
return {
72+
id: `stream:${index}`,
73+
path,
74+
patch: "",
75+
language: "typescript",
76+
stats: { additions: 48, deletions: 48 },
77+
metadata,
78+
agent:
79+
annotations.length > 0
80+
? {
81+
path,
82+
summary: `Synthetic note-heavy benchmark context for ${path}`,
83+
annotations,
84+
}
85+
: null,
86+
};
87+
}
88+
89+
export function createLargeSplitStreamFiles({
90+
fileCount = DEFAULT_FILE_COUNT,
91+
linesPerFile = DEFAULT_LINES_PER_FILE,
92+
notesPerFile = 0,
93+
}: LargeSplitStreamFixtureOptions = {}) {
94+
return Array.from({ length: fileCount }, (_, index) =>
95+
createLargeSplitDiffFile(index + 1, { linesPerFile, notesPerFile }),
96+
);
97+
}
98+
99+
export function createLargeSplitStreamBootstrap({
100+
fileCount = DEFAULT_FILE_COUNT,
101+
linesPerFile = DEFAULT_LINES_PER_FILE,
102+
notesPerFile = 0,
103+
}: LargeSplitStreamFixtureOptions = {}): AppBootstrap {
104+
return {
105+
input: {
106+
kind: "git",
107+
staged: false,
108+
options: {
109+
mode: "auto",
110+
},
111+
},
112+
changeset: {
113+
id: `changeset:large-split-stream:${fileCount}:${linesPerFile}:${notesPerFile}`,
114+
sourceLabel: "repo",
115+
title: "repo working tree",
116+
files: createLargeSplitStreamFiles({ fileCount, linesPerFile, notesPerFile }),
117+
},
118+
initialMode: "split",
119+
initialTheme: "midnight",
120+
initialShowAgentNotes: notesPerFile > 0,
121+
};
122+
}

benchmarks/large-stream-profile.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Profile large split-mode review streams by timing the main pure planning stages
2+
// before the React tree and renderer get involved.
3+
import { performance } from "perf_hooks";
4+
import { buildSplitRows } from "../src/ui/diff/pierre";
5+
import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan";
6+
import { measureDiffSectionMetrics } from "../src/ui/lib/sectionHeights";
7+
import { resolveTheme } from "../src/ui/themes";
8+
import {
9+
createLargeSplitStreamFiles,
10+
DEFAULT_FILE_COUNT,
11+
DEFAULT_LINES_PER_FILE,
12+
DEFAULT_NOTES_PER_FILE,
13+
} from "./large-stream-fixture";
14+
15+
const theme = resolveTheme("midnight", null);
16+
const windowedFiles = createLargeSplitStreamFiles({ notesPerFile: 0 });
17+
const noteFiles = createLargeSplitStreamFiles({ notesPerFile: DEFAULT_NOTES_PER_FILE });
18+
19+
function visibleAgentNotesForFile(file: (typeof noteFiles)[number]) {
20+
const annotations = file.agent?.annotations ?? [];
21+
return annotations.map((annotation, index) => ({
22+
id: `annotation:${file.id}:${annotation.id ?? index}`,
23+
annotation,
24+
}));
25+
}
26+
27+
function measureMs(run: () => void) {
28+
const start = performance.now();
29+
run();
30+
return performance.now() - start;
31+
}
32+
33+
const sectionMetricsMs = measureMs(() => {
34+
windowedFiles.forEach((file) => {
35+
measureDiffSectionMetrics(file, "split", true, theme);
36+
});
37+
});
38+
39+
let windowedRows = 0;
40+
const splitRowsMs = measureMs(() => {
41+
windowedFiles.forEach((file) => {
42+
windowedRows += buildSplitRows(file, null, theme).length;
43+
});
44+
});
45+
46+
let notePlannedRows = 0;
47+
const noteReviewPlanMs = measureMs(() => {
48+
noteFiles.forEach((file) => {
49+
const rows = buildSplitRows(file, null, theme);
50+
notePlannedRows += buildReviewRenderPlan({
51+
fileId: file.id,
52+
rows,
53+
showHunkHeaders: true,
54+
visibleAgentNotes: visibleAgentNotesForFile(file),
55+
}).length;
56+
});
57+
});
58+
59+
console.log(`METRIC section_metrics_ms=${sectionMetricsMs.toFixed(2)}`);
60+
console.log(`METRIC split_rows_ms=${splitRowsMs.toFixed(2)}`);
61+
console.log(`METRIC note_review_plan_ms=${noteReviewPlanMs.toFixed(2)}`);
62+
console.log(`METRIC split_rows=${windowedRows}`);
63+
console.log(`METRIC note_planned_rows=${notePlannedRows}`);
64+
console.log(`METRIC files=${DEFAULT_FILE_COUNT}`);
65+
console.log(`METRIC lines_per_file=${DEFAULT_LINES_PER_FILE}`);
66+
console.log(`METRIC notes_per_file=${DEFAULT_NOTES_PER_FILE}`);

benchmarks/large-stream.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Benchmark split-mode startup and scroll behaviour on very large review streams,
2+
// including note-enabled cases that disable the placeholder windowing path.
3+
import { performance } from "perf_hooks";
4+
import React from "react";
5+
import { testRender } from "@opentui/react/test-utils";
6+
import { act } from "react";
7+
import { App } from "../src/ui/App";
8+
import {
9+
createLargeSplitStreamBootstrap,
10+
DEFAULT_FILE_COUNT,
11+
DEFAULT_LINES_PER_FILE,
12+
DEFAULT_NOTES_PER_FILE,
13+
} from "./large-stream-fixture";
14+
15+
const VIEWPORT = {
16+
width: 240,
17+
height: 28,
18+
} as const;
19+
const SCROLL_TICKS = 18;
20+
const SCROLL_TARGET = {
21+
x: 170,
22+
y: 12,
23+
} as const;
24+
const SELECTED_HIGHLIGHT_MARKER = "stream1_40";
25+
26+
type BenchmarkRenderer = Awaited<ReturnType<typeof testRender>>;
27+
28+
function frameHasHighlightedMarker(
29+
frame: { lines: Array<{ spans: Array<{ text: string }> }> },
30+
marker: string,
31+
) {
32+
return frame.lines.some((line) => {
33+
const text = line.spans.map((span) => span.text).join("");
34+
35+
if (!text.includes(marker)) {
36+
return false;
37+
}
38+
39+
return line.spans.some(
40+
(span) => span.text.includes(marker) && span.text.trim().length < text.trim().length,
41+
);
42+
});
43+
}
44+
45+
async function renderPass(setup: BenchmarkRenderer, passes = 1) {
46+
for (let index = 0; index < passes; index += 1) {
47+
await act(async () => {
48+
await setup.renderOnce();
49+
await Bun.sleep(0);
50+
});
51+
}
52+
}
53+
54+
async function flushSelectedHighlight(setup: BenchmarkRenderer) {
55+
for (let iteration = 0; iteration < 200; iteration += 1) {
56+
await renderPass(setup);
57+
58+
if (frameHasHighlightedMarker(setup.captureSpans(), SELECTED_HIGHLIGHT_MARKER)) {
59+
return;
60+
}
61+
}
62+
}
63+
64+
async function destroyRenderer(setup: BenchmarkRenderer) {
65+
await act(async () => {
66+
setup.renderer.destroy();
67+
});
68+
}
69+
70+
async function measureFirstFrameMs(notesPerFile: number) {
71+
const setup = await testRender(
72+
React.createElement(App, {
73+
bootstrap: createLargeSplitStreamBootstrap({ notesPerFile }),
74+
}),
75+
VIEWPORT,
76+
);
77+
const start = performance.now();
78+
79+
try {
80+
await renderPass(setup);
81+
return performance.now() - start;
82+
} finally {
83+
await flushSelectedHighlight(setup);
84+
await destroyRenderer(setup);
85+
}
86+
}
87+
88+
async function measureScrollTicksMs(notesPerFile: number) {
89+
const setup = await testRender(
90+
React.createElement(App, {
91+
bootstrap: createLargeSplitStreamBootstrap({ notesPerFile }),
92+
}),
93+
VIEWPORT,
94+
);
95+
96+
try {
97+
await renderPass(setup, 2);
98+
const start = performance.now();
99+
100+
for (let index = 0; index < SCROLL_TICKS; index += 1) {
101+
await act(async () => {
102+
await setup.mockMouse.scroll(SCROLL_TARGET.x, SCROLL_TARGET.y, "down");
103+
await setup.renderOnce();
104+
await Bun.sleep(0);
105+
});
106+
}
107+
108+
return performance.now() - start;
109+
} finally {
110+
await flushSelectedHighlight(setup);
111+
await destroyRenderer(setup);
112+
}
113+
}
114+
115+
const coldFirstFrameMs = await measureFirstFrameMs(0);
116+
const warmFirstFrameMs = await measureFirstFrameMs(0);
117+
const noteFirstFrameMs = await measureFirstFrameMs(DEFAULT_NOTES_PER_FILE);
118+
const windowedScrollMs = await measureScrollTicksMs(0);
119+
const noteScrollMs = await measureScrollTicksMs(DEFAULT_NOTES_PER_FILE);
120+
121+
console.log(`METRIC cold_first_frame_ms=${coldFirstFrameMs.toFixed(2)}`);
122+
console.log(`METRIC warm_first_frame_ms=${warmFirstFrameMs.toFixed(2)}`);
123+
console.log(`METRIC note_first_frame_ms=${noteFirstFrameMs.toFixed(2)}`);
124+
console.log(`METRIC windowed_scroll_ticks_ms=${windowedScrollMs.toFixed(2)}`);
125+
console.log(`METRIC note_scroll_ticks_ms=${noteScrollMs.toFixed(2)}`);
126+
console.log(`METRIC scroll_ticks=${SCROLL_TICKS}`);
127+
console.log(`METRIC files=${DEFAULT_FILE_COUNT}`);
128+
console.log(`METRIC lines_per_file=${DEFAULT_LINES_PER_FILE}`);
129+
console.log(`METRIC notes_per_file=${DEFAULT_NOTES_PER_FILE}`);

benchmarks/results/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*
2+
!.gitignore
3+
!.gitkeep

benchmarks/results/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)