diff --git a/.changeset/highlight-worker-offload-prototype.md b/.changeset/highlight-worker-offload-prototype.md new file mode 100644 index 000000000..784bb47e9 --- /dev/null +++ b/.changeset/highlight-worker-offload-prototype.md @@ -0,0 +1,4 @@ +--- +--- + +Prototype highlighting in a Bun Worker: benchmark, worker, and findings. No user-visible change. diff --git a/benchmarks/README.md b/benchmarks/README.md index 69201ed33..d85c301b2 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -37,6 +37,7 @@ 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-offload bun run bench:large-stream bun run bench:interaction-latency bun run bench:non-ascii-stream @@ -58,6 +59,7 @@ 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. +- `highlight-worker-offload.ts` — measures highlighting in a Bun Worker instead of on the main thread, reporting the worst main-thread stall as a median over repetitions, comparing four reply shapes — raw Pierre HAST, compact tokens, compact rebuilt lazily for one viewport, and a transferred columnar payload rebuilt lazily — each timed including the rebuild it costs on arrival, and round tripping through the real worker to check spans stay identical through `buildSplitRows`. `highlight-worker.ts` is the worker and `lib/compactHighlight.ts` the encoding they share. Tunable with `HUNK_BENCH_LINES`, `HUNK_BENCH_REPEATS`, and `HUNK_BENCH_VIEWPORT_ROWS`. See `docs/highlight-worker-offload.md`. - `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. diff --git a/benchmarks/highlight-worker-offload.ts b/benchmarks/highlight-worker-offload.ts new file mode 100644 index 000000000..85a420b76 --- /dev/null +++ b/benchmarks/highlight-worker-offload.ts @@ -0,0 +1,421 @@ +/** + * Measures moving Pierre's whole-file highlight into a Bun Worker, and checks that the compact + * reply the worker sends still reproduces the spans Hunk renders today. + * + * Wall time is not the interesting number. What freezes a terminal UI is how long the main thread + * is blocked, so a 1ms interval runs throughout and records the largest gap between its ticks; an + * idle pass first establishes what that probe reads when nothing is happening. + * + * Two reply shapes are compared. Pierre's HAST is what `loadHighlightedDiff` consumes today and + * arrives ready to use. The compact shape is smaller on the wire but has to be rebuilt into the + * HAST the row builders read, so that rebuild is timed inside the measured region — it is main + * thread work a real integration cannot avoid. + * + * Methodology notes, because earlier revisions of this file got these wrong: + * - Each size is measured over several repetitions and reported as a median with a range, since a + * single sample of the worst stall varies by 3x run to run. + * - The two reply shapes alternate which goes first, because whichever runs second inherits warmer + * JIT and allocator state. + * - The equivalence check round trips through the real worker, not a local copy of the encoder. + * + * Background and conclusions: `docs/highlight-worker-offload.md`. + */ +import { + getHighlighterOptions, + getSharedHighlighter, + parseDiffFromFile, + renderDiffWithHighlighter, + type FileDiffMetadata, +} from "@pierre/diffs"; +import { buildDiffFile } from "../src/core/diffFile"; +import { buildSplitRows, type HighlightedDiffCode } from "../src/ui/diff/diffRows"; +import { THEMES } from "../src/ui/themes"; +import { + decodeColumnarCode, + decodeColumnarWindow, + decodeCompactCode, + decodeCompactWindow, + type ColumnarCode, + type CompactCode, +} from "./lib/compactHighlight"; + +/** + * Read a positive integer from the environment, matching the `HUNK_BENCH_*` naming the other + * benchmarks use. A malformed value fails here with the offending input rather than turning into + * NaN and quietly producing a run that measures nothing. + */ +function positiveIntFromEnv(name: string, fallback: number) { + const raw = process.env[name]; + if (raw === undefined || raw === "") { + return fallback; + } + + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`); + } + + return value; +} + +/** Parse the comma-separated file sizes to measure, rejecting malformed entries. */ +function sizesFromEnv(name: string, fallback: number[]) { + const raw = process.env[name]; + if (raw === undefined || raw === "") { + return fallback; + } + + return raw.split(",").map((entry) => { + const value = Number(entry.trim()); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} entries must be positive integers, got ${JSON.stringify(entry)}`); + } + return value; + }); +} + +const SIZES = sizesFromEnv("HUNK_BENCH_LINES", [2000, 8000, 30000]); +const REPEATS = positiveIntFromEnv("HUNK_BENCH_REPEATS", 5); +const TAB_WIDTH = 4; +// A terminal draws tens of rows whatever the file size, so this is what a windowed consumer needs +// rebuilt before the next paint. +const VIEWPORT_ROWS = positiveIntFromEnv("HUNK_BENCH_VIEWPORT_ROWS", 60); + +const renderOptions = { + theme: "pierre-dark" as const, + useTokenTransformer: false, + tokenizeMaxLineLength: 1_000, + lineDiffType: "word-alt" as const, + maxLineDiffLength: 10_000, +}; + +/** + * "compact-lazy" rebuilds only a viewport of rows on arrival instead of the whole file. + * "columnar-lazy" additionally arrives as transferred buffers rather than a cloned object graph. + */ +type ReplyFormat = "hast" | "compact" | "compact-lazy" | "columnar-lazy"; + +interface WorkerReply { + id: number; + code: { deletionLines: unknown[]; additionLines: unknown[]; palette?: string[] }; + timings: { highlighterMs: number; renderMs: number; encodeMs: number }; +} + +/** Generate TypeScript whose comments and template literals span lines. */ +function makeSource(lines: number, seed = 0) { + const out: string[] = []; + let index = seed; + while (out.length < lines) { + out.push(`/** Handler ${index}`); + out.push(` * continues across lines`); + out.push(` */`); + out.push(`export function handler${index}(input: { id: string; count: number }): string {`); + out.push(` const label = \`item-\${input.id}`); + out.push(` -\${input.count}\`;`); + out.push(` if (input.count > ${index % 97}) {`); + out.push(` return label.toUpperCase(); // note ${index}`); + out.push(` }`); + out.push(` return label;`); + out.push(`}`); + out.push(""); + index += 1; + } + return out.slice(0, lines).join("\n") + "\n"; +} + +/** Records the largest gap between 1ms interval ticks, i.e. the longest main-thread stall. */ +function createStallProbe() { + let last = performance.now(); + let worst = 0; + const timer = setInterval(() => { + const now = performance.now(); + worst = Math.max(worst, now - last); + last = now; + }, 1); + + return { + reset() { + last = performance.now(); + worst = 0; + }, + stop() { + clearInterval(timer); + }, + read() { + return worst; + }, + }; +} + +/** Median of a sample set, so one unlucky run cannot set the headline number. */ +function median(values: number[]) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +} + +/** Format a sample set as "median (min–max)" so spread stays visible. */ +function summarize(values: number[]) { + const lo = Math.min(...values); + const hi = Math.max(...values); + return `${median(values).toFixed(0).padStart(4)}ms (${lo.toFixed(0)}–${hi.toFixed(0)})`; +} + +const probe = createStallProbe(); +await Bun.sleep(50); + +probe.reset(); +await Bun.sleep(400); +console.log(`idle probe floor: ${probe.read().toFixed(1)}ms`); +console.log(`repetitions per measurement: ${REPEATS}\n`); + +const highlighterOptions = getHighlighterOptions("typescript", { theme: "pierre-dark" }); +const highlighter = await getSharedHighlighter({ + ...highlighterOptions, + preferredHighlighter: "shiki-wasm", +}); + +// ".js" resolves to the TypeScript worker from source and to the compiled entrypoint inside a +// `bun build --compile` binary, so one specifier covers both. +const worker = new Worker(new URL("./highlight-worker.js", import.meta.url).href); +let nextRequestId = 1; + +/** Send one highlight request and resolve when the worker replies. */ +function highlightInWorker(metadata: FileDiffMetadata, format: ReplyFormat) { + const id = nextRequestId++; + return new Promise((resolve, reject) => { + const onMessage = (event: MessageEvent) => { + if (event.data.id !== id) return; + worker.removeEventListener("message", onMessage as EventListener); + resolve(event.data); + }; + worker.addEventListener("message", onMessage as EventListener); + worker.addEventListener("error", reject, { once: true }); + // The worker only distinguishes the two encodings; how much of a compact reply the main + // thread rebuilds is the caller's choice. + const wireFormat = + format === "hast" ? "hast" : format === "columnar-lazy" ? "columnar" : "compact"; + worker.postMessage({ + id, + metadata, + language: "typescript", + theme: "pierre-dark", + format: wireFormat, + }); + }); +} + +/** + * Time one worker round trip end to end, including rebuilding a compact reply into the HAST the + * row builders consume. That rebuild is main-thread work a real integration pays per file, so + * leaving it outside the timed region would understate the compact path. + */ +async function measureWorker(metadata: FileDiffMetadata, format: ReplyFormat) { + await Bun.sleep(50); + probe.reset(); + const started = performance.now(); + const reply = await highlightInWorker(metadata, format); + const decoded = + format === "compact" + ? decodeCompactCode(reply.code as CompactCode) + : format === "compact-lazy" + ? decodeCompactWindow(reply.code as CompactCode, VIEWPORT_ROWS) + : format === "columnar-lazy" + ? decodeColumnarWindow(reply.code as unknown as ColumnarCode, VIEWPORT_ROWS) + : (reply.code as { deletionLines: unknown[]; additionLines: unknown[] }); + const wall = performance.now() - started; + await Bun.sleep(50); + const stall = probe.read(); + // Durations measured inside the worker are comparable even though the two threads have different + // time origins. Whatever the round trip costs beyond them is message overhead plus the rebuild. + const overhead = + wall - reply.timings.renderMs - reply.timings.highlighterMs - reply.timings.encodeMs; + + return { + reply, + decoded, + wall, + stall, + overhead, + bytes: JSON.stringify(reply.code).length, + }; +} + +/** Time one whole-file highlight on the main thread, the way Hunk does it today. */ +async function measureMainThread(metadata: FileDiffMetadata) { + await Bun.sleep(50); + probe.reset(); + const started = performance.now(); + renderDiffWithHighlighter(metadata, highlighter, renderOptions); + const wall = performance.now() - started; + await Bun.sleep(50); + return { wall, stall: probe.read() }; +} + +const bootStart = performance.now(); +await highlightInWorker( + parseDiffFromFile( + { name: "warm.ts", contents: "" }, + { name: "warm.ts", contents: makeSource(50) }, + ), + "compact", +); +console.log( + `worker boot + Shiki init (one-time): ${(performance.now() - bootStart).toFixed(0)}ms\n`, +); + +for (const lines of SIZES) { + const source = makeSource(lines); + const metadata = parseDiffFromFile( + { name: "big.ts", contents: "", cacheKey: `${lines}-old` }, + { name: "big.ts", contents: source, cacheKey: `${lines}-new` }, + ); + + console.log(`=== ${lines} lines, ${(source.length / 1024).toFixed(0)}KiB added ===`); + + // Shiki resolves grammars lazily, so warm before timing or the first pass absorbs that cost. + renderDiffWithHighlighter(metadata, highlighter, renderOptions); + await measureWorker(metadata, "hast"); + await measureWorker(metadata, "compact"); + await measureWorker(metadata, "compact-lazy"); + await measureWorker(metadata, "columnar-lazy"); + + const samples = { + mainWall: [] as number[], + mainStall: [] as number[], + hastWall: [] as number[], + hastStall: [] as number[], + compactWall: [] as number[], + compactStall: [] as number[], + lazyWall: [] as number[], + lazyStall: [] as number[], + columnarWall: [] as number[], + columnarStall: [] as number[], + }; + let hastBytes = 0; + let compactBytes = 0; + let paletteSize = 0; + + for (let repeat = 0; repeat < REPEATS; repeat += 1) { + const main = await measureMainThread(metadata); + samples.mainWall.push(main.wall); + samples.mainStall.push(main.stall); + + // Alternate which reply shape runs first: whichever goes second inherits warmer state. + const order: ReplyFormat[] = + repeat % 2 === 0 + ? ["hast", "compact", "compact-lazy", "columnar-lazy"] + : ["columnar-lazy", "compact-lazy", "compact", "hast"]; + for (const format of order) { + const measured = await measureWorker(metadata, format); + if (format === "hast") { + samples.hastWall.push(measured.wall); + samples.hastStall.push(measured.stall); + hastBytes = measured.bytes; + } else if (format === "compact-lazy") { + samples.lazyWall.push(measured.wall); + samples.lazyStall.push(measured.stall); + } else if (format === "columnar-lazy") { + samples.columnarWall.push(measured.wall); + samples.columnarStall.push(measured.stall); + } else { + samples.compactWall.push(measured.wall); + samples.compactStall.push(measured.stall); + compactBytes = measured.bytes; + paletteSize = (measured.reply.code.palette ?? []).length; + } + } + } + + const mib = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MiB`; + console.log( + ` main thread : wall ${summarize(samples.mainWall)} worst stall ${summarize(samples.mainStall)}`, + ); + console.log( + ` worker (hast) : wall ${summarize(samples.hastWall)} worst stall ${summarize(samples.hastStall)} payload ${mib(hastBytes)}`, + ); + console.log( + ` worker (compact): wall ${summarize(samples.compactWall)} worst stall ${summarize(samples.compactStall)} payload ${mib(compactBytes)} palette ${paletteSize}`, + ); + console.log( + ` worker (lazy) : wall ${summarize(samples.lazyWall)} worst stall ${summarize(samples.lazyStall)} rebuilds ${VIEWPORT_ROWS} rows on arrival`, + ); + console.log( + ` worker (columnar): wall ${summarize(samples.columnarWall)} worst stall ${summarize(samples.columnarStall)} transferred buffers, ${VIEWPORT_ROWS} rows on arrival`, + ); + console.log(""); +} + +// Does the compact payload still produce exactly what Hunk renders? Round trip through the real +// worker — not a local copy of the encoder — and run the real row builder over both results. +const equivalenceCases = [ + { name: "rewrite", old: makeSource(3000, 0), next: makeSource(3000, 7) }, + { name: "new file", old: "", next: makeSource(3000, 0) }, +]; + +/** Reduce rows to their rendered spans so only visible output is compared. */ +function spansOf(rows: ReturnType) { + return rows.map((row) => + row.type === "split-line" + ? { left: row.left.spans, right: row.right.spans } + : { other: row.type }, + ); +} + +/** Total spans compared, so a trivially empty comparison cannot read as a pass. */ +function countSpans(rows: ReturnType) { + return rows.reduce( + (total, row) => + "left" in row ? total + (row.left?.length ?? 0) + (row.right?.length ?? 0) : total, + 0, + ); +} + +for (const testCase of equivalenceCases) { + const metadata = parseDiffFromFile( + { name: "sample.ts", contents: testCase.old, cacheKey: `${testCase.name}-old` }, + { name: "sample.ts", contents: testCase.next, cacheKey: `${testCase.name}-new` }, + ); + + const hastReply = await highlightInWorker(metadata, "hast"); + const compactReply = await highlightInWorker(metadata, "compact"); + const columnarReply = await highlightInWorker(metadata, "columnar-lazy"); + + const file = buildDiffFile(metadata, "", 0, "benchmark", null, {}); + const theme = THEMES[0]!; + + const fromHast = spansOf( + buildSplitRows(file, hastReply.code as unknown as HighlightedDiffCode, theme, TAB_WIDTH), + ); + const fromCompact = spansOf( + buildSplitRows( + file, + decodeCompactCode(compactReply.code as CompactCode) as unknown as HighlightedDiffCode, + theme, + TAB_WIDTH, + ), + ); + + const fromColumnar = spansOf( + buildSplitRows( + file, + decodeColumnarCode( + columnarReply.code as unknown as ColumnarCode, + ) as unknown as HighlightedDiffCode, + theme, + TAB_WIDTH, + ), + ); + + const reference = JSON.stringify(fromHast); + const compactIdentical = reference === JSON.stringify(fromCompact); + const columnarIdentical = reference === JSON.stringify(fromColumnar); + console.log( + `round trip through the worker, ${testCase.name.padEnd(8)}: ` + + `${String(fromHast.length).padStart(5)} rows, ${String(countSpans(fromHast)).padStart(6)} spans, ` + + `compact identical=${compactIdentical}, columnar identical=${columnarIdentical}`, + ); +} + +worker.terminate(); +probe.stop(); diff --git a/benchmarks/highlight-worker.ts b/benchmarks/highlight-worker.ts new file mode 100644 index 000000000..ad52fb309 --- /dev/null +++ b/benchmarks/highlight-worker.ts @@ -0,0 +1,100 @@ +/// +/** + * Runs Pierre's whole-file highlight off the main thread for `highlight-worker-offload.ts`. + * + * Replies in one of two shapes so the message cost of each can be compared: the raw Pierre HAST, + * and the compact token encoding from `lib/compactHighlight.ts`. The encoder is shared with the + * driver so the format the benchmark verifies is the format this worker actually sends. + * + * Referenced by URL rather than imported, as `./highlight-worker.js`. That spelling is deliberate: + * Bun resolves it to this file from source and to the compiled entrypoint inside a `bun build + * --compile` binary, so one specifier covers both. + */ +import { + getHighlighterOptions, + getSharedHighlighter, + renderDiffWithHighlighter, + type FileDiffMetadata, +} from "@pierre/diffs"; +import { + columnarTransferList, + encodeColumnarCode, + encodeCompactCode, +} from "./lib/compactHighlight"; + +interface HighlightRequest { + id: number; + metadata: FileDiffMetadata; + language: string; + theme: string; + format: "hast" | "compact" | "columnar"; +} + +const renderOptions = (theme: string) => ({ + theme: theme as "pierre-dark", + useTokenTransformer: false, + tokenizeMaxLineLength: 1_000, + lineDiffType: "word-alt" as const, + maxLineDiffLength: 10_000, +}); + +declare const self: Worker; + +self.onmessage = async (event: MessageEvent) => { + const { id, metadata, language, theme, format } = event.data; + const receivedAt = performance.now(); + + const options = getHighlighterOptions(language, { theme: theme as never }); + const highlighter = await getSharedHighlighter({ + ...options, + preferredHighlighter: "shiki-wasm", + }); + const readyAt = performance.now(); + + const result = renderDiffWithHighlighter(metadata, highlighter, renderOptions(theme)); + const renderedAt = performance.now(); + + if (format === "hast") { + self.postMessage({ + id, + code: result.code, + timings: { + highlighterMs: readyAt - receivedAt, + renderMs: renderedAt - readyAt, + encodeMs: 0, + }, + }); + return; + } + + if (format === "columnar") { + const columnar = encodeColumnarCode(result.code); + // Handing the buffers over rather than copying them is the point of this shape, so the + // transfer list is not optional. + self.postMessage( + { + id, + code: columnar, + timings: { + highlighterMs: readyAt - receivedAt, + renderMs: renderedAt - readyAt, + encodeMs: performance.now() - renderedAt, + }, + }, + columnarTransferList(columnar), + ); + return; + } + + const code = encodeCompactCode(result.code); + + self.postMessage({ + id, + code, + timings: { + highlighterMs: readyAt - receivedAt, + renderMs: renderedAt - readyAt, + encodeMs: performance.now() - renderedAt, + }, + }); +}; diff --git a/benchmarks/lib/compactHighlight.ts b/benchmarks/lib/compactHighlight.ts new file mode 100644 index 000000000..b71d80844 --- /dev/null +++ b/benchmarks/lib/compactHighlight.ts @@ -0,0 +1,313 @@ +/** + * Compact wire format for shipping highlighted lines out of a worker. + * + * Pierre's HAST is a deep tree, but Hunk's span flattener reads only three things per token: the + * text, one foreground color, and whether the token is word-diff emphasis. This encodes exactly + * that, interning colors into a per-file palette so a file's handful of distinct colors travels + * once instead of once per token. + * + * The worker and its driver both import this, so the encode the benchmark verifies is the encode + * the worker actually runs. + */ +import { cleanLastNewline } from "@pierre/diffs"; + +export type HastNode = + | { type: "text"; value: string } + | { + type: "element"; + tagName: string; + properties?: Record; + children?: HastNode[]; + }; + +/** One token: text, palette index (-1 inherits the enclosing color), 1 when word-diff emphasis. */ +export type CompactToken = [string, number, 0 | 1]; + +/** One line's tokens, or undefined where the diff has no line on that side. */ +export type CompactLine = CompactToken[] | undefined; + +export interface CompactCode { + deletionLines: CompactLine[]; + additionLines: CompactLine[]; + palette: string[]; +} + +/** Pull the foreground color out of a Shiki style string. */ +export function colorFromStyle(style: unknown) { + if (typeof style !== "string") { + return undefined; + } + + for (const declaration of style.split(";")) { + const separator = declaration.indexOf(":"); + if (separator < 0) continue; + const name = declaration.slice(0, separator).trim(); + if (name === "color" || name === "--diffs-token-dark" || name === "--diffs-token-light") { + return declaration.slice(separator + 1).trim(); + } + } + + return undefined; +} + +/** Encode one highlighted line, interning its colors into the shared palette. */ +export function encodeLine(node: HastNode | undefined, palette: Map): CompactLine { + if (!node) { + return undefined; + } + + const tokens: CompactToken[] = []; + + const visit = (current: HastNode | undefined, color: number, emphasis: 0 | 1) => { + if (!current) return; + + if (current.type === "text") { + const text = cleanLastNewline(current.value); + if (text.length > 0) tokens.push([text, color, emphasis]); + return; + } + + const properties = current.properties ?? {}; + const raw = colorFromStyle(properties.style); + let nextColor = color; + if (raw != null) { + let paletteIndex = palette.get(raw); + if (paletteIndex == null) { + paletteIndex = palette.size; + palette.set(raw, paletteIndex); + } + nextColor = paletteIndex; + } + const nextEmphasis: 0 | 1 = Object.hasOwn(properties, "data-diff-span") ? 1 : emphasis; + for (const child of current.children ?? []) visit(child, nextColor, nextEmphasis); + }; + + visit(node, -1, 0); + return tokens; +} + +/** Encode both sides of a rendered diff into the compact form. */ +export function encodeCompactCode(code: { + deletionLines: unknown[]; + additionLines: unknown[]; +}): CompactCode { + const palette = new Map(); + const deletionLines = (code.deletionLines as Array).map((line) => + encodeLine(line, palette), + ); + const additionLines = (code.additionLines as Array).map((line) => + encodeLine(line, palette), + ); + + return { deletionLines, additionLines, palette: [...palette.keys()] }; +} + +/** + * Rebuild a HAST line from compact tokens: one flat span per token carrying the palette color and + * the emphasis marker, which is all the span flattener reads. + * + * This is the cost a real integration pays on the main thread, so benchmarks must time it. + */ +export function decodeLine(tokens: CompactLine, palette: string[]): HastNode | undefined { + if (!tokens) { + return undefined; + } + + return { + type: "element", + tagName: "div", + properties: {}, + children: tokens.map(([text, color, emphasis]) => ({ + type: "element" as const, + tagName: "span", + properties: { + ...(color >= 0 ? { style: `color:${palette[color]}` } : {}), + ...(emphasis === 1 ? { "data-diff-span": "" } : {}), + }, + children: [{ type: "text" as const, value: text }], + })), + }; +} + +/** Rebuild both sides of a compact payload into the HAST shape the row builders consume. */ +export function decodeCompactCode(code: CompactCode) { + return { + deletionLines: code.deletionLines.map((line) => decodeLine(line, code.palette)), + additionLines: code.additionLines.map((line) => decodeLine(line, code.palette)), + }; +} + +/** + * Rebuild only the first `rows` lines of each side. + * + * A terminal draws tens of rows no matter how many the file has, so a viewport-sized rebuild is + * what a row-windowed consumer actually needs on arrival. The rest can be rebuilt on demand as the + * user scrolls, which keeps the arrival cost independent of file size. + */ +export function decodeCompactWindow(code: CompactCode, rows: number) { + const take = (lines: CompactLine[]) => { + const out: Array> = []; + for (let index = 0; index < Math.min(rows, lines.length); index += 1) { + out.push(decodeLine(lines[index], code.palette)); + } + return out; + }; + + return { deletionLines: take(code.deletionLines), additionLines: take(code.additionLines) }; +} + +/** + * Columnar payload: the same information as `CompactCode`, but as one text blob plus flat typed + * arrays instead of an array-of-arrays object graph. + * + * Structured clone walks every object and array it is given, so a compact payload still costs + * deserialization time proportional to token count before any of our code runs. Typed arrays can + * instead be handed over with a transfer list, which moves the buffer rather than copying it, and + * one large string clones far faster than millions of small arrays. + */ +export interface ColumnarCode { + /** Every token's text, concatenated. */ + text: string; + /** Four ints per token: text offset, text length, palette index, emphasis flag. */ + tokens: Int32Array; + /** Two ints per line: first token index, then token count, or -1 for an absent line. */ + deletionIndex: Int32Array; + additionIndex: Int32Array; + palette: string[]; +} + +/** The buffers in a columnar payload, for `postMessage`'s transfer list. */ +export function columnarTransferList(code: ColumnarCode) { + return [code.tokens.buffer, code.deletionIndex.buffer, code.additionIndex.buffer]; +} + +/** Encode a rendered diff into the columnar shape. */ +export function encodeColumnarCode(code: { + deletionLines: unknown[]; + additionLines: unknown[]; +}): ColumnarCode { + const palette = new Map(); + const textParts: string[] = []; + const tokenRecords: number[] = []; + let textLength = 0; + + const encodeSide = (lines: Array) => { + const index = new Int32Array(lines.length * 2); + + lines.forEach((node, lineIndex) => { + if (!node) { + index[lineIndex * 2 + 1] = -1; + return; + } + + const firstToken = tokenRecords.length / 4; + let count = 0; + + const visit = (current: HastNode | undefined, color: number, emphasis: number) => { + if (!current) return; + + if (current.type === "text") { + const value = cleanLastNewline(current.value); + if (value.length === 0) return; + textParts.push(value); + tokenRecords.push(textLength, value.length, color, emphasis); + textLength += value.length; + count += 1; + return; + } + + const properties = current.properties ?? {}; + const raw = colorFromStyle(properties.style); + let nextColor = color; + if (raw != null) { + let paletteIndex = palette.get(raw); + if (paletteIndex == null) { + paletteIndex = palette.size; + palette.set(raw, paletteIndex); + } + nextColor = paletteIndex; + } + const nextEmphasis = Object.hasOwn(properties, "data-diff-span") ? 1 : emphasis; + for (const child of current.children ?? []) visit(child, nextColor, nextEmphasis); + }; + + visit(node, -1, 0); + index[lineIndex * 2] = firstToken; + index[lineIndex * 2 + 1] = count; + }); + + return index; + }; + + const deletionIndex = encodeSide(code.deletionLines as Array); + const additionIndex = encodeSide(code.additionLines as Array); + + return { + text: textParts.join(""), + tokens: Int32Array.from(tokenRecords), + deletionIndex, + additionIndex, + palette: [...palette.keys()], + }; +} + +/** Rebuild one line out of a columnar payload. */ +export function decodeColumnarLine( + code: ColumnarCode, + index: Int32Array, + lineIndex: number, +): HastNode | undefined { + const count = index[lineIndex * 2 + 1] ?? -1; + if (count < 0) { + return undefined; + } + + const firstToken = index[lineIndex * 2] ?? 0; + const children: HastNode[] = []; + + for (let token = 0; token < count; token += 1) { + const base = (firstToken + token) * 4; + const offset = code.tokens[base] ?? 0; + const length = code.tokens[base + 1] ?? 0; + const color = code.tokens[base + 2] ?? -1; + const emphasis = code.tokens[base + 3] ?? 0; + children.push({ + type: "element", + tagName: "span", + properties: { + ...(color >= 0 ? { style: `color:${code.palette[color]}` } : {}), + ...(emphasis === 1 ? { "data-diff-span": "" } : {}), + }, + children: [{ type: "text", value: code.text.slice(offset, offset + length) }], + }); + } + + return { type: "element", tagName: "div", properties: {}, children }; +} + +/** Rebuild only the first `rows` lines of each side from a columnar payload. */ +export function decodeColumnarWindow(code: ColumnarCode, rows: number) { + const take = (index: Int32Array) => { + const out: Array = []; + const lines = index.length / 2; + for (let lineIndex = 0; lineIndex < Math.min(rows, lines); lineIndex += 1) { + out.push(decodeColumnarLine(code, index, lineIndex)); + } + return out; + }; + + return { deletionLines: take(code.deletionIndex), additionLines: take(code.additionIndex) }; +} + +/** Rebuild every line from a columnar payload, for equivalence checking. */ +export function decodeColumnarCode(code: ColumnarCode) { + const take = (index: Int32Array) => { + const out: Array = []; + for (let lineIndex = 0; lineIndex < index.length / 2; lineIndex += 1) { + out.push(decodeColumnarLine(code, index, lineIndex)); + } + return out; + }; + + return { deletionLines: take(code.deletionIndex), additionLines: take(code.additionIndex) }; +} diff --git a/docs/highlight-worker-offload.md b/docs/highlight-worker-offload.md new file mode 100644 index 000000000..d02401457 --- /dev/null +++ b/docs/highlight-worker-offload.md @@ -0,0 +1,151 @@ +# Highlighting in a Bun Worker + +Status: measured prototype. Nothing in `src/` has changed. + +## The problem + +`renderDiffWithHighlighter` highlights a whole file in one uninterruptible call. Hunk calls it from +`loadHighlightedDiff` on a timer, which keeps highlight jobs from starving each other but does +nothing about the length of any single call: an 8000-line added file freezes the terminal for the +better part of a second, and a 30,000-line one for about three. + +Hunk makes it worse on purpose. `sourceBackedHighlight.ts` grafts full source text onto a partial +diff so grammar state is correct, converting what would be a cheap per-hunk render into a whole-file +one. + +`@pierre/diffs` cannot be asked for less. It accepts `startingLine`/`totalLines` and has a complete +row-window walk behind them, but discards the range unless the caller also asks for plain text — +because a window tokenized alone starts from an empty TextMate rule stack and mis-colors anything +inside a construct that opened earlier. So the options are a highlighted whole file or an +unhighlighted window. + +This investigation takes the other route: keep the whole-file call, move it off the thread that +paints. That is what Pierre itself does for the browser — a worker pool re-renders and swaps the +highlighted result in, with DOM virtualization keeping the document small and a hard cliff at +100,000 lines past which nothing is highlighted at all. + +Reproduce with `bun run bench:highlight-worker-offload`. + +## What was measured + +Wall time is the wrong metric. What freezes a terminal is how long the main thread is blocked, so +the benchmark runs a 1ms interval throughout and records the largest gap between its ticks. An idle +pass establishes the floor, which reads about 2ms. + +Each cell is the median of 5 repetitions, because a single sample of the worst stall varies by up to +3x. The reply shapes rotate which runs first, since whichever goes last inherits warmer JIT state. + +Worst main-thread stall, by how the worker replies: + +| Added file | Main thread | HAST | Compact | Compact, lazy | Columnar, transferred + lazy | +| ----------- | ----------- | ----- | ------- | ------------- | ---------------------------- | +| 2000 lines | 182ms | 8ms | 7ms | 3ms | **3ms** | +| 8000 lines | 779ms | 32ms | 21ms | 9ms | **3ms** | +| 30000 lines | 2878ms | 146ms | 109ms | 32ms | **6ms** | + +Worker boot plus Shiki init is a one-time ~330ms. Wall time is within noise of the main-thread +render for the columnar shape (2978ms against 2878ms at 30k lines), so the encode and transfer are +close to free in total-CPU terms. + +Treat the small numbers as an order of magnitude, not a measurement. The columnar cell at 30k lines +has read anywhere from 5ms to 24ms across runs on a shared machine, and the main-thread baseline +moves by several hundred milliseconds between runs too. The claim these numbers support is not "6ms" +but "single-digit to low-tens of milliseconds, and not growing with the file" — which is the +difference that matters against a baseline that grows to seconds. + +The last column is the result: the stall stops scaling with file size, across a 15x range, against a +main thread that goes from 182ms to 2878ms. + +## Getting there took three changes, and only the third one mattered most + +**Shrinking the payload.** Pierre's HAST is 20.8MiB for a 30k-line file; the compact token encoding +is 1.9MiB, because Hunk's span flattener reads only three things per token — the text, one +foreground color, and whether the token carries `data-diff-span`. Colors intern into a per-file +palette of about 11 entries. Worth doing, but on its own it only moved 146ms to 109ms, because the +tokens still have to be rebuilt into the HAST the row builders read, and that rebuild is main-thread +work. + +**Rebuilding lazily.** A terminal draws tens of rows no matter how many the file has. Rebuilding one +viewport on arrival instead of the whole file takes 109ms to 32ms, and the rest can be rebuilt as +the user scrolls. This is the change that breaks the link between file size and arrival cost for the +rebuild. + +**Transferring instead of cloning.** What remained at 32ms was structured clone itself: the compact +payload is still an object graph of a million small arrays, and deserializing it happens before any +of our code runs. The columnar shape sends one text blob plus flat `Int32Array`s — four ints per +token, two per line — and hands the buffers over in `postMessage`'s transfer list, which moves them +rather than copying. That takes 32ms to 6ms. + +Each change is cheap on its own and they compose. The order matters for understanding, though: +without lazy rebuilding, transferring buffers would just move the cost around, and without +transferring, lazy rebuilding leaves deserialization as the floor. + +Both wire shapes are verified against Pierre's HAST by round tripping through the real worker — not +a local copy of the encoder — and running Hunk's real `buildSplitRows` over each result. A rewrite +fixture covers word-diff emphasis (818 spans) and a new-file fixture covers every line with no +collapsed context (13,750 spans). Compact and columnar are both identical to HAST on both fixtures. + +## Workers do survive `bun build --compile` + +This was the risk that could have killed the approach, since Hunk ships a compiled binary. Two +things are required, and both are easy to get wrong: + +1. The worker must be passed to `bun build --compile` as an **additional entrypoint**. Without it the + compiler bundles only the main entry and the binary fails at runtime with + `ModuleNotFound resolving "/$bunfs/root/highlight-worker.ts"`. +2. The `new Worker(new URL(...))` specifier must end in **`.js`**, not `.ts`. Bun resolves + `./highlight-worker.js` to the TypeScript file when running from source and to the compiled + entrypoint inside the binary, so one spelling covers both. `./highlight-worker.ts` and + `./highlight-worker` both work from source and both fail compiled — a failure mode that would + only ever show up in a release build. + +Binary cost, measured on a standalone case: a bare Bun binary is 99.3MB, and adding the worker +entrypoint with Pierre and Shiki reached 109.4MB. Hunk's binary already carries Pierre and Shiki for +the main thread, so the incremental cost is whatever the bundler duplicates across the two +entrypoints rather than the full 10MB — that has not been measured against Hunk's real build. + +## The alternative that was measured alongside this + +The other way to attack the same freeze is to keep the work on one thread and cut it into row +windows, patching Pierre to honor a window while highlighting and threading grammar state across +boundaries. That was prototyped and measured separately; it reaches about 28ms per window at +250-row windows on the 8000-line file. + +| | Windowed highlight | Worker offload | +| ------------------------- | ------------------------- | ------------------------------------------------------------ | +| Worst stall, 8000 lines | ~28ms at 250-row windows | **3ms** | +| Worst stall, 30000 lines | scales with window size | **6ms** | +| Scales with file size | Per window, yes | No | +| Depends on | An upstream Pierre change | Nothing external | +| Main thread does the work | Yes, in slices | No | +| Total CPU | Unchanged | Within noise | +| Memory | Unchanged | A second Shiki instance per worker | +| Failure mode | None found | Silent breakage in compiled builds if the specifier is wrong | + +Before the payload work above, the two were close enough that the choice came down to architecture. +They are not close now. Windowing's stall is proportional to window size and the main thread still +performs every millisecond of the highlighting; the worker's is flat at a few milliseconds and the +main thread performs almost none of it. + +The two remain compatible — a worker could render windows and stream them — but there is no longer a +performance argument for windowing standing alone. Its remaining advantages are that it needs no +second Shiki instance and has no compiled-build failure mode. It is still worth sending upstream on +its own merits, since the missing capability is a real gap in `@pierre/diffs` that their own +`Virtualizer` runs into. + +## What integration would actually involve + +Not yet done, and larger than the benchmark makes it look: + +- `HighlightedDiffCode` is HAST today, and `flattenHighlightedLine` caches flattened spans in a + `WeakMap` keyed on HAST node identity. A compact payload either rebuilds throwaway HAST nodes to + keep that cache (what the benchmark does, to prove equivalence) or replaces the cache with + something keyed differently. +- `aliasHighlightedContextLines` and `remapSourceBackedHighlight` both manipulate the HAST arrays and + would need compact-aware equivalents. +- The worker needs Hunk's registered custom syntax themes, since `ensureSyntaxHighlightThemeRegistered` + derives content-addressed themes from user config on the main thread. +- `loadHighlightedSourceLines`, used by gap expansion, goes through `renderFileWithHighlighter` and + would want the same treatment or it becomes the remaining stall. +- Lifecycle: when to spawn, whether to keep a pool, and what happens to in-flight requests when the + theme changes or the review reloads. diff --git a/package.json b/package.json index 2615d69fb..a72435d54 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "bench:changeset-parse": "bun run benchmarks/changeset-parse.ts", "bench:render-layout": "bun run benchmarks/render-layout.ts", "bench:highlight-prefetch": "bun run benchmarks/highlight-prefetch.ts", + "bench:highlight-worker-offload": "bun run benchmarks/highlight-worker-offload.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",