Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .changeset/fast-sstable-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
"loro-crdt": patch
---

Improve snapshot import performance by skipping eager SSTable block metadata validation on fast imports while still verifying block checksums.
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.

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.
11 changes: 11 additions & 0 deletions .changeset/faster-local-text-editing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"loro-crdt": patch
---

Speed up local text editing (~35% faster on the B4 editing trace). Three hot-path
changes: the lock-order debug instrumentation is now compiled out of release
builds (it ran on every per-op lock acquisition); the visible-op count is bumped
incrementally for local ops instead of recomputing it from the version vectors
(which also allocated) on every op; and a couple of per-op allocations on the
text insert/delete path were removed (lazy error-context formatting and inline
storage for entity ranges).
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ members = [
"crates/delta",
"crates/kv-store",
"crates/loro-wasm-tools",
"crates/generic-btree",
]
resolver = "2"

# Use the in-tree fork of generic-btree (loro-dev maintains it). This redirects
# every `generic-btree` dependency in the graph to the workspace crate.
[patch.crates-io]
generic-btree = { path = "crates/generic-btree" }

[workspace.dependencies]
enum_dispatch = "0.3.11"
enum-as-inner = "0.6.0"
Expand All @@ -31,4 +37,3 @@ bytes = "1"
once_cell = "1.18.0"
xxhash-rust = { version = "0.8.12", features = ["xxh32"] }
ensure-cov = "0.1.0"
either = "1.13.0"
181 changes: 181 additions & 0 deletions crates/examples/examples/b4_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! B4 (automerge-paper) performance harness.
//!
//! Usage:
//! cargo run --release -p examples --example b4_bench # phase report
//! cargo run --release -p examples --example b4_bench edit # tight edit loop (for profiler)
//! cargo run --release -p examples --example b4_bench import # tight import loop (for profiler)
//! cargo run --release -p examples --example b4_bench import100 # tight import loop for B4x100
use std::time::{Duration, Instant};

use bench_utils::{get_automerge_actions, TextAction};
use dev_utils::{get_mem_usage, ByteSize};
use loro::{ExportMode, LoroDoc};

fn apply(actions: &[TextAction], n: usize) -> LoroDoc {
let doc = LoroDoc::new();
let text = doc.get_text("text");
for _ in 0..n {
for TextAction { del, ins, pos } in actions.iter() {
text.delete(*pos, *del).unwrap();
text.insert(*pos, ins).unwrap();
}
}
doc.commit();
doc
}

fn median(mut v: Vec<Duration>) -> Duration {
v.sort();
v[v.len() / 2]
}

fn time<T>(runs: usize, mut f: impl FnMut() -> T) -> (Duration, T) {
let mut last = None;
let mut times = Vec::new();
for _ in 0..runs {
let start = Instant::now();
let r = f();
times.push(start.elapsed());
last = Some(r);
}
(median(times), last.unwrap())
}

fn report() {
let actions = get_automerge_actions();
let total_ops: usize = actions.len();
println!("B4 actions: {total_ops} (each = 1 delete + 1 insert)\n");

// ---- Local editing ----
let mem0 = get_mem_usage();
let (t_apply, doc) = time(5, || apply(&actions, 1));
let mem_after_apply = get_mem_usage() - mem0;
println!("== Local editing (one big txn, no subscriber) ==");
println!(
" apply 1x: {:>10.2?} ({:.2} M op/s, {:.0} ns/op)",
t_apply,
(2 * total_ops) as f64 / t_apply.as_secs_f64() / 1e6,
t_apply.as_nanos() as f64 / (2 * total_ops) as f64
);
println!(" doc mem after apply: {}", mem_after_apply);

// ---- Snapshot export ----
let (t_export, snapshot) = time(5, || doc.export(ExportMode::Snapshot).unwrap());
println!("\n== Snapshot export ==");
println!(" export (has cache): {:>10.2?}", t_export);
println!(" snapshot size: {}", ByteSize(snapshot.len()));

let (t_export_nc, _) = time(5, || {
let d = apply(&actions, 1);
d.export(ExportMode::Snapshot).unwrap()
});
println!(" export(+apply,nocache):{:>8.2?} (includes a fresh apply)", t_export_nc);

// ---- Snapshot import ----
let mem_before = get_mem_usage();
let (t_import, imported) = time(5, || {
let d = LoroDoc::new();
d.import(&snapshot).unwrap();
d
});
let mem_imported = get_mem_usage() - mem_before;
println!("\n== Snapshot import (B4) ==");
println!(" import: {:>10.2?}", t_import);
println!(" mem after import: {}", mem_imported);

let (t_import_val, _) = time(5, || {
let d = LoroDoc::new();
d.import(&snapshot).unwrap();
let v = d.get_deep_value();
std::hint::black_box(v);
});
println!(" import + toJSON: {:>10.2?} (forces full state materialization)", t_import_val);
std::hint::black_box(&imported);

// ---- B4 x100 ----
let (t_apply100, doc100) = time(1, || apply(&actions, 100));
let snap100 = doc100.export(ExportMode::Snapshot).unwrap();
println!("\n== B4 x100 ==");
println!(" apply 100x: {:>10.2?}", t_apply100);
println!(" snapshot size: {}", ByteSize(snap100.len()));
let (t_import100, _) = time(5, || {
let d = LoroDoc::new();
d.import(&snap100).unwrap();
d
});
println!(" import: {:>10.2?}", t_import100);
let (t_import100_val, _) = time(5, || {
let d = LoroDoc::new();
d.import(&snap100).unwrap();
std::hint::black_box(d.get_deep_value());
});
println!(" import + toJSON: {:>10.2?}", t_import100_val);

// ---- updates encode/decode (history path) ----
let updates = doc.export(ExportMode::all_updates()).unwrap();
println!("\n== Updates (history) ==");
println!(" updates size: {}", ByteSize(updates.len()));
let (t_dec_updates, _) = time(5, || {
let d = LoroDoc::new();
d.import(&updates).unwrap();
d
});
println!(" import updates: {:>10.2?}", t_dec_updates);
}

