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
176 changes: 176 additions & 0 deletions benchmarks/geometry-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// 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) {
for (let index = 0; index < geometry.rowBounds.length; index += 1) {
if (geometry.getPlannedRow(index)) {
materializedPlannedRows += 1;
}
}
}
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
7 changes: 4 additions & 3 deletions src/ui/components/panes/copySelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ describe("renderCopySelectionText", () => {
const { context, fileSectionLayouts, sectionGeometry } = buildContext("stack");
const section = fileSectionLayouts[0]!;
const geometry = sectionGeometry[0]!;
const rowIndex = geometry.plannedRows.findIndex(
(row) => row.kind === "diff-row" && row.row.type === "stack-line",
);
const rowIndex = geometry.rowBounds.findIndex((_, index) => {
const row = geometry.getPlannedRow(index);
return row?.kind === "diff-row" && row.row.type === "stack-line";
});
const visualRow = section.bodyTop + geometry.rowBounds[rowIndex]!.top;
const { gutterWidth } = resolveStackCellGeometry(
context.width,
Expand Down
4 changes: 2 additions & 2 deletions src/ui/components/panes/copySelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export function renderCopySelectionText({

for (let rowIndex = 0; rowIndex < geometry.rowBounds.length; rowIndex += 1) {
const rowBounds = geometry.rowBounds[rowIndex]!;
const row = geometry.plannedRows[rowIndex];
const row = geometry.getPlannedRow(rowIndex);
if (!row || rowBounds.height <= 0) {
continue;
}
Expand Down Expand Up @@ -466,7 +466,7 @@ export function expandSelectionPoint(
return null;
}

const row = geometry.plannedRows[rowIndex];
const row = geometry.getPlannedRow(rowIndex);
if (!row) {
return null;
}
Expand Down
55 changes: 48 additions & 7 deletions src/ui/diff/diffSectionGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ 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 row behind a measured row. Keep that row stream
* lazy so ordinary large-review scrolling/navigation retain bounds instead of every render row.
*/
export interface DiffSectionGeometry extends SectionGeometry<PlannedHunkBounds> {
getPlannedRow: (rowIndex: number) => PlannedReviewRow | undefined;
lineNumberDigits: number;
plannedRows: PlannedReviewRow[];
rowBounds: DiffSectionRowBounds[];
rowBoundsByKey: Map<string, DiffSectionRowBounds>;
rowBoundsByStableKey: Map<string, DiffSectionRowBounds>;
Expand Down Expand Up @@ -135,6 +133,41 @@ function setCachedSectionGeometry(
SECTION_GEOMETRY_CACHE.set(file, cachedByFile);
}

/** Build planned rows only for uncommon consumers that need row content after geometry exists. */
function createLazyPlannedRowAccessor({
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 (rowIndex: number) => {
plannedRows ??= buildDiffSectionRowPlan({
expandedKeys,
file,
layout,
showHunkHeaders,
sourceStatus,
theme,
visibleAgentNotes,
}).plannedRows;

return plannedRows[rowIndex];
};
}

interface DiffSectionRowHeightOptions {
layout: Exclude<LayoutMode, "auto">;
lineNumberDigits: number;
Expand Down Expand Up @@ -223,10 +256,10 @@ export function measureDiffSectionGeometry(
if (file.metadata.hunks.length === 0) {
return {
bodyHeight: 1,
getPlannedRow: () => undefined,
hunkAnchorRows: new Map(),
hunkBounds: new Map(),
lineNumberDigits: String(findMaxLineNumber(file)).length,
plannedRows: [],
rowBounds: [],
rowBoundsByKey: new Map(),
rowBoundsByStableKey: new Map(),
Expand Down Expand Up @@ -330,10 +363,18 @@ export function measureDiffSectionGeometry(

const geometry: DiffSectionGeometry = {
bodyHeight,
getPlannedRow: createLazyPlannedRowAccessor({
expandedKeys,
file,
layout,
showHunkHeaders,
sourceStatus,
theme,
visibleAgentNotes,
}),
hunkAnchorRows,
hunkBounds,
lineNumberDigits,
plannedRows,
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 @@ -34,10 +35,10 @@ function createTestSectionGeometry(

return {
bodyHeight,
getPlannedRow: (rowIndex) => plannedRows[rowIndex],
hunkAnchorRows: new Map(),
hunkBounds: new Map(),
lineNumberDigits: 1,
plannedRows: rowBounds.map((row) => createTestPlannedRow(row.key)),
rowBounds: normalizedRowBounds,
rowBoundsByKey: new Map(normalizedRowBounds.map((row) => [row.key, row])),
rowBoundsByStableKey: new Map(normalizedRowBounds.map((row) => [row.stableKey, row])),
Expand Down