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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add non-blocking `try_entry` / `try_entry_by_ref` to `LockMap` and `LruLockMap`
- Add `clear` to `LockMap` and `LruLockMap`
- Add `peek` to `LruLockMap`: read a value without promoting it in the LRU list
- Add `pop_lru` to `LruLockMap`: remove and return a least-recently-used entry
(per shard, round-robin across shards; in-use entries are skipped)
- Expand benchmarks with multi-threaded workloads: concurrent get, mixed
get/insert, hot-key entry contention, LRU `get` vs `peek`, and mixed workload
under eviction pressure

### Changed

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ assert!(map.is_empty());

* **Per-Key Locking**: Same fine-grained locking as `LockMap`.
* **Per-Shard LRU Eviction**: Each shard independently tracks access order and evicts least recently used entries when capacity is exceeded.
* **`peek` / `pop_lru`**: Read a value without promoting it in the LRU list, or explicitly remove a least-recently-used entry.
* **Non-Blocking Eviction**: In-use entries are skipped during eviction; traversal continues to the next candidate, ensuring progress even when the tail is held.
* **Intrusive Linked List**: LRU bookkeeping uses pointers embedded directly in each entry, avoiding extra allocations.
* **No Key Duplication**: Uses `hashbrown::HashTable` so each key is stored only once, inside the entry state.
Expand All @@ -118,8 +119,11 @@ assert_eq!(cache.get("key"), Some("value".into()));
entry.insert("new_value".to_string());
}

// Remove a value
assert_eq!(cache.remove("key"), Some("new_value".into()));
// Peek without promoting the entry in the LRU list
assert_eq!(cache.peek("key"), Some("new_value".into()));

// Explicitly pop a least-recently-used entry
assert_eq!(cache.pop_lru(), Some(("key".to_string(), "new_value".to_string())));

// When the cache exceeds capacity, the least recently used
// entries are automatically evicted.
Expand Down
289 changes: 271 additions & 18 deletions benches/bench_lockmap.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,274 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use lockmap::*;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use lockmap::{LockMap, LruLockMap};
use std::hint::black_box;
use std::thread;
use std::time::Duration;

fn criterion_benchmark(c: &mut Criterion) {
/// 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));
}

// ---------------------------------------------------------------------------
// LockMap
// ---------------------------------------------------------------------------

/// Single-threaded bulk insert into a fresh map (allocation + growth path).
fn bench_lockmap_insert(c: &mut Criterion) {
let count = 1 << 20;
c.bench_with_input(
BenchmarkId::new("insert_into_lockmap", count),
&count,
|b, &count| {
b.iter(|| {
let map = LockMap::with_capacity_and_shard_amount(1 << 15, 256);
for i in 0..count {
map.insert(i, i);
}
})
},
);
}

criterion_group!(benches, criterion_benchmark);
let mut group = c.benchmark_group("lockmap_insert");
configure(&mut group);
group.throughput(Throughput::Elements(count as u64));
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, &count| {
b.iter(|| {
let map = LockMap::with_capacity_and_shard_amount(1 << 15, 256);
for i in 0..count {
map.insert(i, i);
}
black_box(map)
})
});
group.finish();
}

/// Concurrent read-only workload over a prefilled map (all hits).
fn bench_lockmap_concurrent_get(c: &mut Criterion) {
const KEYS: u64 = 1 << 16;
let map = LockMap::<u64, u64>::with_capacity(KEYS as usize);
for i in 0..KEYS {
map.insert(i, i);
}

let mut group = c.benchmark_group("lockmap_concurrent_get");
configure(&mut group);
for &threads in THREAD_COUNTS {
group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64));
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
thread::scope(|s| {
for t in 0..threads {
let map = &map;
s.spawn(move || {
let mut rng = seed(t);
let mut sum = 0u64;
for _ in 0..OPS_PER_THREAD {
let key = xorshift(&mut rng) % KEYS;
if let Some(v) = map.get(&key) {
sum = sum.wrapping_add(v);
}
}
black_box(sum);
});
}
});
})
},
);
}
group.finish();
}

