Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
279 changes: 279 additions & 0 deletions benches/bench_compare.rs
Original file line number Diff line number Diff line change
@@ -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<F>(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::<u64, u64>::with_capacity(KEYS as usize);
let dashmap = DashMap::<u64, u64>::with_capacity(KEYS as usize);
let moka = MokaCache::<u64, u64>::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::<u64, u64>::with_capacity(KEYS as usize);
let dashmap = DashMap::<u64, u64>::with_capacity(KEYS as usize);
let moka = MokaCache::<u64, u64>::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::<u64, u64>::new();
let dashmap = DashMap::<u64, u64>::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::<u64, u64>::new(CAPACITY as usize);
let moka = MokaCache::<u64, u64>::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);
Loading