From a35e33e01a978e7aed440242961c1968a1623f44 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 10:11:23 +0100 Subject: [PATCH 1/4] =?UTF-8?q?perf(attribution):=20use=20Uint32Array=20fo?= =?UTF-8?q?r=20char=E2=86=92byte=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on every debounced payload update; switching the storage from boxed `Array` to a contiguous `Uint32Array` is 5-30% faster beyond ~10KB documents (V8 microbench across ASCII/mixed/CJK) and halves the per-codeunit heap footprint. Smaller docs unchanged. --- hub-client/src/services/attribution-runs.test.ts | 4 ++-- hub-client/src/services/attribution-runs.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/hub-client/src/services/attribution-runs.test.ts b/hub-client/src/services/attribution-runs.test.ts index c295ed2de..3f35673aa 100644 --- a/hub-client/src/services/attribution-runs.test.ts +++ b/hub-client/src/services/attribution-runs.test.ts @@ -29,13 +29,13 @@ describe('buildCharToByteMap', () => { // "é" is U+00E9, 2 bytes in UTF-8 (0xc3 0xa9). const map = buildCharToByteMap('aéb'); // 'a' at char 0 → byte 0; 'é' at char 1 → byte 1; 'b' at char 2 → byte 3. - expect(map).toEqual([0, 1, 3, 4]); + expect(Array.from(map)).toEqual([0, 1, 3, 4]); }); it('counts 3-byte UTF-8 sequences correctly (CJK)', () => { // "中" is U+4E2D, 3 bytes in UTF-8 (0xe4 0xb8 0xad). const map = buildCharToByteMap('a中b'); - expect(map).toEqual([0, 1, 4, 5]); + expect(Array.from(map)).toEqual([0, 1, 4, 5]); }); it('handles surrogate-pair (4-byte) codepoints', () => { diff --git a/hub-client/src/services/attribution-runs.ts b/hub-client/src/services/attribution-runs.ts index 3c4086cb1..cc43228a6 100644 --- a/hub-client/src/services/attribution-runs.ts +++ b/hub-client/src/services/attribution-runs.ts @@ -367,9 +367,13 @@ export function updateRunListAttribution( * ASCII-only docs: map is the identity. Non-ASCII docs require this * translation for correctness — a missing translation would silently * misattribute any range past the first multi-byte character. + * + * Returned as `Uint32Array` so the per-codeunit storage is a single + * contiguous buffer of 32-bit ints rather than boxed `number` slots — + * matters because this is rebuilt on every debounced payload update. */ -export function buildCharToByteMap(text: string): number[] { - const map = new Array(text.length + 1); +export function buildCharToByteMap(text: string): Uint32Array { + const map = new Uint32Array(text.length + 1); let byteOff = 0; for (let i = 0; i < text.length; i++) { map[i] = byteOff; @@ -399,7 +403,7 @@ export function buildCharToByteMap(text: string): number[] { */ export function runsCharToByteOffsets( runs: AttributionRun[], - charToByte: number[], + charToByte: Uint32Array, ): AttributionRun[] { return runs.map(r => ({ start: charToByte[r.start] ?? r.start, From abee18270322b92c318c41626036ae6068bd0aa3 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 10:40:13 +0100 Subject: [PATCH 2/4] perf(attribution): drop one yield per build Move the chunk yield from the top of the loop to between chunks, guarded by `chunkEnd < history.length`. A history that fits in one chunk now never pays an idle-callback round-trip; longer builds save one yield as well. Runs output unchanged; existing tests pass. --- hub-client/src/services/attribution-runs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hub-client/src/services/attribution-runs.ts b/hub-client/src/services/attribution-runs.ts index cc43228a6..0615ede02 100644 --- a/hub-client/src/services/attribution-runs.ts +++ b/hub-client/src/services/attribution-runs.ts @@ -249,7 +249,6 @@ export async function buildRunListAttribution( let lastHeads: unknown[] = []; for (let chunkStart = 0; chunkStart < history.length; chunkStart += CHUNK_SIZE) { - await waitForIdle(); if (signal?.aborted) return null; const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, history.length); @@ -288,6 +287,8 @@ export async function buildRunListAttribution( prevHeads = currHeads; lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads]; } + + if (chunkEnd < history.length) await waitForIdle(); } return { From a50813349975639aeb669b7284087861154048e7 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:05:47 +0100 Subject: [PATCH 3/4] Update attribution chunk benchmarking comment --- hub-client/src/services/attribution-runs.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hub-client/src/services/attribution-runs.ts b/hub-client/src/services/attribution-runs.ts index 0615ede02..fe09a0f8c 100644 --- a/hub-client/src/services/attribution-runs.ts +++ b/hub-client/src/services/attribution-runs.ts @@ -96,10 +96,13 @@ export function extractChangeHash(heads: unknown): string | null { /** * History entries processed between idle-callback yields. Larger - * values reduce the number of rIC round trips (faster - * time-to-attribution) but make each slice's CPU block bigger (more - * frame jank risk). 500 gives ~2.5 ms of CPU per slice at the - * prototype's bench-measured ~5 µs/entry. + * values cut yield overhead at the cost of bigger per-slice CPU. + * + * Per-entry CPU is super-linear (≈30 / 90 / 420 µs at N=500 / 2000 / + * 10000 — `A.diff` scales with doc state). 500 sits near the + * wallclock knee; raising it saves <20 % even in worst-case yield + * scenarios and pushes slices past one frame (16.67 ms) at typical N. + * The dominant cost is `A.diff` itself, not this constant. */ export const CHUNK_SIZE = 500; From 7a6bc8a4104d34d28a8c1cb6fad7112c076106ee Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:32:49 +0100 Subject: [PATCH 4/4] perf(attribution): replay history via applyChanges, not per-step diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRunListAttribution pre-indexes changes by hash and forward-replays via `A.applyChanges` with patchCallback. Eliminates the super-linear `A.diff` cost: ~30x faster at N=10000 changes (4.1s → 142ms in Node bench). Runs verified equivalent across append-only, mixed-edit, and multi-actor (sequential + diamond-DAG) fixtures. The RunListAttribution state carries an internal `_workDoc` so incremental updates apply only new changes; hand-built states without it fall back to HistoryCompactedError → full rebuild. --- hub-client/src/services/attribution-runs.ts | 159 ++++++++++++-------- 1 file changed, 99 insertions(+), 60 deletions(-) diff --git a/hub-client/src/services/attribution-runs.ts b/hub-client/src/services/attribution-runs.ts index fe09a0f8c..a86ce3ece 100644 --- a/hub-client/src/services/attribution-runs.ts +++ b/hub-client/src/services/attribution-runs.ts @@ -21,8 +21,14 @@ * correct in their own coordinate space. */ -import { diff } from '@automerge/automerge'; -import type { Heads } from '@automerge/automerge'; +import { + applyChanges, + decodeChange, + getAllChanges, + getChanges, + init, +} from '@automerge/automerge'; +import type { Change, Doc, Patch } from '@automerge/automerge'; import { decodeHeads } from '@automerge/automerge-repo'; import type { DocHandle } from '@automerge/automerge-repo'; @@ -48,6 +54,15 @@ export interface RunListAttribution { runs: AttributionRun[]; processedHeads: unknown[]; processedHistoryIndex: number; + /** + * Internal: forward-replay doc held at `processedHeads`, fed to + * `A.applyChanges` so each incremental update only pays for new + * changes (not a full doc-state load per step like `A.diff` did). + * Absent when state is hand-constructed (tests), in which case + * `updateRunListAttribution` throws `HistoryCompactedError` and + * the caller (`useAttribution`) falls back to a full rebuild. + */ + _workDoc?: Doc; } interface SplicePatch { @@ -98,11 +113,10 @@ export function extractChangeHash(heads: unknown): string | null { * History entries processed between idle-callback yields. Larger * values cut yield overhead at the cost of bigger per-slice CPU. * - * Per-entry CPU is super-linear (≈30 / 90 / 420 µs at N=500 / 2000 / - * 10000 — `A.diff` scales with doc state). 500 sits near the - * wallclock knee; raising it saves <20 % even in worst-case yield - * scenarios and pushes slices past one frame (16.67 ms) at typical N. - * The dominant cost is `A.diff` itself, not this constant. + * Per-entry CPU is ~15 µs (applyChanges forward-replay, roughly + * constant in N), so a 500-entry slice is ≈7-8 ms — comfortably + * under one frame (16.67 ms). Slices above ~1000 risk overrunning a + * frame in busier browsers. */ export const CHUNK_SIZE = 500; @@ -230,6 +244,45 @@ function applyPatchToRuns( } } +// --------------------------------------------------------------------------- +// Internal: shared replay loop +// --------------------------------------------------------------------------- + +/** + * The new change introduced at this history step is whichever hash is in + * `currHeads` but not in `prevHeads`. For the first step, take the first + * head. Returns null if no new change can be identified (defensive). + */ +function newChangeHashAt(prevHeads: string[] | null, currHeads: string[]): string | null { + if (currHeads.length === 0) return null; + if (prevHeads === null) return currHeads[0]; + const prevSet = new Set(prevHeads); + return currHeads.find(h => !prevSet.has(h)) ?? null; +} + +/** + * Apply one change to `workDoc`, collect any patches via patchCallback, + * and fold them into the running runs list using the change's own + * actor/time. Returns the advanced workDoc. + */ +function replayChange( + workDoc: Doc, + change: Change, + textFieldName: string, + runs: AttributionRun[], +): Doc { + const decoded = decodeChange(change); + const attribution: CharAttribution = { actor: decoded.actor, time: decoded.time }; + let collected: Patch[] = []; + const [next] = applyChanges(workDoc, [change], { + patchCallback: (patches: Patch[]) => { collected = patches; }, + }); + for (const patch of collected) { + if (isTextPatch(patch, textFieldName)) applyPatchToRuns(runs, patch, attribution); + } + return next; +} + // --------------------------------------------------------------------------- // buildRunListAttribution — full history processing // --------------------------------------------------------------------------- @@ -244,12 +297,21 @@ export async function buildRunListAttribution( if (!history) return null; if (history.length === 0) { - return { runs: [], processedHeads: [], processedHistoryIndex: 0 }; + return { runs: [], processedHeads: [], processedHistoryIndex: 0, _workDoc: init() }; + } + + // Pre-index every change in the doc by hash so each history step can + // look up its corresponding change in O(1). + const doc = viewable.doc() as Doc; + const changeByHash = new Map(); + for (const c of getAllChanges(doc)) { + changeByHash.set(decodeChange(c).hash, c); } const runs: AttributionRun[] = []; - let prevHeads: unknown = null; + let prevHeads: string[] | null = null; let lastHeads: unknown[] = []; + let workDoc: Doc = init(); for (let chunkStart = 0; chunkStart < history.length; chunkStart += CHUNK_SIZE) { if (signal?.aborted) return null; @@ -257,37 +319,13 @@ export async function buildRunListAttribution( const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, history.length); for (let i = chunkStart; i < chunkEnd; i++) { const currHeads = history[i]; - const changeHash = extractChangeHash(currHeads); - const meta = changeHash ? viewable.metadata(changeHash) : undefined; - const attribution: CharAttribution = { - actor: meta?.actor ?? 'unknown', - time: meta?.time ?? 0, - }; - const decodedCurr = decodeHeads(currHeads as Parameters[0]); - let patches: unknown[]; - if (prevHeads === null) { - patches = diff( - viewable.doc() as Parameters[0], - [] as unknown as Heads, - decodedCurr as unknown as Heads, - ); - } else { - const decodedPrev = decodeHeads(prevHeads as Parameters[0]); - patches = diff( - viewable.doc() as Parameters[0], - decodedPrev as unknown as Heads, - decodedCurr as unknown as Heads, - ); + const newHash = newChangeHashAt(prevHeads, decodedCurr); + const change = newHash ? changeByHash.get(newHash) : undefined; + if (change) { + workDoc = replayChange(workDoc, change, textFieldName, runs); } - - for (const patch of patches) { - if (isTextPatch(patch, textFieldName)) { - applyPatchToRuns(runs, patch, attribution); - } - } - - prevHeads = currHeads; + prevHeads = decodedCurr; lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads]; } @@ -298,6 +336,7 @@ export async function buildRunListAttribution( runs, processedHeads: lastHeads as unknown[], processedHistoryIndex: history.length, + _workDoc: workDoc, }; } @@ -314,35 +353,34 @@ export function updateRunListAttribution( const history = viewable.history(); if (!history) throw new HistoryCompactedError(); if (state.processedHistoryIndex > history.length) throw new HistoryCompactedError(); + if (!state._workDoc) throw new HistoryCompactedError(); + + if (state.processedHistoryIndex === history.length) { + return state; + } + + // Pull just the new changes (since workDoc's heads), index by hash. + const doc = viewable.doc() as Doc; + const newChanges = getChanges(state._workDoc, doc); + const changeByHash = new Map(); + for (const c of newChanges) { + changeByHash.set(decodeChange(c).hash, c); + } const runs = state.runs.map(r => ({ ...r })); - let prevHeads = state.processedHeads; - let lastHeads = state.processedHeads; + let prevHeads = decodeHeads(state.processedHeads as Parameters[0]); + let lastHeads: unknown[] = state.processedHeads; + let workDoc: Doc = state._workDoc; for (let i = state.processedHistoryIndex; i < history.length; i++) { const currHeads = history[i]; - const changeHash = extractChangeHash(currHeads); - const meta = changeHash ? viewable.metadata(changeHash) : undefined; - const attribution: CharAttribution = { - actor: meta?.actor ?? 'unknown', - time: meta?.time ?? 0, - }; - - const decodedPrev = decodeHeads(prevHeads as Parameters[0]); const decodedCurr = decodeHeads(currHeads as Parameters[0]); - const patches = diff( - viewable.doc() as Parameters[0], - decodedPrev as unknown as Heads, - decodedCurr as unknown as Heads, - ); - - for (const patch of patches) { - if (isTextPatch(patch, textFieldName)) { - applyPatchToRuns(runs, patch, attribution); - } + const newHash = newChangeHashAt(prevHeads, decodedCurr); + const change = newHash ? changeByHash.get(newHash) : undefined; + if (change) { + workDoc = replayChange(workDoc, change, textFieldName, runs); } - - prevHeads = currHeads as unknown[]; + prevHeads = decodedCurr; lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads]; } @@ -350,6 +388,7 @@ export function updateRunListAttribution( runs, processedHeads: lastHeads as unknown[], processedHistoryIndex: history.length, + _workDoc: workDoc, }; }