Skip to content

perf: build shallow root state by forward replay instead of reverse checkout - #1091

Open
zxch3n wants to merge 12 commits into
test/shallow-snapshot-concurrencyfrom
perf/shallow-export
Open

perf: build shallow root state by forward replay instead of reverse checkout#1091
zxch3n wants to merge 12 commits into
test/shallow-snapshot-concurrencyfrom
perf/shallow-export

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stack: 6/6 — merge order: #1093#1085#1086#1087#1090#1091

Why

export({ mode: "shallow-snapshot" }) was 4–20x slower than a full snapshot on real docs. On a synthetic doc shaped like the reported workload (~66k containers: a list of 200 maps, each with a nested list of 30 maps holding 10 short texts; ~720k ops from simulated streaming edits), with the shallow root at mid-history (~393k retained ops):

export before after
full snapshot 1.5 ms 1.6 ms
shallow snapshot 3227 ms 206 ms (~16x)

The produced blob is also ~17% smaller on this fixture (2.46 MB -> 2.06 MB) and imports to a logically identical state.

Where the time went

Phase timing + sample profiling on the synthetic doc:

  • oplog.export_change_store_from (retained history): 13 ms
  • checkout latest -> root: 2913 ms (~90%)
  • checkout root -> latest: 100 ms
  • alive-container walks, KV clones, redaction, exports: ~200 ms total

83% of all samples sat in replay_container_ops_from_empty under RichtextDiffCalculator::build_full_crdt_tracker: a reverse diff sets has_retreat, so calculate_diff takes the should_rebuild path and reconstructs a full CRDT tracker from genesis for every list-like container touched in the range (diff_calc.rs). The forward direction needs no rebuild, which is why the root -> latest checkout was ~30x cheaper.

What changed

Forward-replay root state (gated)

When the source doc is not shallow, its state is at the latest version, and at least 65536 ops are retained since the root (MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE) — with the pre-root prefix additionally bounded, see below — export_shallow_snapshot_inner:

  1. pre-encodes the pre-root history with export_fast_updates_in_range while the oplog lock is held (calling LoroDoc::export inside would re-enter with_barrier and violate the txn lock order),
  2. imports it into a temporary doc, reconstructing the root state by forward replay,
  3. mirrors the live store's root container entries into the replay doc via a root-only key scan (DocState::existing_retention_roots), so accessed-but-op-less root containers still ship — without load_all,
  4. copies the live doc's deleted_root_containers into the replay doc's config, so a root deleted before the shallow root is dropped at flush instead of being resurrected as an empty entry,
  5. reads the latest-state overlay from the live store directly — the live doc is never checked out, so no state restore is needed.

Detached docs, already-shallow sources, and small retained ranges keep the previous checkout path.

Gate choice, from measurement (lazy-imported fixture doc, cold export)

F position (retained ops) checkout path forward replay time change (new vs old) ratio (old/new) peak mem: checkout / forward picked
latest (0) 186 ms / 178 ms 258 ms / 505 ms +39% / +184% 1.4x slower / 2.8x slower 29.1 MiB / 318.7 MiB checkout
p99 (~7.9k) 576 ms / 485 ms 518 ms / 1071 ms -10% / +121% 1.1x / 2.2x slower 83.3 MiB / 317.4 MiB checkout
p97 (~23k) 745 ms 1041 ms +40% 1.4x slower 89.3 MiB / 310.5 MiB checkout
p95 (~39k) 934 ms 1488 ms +59% 1.6x slower 95.1 MiB / 305.3 MiB checkout
p90 (~79k) 1821 ms / 1570 ms 506 ms / 1264 ms -72% / -19% 3.6x / 1.2x 109.7 MiB / 292.2 MiB forward
p50 (~393k) 5848 ms / 12683 ms 417 ms / 1224 ms -93% / -90% 14.0x / 10.4x 256.1 MiB / 176.1 MiB forward

Two measurement runs are shown (machine load varied between them); time change is (new − old) / old, negative = faster. Peak memory deltas were stable across runs, so a single value is listed per path.

Below ~39k retained ops the forward-replay path is 40-60% slower in time and peaks at ~3-4x the memory; from ~79k up it wins, reaching 10-14x at 393k. The crossover sits between ~39k and ~79k retained ops; the threshold 65536 falls inside it. This fixture has tiny per-container histories (12 ops/text); docs with long text histories penalize the checkout path much more (full tracker rebuilds), shifting the real crossover lower. Below the threshold the paths are never far apart in time and the checkout path peaks at ~4x less memory — which is the OOM-relevant axis on wasm32 for exactly the "export right after import" flow.

