Skip to content

perf: coalesce contiguous leaf updates during tracker checkout - #1082

Open
rexikan wants to merge 1 commit into
loro-dev:mainfrom
rexikan:perf/coalesce-leaf-updates-giant-op
Open

perf: coalesce contiguous leaf updates during tracker checkout#1082
rexikan wants to merge 1 commit into
loro-dev:mainfrom
rexikan:perf/coalesce-leaf-updates-giant-op

Conversation

@rexikan

@rexikan rexikan commented Sep 1, 2026

Copy link
Copy Markdown

Summary

Checkout across a single large text op is O((len/256)^2). A 1.2M-char paste (one op) takes ~3s to checkout away from and ~12s to checkout back across (native, M-series; the wasm build shows 12-26s for checkout/diff/forkAt across the same op), while the insert itself takes ~5ms. This PR makes all of these single-digit milliseconds by coalescing contiguous same-effect LeafUpdates in CrdtRope::update.

The user-facing shape of this is any app that shows document history. A note that starts life as a large import - a book, a long paste, a migrated document - has that import as its first op forever. Letting the user click through history versions means checking out or diffing across that op, and today the very first click freezes the UI for 10+ seconds. With this fix, time travel over such documents is instant at every size we tested (linear to 12M chars).

let doc = LoroDoc::new();
doc.get_text("t").insert(0, &"lorem ipsum ".repeat(100_000)).unwrap(); // 1.2M chars, ~5ms
doc.commit();
let latest = doc.oplog_frontiers();
doc.checkout(&Frontiers::default()).unwrap(); // ~3.0s  -> 3.3ms
doc.checkout(&latest).unwrap();               // ~11.6s -> 2.6ms

Cause

A single op of length L is stored in IdToCursor as L/MAX_FRAGMENT_LEN (256) fragments. Tracker::_checkout across the op therefore emits one LeafUpdate per fragment — all targeting the same rope leaf with the same status change. CrdtRope::update splits the leaf at every fragment boundary, applies the identical update to each part, and update_leaves_with_arg_in_ranges's insert_by_path immediately merges every part back into the previous leaf (contiguous, identical status ⇒ can_merge), returning the same leaf index once per fragment, each reporting the full op id-span. Tracker::update_insert_by_split then re-maps the whole op span in IdToCursor once per fragment: ~22M inner iterations for 1.2M chars, ~98% of the checkout's profile samples.

diff(), forkAt(), re-attaching after a backwards checkout, and import-with-concurrency all funnel through the same Tracker::_checkout, so they all hit it, and one fix covers them all.

Fix

Coalesce adjacent updates (after the existing stable sort by leaf) when they target the same leaf with the same set_future/delete_times_diff and have contiguous forward id spans. The per-fragment updates collapse into one; no split/re-merge churn happens at all. A debug_assert pins the (verified) invariant that LeafUpdate spans are never reversed, and the merged bound is written via the normalized ctr_end().

Measured (release, M-series):

scenario (1.2M chars, single op) before after
checkout to empty / back to latest 3.0s / 11.6s 3.3ms / 2.6ms
diff(empty, latest) / diff(latest, empty) 21.6s / 26.9s 2.7ms / 2.2ms
fork_at mid-op / at empty 5.3s / 18.3s 3.2ms / 2.8ms
giant select-all delete, checkout across 3.2-15.1s 0.3-1.7ms
import into doc with a concurrent op 6.1s 2.0ms
12M chars checkout ~11ms (scales linearly)

Also adds an #[ignore]d regression test in the style of the existing perf_* tests (LORO_PERF_CHARS-scalable, generous 2s bound that the pre-fix code trips even at 1/6 the default size).

Validation

  • cargo test -p loro-internal --features test_utils,jsonpath --lib (409 passed), full -p loro suites, -p fuzz (all recorded cases + random_fuzz).
  • Differential testing against the parent commit: randomized multi-peer histories (multi-fragment pastes, forward and reverse deletes, marks, sync both directions), 100+ seeds, 12k+ checkout/fork_at/snapshot_at records — byte-identical results before/after, while the coalescing demonstrably fired millions of times. Same for a giant-op shadow-model fuzzer (600 seeds) running Tracker::check() invariants in debug mode.
  • Semantics argument: merging [a,b)+[b,c) into [a,c) for one leaf (= one FugueSpan) only removes an interior split point; every position receives the identical set_future assignment / delete_times_diff sum exactly once either way, and overlapping spans (intentional double-deletes) are never merged because the guard requires strict contiguity.

Two observations from the review that may interest you (not addressed here):

  • The fuzz targets' text actions only insert stringified numbers (≤ ~22 chars), so no fuzz input can create a multi-fragment (>256-id) op — this whole path had no fuzz coverage. A long-string text action would close that.
  • check_id_to_cursor_insertions_correctness computes span.contains(...) twice and discards both results (missing assert!).

Disclosure

This fix was produced with substantial AI assistance (Claude): profiling, root-cause analysis, the patch, and an extensive adversarial review (7 independent agent reviews including differential testing against the parent commit) were AI-driven, directed and reviewed by me.

A single large text op is stored in IdToCursor as one fragment per
MAX_FRAGMENT_LEN (256) ids. A checkout across such an op therefore
emits one LeafUpdate per fragment, all targeting the same rope leaf
with the same status change. CrdtRope::update then split the leaf at
every fragment boundary, applied the identical update to each part,
and insert_by_path merged every part straight back into the previous
leaf - returning the same leaf index once per fragment. The caller
re-mapped each of those to the full op span in IdToCursor, making the
checkout O((len/256)^2): a 1.2M-char paste took ~3-12s to checkout
across, while the insert itself took ~5ms.

Merging adjacent same-effect, contiguous-span updates before building
the split points removes both the split/re-merge churn and the
quadratic re-mapping: the same checkout now takes ~3ms.

Adds an ignored perf regression test in the style of the existing
perf_update_insert_by_split_quadratic tests, with a deliberately loose
2s bound that a regression to quadratic always trips.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant