Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-geometry-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Reduced retained memory for large reviews by lazily materializing cached geometry row plans only when copy selection needs them.
2 changes: 2 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ bun run bench:non-ascii-stream
bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
bun run bench:geometry-memory
bun run bench:navigation-memory
bun run bench:resize-memory
bun run bench:competitors
Expand All @@ -61,6 +62,7 @@ bun run bench:competitors
- `huge-stream.ts` — opt-in huge tier (`--include-huge` or `HUNK_BENCH_INCLUDE_HUGE=1`): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
- `large-stream-profile.ts` — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
- `memory.ts` — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
- `geometry-memory.ts` — optional local retained-memory profiler for all-files section geometry, including the lazy planned-row materialization path used by copy selection.
- `navigation-memory.ts` — optional local retained-memory profiler for repeated hunk navigation through a mounted review stream.
- `resize-memory.ts` — optional local retained-memory profiler for repeated terminal-width changes through a mounted review stream; this targets geometry-cache retention across resize variants.
- `competitors.ts` — optional local informational comparisons against `git diff --no-ext-diff`, `delta`, `difftastic`, and `diff-so-fancy` when installed.
Expand Down
172 changes: 172 additions & 0 deletions benchmarks/geometry-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Track retained memory for the all-files geometry cache used by review scrolling/navigation.
import { performance } from "node:perf_hooks";
import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry";
import { resolveTheme } from "../src/ui/themes";
import {
createLargeSplitStreamBootstrap,
DEFAULT_FILE_COUNT,
DEFAULT_LINES_PER_FILE,
} from "./large-stream-fixture";

type MemorySample = {
rssBytes: number;
heapUsedBytes: number;
heapTotalBytes: number;
externalBytes: number;
arrayBuffersBytes: number;
};

type CliOptions = {
fileCount: number;
linesPerFile: number;
width: number;
gc: boolean;
};

const defaultOptions: CliOptions = {
fileCount: DEFAULT_FILE_COUNT,
linesPerFile: DEFAULT_LINES_PER_FILE,
width: 240,
gc: true,
};