Pre-root prefix bound

The retained-ops gate alone was not sufficient: the fast path re-encodes and replays ALL pre-root history into the temp doc, so its cost scales with the prefix, not the tail. A doc with millions of unrelated same-key Map overwrites before the root and a single 65k-atom Text insert after it would replay the whole huge prefix while the checkout path only walks the tail. Two extra conditions now keep that shape off the fast path:

  • prefix/tail ratio: pre_root_ops <= 16 * ops_num (MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO) — the measured crossover on the fixture: forward wins at ratio 9 (p90), loses at ratio 19 (p95);
  • absolute op cap: pre_root_ops <= 1_000_000 (MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY) — replaying ~1M ops costs ~0.5-1s and several hundred MiB of peak memory; beyond that the checkout path is safer on wasm32;
  • decoded-byte cap: the prefix's payload size is estimated BEFORE any value is copied (estimate_ops_content_bytes, capped at 32 MiB by MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY). Op-atom counts miss value sizes (a Map write is one atom regardless of how large its Binary/String payload is), so a byte-heavy low-op prefix would otherwise bypass the op gates. The estimate follows arena slices by reference (new SharedArena::with_values), recurses into nested LoroValue::List/Map, and counts every variable-length field the block encoder copies: map and style keys, style-mark values, tree fractional indexes, root container names, unknown-op OwnedValue payloads (including MarkStart keys and MarkStart/ListSet values), and commit messages; every counting step is budget-aware and short-circuits past the cap — a rejected prefix costs one bounded walk and zero payload copies. Checking the encoded blob afterwards would be too late: export_fast_updates_in_range slice-copies values into a fresh ChangeStore while building it, which is exactly the allocation the cap exists to prevent. Because the estimate works on decoded bytes, compressible payloads are bounded too.

Regression guard bench shallow_export_scalar_prefix/export (2M same-key Map overwrites before the root + one 70k-atom Text insert after it): 280 ms un-gated vs 56 ms gated, and the un-gated variant also duplicates a 2M-op oplog + state in memory.

Lazy-input regression fixed

Exporting a shallow snapshot at F == latest on a lazily imported doc (imported from snapshot, never read):

base first revision this revision
time 186 ms 349 ms 160 ms
peak allocation delta 29.0 MiB 318.7 MiB 29.1 MiB
retained after return 17.7 MiB 48.3 MiB 17.8 MiB

The first revision's regression came from replaying the whole history into a temp doc even when the checkout path had nothing to walk back, plus a load_all() on the live store (iter_all_container_ids) for root mirroring. Both are gone: the gate excludes small/zero retained ranges, and mirroring uses the root-only scan.

Equivalence

Fast and slow paths are not byte-identical (the replay doc re-encodes state), so the tests assert semantic identity:

  • shallow_export_forward_replay_matches_checkout_path: same doc exported via both paths (attached vs detached), tail past the 256-op overlay threshold and the 65536-op gate; compares deep value, blob metadata (mode, start frontiers, start/end vv, change_num), retained history (len_changes/len_ops/oplog_vv/oplog_frontiers of both imported docs), shallow root metadata, and the accessed-but-op-less root container case.
  • shallow_export_deleted_root_containers_match_checkout_path: root deleted before F and after F, each with and without the overlay; asserts path parity, that the pre-F-deleted root is not resurrected, and that at-root content survives for post-F deletions.

Note: these tests surfaced a pre-existing quirk on both paths (reproduced on the base commit): with an overlay, a root deleted after the shallow root still shows its at-root content at the imported doc's latest version, and a checkout back to the root doubles it. Out of scope here.

Benchmark

crates/loro-internal/benches/shallow_export.rs ([[bench]] shallow_export): full_snapshot vs shallow_snapshot on the attached fixture, shallow_export_lazy/at_latest which exports from a freshly imported (cold, lazy) doc each iteration so the lazy regression stays measurable, shallow_export_scalar_prefix/export for the op-heavy prefix bound, and shallow_export_byte_prefix/export (64 distinct 1 MiB incompressible Binary values before the root + one 70k-atom Text insert after it) for the byte bound (gated: 54-124 ms; un-gated the 64 MiB prefix would be fully copied into a temp doc before any cap could react):

