Skip to content

Commit 9b6330d

Browse files
zxch3nclaude
andauthored
perf: speed up B4 local editing ~2.4x and snapshot import ~45% (#1033)
* perf: speed up B4 local editing (~42%) and snapshot import (~45%) Local text editing (applying the automerge-paper trace): ~112ms -> ~65ms. - Compile the lock-order debug instrumentation out of release builds; it ran on every per-op OpLog+DocState lock acquire/release (~30% of edit time). In release `can_lock_in_this_thread` returns false, backed by the now-exact cached visible op count. - Bump `visible_op_count` incrementally for local ops instead of recomputing it from the version vectors (which also heap-allocated an im::HashMap iterator) on every op. - Build the position-context error string in `checked_range_end` lazily (no per-op alloc) and return entity ranges in a SmallVec (no per-delete Vec alloc). - Route the per-insert event-index computation through the existing cursor cache instead of a fresh `visit_previous_caches` walk every op. Snapshot import (fast snapshot): B4 ~135us -> ~80us; B4x100 (22MB) ~8.15ms -> ~4.5ms. - Skip the redundant per-block SSTable checksum on full import; the whole body is already covered by the document checksum verified in parse_header_and_body. Adds crates/examples/examples/b4_bench.rs (phase-timed B4 harness) plus regression tests for the cached visible op count and the block-checksum skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: avoid per-op heap alloc in DocState::is_deleted `is_deleted` allocated a fresh `visited` Vec on every local op (the #1 allocation source after the earlier fixes: ~260k allocs on the B4 trace). Parent chains are shallow (depth 1 for a root container), so use inline SmallVec storage. apply 1x: ~65ms -> ~61ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: vendor generic-btree into the workspace Fork crates.io generic-btree 0.10.7 (which loro-dev maintains) into crates/generic-btree and redirect all dependents via [patch.crates-io], so the b-tree can evolve in-tree (e.g. deferred cache propagation). This is a verbatim vendoring of 0.10.7 (build is transparent: B4 apply unchanged at ~62ms); only the manifest is trimmed (benches dropped, dev-deps reduced to what the in-src tests need). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: fast path for plain-text local edits (B4 apply ~61ms -> ~46ms) Add a specialized insert/delete path for style-free text on the attached, non-wasm, unicode-index path (the common Rust text-editing case). When the richtext has no style anchors, entity_index == event_index == unicode pos, so the entire read phase -- cursor location, two `visit_previous_caches` coordinate walks, and the styles lookup -- is unnecessary; `apply_local_op` then locates the cursor exactly once. The delete path likewise skips the two `index_to_event_index` walks. Falls back to the general path when styles are present, on wasm, or for non-unicode position types, so results are unchanged (snapshot bytes identical; loro, loro-internal lib, and mergeable tests all pass). Also gate `apply_local_op`'s txn/doc context check (a per-op `Weak::upgrade`) to debug builds, since the handler always passes its own doc. Cumulative B4 apply: 112ms -> ~46ms (~2.4x), ~11.5 M op/s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: keep integrity checks on public APIs (review fixes) Address two correctness regressions introduced by the B4 perf work: 1. The txn/doc context check in `Transaction::apply_local_op` was gated to debug builds. `insert_with_txn`/`delete_with_txn` are public API, so a caller can feed one document's transaction to another document's handler; in release that silently stamped the target doc's state/oplog with the wrong peer+counter instead of returning `UnmatchedContext`. Restore the check for all builds using a cheap `Weak`-pointer comparison (no atomic upgrade on the hot path; upgrade only to fill in the error on mismatch). 2. `MemKvStore::import_all` (re-exported publicly via loro-crdt) dropped per-block checksums for all callers. Split the API: public `import_all` (and `SsTable::import_all`) always verifies block checksums; a new `import_all_unchecked` opts into the fast path and is used only by Loro's snapshot decode (`ChangeStore::import_all`, `KvWrapper::import`), where the document-level checksum from `parse_header_and_body` already guarantees integrity over the whole body. Adds regression tests: `cross_doc_txn_is_rejected` and the updated `sstable_import_block_checksum_only_skipped_when_unchecked`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: make fuzz sanitizer platform-aware --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3986805 commit 9b6330d

30 files changed

Lines changed: 8491 additions & 106 deletions

.changeset/fast-sstable-import.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
"loro-crdt": patch
33
---
44

5-
Improve snapshot import performance by skipping eager SSTable block metadata validation on fast imports while still verifying block checksums.
5+
Speed up snapshot import. When decoding a Loro snapshot, the redundant per-block SSTable validation (eager block-metadata decode and per-block checksums) is now skipped, because the whole snapshot body is already protected by the document-level checksum verified during decoding. This removes a second hash pass over the data (roughly halving B4 snapshot import time) while preserving integrity guarantees.
6+
7+
This fast path is internal to Loro's snapshot decoding. The public `MemKvStore::import_all` still verifies every block's checksum; a separate `import_all_unchecked` opts into the unchecked path and is only used where an outer checksum already guarantees integrity.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"loro-crdt": patch
3+
---
4+
5+
Speed up local text editing (~35% faster on the B4 editing trace). Three hot-path
6+
changes: the lock-order debug instrumentation is now compiled out of release
7+
builds (it ran on every per-op lock acquisition); the visible-op count is bumped
8+
incrementally for local ops instead of recomputing it from the version vectors
9+
(which also allocated) on every op; and a couple of per-op allocations on the
10+
text insert/delete path were removed (lazy error-context formatting and inline
11+
storage for entity ranges).

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ members = [
1313
"crates/delta",
1414
"crates/kv-store",
1515
"crates/loro-wasm-tools",
16+
"crates/generic-btree",
1617
]
1718
resolver = "2"
1819

20+
# Use the in-tree fork of generic-btree (loro-dev maintains it). This redirects
21+
# every `generic-btree` dependency in the graph to the workspace crate.
22+
[patch.crates-io]
23+
generic-btree = { path = "crates/generic-btree" }
24+
1925
[workspace.dependencies]
2026
enum_dispatch = "0.3.11"
2127
enum-as-inner = "0.6.0"
@@ -31,4 +37,3 @@ bytes = "1"
3137
once_cell = "1.18.0"
3238
xxhash-rust = { version = "0.8.12", features = ["xxh32"] }
3339
ensure-cov = "0.1.0"
34-
either = "1.13.0"
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
//! B4 (automerge-paper) performance harness.
2+
//!
3+
//! Usage:
4+
//! cargo run --release -p examples --example b4_bench # phase report
5+
//! cargo run --release -p examples --example b4_bench edit # tight edit loop (for profiler)
6+
//! cargo run --release -p examples --example b4_bench import # tight import loop (for profiler)
7+
//! cargo run --release -p examples --example b4_bench import100 # tight import loop for B4x100
8+
use std::time::{Duration, Instant};
9+
10+
use bench_utils::{get_automerge_actions, TextAction};
11+
use dev_utils::{get_mem_usage, ByteSize};
12+
use loro::{ExportMode, LoroDoc};
13+
14+
fn apply(actions: &[TextAction], n: usize) -> LoroDoc {
15+
let doc = LoroDoc::new();
16+
let text = doc.get_text("text");
17+
for _ in 0..n {
18+
for TextAction { del, ins, pos } in actions.iter() {
19+
text.delete(*pos, *del).unwrap();
20+
text.insert(*pos, ins).unwrap();
21+
}
22+
}
23+
doc.commit();
24+
doc
25+
}
26+
27+
fn median(mut v: Vec<Duration>) -> Duration {
28+
v.sort();
29+
v[v.len() / 2]
30+
}
31+
32+
fn time<T>(runs: usize, mut f: impl FnMut() -> T) -> (Duration, T) {
33+
let mut last = None;
34+
let mut times = Vec::new();
35+
for _ in 0..runs {
36+
let start = Instant::now();
37+
let r = f();
38+
times.push(start.elapsed());
39+
last = Some(r);
40+
}
41+
(median(times), last.unwrap())
42+
}
43+
44+
fn report() {
45+
let actions = get_automerge_actions();
46+
let total_ops: usize = actions.len();
47+
println!("B4 actions: {total_ops} (each = 1 delete + 1 insert)\n");
48+
49+
// ---- Local editing ----
50+
let mem0 = get_mem_usage();
51+
let (t_apply, doc) = time(5, || apply(&actions, 1));
52+
let mem_after_apply = get_mem_usage() - mem0;
53+
println!("== Local editing (one big txn, no subscriber) ==");
54+
println!(
55+
" apply 1x: {:>10.2?} ({:.2} M op/s, {:.0} ns/op)",
56+
t_apply,
57+
(2 * total_ops) as f64 / t_apply.as_secs_f64() / 1e6,
58+
t_apply.as_nanos() as f64 / (2 * total_ops) as f64
59+
);
60+
println!(" doc mem after apply: {}", mem_after_apply);
61+
62+
// ---- Snapshot export ----
63+
let (t_export, snapshot) = time(5, || doc.export(ExportMode::Snapshot).unwrap());
64+
println!("\n== Snapshot export ==");
65+
println!(" export (has cache): {:>10.2?}", t_export);
66+
println!(" snapshot size: {}", ByteSize(snapshot.len()));
67+
68+
let (t_export_nc, _) = time(5, || {
69+
let d = apply(&actions, 1);
70+
d.export(ExportMode::Snapshot).unwrap()
71+
});
72+
println!(" export(+apply,nocache):{:>8.2?} (includes a fresh apply)", t_export_nc);
73+
74+
// ---- Snapshot import ----
75+
let mem_before = get_mem_usage();
76+
let (t_import, imported) = time(5, || {
77+
let d = LoroDoc::new();
78+
d.import(&snapshot).unwrap();
79+
d
80+
});
81+
let mem_imported = get_mem_usage() - mem_before;
82+
println!("\n== Snapshot import (B4) ==");
83+
println!(" import: {:>10.2?}", t_import);
84+
println!(" mem after import: {}", mem_imported);
85+
86+
let (t_import_val, _) = time(5, || {
87+
let d = LoroDoc::new();
88+
d.import(&snapshot).unwrap();
89+
let v = d.get_deep_value();
90+
std::hint::black_box(v);
91+
});
92+
println!(" import + toJSON: {:>10.2?} (forces full state materialization)", t_import_val);
93+
std::hint::black_box(&imported);
94+
95+
// ---- B4 x100 ----
96+
let (t_apply100, doc100) = time(1, || apply(&actions, 100));
97+
let snap100 = doc100.export(ExportMode::Snapshot).unwrap();
98+
println!("\n== B4 x100 ==");
99+
println!(" apply 100x: {:>10.2?}", t_apply100);
100+
println!(" snapshot size: {}", ByteSize(snap100.len()));
101+
let (t_import100, _) = time(5, || {
102+
let d = LoroDoc::new();
103+
d.import(&snap100).unwrap();
104+
d
105+
});
106+
println!(" import: {:>10.2?}", t_import100);
107+
let (t_import100_val, _) = time(5, || {
108+
let d = LoroDoc::new();
109+
d.import(&snap100).unwrap();
110+
std::hint::black_box(d.get_deep_value());
111+
});
112+
println!(" import + toJSON: {:>10.2?}", t_import100_val);
113+
114+
// ---- updates encode/decode (history path) ----
115+
let updates = doc.export(ExportMode::all_updates()).unwrap();
116+
println!("\n== Updates (history) ==");
117+
println!(" updates size: {}", ByteSize(updates.len()));
118+
let (t_dec_updates, _) = time(5, || {
119+
let d = LoroDoc::new();
120+
d.import(&updates).unwrap();
121+
d
122+
});
123+
println!(" import updates: {:>10.2?}", t_dec_updates);
124+
}
125+
126+
/// Tight loop over `f` for `secs` seconds. Use with an external sampling
127+
/// profiler, e.g.:
128+
/// cargo instruments -t time --release -p examples --example b4_bench -- edit 20
129+
fn loop_for(secs: u64, _label: &str, mut f: impl FnMut()) {
130+
let start = Instant::now();
131+
let mut iters = 0u64;
132+
while start.elapsed() < Duration::from_secs(secs) {
133+
f();
134+
iters += 1;
135+
}
136+
eprintln!("ran {iters} iters in {:?}", start.elapsed());
137+
}
138+
139+
fn main() {
140+
let mode = std::env::args().nth(1).unwrap_or_default();
141+
let secs: u64 = std::env::args()
142+
.nth(2)
143+
.and_then(|s| s.parse().ok())
144+
.unwrap_or(12);
145+
match mode.as_str() {
146+
"edit" => {
147+
let actions = get_automerge_actions();
148+
loop_for(secs, "edit", || {
149+
std::hint::black_box(apply(&actions, 1));
150+
});
151+
}
152+
"import" => {
153+
let actions = get_automerge_actions();
154+
let snapshot = apply(&actions, 1).export(ExportMode::Snapshot).unwrap();
155+
loop_for(secs, "import", || {
156+
let d = LoroDoc::new();
157+
d.import(&snapshot).unwrap();
158+
std::hint::black_box(d);
159+
});
160+
}
161+
"import100" => {
162+
let actions = get_automerge_actions();
163+
let snapshot = apply(&actions, 100).export(ExportMode::Snapshot).unwrap();
164+
loop_for(secs, "import100", || {
165+
let d = LoroDoc::new();
166+
d.import(&snapshot).unwrap();
167+
std::hint::black_box(d);
168+
});
169+
}
170+
"import_val" => {
171+
let actions = get_automerge_actions();
172+
let snapshot = apply(&actions, 1).export(ExportMode::Snapshot).unwrap();
173+
loop_for(secs, "import_val", || {
174+
let d = LoroDoc::new();
175+
d.import(&snapshot).unwrap();
176+
std::hint::black_box(d.get_deep_value());
177+
});
178+
}
179+
_ => report(),
180+
}
181+
}

crates/fuzz/fuzz/Cargo.lock

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/generic-btree/Cargo.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "generic-btree"
3+
version = "0.10.7"
4+
edition = "2021"
5+
authors = ["zxch3n <remch183@outlook.com>"]
6+
description = "Generic BTree for versatile purposes"
7+
homepage = "https://github.com/loro-dev/generic-btree"
8+
documentation = "https://docs.rs/generic-btree"
9+
readme = "README.md"
10+
keywords = ["btree", "data-structure"]
11+
license = "MIT"
12+
repository = "https://github.com/loro-dev/generic-btree"
13+
14+
# Vendored into the loro workspace (fork of crates.io generic-btree 0.10.7) so we
15+
# can evolve the b-tree (e.g. deferred cache propagation). Redirected from
16+
# crates.io via [patch.crates.io] in the root Cargo.toml.
17+
18+
[features]
19+
test = []
20+
21+
[lib]
22+
name = "generic_btree"
23+
path = "src/lib.rs"
24+
25+
[dependencies]
26+
arref = "0.1.0"
27+
heapless = "0.9.1"
28+
itertools = "0.11.0"
29+
proc-macro2 = "1.0.67"
30+
rustc-hash = "2.1.1"
31+
thunderdome = { version = "0.6.2", package = "loro-thunderdome" }
32+
33+
[dev-dependencies]
34+
arbitrary = { version = "1", features = ["derive"] }
35+
rand = "0.8.5"

0 commit comments

Comments
 (0)