From 2e9ee8b3896840eebd89c42d9ebfad7caf469127 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 15 Jun 2026 20:27:45 +0900 Subject: [PATCH] perf(coding-agent): fast-path single-line intra-line diffs Plan: plans/port-gajae-3day-remaining-optimizations.md --- bench/baseline/word-diff-baseline.json | 68 ++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/bench/word-diff.ts | 94 +++++++++++++++++++ .../src/modes/interactive/components/diff.ts | 94 ++++++++++++++++++- .../test/diff-intraline-fastpath.test.ts | 43 +++++++++ .../test/diff-intraline-no-diffwords.test.ts | 53 +++++++++++ scripts/run-pr530-benchmarks.mjs | 1 + 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 bench/baseline/word-diff-baseline.json create mode 100644 packages/coding-agent/bench/word-diff.ts create mode 100644 packages/coding-agent/test/diff-intraline-fastpath.test.ts create mode 100644 packages/coding-agent/test/diff-intraline-no-diffwords.test.ts diff --git a/bench/baseline/word-diff-baseline.json b/bench/baseline/word-diff-baseline.json new file mode 100644 index 000000000..ba9088cef --- /dev/null +++ b/bench/baseline/word-diff-baseline.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "createdAt": "2026-06-15T10:37:45.080Z", + "gitCommit": "4004d594227e25558990d5e720b8fcba35fc3c29", + "iterations": 30, + "durationMs": 1153.193667, + "results": [ + { + "suite": "word-diff", + "package": "@code-yeongyu/senpi", + "fixture": "5-single-line-diff-cases", + "iterations": 30, + "samples": [ + 26.71362499999998, + 26.953125, + 25.40891700000003, + 26.359959000000003, + 24.547124999999937, + 25.315541999999937, + 22.533000000000015, + 22.749584000000027, + 22.48199999999997, + 22.357333999999923, + 22.30574999999999, + 22.35583299999996, + 22.230583000000024, + 22.32379100000003, + 22.413125000000036, + 22.256041000000096, + 22.238624999999956, + 22.34375, + 22.092667000000006, + 22.41504199999997, + 21.774666000000025, + 22.112041999999974, + 22.203499999999963, + 22.27870900000005, + 22.003000000000043, + 22.05716700000005, + 22.376249999999914, + 22.076624999999922, + 22.72583399999985, + 22.159374999999955 + ], + "medianMs": 22.34375, + "p95Ms": 26.71362499999998, + "heapDeltaBytes": -130976, + "rssDeltaBytes": 655360, + "fastPathEligibleCases": [ + "identical", + "single-word-replacement", + "long-single-span-replacement" + ], + "fallbackCases": [ + "whitespace-only", + "multi-span" + ], + "metadata": { + "createdAt": "2026-06-15T10:37:45.073Z", + "gitCommit": "4004d594227e25558990d5e720b8fcba35fc3c29", + "nodeVersion": "v26.3.0", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M4 Pro" + } + } + ] +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9aee1d5b1..2b802a470 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Changed +- Improved single-line intra-line diff rendering with conservative fast paths for identical, long, and single-span edits. + ### Removed ## [2026.6.15] - 2026-06-15 diff --git a/packages/coding-agent/bench/word-diff.ts b/packages/coding-agent/bench/word-diff.ts new file mode 100644 index 000000000..11e4e9ad0 --- /dev/null +++ b/packages/coding-agent/bench/word-diff.ts @@ -0,0 +1,94 @@ +import { renderDiff } from "../src/modes/interactive/components/diff.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { forceGc, metadata, percentile, readIterations } from "../../tui/bench/_meta.ts"; + +const REPEATS_PER_SAMPLE = 600; +const LONG_LINE_PREFIX = Array.from({ length: 120 }, (_, index) => `token${index}`).join(" "); + +type WordDiffCase = { + readonly name: string; + readonly removed: string; + readonly added: string; + readonly fastPathEligible: boolean; +}; + +const CASES: readonly WordDiffCase[] = [ + { + name: "identical", + removed: "const unchanged = formatValue(input);", + added: "const unchanged = formatValue(input);", + fastPathEligible: true, + }, + { + name: "single-word-replacement", + removed: " return format(oldValue);", + added: " return format(newValue);", + fastPathEligible: true, + }, + { + name: "long-single-span-replacement", + removed: `${LONG_LINE_PREFIX} before tail`, + added: `${LONG_LINE_PREFIX} after tail`, + fastPathEligible: true, + }, + { + name: "whitespace-only", + removed: "const result = format(value);", + added: "const result = format(value);", + fastPathEligible: false, + }, + { + name: "multi-span", + removed: "alpha beta gamma delta", + added: "alpha theta gamma omega", + fastPathEligible: false, + }, +] as const; + +function diffText(testCase: WordDiffCase): string { + return [`-1 ${testCase.removed}`, `+1 ${testCase.added}`].join("\n"); +} + +function runScenario(): number { + let renderedLength = 0; + for (let repeat = 0; repeat < REPEATS_PER_SAMPLE; repeat++) { + for (const testCase of CASES) { + renderedLength += renderDiff(diffText(testCase)).length; + } + } + return renderedLength; +} + +function timeScenario(): number { + const start = performance.now(); + const renderedLength = runScenario(); + if (renderedLength === 0) throw new Error("Expected rendered diff output"); + return performance.now() - start; +} + +initTheme("dark"); +const iterations = readIterations(20); +for (let i = 0; i < Math.min(3, iterations); i++) runScenario(); +forceGc(); +const before = process.memoryUsage(); +const samples: number[] = []; +for (let i = 0; i < iterations; i++) samples.push(timeScenario()); +forceGc(); +const after = process.memoryUsage(); + +console.log( + JSON.stringify({ + suite: "word-diff", + package: "@code-yeongyu/senpi", + fixture: `${CASES.length}-single-line-diff-cases`, + iterations, + samples, + medianMs: percentile(samples, 50), + p95Ms: percentile(samples, 95), + heapDeltaBytes: after.heapUsed - before.heapUsed, + rssDeltaBytes: after.rss - before.rss, + fastPathEligibleCases: CASES.filter((testCase) => testCase.fastPathEligible).map((testCase) => testCase.name), + fallbackCases: CASES.filter((testCase) => !testCase.fastPathEligible).map((testCase) => testCase.name), + metadata: metadata(), + }), +); diff --git a/packages/coding-agent/src/modes/interactive/components/diff.ts b/packages/coding-agent/src/modes/interactive/components/diff.ts index e227dca05..64af6cd53 100644 --- a/packages/coding-agent/src/modes/interactive/components/diff.ts +++ b/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -1,6 +1,21 @@ +import type { Change } from "diff"; import * as Diff from "diff"; import { theme } from "../theme/theme.ts"; +export const LONG_LINE_FAST_PATH_LIMIT = 500; + +type IntraLineDiff = { + readonly removedLine: string; + readonly addedLine: string; +}; + +type ReplacementSpan = { + readonly prefix: string; + readonly removed: string; + readonly added: string; + readonly suffix: string; +}; + /** * Parse diff line to extract prefix, line number, and content. * Format: "+123 content" or "-123 content" or " 123 content" or " ..." @@ -23,8 +38,8 @@ function replaceTabs(text: string): string { * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. * Strips leading whitespace from inverse to avoid highlighting indentation. */ -function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } { - const wordDiff = Diff.diffWords(oldContent, newContent); +export function renderIntraLineDiffWithDiffWords(oldContent: string, newContent: string): IntraLineDiff { + const wordDiff: readonly Change[] = Diff.diffWords(oldContent, newContent); let removedLine = ""; let addedLine = ""; @@ -65,6 +80,81 @@ function renderIntraLineDiff(oldContent: string, newContent: string): { removedL return { removedLine, addedLine }; } +export function renderIntraLineDiff(oldContent: string, newContent: string): IntraLineDiff { + return ( + renderIntraLineDiffFastPath(oldContent, newContent) ?? renderIntraLineDiffWithDiffWords(oldContent, newContent) + ); +} + +export function renderIntraLineDiffFastPath(oldContent: string, newContent: string): IntraLineDiff | null { + if (oldContent === newContent) return { removedLine: oldContent, addedLine: newContent }; + return renderSingleSpanIntraLineDiff(oldContent, newContent); +} + +function renderSingleSpanIntraLineDiff(oldContent: string, newContent: string): IntraLineDiff | null { + const span = findSingleDiffWordsReplacement(oldContent, newContent); + if (!span) return null; + return { + removedLine: `${span.prefix}${theme.inverse(span.removed)}${span.suffix}`, + addedLine: `${span.prefix}${theme.inverse(span.added)}${span.suffix}`, + }; +} + +function findSingleDiffWordsReplacement(oldContent: string, newContent: string): ReplacementSpan | null { + let start = 0; + while (start < oldContent.length && start < newContent.length && oldContent[start] === newContent[start]) { + start++; + } + + let oldEnd = oldContent.length; + let newEnd = newContent.length; + while (oldEnd > start && newEnd > start && oldContent[oldEnd - 1] === newContent[newEnd - 1]) { + oldEnd--; + newEnd--; + } + + while ( + start > 0 && + (isAsciiWordCode(oldContent.charCodeAt(start - 1)) || isAsciiWordCode(newContent.charCodeAt(start - 1))) + ) { + start--; + } + while ( + oldEnd < oldContent.length && + newEnd < newContent.length && + (isAsciiWordCode(oldContent.charCodeAt(oldEnd)) || isAsciiWordCode(newContent.charCodeAt(newEnd))) + ) { + oldEnd++; + newEnd++; + } + + const prefix = oldContent.slice(0, start); + const removed = oldContent.slice(start, oldEnd); + const added = newContent.slice(start, newEnd); + const oldSuffix = oldContent.slice(oldEnd); + const newSuffix = newContent.slice(newEnd); + + if (oldSuffix !== newSuffix) return null; + if (!isSingleDiffWordsReplacement(removed, added)) return null; + return { prefix, removed, added, suffix: oldSuffix }; +} + +function isSingleDiffWordsReplacement(removed: string, added: string): boolean { + return removed.length > 0 && added.length > 0 && isSimpleDiffToken(removed) && isSimpleDiffToken(added); +} + +function isSimpleDiffToken(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (!isAsciiWordCode(code)) return false; + } + return true; +} + +function isAsciiWordCode(code: number): boolean { + return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || code === 95 || (code >= 97 && code <= 122); +} + export interface RenderDiffOptions { /** File path (unused, kept for API compatibility) */ filePath?: string; diff --git a/packages/coding-agent/test/diff-intraline-fastpath.test.ts b/packages/coding-agent/test/diff-intraline-fastpath.test.ts new file mode 100644 index 000000000..977e7e8d9 --- /dev/null +++ b/packages/coding-agent/test/diff-intraline-fastpath.test.ts @@ -0,0 +1,43 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + LONG_LINE_FAST_PATH_LIMIT, + renderIntraLineDiffFastPath, + renderIntraLineDiffWithDiffWords, +} from "../src/modes/interactive/components/diff.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +describe("renderIntraLineDiff fast path", () => { + beforeAll(() => { + initTheme("dark"); + }); + + it("returns byte-identical output to diffWords for provable single-span replacements", () => { + const longPrefix = Array.from({ length: 90 }, (_, index) => `token${index}`).join(" "); + const cases: ReadonlyArray = [ + ["const status = oldValue;", "const status = newValue;"], + [" return format(oldValue);", " return format(newValue);"], + ["alpha beta gamma", "alpha delta gamma"], + ["alphaBetaGamma", "alphaThetaGamma"], + [`${longPrefix} before tail`, `${longPrefix} after tail`], + ]; + expect(longPrefix.length).toBeGreaterThan(LONG_LINE_FAST_PATH_LIMIT); + + for (const [oldContent, newContent] of cases) { + const fastPath = renderIntraLineDiffFastPath(oldContent, newContent); + + expect(fastPath).toEqual(renderIntraLineDiffWithDiffWords(oldContent, newContent)); + } + }); + + it("falls back for whitespace-only and multi-span changes", () => { + const cases: ReadonlyArray = [ + ["const x = 1;", "const x = 1;"], + ["alpha beta gamma delta", "alpha theta gamma omega"], + [" a-", " --"], + ]; + + for (const [oldContent, newContent] of cases) { + expect(renderIntraLineDiffFastPath(oldContent, newContent)).toBeNull(); + } + }); +}); diff --git a/packages/coding-agent/test/diff-intraline-no-diffwords.test.ts b/packages/coding-agent/test/diff-intraline-no-diffwords.test.ts new file mode 100644 index 000000000..9eabf2519 --- /dev/null +++ b/packages/coding-agent/test/diff-intraline-no-diffwords.test.ts @@ -0,0 +1,53 @@ +import type { Change } from "diff"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + LONG_LINE_FAST_PATH_LIMIT, + renderIntraLineDiff, + renderIntraLineDiffFastPath, +} from "../src/modes/interactive/components/diff.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +type DiffWords = (oldStr: string, newStr: string) => Change[]; + +const diffWordsMock = vi.hoisted(() => + vi.fn(() => { + throw new Error("diffWords should not be called"); + }), +); + +vi.mock("diff", () => ({ + diffWords: diffWordsMock, +})); + +describe("renderIntraLineDiff no-diffWords fast path", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + diffWordsMock.mockClear(); + }); + + it("skips diffWords when the line content is identical", () => { + const content = "const unchanged = formatValue(input);"; + + const result = renderIntraLineDiff(content, content); + + expect(result).toEqual({ removedLine: content, addedLine: content }); + expect(diffWordsMock).not.toHaveBeenCalled(); + }); + + it("skips diffWords when a very long line has one replacement span", () => { + const prefix = Array.from({ length: 90 }, (_, index) => `token${index}`).join(" "); + expect(prefix.length).toBeGreaterThan(LONG_LINE_FAST_PATH_LIMIT); + const removed = `${prefix} before tail`; + const added = `${prefix} after tail`; + const fastPath = renderIntraLineDiffFastPath(removed, added); + + const result = renderIntraLineDiff(removed, added); + + expect(fastPath).not.toBeNull(); + expect(result).toEqual(fastPath); + expect(diffWordsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/run-pr530-benchmarks.mjs b/scripts/run-pr530-benchmarks.mjs index cdaef470a..5a3600393 100644 --- a/scripts/run-pr530-benchmarks.mjs +++ b/scripts/run-pr530-benchmarks.mjs @@ -12,6 +12,7 @@ const BENCHES = { "coding-agent-bash-output": "packages/coding-agent/bench/bash-output.ts", "coding-agent-jsonl-parse": "packages/coding-agent/bench/jsonl-parse.ts", "coding-agent-rpc-event-emit": "packages/coding-agent/bench/rpc-event-emit.ts", + "word-diff": "packages/coding-agent/bench/word-diff.ts", }; function parseArgs(argv) {