cargo bench --bench shallow_export -p loro-internal

Validation

  • cargo nextest run --features=test_utils,jsonpath --no-fail-fast: 1435 passed, 0 failed
  • cargo test -p loro -p loro-internal: all targets pass (including test_memory_leak)
  • cargo test -p loro-internal --doc: 4 passed
  • context/internal-encoding.md updated to describe the gated paths

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3146.41 KB
  • Gzipped size: 1042.97 KB
  • Brotli size: 732.27 KB

…oots

- Mirror the live doc's deleted_root_containers into the replay doc so a root
  deleted before the shallow root is dropped at flush instead of being
  resurrected as an empty entry.
- Mirror root containers via a root-only key scan (existing_retention_roots)
  instead of iter_all_container_ids, which called load_all and defeated lazy
  imports.
- Only use forward replay when >= 65536 ops are retained since the root;
  below that the checkout path ties in time and peaks at ~4x less memory
  (measured on the 66k-container fixture at F = 50%..100% of history).
- Extend the path-equivalence test past the overlay threshold with full
  metadata and retained-history comparison, add deleted-root parity tests,
  and add a lazy-import benchmark entry.
The retained-ops gate alone could select the forward-replay path for a doc
whose pre-root history is huge but unrelated to the tail (e.g. millions of
same-key Map overwrites before the root, one 65k-atom Text insert after it),
re-encoding and replaying the whole prefix while the checkout path only walks
the tail. Cap the prefix absolutely (1M ops) and relative to the tail (16x;
measured crossover: forward wins at ratio 9, loses at 19). Add a
scalar-prefix-heavy benchmark entry as a regression guard: 280ms un-gated vs
56ms gated on a 2M-op prefix + 70k-atom tail fixture.
Op-atom counts miss value sizes: a Map write is one atom regardless of how
large its Binary/String payload is, so a byte-heavy low-op prefix could bypass
the op-count gates and be fully re-encoded and replayed into the temp doc.
Encode the (cheap, block-copied) prefix blob first, then drop it when it
exceeds 32 MiB. The gate logic is extracted into a pure predicate with unit
tests, plus a byte-heavy low-op prefix export-correctness test and a
byte-prefix benchmark entry. Also assert the op-less root container's
existence with has_container before materializing it in the path-equivalence
test.
… values

The encoded-byte filter ran only after export_fast_updates_in_range had
already slice-copied every prefix value into a fresh ChangeStore, so the cap
could not prevent the large allocation it was meant to avoid. Replace it with
a decoded-size estimate that walks op payloads by reference (new
SharedArena::with_values) and short-circuits past the cap, so a rejected
prefix costs one bounded walk and zero payload copies. Add an estimator unit
test.
…-aware

The estimator counted only top-level String/Binary payloads; nested
LoroValue::List/Map were charged a flat 16 bytes while the encoder recurses
into them, so { payload: <huge String> } still bypassed the byte cap. Count
nested values recursively, include StyleStart values and commit messages, and
give every counting step a remaining budget so the walk short-circuits past
the cap. The byte-heavy prefix regression test now nests its payload one
level down.
…timate

The estimator missed fields the block encoder copies: StyleStart keys (which
go into the block's key register), TreeOp fractional indexes, root container
names (copied into the block's container arena), and unknown-future payload
bytes. All are now counted with the same cap-aware budget, covering the
huge-style-key-on-deleted-pre-root-text scenario.
…imate

owned_value_bytes_capped folded every other OwnedValue variant to a flat 16
bytes, but the encoder writes MarkStart keys into the block key register and
recurses into MarkStart/ListSet values, so unknown-container ops (whose
decoder accepts any Value) could still smuggle a huge payload past the byte
cap. Count both, with the same cap-aware recursion. TreeMove/ListMove keep a
flat charge: they carry only fixed-size indices.
Such a change is concurrent with the root frontier op: its causal past is
covered by the root state, so the boundary shortcut in frontiers_to_vv
resolved it to the shallow vv and the import check let it through. But the
dep ids are trimmed from the DAG, so the change was parked as pending and then
panicked in calc_unknown_lamport_change (unwrap on Err). Reject it with
ImportUpdatesThatDependsOnOutdatedVersion like any other pre-root update.

Adds a dag-level unit test and an import-level test that also locks the
'dropped, not pending' guarantee by asserting pending_changes_len() stays 0.
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from ca2d2f3 to cf8ee1b Compare September 5, 2026 05:15
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