pmacs/tests/m10_2_perf.rs

603 lines
23 KiB
Rust

//! T M10.2 Day 7 — performance regression check.
//!
//! Measures CRDT-mode buffer performance against v0.1-mode buffer
//! performance across four axes, with methodology matching M10.1's
//! library-survey benchmarks so numbers are directly comparable.
//!
//! All tests are `#[ignore]` — they don't run in CI. Invocation:
//!
//! ```sh
//! cargo test --release --features "luajit crdt" \
//! --test m10_2_perf -- --ignored --nocapture
//! ```
//!
//! Methodology (pinned for reproducibility against M10.1 + future
//! re-runs):
//! - Document size points: 1KB / 100KB / 1MB / 10MB
//! - Mixed-workload mix: 50% inserts / 30% deletes / 15% replaces / 5% large
//! - Op-size distribution: log-normal mu=1.1 sigma=1.5 for small,
//! mu=6 sigma=1.5 for large (matches M10.1)
//! - Deterministic seed: 0xc0ffee (matches M10.1)
//! - Window: 30s for the audit numbers; 5s for development iteration
//! - Initial document for mixed workload: 100KB
//! - Release profile, single-thread
//!
//! Reports printed to stderr via `eprintln!` (visible with --nocapture).
#![cfg(feature = "crdt")]
#![allow(
clippy::uninlined_format_args,
clippy::unreadable_literal,
reason = "perf bench: table-formatted numeric output reads better column-aligned than inline"
)]
use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::rope::Range;
use rand::{Rng, SeedableRng};
use rand_distr::{Distribution, LogNormal};
use std::time::{Duration, Instant};
const SEED: u64 = 0xc0ffee;
const WINDOW_SECS: u64 = 30;
const ASCII_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ\n";
fn fmt_size(n: usize) -> String {
if n >= 1_000_000 {
format!("{} MB", n / 1_000_000)
} else if n >= 1_000 {
format!("{} KB", n / 1_000)
} else {
format!("{} B", n)
}
}
fn random_ascii(n: usize, rng: &mut impl Rng) -> Vec<u8> {
(0..n)
.map(|_| ASCII_ALPHABET[rng.r#gen_range(0..ASCII_ALPHABET.len())])
.collect()
}
// ---------------------------------------------------------------------------
// 1. Microbenchmark — document-size sweep.
//
// Bulk insert + snapshot (Arc-clone of rope) at 4 size points,
// measured for both modes. The bulk insert exercises the rope's
// build path; the snapshot exercises the worker-facing handoff.
//
// Expected: v0.1 mode unchanged (no CRDT field touched). CRDT mode
// pays loro's bulk-insert + version-capture/export overhead per call.
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_document_size_sweep() {
let sizes = [1_000usize, 100_000, 1_000_000, 10_000_000];
eprintln!("\n=== M10.2 Day 7 — document size sweep ===\n");
eprintln!(
"{:>8} | {:>14} | {:>14} | {:>10}",
"size", "v0.1 bulk", "CRDT bulk", "ratio"
);
eprintln!("{}", "-".repeat(60));
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
for &size in &sizes {
let payload = random_ascii(size, &mut rng);
// v0.1 mode: build via Buffer::from_bytes.
let t = Instant::now();
let _b_v01 = Buffer::from_bytes(BufferId::next(), "v01", &payload);
let v01_us = t.elapsed().as_micros();
// CRDT mode: build via Buffer::from_bytes_with_crdt.
let t = Instant::now();
let _b_crdt = Buffer::from_bytes_with_crdt(BufferId::next(), "crdt", &payload, 1)
.expect("crdt construct");
let crdt_us = t.elapsed().as_micros();
let ratio = if v01_us > 0 {
crdt_us as f64 / v01_us as f64
} else {
f64::NAN
};
eprintln!(
"{:>8} | {:>11} us | {:>11} us | {:>9.2}x",
fmt_size(size),
v01_us,
crdt_us,
ratio
);
}
eprintln!();
}
// ---------------------------------------------------------------------------
// 2. Per-op throughput — mixed workload (M10.1 methodology).
//
// 50/30/15/5 mix, log-normal op sizes, 30s window. Comparable to the
// M10.1 library benchmarks (loro: 279k ops/sec at 30s; yrs: 1.5k).
// pmacs's Buffer adds intercept dispatch, mark adjustment, undo
// bookkeeping, and on_edit broadcast on top of the underlying CRDT
// or rope operations — those overheads are part of the measurement.
//
// Expected: v0.1 mode in the hundreds of thousands of ops/sec range
// (rope edits are cheap). CRDT mode pays additional cost per op
// (CRDT apply + Day 3 crdt_op extraction).
// ---------------------------------------------------------------------------
#[derive(Clone, Copy)]
enum OpKind {
Insert,
Delete,
Replace,
LargeOp,
}
fn pick_op<R: Rng>(rng: &mut R) -> OpKind {
let r: f64 = rng.r#gen();
if r < 0.50 {
OpKind::Insert
} else if r < 0.80 {
OpKind::Delete
} else if r < 0.95 {
OpKind::Replace
} else {
OpKind::LargeOp
}
}
fn op_size_lognormal<R: Rng>(rng: &mut R, large: bool) -> usize {
let mu = if large { 6.0 } else { 1.1 };
let dist = LogNormal::new(mu, 1.5).unwrap();
let v: f64 = dist.sample(rng);
let n = v.round() as usize;
n.clamp(1, if large { 5_000 } else { 50 })
}
fn run_workload(buf: &mut Buffer, window: Duration) -> u64 {
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + window;
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = buf.len() as usize;
if len == 0 {
let _ = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
});
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len) as u64;
let bytes = random_ascii(size, &mut rng);
let _ = buf.apply_edit(EditOp::Insert { pos, bytes: &bytes });
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l)) as u64;
let _ = buf.apply_edit(EditOp::Delete {
range: Range::new(pos, pos + l as u64),
});
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l)) as u64;
let bytes = random_ascii(new_size, &mut rng);
let _ = buf.apply_edit(EditOp::Replace {
range: Range::new(pos, pos + l as u64),
bytes: &bytes,
});
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len) as u64;
let bytes = random_ascii(size, &mut rng);
let _ = buf.apply_edit(EditOp::Insert { pos, bytes: &bytes });
}
}
ops += 1;
}
ops
}
/// Run M10.1-style mixed workload against a bare `CrdtState` (no
/// Buffer wrapper). Returns the op count over the window. Mirrors
/// `run_workload` but for `CrdtState`'s byte-native methods.
fn run_workload_bare_crdt(state: &pmacs::crdt::CrdtState, window: Duration) -> u64 {
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + window;
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = state.len_utf8();
if len == 0 {
let _ = state.insert(0, "x");
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.insert(pos, s);
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let _ = state.delete(pos, l);
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let bytes = random_ascii(new_size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.delete(pos, l);
let _ = state.insert(pos, s);
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.insert(pos, s);
}
}
ops += 1;
}
ops
}
/// Methodology-reconciliation bench: M10.1 measured 314,691 ops/sec
/// for bare loro at the same mixed-workload methodology. Day 7's
/// initial export-overhead-isolation test measured 41 µs/op for
/// bare `CrdtState` — but on a *different* workload (sequential
/// append from empty, no deletes/replaces). This test re-runs
/// M10.1's exact methodology against bare `CrdtState` to determine
/// whether the gap is workload-shape (expected) or regression
/// (alarming).
/// Bare-loro mixed workload using `insert` (unicode positions) —
/// matches M10.1's methodology exactly. If this produces ~3 µs/op
/// it confirms that the gap between M10.1 (unicode path) and Day 7
/// (byte-native path) is the byte-vs-unicode method choice, not a
/// regression.
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_bare_loro_unicode_path_matches_m10_1() {
use loro::LoroDoc;
eprintln!("\n=== M10.2 Day 7 — direct loro unicode-path bench (M10.1 replica) ===\n");
eprintln!("Uses text.insert/delete (unicode positions), matching M10.1's methodology.\n");
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
let doc = LoroDoc::new();
doc.set_peer_id(1).expect("peer");
let text = doc.get_text("body");
text.insert(0, std::str::from_utf8(&seed_bytes).expect("ASCII seed"))
.expect("seed");
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + Duration::from_secs(WINDOW_SECS);
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = text.len_unicode();
if len == 0 {
let _ = text.insert(0, "x");
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let _ = text.delete(pos, l);
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let bytes = random_ascii(new_size, &mut rng);
let _ = text.delete(pos, l);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
}
ops += 1;
}
let per_sec = ops as f64 / WINDOW_SECS as f64;
let us = 1_000_000.0 / per_sec;
eprintln!(
"bare loro (unicode path, M10.1 replica): {:>9} ops | {:>9.0} ops/sec | {:>7.2} us/op",
ops, per_sec, us
);
eprintln!();
eprintln!("If close to 314,691 ops/sec (M10.1's number): confirms the byte-native");
eprintln!("methods (insert_utf8/delete_utf8) are dramatically more expensive than");
eprintln!("the unicode methods (insert/delete) at non-trivial doc sizes.");
}
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_bare_crdt_mixed_workload_reconcile_with_m10_1() {
use pmacs::crdt::CrdtState;
eprintln!("\n=== M10.2 Day 7 reconciliation — bare CrdtState mixed workload ===\n");
eprintln!("Methodology: M10.1's exact pattern (50/30/15/5, log-normal sizes, 30s, 100KB seed)");
eprintln!("Comparison target: M10.1's bare-loro number was 314,691 ops/sec (3.18 µs/op)\n");
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
let state = CrdtState::from_bytes(1, &seed_bytes).expect("seed");
let bare_ops = run_workload_bare_crdt(&state, Duration::from_secs(WINDOW_SECS));
let bare_per_sec = bare_ops as f64 / WINDOW_SECS as f64;
let bare_us = 1_000_000.0 / bare_per_sec;
let final_len = state.len_utf8();
eprintln!(
"bare CrdtState (M10.1 methodology): {:>9} ops | {:>9.0} ops/sec | {:>7.2} us/op | final doc {} B",
bare_ops, bare_per_sec, bare_us, final_len
);
eprintln!();
eprintln!("Reconciliation:");
eprintln!(" M10.1 bare loro (mixed workload): 314,691 ops/sec");
eprintln!(
" Day 7 bare CrdtState (mixed): {:>9.0} ops/sec",
bare_per_sec
);
let m101_ratio = 314_691.0 / bare_per_sec;
eprintln!(
" ratio: {:>8.2}x slower than M10.1",
m101_ratio
);
eprintln!();
eprintln!("If close to 1x: workload-shape was the gap (Day 7's export-overhead-");
eprintln!(" isolation used sequential append-from-empty, not M10.1's mixed).");
eprintln!("If much greater than 1x: real regression vs M10.1 worth investigating.");
eprintln!();
}
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_mixed_workload_throughput() {
eprintln!("\n=== M10.2 Day 7 — per-op throughput (mixed workload, 30s) ===\n");
eprintln!(
"Initial doc: 100KB; mix: 50/30/15/5; window: {}s\n",
WINDOW_SECS
);
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
// v0.1 mode
let mut b_v01 = Buffer::from_bytes(BufferId::next(), "v01", &seed_bytes);
let v01_ops = run_workload(&mut b_v01, Duration::from_secs(WINDOW_SECS));
let v01_per_sec = v01_ops as f64 / WINDOW_SECS as f64;
let v01_us = 1_000_000.0 / v01_per_sec;
eprintln!(
"v0.1 mode: {:>9} ops total | {:>9.0} ops/sec | {:>7.2} us/op",
v01_ops, v01_per_sec, v01_us
);
// CRDT mode
let mut b_crdt =
Buffer::from_bytes_with_crdt(BufferId::next(), "crdt", &seed_bytes, 1).expect("crdt");
let crdt_ops = run_workload(&mut b_crdt, Duration::from_secs(WINDOW_SECS));
let crdt_per_sec = crdt_ops as f64 / WINDOW_SECS as f64;
let crdt_us = 1_000_000.0 / crdt_per_sec;
eprintln!(
"CRDT mode: {:>9} ops total | {:>9.0} ops/sec | {:>7.2} us/op",
crdt_ops, crdt_per_sec, crdt_us
);
let ratio = v01_per_sec / crdt_per_sec;
eprintln!("\nCRDT mode is {:.2}x slower per op than v0.1 mode", ratio);
eprintln!("(M10.2 target: within 2x of v0.1 for typical edit patterns)\n");
}
// ---------------------------------------------------------------------------
// 3. Export overhead — CRDT mode without crdt_op extraction vs with.
//
// Day 3's framing called for this specific measurement: separate the
// cost of "apply CRDT op" from the cost of "export the delta bytes."
// The wrapper always extracts; to measure without, we time the loro
// underlying ops directly (via the CrdtState wrapper) against the full
// Buffer::apply_edit path.
//
// Specifically:
// - bare_crdt: time N inserts into CrdtState directly (no wrapper)
// - with_extraction: time N inserts via Buffer::apply_edit (full path,
// includes version_capture / op application / export)
// - difference = wrapper overhead (extraction + rope-sync + bookkeeping)
//
// This isn't a perfectly-isolated "extraction only" measurement
// because the wrapper also does rope-sync and undo bookkeeping. But
// it scopes the wrapper cost so the audit can record both numbers.
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_export_overhead_isolation() {
use pmacs::crdt::CrdtState;
const N_OPS: usize = 10_000;
eprintln!("\n=== M10.2 Day 7 — export overhead isolation ===\n");
eprintln!(
"Workload: {} sequential inserts, each ~5 bytes of ASCII\n",
N_OPS
);
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let payload: Vec<Vec<u8>> = (0..N_OPS).map(|_| random_ascii(5, &mut rng)).collect();
// 3a. Bare CRDT (no wrapper, no extraction).
let state = CrdtState::new(1).expect("crdt");
let t = Instant::now();
for bytes in &payload {
let s = std::str::from_utf8(bytes).expect("ASCII");
state.insert(state.len_utf8(), s).expect("insert");
}
let bare_us = t.elapsed().as_micros() as f64;
eprintln!(
"bare CrdtState (no wrapper): {:>10.0} us total | {:>6.2} us/op",
bare_us,
bare_us / N_OPS as f64
);
// 3b. Bare CRDT + per-op export (the extraction step in isolation).
let state = CrdtState::new(2).expect("crdt");
let t = Instant::now();
for bytes in &payload {
let pre = state.version();
let s = std::str::from_utf8(bytes).expect("ASCII");
state.insert(state.len_utf8(), s).expect("insert");
let _ = state.export_updates_since(&pre).expect("export");
}
let with_export_us = t.elapsed().as_micros() as f64;
eprintln!(
"bare CrdtState + per-op export: {:>10.0} us total | {:>6.2} us/op",
with_export_us,
with_export_us / N_OPS as f64
);
let export_only_us_per_op = (with_export_us - bare_us) / N_OPS as f64;
eprintln!(
" → export-only overhead: {:>6.2} us/op",
export_only_us_per_op
);
// 3c. Full Buffer::apply_edit (CRDT mode). Includes:
// - intercept dispatch (no intercepts attached, so cheap)
// - lossy UTF-8 normalization (no-op for ASCII)
// - version capture + CRDT apply + export
// - rope mutation
// - mark adjustment (no marks attached, so cheap)
// - undo stack push
// - on_edit broadcast (no views, so cheap)
let mut buf = Buffer::new_with_crdt(BufferId::next(), "full", 3).expect("crdt buf");
let t = Instant::now();
for bytes in &payload {
let pos = buf.len();
let _ = buf.apply_edit(EditOp::Insert { pos, bytes }).expect("ins");
}
let full_us = t.elapsed().as_micros() as f64;
eprintln!(
"Buffer::apply_edit (CRDT mode): {:>10.0} us total | {:>6.2} us/op",
full_us,
full_us / N_OPS as f64
);
let wrapper_us_per_op = (full_us - with_export_us) / N_OPS as f64;
eprintln!(
" → wrapper overhead (rope + bookkeeping): {:>6.2} us/op",
wrapper_us_per_op
);
// 3d. Full Buffer::apply_edit (v0.1 mode), for comparison.
let mut buf_v01 = Buffer::new(BufferId::next(), "v01");
let t = Instant::now();
for bytes in &payload {
let pos = buf_v01.len();
let _ = buf_v01
.apply_edit(EditOp::Insert { pos, bytes })
.expect("ins");
}
let v01_us = t.elapsed().as_micros() as f64;
eprintln!(
"Buffer::apply_edit (v0.1 mode): {:>10.0} us total | {:>6.2} us/op",
v01_us,
v01_us / N_OPS as f64
);
eprintln!();
eprintln!("Decomposition (per-op):");
eprintln!(" raw CRDT op: {:>6.2} us", bare_us / N_OPS as f64);
eprintln!(
" + export extraction: {:>6.2} us (Day 3 cost)",
export_only_us_per_op
);
eprintln!(
" + wrapper overhead: {:>6.2} us (rope sync + bookkeeping)",
wrapper_us_per_op
);
eprintln!(" = full CRDT mode: {:>6.2} us", full_us / N_OPS as f64);
eprintln!(" v0.1 mode baseline: {:>6.2} us", v01_us / N_OPS as f64);
eprintln!();
}
// ---------------------------------------------------------------------------
// 4. Undo cost scaling.
//
// Day 2 framing noted undo cost scales with the size of the undone
// edit in CRDT mode (the synthetic-Replace op carries the full pre-
// edit content). Confirm linear scaling; surface superlinear if
// present.
//
// Methodology: build a buffer, apply an N-byte edit, time the undo.
// N in {10, 100, 1000, 10000}. Run both modes. v0.1 should be ~flat
// (pre-edit rope is held via Arc); CRDT should scale with N (synthetic
// Replace reads pre-edit bytes from the saved rope).
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_undo_cost_scaling() {
eprintln!("\n=== M10.2 Day 7 — undo cost scaling ===\n");
eprintln!(
"{:>9} | {:>14} | {:>14} | {:>10}",
"edit size", "v0.1 undo", "CRDT undo", "ratio"
);
eprintln!("{}", "-".repeat(60));
let sizes = [10usize, 100, 1_000, 10_000];
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
for &n in &sizes {
let payload = random_ascii(n, &mut rng);
// v0.1 mode
let mut b_v01 = Buffer::new(BufferId::next(), "v01");
b_v01
.apply_edit(EditOp::Insert {
pos: 0,
bytes: &payload,
})
.unwrap();
let t = Instant::now();
b_v01.undo().expect("v01 undo");
let v01_us = t.elapsed().as_micros();
// CRDT mode
let mut b_crdt = Buffer::new_with_crdt(BufferId::next(), "crdt", 1).expect("crdt buf");
b_crdt
.apply_edit(EditOp::Insert {
pos: 0,
bytes: &payload,
})
.unwrap();
let t = Instant::now();
b_crdt.undo().expect("crdt undo");
let crdt_us = t.elapsed().as_micros();
let ratio = if v01_us > 0 {
crdt_us as f64 / v01_us as f64
} else {
f64::NAN
};
eprintln!(
"{:>9} B | {:>11} us | {:>11} us | {:>9.2}x",
n, v01_us, crdt_us, ratio
);
}
eprintln!();
}