function parseNumberOption(name: string, value: string | undefined) {
if (value === undefined) {
throw new Error(`Missing value for ${name}.`);
}

const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Expected ${name} to be a non-negative number.`);
}

return parsed;
}

/** Parse benchmark-only flags without pulling a CLI dependency into local diagnostics. */
function parseArgs(argv: string[]): CliOptions {
const options = { ...defaultOptions };

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]!;
if (arg === "--help" || arg === "-h") {
console.log(
`Usage: bun run benchmarks/geometry-memory.ts [options]\n\nOptions:\n --file-count <n> Synthetic review files (default ${defaultOptions.fileCount})\n --lines-per-file <n> Source lines per synthetic file (default ${defaultOptions.linesPerFile})\n --width <n> Geometry measurement width (default ${defaultOptions.width})\n --no-gc Do not force Bun.gc before memory samples\n`,
);
process.exit(0);
}

const next = () => argv[++index];
switch (arg) {
case "--file-count":
options.fileCount = parseNumberOption(arg, next());
break;
case "--lines-per-file":
options.linesPerFile = parseNumberOption(arg, next());
break;
case "--width":
options.width = parseNumberOption(arg, next());
break;
case "--no-gc":
options.gc = false;
break;
default:
throw new Error(`Unknown option: ${arg}`);
}
}

options.fileCount = Math.max(1, Math.trunc(options.fileCount));
options.linesPerFile = Math.max(1, Math.trunc(options.linesPerFile));
options.width = Math.max(40, Math.trunc(options.width));
return options;
}

function maybeGc(enabled: boolean) {
if (enabled) {
Bun.gc(true);
}
}

function sampleMemory(options: CliOptions): MemorySample {
maybeGc(options.gc);
const usage = process.memoryUsage();
return {
rssBytes: usage.rss,
heapUsedBytes: usage.heapUsed,
heapTotalBytes: usage.heapTotal,
externalBytes: usage.external,
arrayBuffersBytes: usage.arrayBuffers,
};
}

function printMemory(prefix: string, sample: MemorySample) {
console.log(`METRIC ${prefix}_rss_bytes=${sample.rssBytes}`);
console.log(`METRIC ${prefix}_heap_used_bytes=${sample.heapUsedBytes}`);
console.log(`METRIC ${prefix}_heap_total_bytes=${sample.heapTotalBytes}`);
console.log(`METRIC ${prefix}_external_bytes=${sample.externalBytes}`);
console.log(`METRIC ${prefix}_array_buffers_bytes=${sample.arrayBuffersBytes}`);
}

const options = parseArgs(process.argv.slice(2));
const theme = resolveTheme("midnight", null);
const bootstrapStart = performance.now();
const bootstrap = createLargeSplitStreamBootstrap({
fileCount: options.fileCount,
linesPerFile: options.linesPerFile,
});

console.log(
`geometry memory fixture files=${options.fileCount} lines=${options.linesPerFile} ` +
`width=${options.width} gc=${options.gc ? "on" : "off"}`,
);
console.log(`METRIC bootstrap_fixture_ms=${(performance.now() - bootstrapStart).toFixed(2)}`);

const afterBootstrap = sampleMemory(options);
printMemory("after_bootstrap", afterBootstrap);

let bodyRows = 0;
let rowBounds = 0;
let materializedPlannedRows = 0;
const geometryStart = performance.now();
const geometries = bootstrap.changeset.files.map((file) => {
const geometry = measureDiffSectionGeometry(file, "split", true, theme, [], options.width);
bodyRows += geometry.bodyHeight;
rowBounds += geometry.rowBounds.length;
return geometry;
});

const geometryMs = performance.now() - geometryStart;
const afterGeometry = sampleMemory(options);
printMemory("after_geometry", afterGeometry);

// Materialize the lazy planned-row streams as an upper-bound diagnostic for copy-selection style
// consumers. Normal review scrolling/navigation should not pay this retained-memory cost.
const materializeStart = performance.now();
for (const geometry of geometries) {
materializedPlannedRows += geometry.plannedRows.length;
}
const materializeMs = performance.now() - materializeStart;
const afterMaterializedRows = sampleMemory(options);
printMemory("after_materialized_planned_rows", afterMaterializedRows);

console.log(`METRIC geometry_ms=${geometryMs.toFixed(2)}`);
console.log(`METRIC geometry_body_rows=${bodyRows}`);
console.log(`METRIC geometry_row_bounds=${rowBounds}`);
console.log(
`METRIC geometry_heap_growth_bytes=${afterGeometry.heapUsedBytes - afterBootstrap.heapUsedBytes}`,
);
console.log(`METRIC geometry_rss_growth_bytes=${afterGeometry.rssBytes - afterBootstrap.rssBytes}`);
console.log(`METRIC materialize_planned_rows_ms=${materializeMs.toFixed(2)}`);
console.log(`METRIC materialized_planned_rows=${materializedPlannedRows}`);
console.log(
`METRIC materialized_planned_rows_heap_growth_bytes=${
afterMaterializedRows.heapUsedBytes - afterGeometry.heapUsedBytes
}`,
);
console.log(
`METRIC materialized_planned_rows_rss_growth_bytes=${
afterMaterializedRows.rssBytes - afterGeometry.rssBytes
}`,
);
console.log(`METRIC files=${options.fileCount}`);
console.log(`METRIC lines_per_file=${options.linesPerFile}`);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"bench:huge-stream": "bun run benchmarks/huge-stream.ts",
"bench:large-stream-profile": "bun run benchmarks/large-stream-profile.ts",
"bench:memory": "bun run benchmarks/memory.ts",
"bench:geometry-memory": "bun run benchmarks/geometry-memory.ts",
"bench:navigation-memory": "bun run benchmarks/navigation-memory.ts",
"bench:resize-memory": "bun run benchmarks/resize-memory.ts",
"bench:daemon-memory": "bun run scripts/daemon-memory-check.ts",
Expand Down
3 changes: 2 additions & 1 deletion src/ui/components/panes/copySelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,11 @@ export function renderCopySelectionText({
(context.layout === "split" && start.kind === "review-row"
? resolveCopySelectionSide(start.column, context.layout, context.width)
: undefined);
const plannedRows = geometry.plannedRows;

for (let rowIndex = 0; rowIndex < geometry.rowBounds.length; rowIndex += 1) {
const rowBounds = geometry.rowBounds[rowIndex]!;
const row = geometry.plannedRows[rowIndex];
const row = plannedRows[rowIndex];
if (!row || rowBounds.height <= 0) {
continue;
}
Expand Down
18 changes: 18 additions & 0 deletions src/ui/diff/diffSectionGeometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ describe("measureDiffSectionGeometry", () => {
expect(differentAddNotePolicy).not.toBe(first);
});

test("caches planned rows after the lazy geometry property is first read", () => {
const geometry = measureDiffSectionGeometry(
createTestDiffFile(),
"split",
true,
theme,
[],
120,
true,
false,
);

const plannedRows = geometry.plannedRows;

expect(plannedRows).toHaveLength(geometry.rowBounds.length);
expect(geometry.plannedRows).toBe(plannedRows);
});

test("replaces stale width variants while retaining separate base and note slots", () => {
const file = createTestDiffFile();
const visibleAgentNotes: VisibleAgentNote[] = [
Expand Down
54 changes: 49 additions & 5 deletions src/ui/diff/diffSectionGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ export interface DiffSectionRowBounds extends VerticalBounds {
/**
* Cached placeholder sizing and hunk navigation geometry for one file section.
*
* `plannedRows` is retained alongside the row-bounds map so downstream features (notably
* clipboard rendering of mouse selections) can re-render the exact same rows the layout was
* measured against, without rebuilding the plan. The cache is owned by the immutable DiffFile
* object and includes note content in its key, so memory grows with visible diff lifetimes.
* Copy selection occasionally needs the planned rows behind the measured bounds. The geometry
* implementations keep that row stream lazy so ordinary scrolling/navigation retain only bounds.
*/
export interface DiffSectionGeometry extends SectionGeometry<PlannedHunkBounds> {
lineNumberDigits: number;
Expand Down Expand Up @@ -135,6 +133,41 @@ function setCachedSectionGeometry(
SECTION_GEOMETRY_CACHE.set(file, cachedByFile);
}

/** Resolve planned rows only for uncommon consumers that need row content after geometry exists. */
function createLazyPlannedRowsResolver({
expandedKeys,
file,
layout,
showHunkHeaders,
sourceStatus,
theme,
visibleAgentNotes,
}: {
expandedKeys: ReadonlySet<string>;
file: DiffFile;
layout: Exclude<LayoutMode, "auto">;
showHunkHeaders: boolean;
sourceStatus: FileSourceStatus | undefined;
theme: AppTheme;
visibleAgentNotes: VisibleAgentNote[];
}) {
let plannedRows: PlannedReviewRow[] | null = null;

return () => {
plannedRows ??= buildDiffSectionRowPlan({
expandedKeys,
file,
layout,
showHunkHeaders,
sourceStatus,
theme,
visibleAgentNotes,
}).plannedRows;

return plannedRows;
};
}

interface DiffSectionRowHeightOptions {
layout: Exclude<LayoutMode, "auto">;
lineNumberDigits: number;
Expand Down Expand Up @@ -328,12 +361,23 @@ export function measureDiffSectionGeometry(
bodyHeight += height;
}

const resolvePlannedRows = createLazyPlannedRowsResolver({
expandedKeys,
file,
layout,
showHunkHeaders,
sourceStatus,
theme,
visibleAgentNotes,
});
const geometry: DiffSectionGeometry = {
bodyHeight,
hunkAnchorRows,
hunkBounds,
lineNumberDigits,
plannedRows,
get plannedRows() {
return resolvePlannedRows();
},
rowBounds,
rowBoundsByKey,
rowBoundsByStableKey,
Expand Down
3 changes: 2 additions & 1 deletion src/ui/diff/rowWindowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function createTestSectionGeometry(
rowBounds: Array<{ key: string; top: number; height: number }>,
bodyHeight: number,
): DiffSectionGeometry {
const plannedRows = rowBounds.map((row) => createTestPlannedRow(row.key));
const normalizedRowBounds = rowBounds.map((row) => ({
...row,
stableKey: row.key,
Expand All @@ -37,7 +38,7 @@ function createTestSectionGeometry(
hunkAnchorRows: new Map(),
hunkBounds: new Map(),
lineNumberDigits: 1,
plannedRows: rowBounds.map((row) => createTestPlannedRow(row.key)),
plannedRows,
rowBounds: normalizedRowBounds,
rowBoundsByKey: new Map(normalizedRowBounds.map((row) => [row.key, row])),
rowBoundsByStableKey: new Map(normalizedRowBounds.map((row) => [row.stableKey, row])),
Expand Down