Skip to content
Merged
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/worker-highlight-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Reuse worker-highlighted diffs after the terminal cache evicts them.
4 changes: 4 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ bun run bench:working-tree-load
bun run bench:changeset-parse
bun run bench:render-layout
bun run bench:highlight-prefetch
bun run bench:highlight-worker-cache
bun run bench:highlight-cache-layers
bun run bench:large-stream
bun run bench:interaction-latency
bun run bench:non-ascii-stream
Expand All @@ -58,6 +60,8 @@ bun run bench:competitors
- `changeset-parse.ts` — measures patch normalization, Pierre parsing, patch chunking, and normalized `DiffFile` construction for many-small-files, balanced, and large-single-file patches.
- `render-layout.ts` — measures pure split/stack row building, section geometry, and review-plan construction for many-small-files, balanced, and large-single-file streams.
- `highlight-prefetch.ts` — measures selected-file highlight startup and adjacent prefetch readiness.
- `worker-highlight-cache.ts` — measures a cold worker highlight against an immediate compact-result cache hit.
- `highlight-cache-layers.ts` — measures a resident terminal-cache hit against a worker-cache revisit after the terminal cache evicts the diff.
- `large-stream.ts` — measures large split-stream first-frame and scroll cost.
- `interaction-latency.ts` — measures per-press `]` hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of `memory.ts`).
- `non-ascii-stream.ts` — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs.
Expand Down
74 changes: 74 additions & 0 deletions benchmarks/highlight-cache-layers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Compare a resident main-process cache hit with a worker-LRU revisit after main-cache eviction.
import { performance } from "node:perf_hooks";
import { parseDiffFromFile } from "@pierre/diffs";
import type { DiffFile } from "../src/core/types";
import { resolveTheme } from "../src/ui/themes";
import { disposeHighlightWorker } from "../src/ui/diff/worker/highlightWorkerClient";
import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff";

const lineCount = 8_000;
const theme = resolveTheme("github-dark-default", null);

/** Builds one unique large diff that always qualifies for worker highlighting. */
function createFile(index: number): DiffFile {
const additions = Array.from(
{ length: lineCount },
(_, line) => `export const marker${index}_${line} = ${line};`,
).join("\n");
const path = `src/benchmark-${index}.ts`;
const metadata = parseDiffFromFile(
{ name: path, contents: "export const prior = 1;\n", cacheKey: `before:${index}` },
{
name: path,
contents: `export const prior = ${index};\n${additions}\n`,
cacheKey: `after:${index}`,
},
{ context: 3 },
true,
);

return {
id: `benchmark:${index}`,
path,
patch: "",
language: "typescript",
stats: { additions: lineCount, deletions: 1 },
metadata,
agent: null,
};
}

/** Measures production prefetch orchestration for one large diff. */
async function timeHighlight(file: DiffFile) {
const start = performance.now();
await prefetchHighlightedDiff({ file, offloadLargeDiff: true, theme });
return performance.now() - start;
}

try {
const files = Array.from({ length: 8 }, (_, index) => createFile(index));
const first = files[0];
if (!first) {
throw new Error("Expected benchmark files.");
}

const coldMs = await timeHighlight(first);
const mainCacheHitMs = await timeHighlight(first);

// Eight 8k-line entries exceed the 60k-line main-cache budget but fit in the 8 MiB compact
// worker cache, making the next request a worker-LRU revisit.
for (const file of files.slice(1)) {
await timeHighlight(file);
}
const workerCacheHitAfterMainEvictionMs = await timeHighlight(first);

console.log(`METRIC cold_ms=${coldMs.toFixed(2)}`);
console.log(`METRIC main_cache_hit_ms=${mainCacheHitMs.toFixed(2)}`);
console.log(
`METRIC worker_cache_hit_after_main_eviction_ms=${workerCacheHitAfterMainEvictionMs.toFixed(2)}`,
);
console.log(`METRIC files=${files.length}`);
console.log(`METRIC changed_lines=${lineCount}`);
} finally {
disposeHighlightWorker();
}
56 changes: 56 additions & 0 deletions benchmarks/worker-highlight-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Measure a cold compact-worker highlight against a worker-local result-cache hit.
import { performance } from "node:perf_hooks";
import { parseDiffFromFile } from "@pierre/diffs";
import {
disposeHighlightWorker,
highlightDiffInWorker,
} from "../src/ui/diff/worker/highlightWorkerClient";
import { compactHighlightedDiffByteLength } from "../src/ui/diff/worker/highlightCompact";

const lineCount = 8_000;
const additions = Array.from(
{ length: lineCount },
(_, index) => `export const marker${index} = ${index};`,
).join("\n");
const metadata = parseDiffFromFile(
{ name: "large.ts", contents: "export const prior = 1;\n", cacheKey: "measure:before" },
{
name: "large.ts",
contents: `export const prior = 2;\n${additions}\n`,
cacheKey: "measure:after",
},
{ context: 3 },
true,
);