/// Concurrent mixed workload: 90% get / 10% insert over random keys.
fn bench_lockmap_concurrent_mixed(c: &mut Criterion) {
const KEYS: u64 = 1 << 16;
let map = LockMap::<u64, u64>::with_capacity(KEYS as usize);
for i in 0..KEYS {
map.insert(i, i);
}

let mut group = c.benchmark_group("lockmap_concurrent_mixed");
configure(&mut group);
for &threads in THREAD_COUNTS {
group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64));
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
thread::scope(|s| {
for t in 0..threads {
let map = &map;
s.spawn(move || {
let mut rng = seed(t);
for _ in 0..OPS_PER_THREAD {
let r = xorshift(&mut rng);
let key = r % KEYS;
if r % 10 == 0 {
map.insert(key, key);
} else {
black_box(map.get(&key));
}
}
});
}
});
})
},
);
}
group.finish();
}

/// Heavy contention on a small set of hot keys via the entry API.
fn bench_lockmap_hot_key_entry(c: &mut Criterion) {
const HOT_KEYS: u64 = 8;
let map = LockMap::<u64, u64>::new();
for i in 0..HOT_KEYS {
map.insert(i, 0);
}

let mut group = c.benchmark_group("lockmap_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::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
thread::scope(|s| {
for t in 0..threads {
let map = &map;
s.spawn(move || {
let mut rng = seed(t);
for _ in 0..OPS_PER_THREAD {
let key = xorshift(&mut rng) % HOT_KEYS;
let mut entry = map.entry(key);
if let Some(v) = entry.get_mut() {
*v = v.wrapping_add(1);
}
}
});
}
});
})
},
);
}
group.finish();
}

// ---------------------------------------------------------------------------
// LruLockMap
// ---------------------------------------------------------------------------

/// Concurrent read-only workload over a prefilled cache: `get` promotes the
/// entry in the LRU list, `peek` does not.
fn bench_lru_concurrent_read(c: &mut Criterion) {
const KEYS: u64 = 1 << 15;
let cache = LruLockMap::<u64, u64>::new(1 << 16);
for i in 0..KEYS {
cache.insert(i, i);
}

let mut group = c.benchmark_group("lru_concurrent_read");
configure(&mut group);
for &threads in THREAD_COUNTS {
group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64));
for (name, use_peek) in [("get", false), ("peek", true)] {
group.bench_with_input(BenchmarkId::new(name, threads), &threads, |b, &threads| {
b.iter(|| {
thread::scope(|s| {
for t in 0..threads {
let cache = &cache;
s.spawn(move || {
let mut rng = seed(t);
let mut sum = 0u64;
for _ in 0..OPS_PER_THREAD {
let key = xorshift(&mut rng) % KEYS;
let value = if use_peek {
cache.peek(&key)
} else {
cache.get(&key)
};
if let Some(v) = value {
sum = sum.wrapping_add(v);
}
}
black_box(sum);
});
}
});
})
});
}
}
group.finish();
}

/// Concurrent mixed workload with constant eviction pressure: the key space is
/// four times the cache capacity, so inserts continuously evict LRU entries.
fn bench_lru_concurrent_mixed_evict(c: &mut Criterion) {
const KEYS: u64 = 1 << 16;
let cache = LruLockMap::<u64, u64>::new(1 << 14);

let mut group = c.benchmark_group("lru_concurrent_mixed_evict");
configure(&mut group);
for &threads in THREAD_COUNTS {
group.throughput(Throughput::Elements((threads * OPS_PER_THREAD) as u64));
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
thread::scope(|s| {
for t in 0..threads {
let cache = &cache;
s.spawn(move || {
let mut rng = seed(t);
for _ in 0..OPS_PER_THREAD {
let r = xorshift(&mut rng);
let key = r % KEYS;
if r % 2 == 0 {
cache.insert(key, key);
} else {
black_box(cache.get(&key));
}
}
});
}
});
})
},
);
}
group.finish();
}

criterion_group!(
benches,
bench_lockmap_insert,
bench_lockmap_concurrent_get,
bench_lockmap_concurrent_mixed,
bench_lockmap_hot_key_entry,
bench_lru_concurrent_read,
bench_lru_concurrent_mixed_evict,
);
criterion_main!(benches);
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! - **Deadlock prevention**: `LockMap` provides [`batch_lock`](LockMap::batch_lock) for safe multi-key locking
//! - **Non-blocking locking**: [`try_entry`](LockMap::try_entry) returns `None` instead of blocking when a key is held
//! - **LRU eviction**: `LruLockMap` automatically evicts least recently used entries when capacity is exceeded
//! - **LRU inspection**: [`peek`](LruLockMap::peek) reads without promoting; [`pop_lru`](LruLockMap::pop_lru) removes a least-recently-used entry
//! - **Non-blocking eviction**: In-use entries are skipped during eviction; traversal continues to the next candidate
//!
//! # Examples
Expand Down
Loading
Loading