Skip to content

Commit 49617d4

Browse files
authored
perf(attribution): speed up the Authors overlay producer (~30× at 10k changes) (#225)
* perf(attribution): use Uint32Array for char→byte map Rebuilt on every debounced payload update; switching the storage from boxed `Array<number>` 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. * 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. * Update attribution chunk benchmarking comment * perf(attribution): replay history via applyChanges, not per-step diff 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.
1 parent 2c3bd97 commit 49617d4

2 files changed

Lines changed: 112 additions & 65 deletions

File tree

hub-client/src/services/attribution-runs.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ describe('buildCharToByteMap', () => {
2929
// "é" is U+00E9, 2 bytes in UTF-8 (0xc3 0xa9).
3030
const map = buildCharToByteMap('aéb');
3131
// 'a' at char 0 → byte 0; 'é' at char 1 → byte 1; 'b' at char 2 → byte 3.
32-
expect(map).toEqual([0, 1, 3, 4]);
32+
expect(Array.from(map)).toEqual([0, 1, 3, 4]);
3333
});
3434

3535
it('counts 3-byte UTF-8 sequences correctly (CJK)', () => {
3636
// "中" is U+4E2D, 3 bytes in UTF-8 (0xe4 0xb8 0xad).
3737
const map = buildCharToByteMap('a中b');
38-
expect(map).toEqual([0, 1, 4, 5]);
38+
expect(Array.from(map)).toEqual([0, 1, 4, 5]);
3939
});
4040

4141
it('handles surrogate-pair (4-byte) codepoints', () => {

hub-client/src/services/attribution-runs.ts

Lines changed: 110 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@
2121
* correct in their own coordinate space.
2222
*/
2323

24-
import { diff } from '@automerge/automerge';
25-
import type { Heads } from '@automerge/automerge';
24+
import {
25+
applyChanges,
26+
decodeChange,
27+
getAllChanges,
28+
getChanges,
29+
init,
30+
} from '@automerge/automerge';
31+
import type { Change, Doc, Patch } from '@automerge/automerge';
2632
import { decodeHeads } from '@automerge/automerge-repo';
2733
import type { DocHandle } from '@automerge/automerge-repo';
2834

@@ -48,6 +54,15 @@ export interface RunListAttribution {
4854
runs: AttributionRun[];
4955
processedHeads: unknown[];
5056
processedHistoryIndex: number;
57+
/**
58+
* Internal: forward-replay doc held at `processedHeads`, fed to
59+
* `A.applyChanges` so each incremental update only pays for new
60+
* changes (not a full doc-state load per step like `A.diff` did).
61+
* Absent when state is hand-constructed (tests), in which case
62+
* `updateRunListAttribution` throws `HistoryCompactedError` and
63+
* the caller (`useAttribution`) falls back to a full rebuild.
64+
*/
65+
_workDoc?: Doc<unknown>;
5166
}
5267

5368
interface SplicePatch {
@@ -96,10 +111,12 @@ export function extractChangeHash(heads: unknown): string | null {
96111

97112
/**
98113
* History entries processed between idle-callback yields. Larger
99-
* values reduce the number of rIC round trips (faster
100-
* time-to-attribution) but make each slice's CPU block bigger (more
101-
* frame jank risk). 500 gives ~2.5 ms of CPU per slice at the
102-
* prototype's bench-measured ~5 µs/entry.
114+
* values cut yield overhead at the cost of bigger per-slice CPU.
115+
*
116+
* Per-entry CPU is ~15 µs (applyChanges forward-replay, roughly
117+
* constant in N), so a 500-entry slice is ≈7-8 ms — comfortably
118+
* under one frame (16.67 ms). Slices above ~1000 risk overrunning a
119+
* frame in busier browsers.
103120
*/
104121
export const CHUNK_SIZE = 500;
105122

@@ -227,6 +244,45 @@ function applyPatchToRuns(
227244
}
228245
}
229246

247+
// ---------------------------------------------------------------------------
248+
// Internal: shared replay loop
249+
// ---------------------------------------------------------------------------
250+
251+
/**
252+
* The new change introduced at this history step is whichever hash is in
253+
* `currHeads` but not in `prevHeads`. For the first step, take the first
254+
* head. Returns null if no new change can be identified (defensive).
255+
*/
256+
function newChangeHashAt(prevHeads: string[] | null, currHeads: string[]): string | null {
257+
if (currHeads.length === 0) return null;
258+
if (prevHeads === null) return currHeads[0];
259+
const prevSet = new Set(prevHeads);
260+
return currHeads.find(h => !prevSet.has(h)) ?? null;
261+
}
262+
263+
/**
264+
* Apply one change to `workDoc`, collect any patches via patchCallback,
265+
* and fold them into the running runs list using the change's own
266+
* actor/time. Returns the advanced workDoc.
267+
*/
268+
function replayChange(
269+
workDoc: Doc<unknown>,
270+
change: Change,
271+
textFieldName: string,
272+
runs: AttributionRun[],
273+
): Doc<unknown> {
274+
const decoded = decodeChange(change);
275+
const attribution: CharAttribution = { actor: decoded.actor, time: decoded.time };
276+
let collected: Patch[] = [];
277+
const [next] = applyChanges(workDoc, [change], {
278+
patchCallback: (patches: Patch[]) => { collected = patches; },
279+
});
280+
for (const patch of collected) {
281+
if (isTextPatch(patch, textFieldName)) applyPatchToRuns(runs, patch, attribution);
282+
}
283+
return next;
284+
}
285+
230286
// ---------------------------------------------------------------------------
231287
// buildRunListAttribution — full history processing
232288
// ---------------------------------------------------------------------------
@@ -241,59 +297,46 @@ export async function buildRunListAttribution(
241297
if (!history) return null;
242298

243299
if (history.length === 0) {
244-
return { runs: [], processedHeads: [], processedHistoryIndex: 0 };
300+
return { runs: [], processedHeads: [], processedHistoryIndex: 0, _workDoc: init() };
301+
}
302+
303+
// Pre-index every change in the doc by hash so each history step can
304+
// look up its corresponding change in O(1).
305+
const doc = viewable.doc() as Doc<unknown>;
306+
const changeByHash = new Map<string, Change>();
307+
for (const c of getAllChanges(doc)) {
308+
changeByHash.set(decodeChange(c).hash, c);
245309
}
246310

247311
const runs: AttributionRun[] = [];
248-
let prevHeads: unknown = null;
312+
let prevHeads: string[] | null = null;
249313
let lastHeads: unknown[] = [];
314+
let workDoc: Doc<unknown> = init();
250315

251316
for (let chunkStart = 0; chunkStart < history.length; chunkStart += CHUNK_SIZE) {
252-
await waitForIdle();
253317
if (signal?.aborted) return null;
254318

255319
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, history.length);
256320
for (let i = chunkStart; i < chunkEnd; i++) {
257321
const currHeads = history[i];
258-
const changeHash = extractChangeHash(currHeads);
259-
const meta = changeHash ? viewable.metadata(changeHash) : undefined;
260-
const attribution: CharAttribution = {
261-
actor: meta?.actor ?? 'unknown',
262-
time: meta?.time ?? 0,
263-
};
264-
265322
const decodedCurr = decodeHeads(currHeads as Parameters<typeof decodeHeads>[0]);
266-
let patches: unknown[];
267-
if (prevHeads === null) {
268-
patches = diff(
269-
viewable.doc() as Parameters<typeof diff>[0],
270-
[] as unknown as Heads,
271-
decodedCurr as unknown as Heads,
272-
);
273-
} else {
274-
const decodedPrev = decodeHeads(prevHeads as Parameters<typeof decodeHeads>[0]);
275-
patches = diff(
276-
viewable.doc() as Parameters<typeof diff>[0],
277-
decodedPrev as unknown as Heads,
278-
decodedCurr as unknown as Heads,
279-
);
323+
const newHash = newChangeHashAt(prevHeads, decodedCurr);
324+
const change = newHash ? changeByHash.get(newHash) : undefined;
325+
if (change) {
326+
workDoc = replayChange(workDoc, change, textFieldName, runs);
280327
}
281-
282-
for (const patch of patches) {
283-
if (isTextPatch(patch, textFieldName)) {
284-
applyPatchToRuns(runs, patch, attribution);
285-
}
286-
}
287-
288-
prevHeads = currHeads;
328+
prevHeads = decodedCurr;
289329
lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads];
290330
}
331+
332+
if (chunkEnd < history.length) await waitForIdle();
291333
}
292334

293335
return {
294336
runs,
295337
processedHeads: lastHeads as unknown[],
296338
processedHistoryIndex: history.length,
339+
_workDoc: workDoc,
297340
};
298341
}
299342

@@ -310,42 +353,42 @@ export function updateRunListAttribution(
310353
const history = viewable.history();
311354
if (!history) throw new HistoryCompactedError();
312355
if (state.processedHistoryIndex > history.length) throw new HistoryCompactedError();
356+
if (!state._workDoc) throw new HistoryCompactedError();
357+
358+
if (state.processedHistoryIndex === history.length) {
359+
return state;
360+
}
361+
362+
// Pull just the new changes (since workDoc's heads), index by hash.
363+
const doc = viewable.doc() as Doc<unknown>;
364+
const newChanges = getChanges(state._workDoc, doc);
365+
const changeByHash = new Map<string, Change>();
366+
for (const c of newChanges) {
367+
changeByHash.set(decodeChange(c).hash, c);
368+
}
313369

314370
const runs = state.runs.map(r => ({ ...r }));
315-
let prevHeads = state.processedHeads;
316-
let lastHeads = state.processedHeads;
371+
let prevHeads = decodeHeads(state.processedHeads as Parameters<typeof decodeHeads>[0]);
372+
let lastHeads: unknown[] = state.processedHeads;
373+
let workDoc: Doc<unknown> = state._workDoc;
317374

318375
for (let i = state.processedHistoryIndex; i < history.length; i++) {
319376
const currHeads = history[i];
320-
const changeHash = extractChangeHash(currHeads);
321-
const meta = changeHash ? viewable.metadata(changeHash) : undefined;
322-
const attribution: CharAttribution = {
323-
actor: meta?.actor ?? 'unknown',
324-
time: meta?.time ?? 0,
325-
};
326-
327-
const decodedPrev = decodeHeads(prevHeads as Parameters<typeof decodeHeads>[0]);
328377
const decodedCurr = decodeHeads(currHeads as Parameters<typeof decodeHeads>[0]);
329-
const patches = diff(
330-
viewable.doc() as Parameters<typeof diff>[0],
331-
decodedPrev as unknown as Heads,
332-
decodedCurr as unknown as Heads,
333-
);
334-
335-
for (const patch of patches) {
336-
if (isTextPatch(patch, textFieldName)) {
337-
applyPatchToRuns(runs, patch, attribution);
338-
}
378+
const newHash = newChangeHashAt(prevHeads, decodedCurr);
379+
const change = newHash ? changeByHash.get(newHash) : undefined;
380+
if (change) {
381+
workDoc = replayChange(workDoc, change, textFieldName, runs);
339382
}
340-
341-
prevHeads = currHeads as unknown[];
383+
prevHeads = decodedCurr;
342384
lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads];
343385
}
344386

345387
return {
346388
runs,
347389
processedHeads: lastHeads as unknown[],
348390
processedHistoryIndex: history.length,
391+
_workDoc: workDoc,
349392
};
350393
}
351394

@@ -367,9 +410,13 @@ export function updateRunListAttribution(
367410
* ASCII-only docs: map is the identity. Non-ASCII docs require this
368411
* translation for correctness — a missing translation would silently
369412
* misattribute any range past the first multi-byte character.
413+
*
414+
* Returned as `Uint32Array` so the per-codeunit storage is a single
415+
* contiguous buffer of 32-bit ints rather than boxed `number` slots —
416+
* matters because this is rebuilt on every debounced payload update.
370417
*/
371-
export function buildCharToByteMap(text: string): number[] {
372-
const map = new Array<number>(text.length + 1);
418+
export function buildCharToByteMap(text: string): Uint32Array {
419+
const map = new Uint32Array(text.length + 1);
373420
let byteOff = 0;
374421
for (let i = 0; i < text.length; i++) {
375422
map[i] = byteOff;
@@ -399,7 +446,7 @@ export function buildCharToByteMap(text: string): number[] {
399446
*/
400447
export function runsCharToByteOffsets(
401448
runs: AttributionRun[],
402-
charToByte: number[],
449+
charToByte: Uint32Array,
403450
): AttributionRun[] {
404451
return runs.map(r => ({
405452
start: charToByte[r.start] ?? r.start,

0 commit comments

Comments
 (0)