/** Submit the same immutable metadata so the second call exercises the worker-owned LRU. */
function requestHighlight() {
return highlightDiffInWorker({
aliasContext: true,
appearance: "dark",
language: "typescript",
metadata,
theme: "pierre-dark",
});
}

try {
const coldStart = performance.now();
const cold = await requestHighlight();
const coldMs = performance.now() - coldStart;

const warmStart = performance.now();
const warm = await requestHighlight();
const warmMs = performance.now() - warmStart;

if (cold.addition.starts.length !== warm.addition.starts.length) {
throw new Error("Worker cache returned a compact payload with different syntax runs.");
}

console.log(`METRIC cold_ms=${coldMs.toFixed(2)}`);
console.log(`METRIC worker_cache_hit_ms=${warmMs.toFixed(2)}`);
console.log(`METRIC compact_payload_bytes=${compactHighlightedDiffByteLength(cold)}`);
console.log(`METRIC changed_lines=${lineCount}`);
} finally {
disposeHighlightWorker();
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@
"bench:render-layout": "bun run benchmarks/render-layout.ts",
"bench:highlight-prefetch": "bun run benchmarks/highlight-prefetch.ts",
"bench:highlight-compact-payload": "bun run benchmarks/compact-highlight-payload.ts",
"bench:highlight-worker-cache": "bun run benchmarks/worker-highlight-cache.ts",
"bench:highlight-cache-layers": "bun run benchmarks/highlight-cache-layers.ts",
"bench:large-stream": "bun run benchmarks/large-stream.ts",
"bench:interaction-latency": "bun run benchmarks/interaction-latency.ts",
"bench:non-ascii-stream": "bun run benchmarks/non-ascii-stream.ts",
Expand Down
9 changes: 6 additions & 3 deletions src/ui/diff/diffRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,12 +628,15 @@ async function loadWorkerHighlightedDiff(
theme: AppTheme,
sourcePlan: SourceBackedHighlightPlan | null,
) {
const aliasContext = sourcePlan === null;
const language = file.language ?? "text";
const syntaxTheme = syntaxHighlightThemeName(theme);
const payload = await highlightDiffInWorker({
aliasContext: sourcePlan === null,
aliasContext,
appearance: theme.appearance,
language: file.language ?? "text",
language,
metadata,
theme: syntaxHighlightThemeName(theme),
theme: syntaxTheme,
});
validateCompactHighlightedDiff(payload, compactHighlightLineLengths(metadata));

Expand Down
20 changes: 20 additions & 0 deletions src/ui/diff/worker/highlightCompact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,26 @@ export function compactHighlightTransferList(payload: CompactHighlightedDiff) {
return [...sideTransferList(payload.deletion), ...sideTransferList(payload.addition)];
}

/** Clone one payload before transferring it so a worker-owned cache keeps its buffers. */
export function cloneCompactHighlightedDiff(
payload: CompactHighlightedDiff,
): CompactHighlightedDiff {
const cloneSide = (side: CompactHighlightSide): CompactHighlightSide => ({
lineOffsets: side.lineOffsets.slice(),
starts: side.starts.slice(),
ends: side.ends.slice(),
styleIds: side.styleIds.slice(),
flags: side.flags.slice(),
});

return {
version: payload.version,
foregroundPalette: [...payload.foregroundPalette],
deletion: cloneSide(payload.deletion),
addition: cloneSide(payload.addition),
};
}

/** Estimate the retained wire size, including the small cloned color palette. */
export function compactHighlightedDiffByteLength(payload: CompactHighlightedDiff) {
const numericBytes = compactHighlightTransferList(payload).reduce(
Expand Down
51 changes: 37 additions & 14 deletions src/ui/diff/worker/highlightWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import {
} from "@pierre/diffs";
import { aliasContextHighlightLines } from "./highlightContext";
import {
cloneCompactHighlightedDiff,
compactHighlightTransferList,
encodeCompactHighlightedDiff,
type CompactHighlightedDiff,
type HighlightedHastLines,
} from "./highlightCompact";
import type { HighlightedHastLines } from "./highlightCompact";
import { HighlightWorkerCache } from "./highlightWorkerCache";
import { highlightWorkerCacheKey } from "./highlightWorkerIdentity";

interface HighlightWorkerRequest {
version: 3;
Expand All @@ -38,6 +41,8 @@ type HighlightWorkerResponse =
}
| { version: 3; id: number; ok: false; message: string };

const highlightedDiffCache = new HighlightWorkerCache();

/** Build the fixed Pierre render options shared with the terminal highlighter. */
function workerRenderOptions(theme: string) {
return {
Expand Down Expand Up @@ -71,20 +76,38 @@ self.onmessage = async (event: MessageEvent<HighlightWorkerRequest>) => {
}

try {
const options = getHighlighterOptions(language, { theme: theme as never });
const highlighter = await getSharedHighlighter({
...options,
preferredHighlighter: "shiki-wasm",
});
const result = renderDiffWithHighlighter(metadata, highlighter, workerRenderOptions(theme));
const highlighted = result.code as {
deletionLines: HighlightedHastLines;
additionLines: HighlightedHastLines;
};
const code = encodeCompactHighlightedDiff(
aliasContext ? aliasContextHighlightLines(metadata, highlighted) : highlighted,
const cacheKey = highlightWorkerCacheKey({
aliasContext,
appearance,
);
language,
metadata,
theme,
});
// A transferred response detaches its buffers. Cache hits therefore return a fresh typed-array
// copy, while the worker retains its own compact payload for a later request.
let code = highlightedDiffCache.get(cacheKey);
if (!code) {
const highlighter = await getSharedHighlighter({
...getHighlighterOptions(language, { theme: theme as never }),
preferredHighlighter: "shiki-wasm",
});
const result = renderDiffWithHighlighter(metadata, highlighter, workerRenderOptions(theme));
const highlighted = result.code as {
deletionLines: HighlightedHastLines;
additionLines: HighlightedHastLines;
};
const cachedCode = encodeCompactHighlightedDiff(
aliasContext ? aliasContextHighlightLines(metadata, highlighted) : highlighted,
appearance,
);

// Oversized payloads stay uncached and transfer their only copy, avoiding a temporary
// second typed-array payload that would violate the worker cache's memory bound.
code = highlightedDiffCache.set(cacheKey, cachedCode)
? cloneCompactHighlightedDiff(cachedCode)
: cachedCode;
}

const response: HighlightWorkerResponse = {
version: 3,
id,
Expand Down
87 changes: 87 additions & 0 deletions src/ui/diff/worker/highlightWorkerCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { compactHighlightTransferList, type CompactHighlightedDiff } from "./highlightCompact";
import { HighlightWorkerCache } from "./highlightWorkerCache";

/** Builds one valid compact payload with a predictable retained size. */
function createTestCompactPayload(lineCount = 1): CompactHighlightedDiff {
return {
version: 1,
foregroundPalette: ["#keyword"],
deletion: {
lineOffsets: Uint32Array.from({ length: lineCount + 1 }, (_, index) => index),
starts: Uint32Array.from({ length: lineCount }, () => 0),
ends: Uint32Array.from({ length: lineCount }, () => 4),
styleIds: Uint16Array.from({ length: lineCount }, () => 1),
flags: new Uint8Array(lineCount),
},
addition: {
lineOffsets: Uint32Array.of(0),
starts: new Uint32Array(),
ends: new Uint32Array(),
styleIds: new Uint16Array(),
flags: new Uint8Array(),
},
};
}

describe("highlight worker cache", () => {
test("returns a transferable clone without detaching its retained payload", () => {
const cache = new HighlightWorkerCache();
const payload = createTestCompactPayload();
cache.set("first", payload);

const firstResponse = cache.get("first");
expect(firstResponse).toBeDefined();
expect(firstResponse).not.toBe(payload);
expect(firstResponse?.deletion.starts).not.toBe(payload.deletion.starts);

const transferred = structuredClone(firstResponse!, {
transfer: compactHighlightTransferList(firstResponse!),
});
expect(firstResponse?.deletion.starts.byteLength).toBe(0);
expect(transferred.deletion.starts).toEqual(Uint32Array.of(0));
expect(cache.get("first")?.deletion.starts).toEqual(Uint32Array.of(0));
});

test("evicts the least-recently-used payload under its byte budget", () => {
const payload = createTestCompactPayload();
const onePayloadBytes = new HighlightWorkerCache();
onePayloadBytes.set("measure", payload);
const cache = new HighlightWorkerCache(onePayloadBytes.getCachedBytes() * 2);

cache.set("first", createTestCompactPayload());
cache.set("second", createTestCompactPayload());
expect(cache.get("first")).toBeDefined();
cache.set("third", createTestCompactPayload());

expect(cache.get("first")).toBeDefined();
expect(cache.get("second")).toBeUndefined();
expect(cache.get("third")).toBeDefined();
});

test("skips an oversized payload without evicting a fitting resident entry", () => {
const payload = createTestCompactPayload();
const measured = new HighlightWorkerCache();
measured.set("measure", payload);
const cache = new HighlightWorkerCache(measured.getCachedBytes());
expect(cache.set("fitting", payload)).toBe(true);
expect(cache.set("oversized", createTestCompactPayload(2))).toBe(false);

expect(cache.get("fitting")).toBeDefined();
expect(cache.get("oversized")).toBeUndefined();
});

test("releases a replaced payload's previous byte charge", () => {
const payload = createTestCompactPayload();
const measured = new HighlightWorkerCache();
measured.set("measure", payload);
const cache = new HighlightWorkerCache(measured.getCachedBytes() * 2);

cache.set("reloaded", createTestCompactPayload());
cache.set("reloaded", createTestCompactPayload(2));
cache.set("kept", createTestCompactPayload());

expect(cache.get("reloaded")).toBeUndefined();
expect(cache.get("kept")).toBeDefined();
});
});
Loading
Loading