/// Tight loop over `f` for `secs` seconds. Use with an external sampling
/// profiler, e.g.:
/// cargo instruments -t time --release -p examples --example b4_bench -- edit 20
fn loop_for(secs: u64, _label: &str, mut f: impl FnMut()) {
let start = Instant::now();
let mut iters = 0u64;
while start.elapsed() < Duration::from_secs(secs) {
f();
iters += 1;
}
eprintln!("ran {iters} iters in {:?}", start.elapsed());
}

fn main() {
let mode = std::env::args().nth(1).unwrap_or_default();
let secs: u64 = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(12);
match mode.as_str() {
"edit" => {
let actions = get_automerge_actions();
loop_for(secs, "edit", || {
std::hint::black_box(apply(&actions, 1));
});
}
"import" => {
let actions = get_automerge_actions();
let snapshot = apply(&actions, 1).export(ExportMode::Snapshot).unwrap();
loop_for(secs, "import", || {
let d = LoroDoc::new();
d.import(&snapshot).unwrap();
std::hint::black_box(d);
});
}
"import100" => {
let actions = get_automerge_actions();
let snapshot = apply(&actions, 100).export(ExportMode::Snapshot).unwrap();
loop_for(secs, "import100", || {
let d = LoroDoc::new();
d.import(&snapshot).unwrap();
std::hint::black_box(d);
});
}
"import_val" => {
let actions = get_automerge_actions();
let snapshot = apply(&actions, 1).export(ExportMode::Snapshot).unwrap();
loop_for(secs, "import_val", || {
let d = LoroDoc::new();
d.import(&snapshot).unwrap();
std::hint::black_box(d.get_deep_value());
});
}
_ => report(),
}
}
38 changes: 19 additions & 19 deletions crates/fuzz/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions crates/generic-btree/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[package]
name = "generic-btree"
version = "0.10.7"
edition = "2021"
authors = ["zxch3n <remch183@outlook.com>"]
description = "Generic BTree for versatile purposes"
homepage = "https://github.com/loro-dev/generic-btree"
documentation = "https://docs.rs/generic-btree"
readme = "README.md"
keywords = ["btree", "data-structure"]
license = "MIT"
repository = "https://github.com/loro-dev/generic-btree"

# Vendored into the loro workspace (fork of crates.io generic-btree 0.10.7) so we
# can evolve the b-tree (e.g. deferred cache propagation). Redirected from
# crates.io via [patch.crates.io] in the root Cargo.toml.

[features]
test = []

[lib]
name = "generic_btree"
path = "src/lib.rs"

[dependencies]
arref = "0.1.0"
heapless = "0.9.1"
itertools = "0.11.0"
proc-macro2 = "1.0.67"
rustc-hash = "2.1.1"
thunderdome = { version = "0.6.2", package = "loro-thunderdome" }

[dev-dependencies]
arbitrary = { version = "1", features = ["derive"] }
rand = "0.8.5"
Loading
Loading