diff --git a/CHANGELOG.md b/CHANGELOG.md index 6722a0a..699d3b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 not promote entries) - Add `or_insert` / `or_insert_with` to `Entry` and `LruEntry` - Add `batch_lock` to `LruLockMap` for deadlock-safe multi-key locking +- Add a comparison benchmark suite against `dashmap` and `moka` + (`cargo bench --bench bench_compare`) ### Changed diff --git a/Cargo.toml b/Cargo.toml index 128a58d..abac9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,14 @@ parking_lot = "0.12" [dev-dependencies] criterion = "0.8" +dashmap = "6" +moka = { version = "0.12", features = ["sync"] } rand = "0.10" [[bench]] name = "bench_lockmap" harness = false + +[[bench]] +name = "bench_compare" +harness = false diff --git a/README.md b/README.md index 3f701a1..94bf3ef 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ assert_eq!(cache.pop_lru(), Some(("key".to_string(), "new_value".to_string()))); // entries are automatically evicted. ``` +## Benchmarks + +The repository ships two Criterion benchmark suites: + +```bash +cargo bench --bench bench_lockmap # multi-threaded workloads for LockMap / LruLockMap +cargo bench --bench bench_compare # comparison against dashmap and moka +``` + +The comparison suite normalizes semantics where the crates differ (see the +fairness notes in `benches/bench_compare.rs`). Rough expectations: `LockMap` +trades a little single-thread overhead for per-key exclusive access — under +short critical sections `DashMap`'s shard lock is faster on hot keys, while +`LockMap` scales better on mixed/read workloads and never blocks unrelated keys +while an entry is held. `moka` maintains hit-rate-oriented bookkeeping (TinyLFU) +and targets a different trade-off than `LruLockMap`'s raw-throughput sharded LRU; +run the suite on your own hardware and workload before drawing conclusions. + ## Important Caveats ### 1. No Lock Poisoning diff --git a/benches/bench_compare.rs b/benches/bench_compare.rs new file mode 100644 index 0000000..85e8542 --- /dev/null +++ b/benches/bench_compare.rs @@ -0,0 +1,279 @@ +//! Comparison benchmarks against `dashmap` and `moka`. +//! +//! # Fairness notes +//! +//! The three crates have different semantics, so the workloads are normalized +//! to the closest common behaviour: +//! +//! - **get**: `LockMap::get` and `moka::sync::Cache::get` return a clone of the +//! value; `DashMap::get` returns a guard, so the value is copied out of the +//! guard (`V = u64`, clone == copy). +//! - **entry**: `LockMap::entry` locks a single key; `DashMap::entry` locks the +//! whole shard. `moka` has no comparable exclusive-entry API and is excluded +//! from that group. +//! - **LRU eviction**: `LruLockMap` evicts synchronously per shard; `moka` +//! batches eviction internally. `DashMap` has no eviction and is excluded +//! from that group. + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use dashmap::DashMap; +use lockmap::{LockMap, LruLockMap}; +use moka::sync::Cache as MokaCache; +use std::hint::black_box; +use std::thread; +use std::time::Duration; + +/// Thread counts used for the concurrent benchmarks. +const THREAD_COUNTS: &[usize] = &[1, 4, 8]; + +/// Operations performed by each thread per benchmark iteration. +const OPS_PER_THREAD: usize = 1 << 14; + +/// Simple xorshift64 PRNG: deterministic and cheap, so the RNG does not +/// dominate the measured map operations. +#[inline] +fn xorshift(state: &mut u64) -> u64 { + let mut x = *state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *state = x; + x +} + +#[inline] +fn seed(thread: usize) -> u64 { + (thread as u64 + 1) * 0x9E37_79B9_7F4A_7C15 +} + +fn configure(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) { + group.sample_size(10); + group.warm_up_time(Duration::from_secs(1)); + group.measurement_time(Duration::from_secs(3)); +} + +/// Runs `op` on `threads` scoped threads, `OPS_PER_THREAD` times each, feeding +/// it a per-thread RNG. +fn run_threads(threads: usize, op: F) +where + F: Fn(&mut u64) + Sync, +{ + thread::scope(|s| { + for t in 0..threads { + let op = &op; + s.spawn(move || { + let mut rng = seed(t); + for _ in 0..OPS_PER_THREAD { + op(&mut rng); + } + }); + } + }); +} + +// --------------------------------------------------------------------------- +// Concurrent get (all hits) +// --------------------------------------------------------------------------- + +fn bench_compare_get(c: &mut Criterion) { + const KEYS: u64 = 1 << 16; + + let lockmap = LockMap::::with_capacity(KEYS as usize); + let dashmap = DashMap::::with_capacity(KEYS as usize); + let moka = MokaCache::::new(KEYS * 2); + for i in 0..KEYS { + lockmap.insert(i, i); + dashmap.insert(i, i); + moka.insert(i, i); + } + + let mut group = c.benchmark_group("compare_get"); + configure(&mut group); + for &threads in THREAD_COUNTS { + group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64)); + group.bench_with_input(BenchmarkId::new("lockmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let key = xorshift(rng) % KEYS; + black_box(lockmap.get(&key)); + }) + }) + }); + group.bench_with_input(BenchmarkId::new("dashmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let key = xorshift(rng) % KEYS; + black_box(dashmap.get(&key).map(|v| *v)); + }) + }) + }); + group.bench_with_input(BenchmarkId::new("moka", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let key = xorshift(rng) % KEYS; + black_box(moka.get(&key)); + }) + }) + }); + } + group.finish(); +} + +// --------------------------------------------------------------------------- +// Concurrent mixed: 90% get / 10% insert +// --------------------------------------------------------------------------- + +fn bench_compare_mixed(c: &mut Criterion) { + const KEYS: u64 = 1 << 16; + + let lockmap = LockMap::::with_capacity(KEYS as usize); + let dashmap = DashMap::::with_capacity(KEYS as usize); + let moka = MokaCache::::new(KEYS * 2); + for i in 0..KEYS { + lockmap.insert(i, i); + dashmap.insert(i, i); + moka.insert(i, i); + } + + let mut group = c.benchmark_group("compare_mixed"); + configure(&mut group); + for &threads in THREAD_COUNTS { + group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64)); + group.bench_with_input(BenchmarkId::new("lockmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let r = xorshift(rng); + let key = r % KEYS; + if r % 10 == 0 { + lockmap.insert(key, key); + } else { + black_box(lockmap.get(&key)); + } + }) + }) + }); + group.bench_with_input(BenchmarkId::new("dashmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let r = xorshift(rng); + let key = r % KEYS; + if r % 10 == 0 { + dashmap.insert(key, key); + } else { + black_box(dashmap.get(&key).map(|v| *v)); + } + }) + }) + }); + group.bench_with_input(BenchmarkId::new("moka", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let r = xorshift(rng); + let key = r % KEYS; + if r % 10 == 0 { + moka.insert(key, key); + } else { + black_box(moka.get(&key)); + } + }) + }) + }); + } + group.finish(); +} + +// --------------------------------------------------------------------------- +// Hot-key entry contention (exclusive per-key access) +// --------------------------------------------------------------------------- + +fn bench_compare_hot_key_entry(c: &mut Criterion) { + const HOT_KEYS: u64 = 8; + + let lockmap = LockMap::::new(); + let dashmap = DashMap::::new(); + for i in 0..HOT_KEYS { + lockmap.insert(i, 0); + dashmap.insert(i, 0); + } + + let mut group = c.benchmark_group("compare_hot_key_entry"); + configure(&mut group); + for &threads in THREAD_COUNTS { + group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64)); + group.bench_with_input(BenchmarkId::new("lockmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let key = xorshift(rng) % HOT_KEYS; + let mut entry = lockmap.entry(key); + if let Some(v) = entry.get_mut() { + *v = v.wrapping_add(1); + } + }) + }) + }); + group.bench_with_input(BenchmarkId::new("dashmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let key = xorshift(rng) % HOT_KEYS; + let mut entry = dashmap.entry(key).or_insert(0); + *entry = entry.wrapping_add(1); + }) + }) + }); + } + group.finish(); +} + +// --------------------------------------------------------------------------- +// LRU: mixed workload under eviction pressure +// --------------------------------------------------------------------------- + +fn bench_compare_lru_evict(c: &mut Criterion) { + const KEYS: u64 = 1 << 16; + const CAPACITY: u64 = 1 << 14; + + let lru = LruLockMap::::new(CAPACITY as usize); + let moka = MokaCache::::new(CAPACITY); + + let mut group = c.benchmark_group("compare_lru_evict"); + configure(&mut group); + for &threads in THREAD_COUNTS { + group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64)); + group.bench_with_input(BenchmarkId::new("lockmap", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let r = xorshift(rng); + let key = r % KEYS; + if r % 2 == 0 { + lru.insert(key, key); + } else { + black_box(lru.get(&key)); + } + }) + }) + }); + group.bench_with_input(BenchmarkId::new("moka", threads), &threads, |b, &t| { + b.iter(|| { + run_threads(t, |rng| { + let r = xorshift(rng); + let key = r % KEYS; + if r % 2 == 0 { + moka.insert(key, key); + } else { + black_box(moka.get(&key)); + } + }) + }) + }); + } + group.finish(); +} + +criterion_group!( + benches, + bench_compare_get, + bench_compare_mixed, + bench_compare_hot_key_entry, + bench_compare_lru_evict, +); +criterion_main!(benches);