perf: build shallow root state by forward replay instead of reverse checkout - #1091
Open
zxch3n wants to merge 12 commits into
Open
perf: build shallow root state by forward replay instead of reverse checkout#1091zxch3n wants to merge 12 commits into
zxch3n wants to merge 12 commits into
Conversation
Contributor
WASM Size Report
|
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 04:18
51b202f to
31e9ad7
Compare
zxch3n
changed the base branch from
main
to
test/shallow-snapshot-concurrency
September 4, 2026 04:19
This was referenced Sep 4, 2026
Merged
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 11:22
1ef09dc to
6d401fc
Compare
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 12:17
6d401fc to
7baf818
Compare
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 16:38
6dde492 to
26c91d2
Compare
…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
force-pushed
the
perf/shallow-export
branch
from
September 5, 2026 05:15
ca2d2f3 to
cf8ee1b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):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 +
sampleprofiling on the synthetic doc:oplog.export_change_store_from(retained history): 13 ms83% of all samples sat in
replay_container_ops_from_emptyunderRichtextDiffCalculator::build_full_crdt_tracker: a reverse diff setshas_retreat, socalculate_difftakes theshould_rebuildpath 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:export_fast_updates_in_rangewhile the oplog lock is held (callingLoroDoc::exportinside would re-enterwith_barrierand violate the txn lock order),DocState::existing_retention_roots), so accessed-but-op-less root containers still ship — withoutload_all,deleted_root_containersinto 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,Detached docs, already-shallow sources, and small retained ranges keep the previous checkout path.
Gate choice, from measurement (lazy-imported fixture doc, cold export)
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:
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);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;estimate_ops_content_bytes, capped at 32 MiB byMAX_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 (newSharedArena::with_values), recurses into nestedLoroValue::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-opOwnedValuepayloads (includingMarkStartkeys andMarkStart/ListSetvalues), 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_rangeslice-copies values into a freshChangeStorewhile 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):
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_snapshotvsshallow_snapshoton the attached fixture,shallow_export_lazy/at_latestwhich exports from a freshly imported (cold, lazy) doc each iteration so the lazy regression stays measurable,shallow_export_scalar_prefix/exportfor the op-heavy prefix bound, andshallow_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):Validation
cargo nextest run --features=test_utils,jsonpath --no-fail-fast: 1435 passed, 0 failedcargo test -p loro -p loro-internal: all targets pass (includingtest_memory_leak)cargo test -p loro-internal --doc: 4 passedcontext/internal-encoding.mdupdated to describe the gated paths