From 82ba25ca5fc20a5bcb45b654615af078fd1eebfb Mon Sep 17 00:00:00 2001 From: SF-Zhou Date: Thu, 16 Jul 2026 10:19:20 +0800 Subject: [PATCH] feat: add LruLockMap peek/pop_lru, expand multi-threaded benchmarks - Add peek(): read a value without promoting it in the LRU list (~3x faster than get() in a 4-thread read benchmark, since no move_to_front write happens under the shard lock) - Add pop_lru(): remove and return a least-recently-used entry; per-shard LRU order with round-robin shard selection, in-use entries are skipped like the eviction policy - Extract key/value ownership on pop via AliasableBox::into_unique - Add 8 tests covering peek promotion semantics, pop_lru ordering, in-use/empty entry skipping, multi-shard draining and concurrency - Rewrite benches: concurrent get, mixed get/insert, hot-key entry contention, LRU get vs peek, and mixed workload under eviction pressure, each at 1/4/8 threads with throughput reporting --- CHANGELOG.md | 6 + README.md | 8 +- benches/bench_lockmap.rs | 289 ++++++++++++++++++++++++++++++++++++--- src/lib.rs | 1 + src/lru_lockmap.rs | 266 ++++++++++++++++++++++++++++++++++- 5 files changed, 549 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4a262..befaf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5ae900b..d31f5ef 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/benches/bench_lockmap.rs b/benches/bench_lockmap.rs index 4556e1d..4c5e238 100644 --- a/benches/bench_lockmap.rs +++ b/benches/bench_lockmap.rs @@ -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::::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 = ↦ + 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::::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 = ↦ + 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::::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 = ↦ + 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::::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::::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); diff --git a/src/lib.rs b/src/lib.rs index 925d424..502769f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/lru_lockmap.rs b/src/lru_lockmap.rs index 0a64876..7c1bde2 100644 --- a/src/lru_lockmap.rs +++ b/src/lru_lockmap.rs @@ -8,7 +8,7 @@ use parking_lot::{Mutex, RawMutex}; use std::borrow::Borrow; use std::cell::UnsafeCell; use std::hash::{BuildHasher, Hash}; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; // --------------------------------------------------------------------------- // State – per-key state with intrusive LRU list pointers @@ -215,6 +215,40 @@ impl LruShardInner { cursor = prev; } } + + /// Removes and returns the least-recently-used entry of this shard. + /// + /// Walks from the tail (LRU end) towards the head (MRU end). Entries that + /// are currently in use (refcnt > 0) are skipped. Returns `None` if no + /// removable entry exists. + fn pop_lru(&mut self) -> Option<(K, V)> { + let mut cursor = self.tail; + while !cursor.is_null() { + // SAFETY: cursor is a valid node of this shard's list; the shard + // mutex is held. `prev` is read before any removal. + let prev = unsafe { *(*cursor).prev.get() }; + let state = unsafe { &*cursor }; + + if state.flags().refcnt() == 0 { + let hash = state.hash; + // SAFETY: refcnt == 0 → no guard exists; safe to detach and remove. + unsafe { self.detach(cursor) }; + if let Ok(entry) = self.table.find_entry(hash, |s| std::ptr::eq(&**s, cursor)) { + let (state_box, _) = entry.remove(); + // SAFETY: the state is no longer shared: it has been removed + // from the table and the list, and refcnt == 0. + let state = AliasableBox::into_unique(state_box); + let State { key, value, .. } = *state; + if let Some(value) = value.into_inner() { + return Some((key, value)); + } + // Entry without a value; keep scanning. + } + } + cursor = prev; + } + None + } } // --------------------------------------------------------------------------- @@ -296,6 +330,8 @@ impl LruShardMap { pub struct LruLockMap { shards: Vec>, hasher: RandomState, + /// Round-robin cursor for [`pop_lru`](Self::pop_lru) shard selection. + pop_cursor: AtomicUsize, } impl LruLockMap { @@ -318,6 +354,7 @@ impl LruLockMap { .map(|_| LruShardMap::with_capacity(per_shard_max_size, per_shard_capacity)) .collect(), hasher: RandomState::default(), + pop_cursor: AtomicUsize::new(0), } } @@ -624,6 +661,62 @@ impl LruLockMap { self.guard(ptr).get().clone() } + /// Gets the value associated with the given key **without** promoting it + /// in the LRU list. + /// + /// Unlike [`get`](Self::get), calling `peek` does not affect the eviction + /// order: the entry keeps its current position in the LRU list. + /// + /// **Locking behaviour:** Deadlock if called when holding the same entry. + /// + /// # Examples + /// + /// ``` + /// # use lockmap::LruLockMap; + /// let cache = LruLockMap::::new(100); + /// cache.insert("key".to_string(), 42); + /// assert_eq!(cache.peek("key"), Some(42)); + /// assert_eq!(cache.peek("missing"), None); + /// ``` + pub fn peek(&self, key: &Q) -> Option + where + K: Borrow, + V: Clone, + Q: Eq + Hash + ?Sized, + { + let hash = self.hasher.hash_one(key); + let shard = &self.shards[self.shard_index(hash)]; + let mut ptr: *mut State = std::ptr::null_mut(); + + let value = { + let inner = shard.inner.lock(); + let p = inner + .table + .find(hash, |s| s.key.borrow() == key) + .map(|s| &**s as *const State as *mut State) + .unwrap_or(std::ptr::null_mut()); + if !p.is_null() { + let state = unsafe { &*p }; + if state.flags().refcnt() == 0 { + // SAFETY: refcnt == 0 means no Entry guard exists. + unsafe { state.value_ref() }.clone() + } else { + state.inc_ref(); + ptr = p; + None + } + } else { + None + } + }; + + if ptr.is_null() { + return value; + } + + self.guard(ptr).get().clone() + } + /// Inserts a value into the cache, returning the previous value if any. /// /// **Locking behaviour:** Deadlock if called when holding the same entry. @@ -836,6 +929,41 @@ impl LruLockMap { self.guard(ptr).remove() } + /// Removes and returns a least-recently-used entry from the cache. + /// + /// Since the LRU order is maintained **per shard**, the returned entry is + /// the least recently used of a single shard, not necessarily of the whole + /// cache. Shards are visited in round-robin order across calls so repeated + /// invocations drain all shards fairly. + /// + /// Entries that are currently held by an [`LruEntry`] guard are skipped, + /// mirroring the eviction policy. Returns `None` if no removable entry + /// exists. + /// + /// # Examples + /// + /// ``` + /// # use lockmap::LruLockMap; + /// let cache = LruLockMap::::with_options(10, 10, 1); + /// cache.insert(1, 10); + /// cache.insert(2, 20); + /// cache.get(&1); // promote key=1 + /// assert_eq!(cache.pop_lru(), Some((2, 20))); + /// assert_eq!(cache.pop_lru(), Some((1, 10))); + /// assert_eq!(cache.pop_lru(), None); + /// ``` + pub fn pop_lru(&self) -> Option<(K, V)> { + let shard_count = self.shards.len(); + let start = self.pop_cursor.fetch_add(1, Ordering::Relaxed) % shard_count; + for i in 0..shard_count { + let shard = &self.shards[(start + i) % shard_count]; + if let Some(kv) = shard.inner.lock().pop_lru() { + return Some(kv); + } + } + None + } + /// Removes all key-value pairs from the cache. /// /// Entries that are currently held by an [`LruEntry`] guard are cleared by @@ -1930,6 +2058,142 @@ mod tests { assert!(cache.is_empty()); } + // --- peek / pop_lru --- + + #[test] + fn test_lru_peek_basic() { + let cache = LruLockMap::::new(100); + assert_eq!(cache.peek("missing"), None); + cache.insert("key".to_string(), 42); + assert_eq!(cache.peek("key"), Some(42)); + // Peeking does not remove or modify the value. + assert_eq!(cache.get("key"), Some(42)); + } + + #[test] + fn test_lru_peek_does_not_promote() { + let cache = LruLockMap::::with_options(3, 3, 1); + cache.insert(1, 10); + cache.insert(2, 20); + cache.insert(3, 30); + + // Peek key=1: unlike get, this must NOT promote it. + assert_eq!(cache.peek(&1), Some(10)); + + cache.insert(4, 40); + assert_eq!(cache.peek(&1), None); // still evicted as LRU + assert_eq!(cache.peek(&2), Some(20)); + assert_eq!(cache.peek(&3), Some(30)); + assert_eq!(cache.peek(&4), Some(40)); + } + + #[test] + fn test_lru_peek_held_entry() { + let cache = Arc::new(LruLockMap::::new(100)); + cache.insert(1, 10); + + let entry = cache.entry(1); + let peeker = { + let cache = cache.clone(); + // peek on a held key blocks until the guard is released. + std::thread::spawn(move || cache.peek(&1)) + }; + std::thread::sleep(std::time::Duration::from_millis(10)); + drop(entry); + assert_eq!(peeker.join().unwrap(), Some(10)); + } + + #[test] + fn test_lru_pop_lru_order() { + let cache = LruLockMap::::with_options(10, 10, 1); + cache.insert(1, 10); + cache.insert(2, 20); + cache.insert(3, 30); + + // Promote key=1; LRU order becomes 2, 3, 1. + assert_eq!(cache.get(&1), Some(10)); + + assert_eq!(cache.pop_lru(), Some((2, 20))); + assert_eq!(cache.pop_lru(), Some((3, 30))); + assert_eq!(cache.pop_lru(), Some((1, 10))); + assert_eq!(cache.pop_lru(), None); + assert!(cache.is_empty()); + } + + #[test] + fn test_lru_pop_lru_skips_in_use() { + let cache = LruLockMap::::with_options(10, 10, 1); + cache.insert(1, 10); + cache.insert(2, 20); + + // Hold key=1 (the LRU tail); pop_lru must skip it. + let held = cache.entry(1); + assert_eq!(cache.pop_lru(), Some((2, 20))); + assert_eq!(cache.pop_lru(), None); // only the held entry remains + drop(held); + + assert_eq!(cache.pop_lru(), Some((1, 10))); + } + + #[test] + fn test_lru_pop_lru_skips_empty_entry() { + let cache = LruLockMap::::with_options(10, 10, 1); + // A held entry without a value must not be returned. + let held = cache.entry(1); + assert_eq!(cache.pop_lru(), None); + drop(held); + assert_eq!(cache.pop_lru(), None); + assert!(cache.is_empty()); + } + + #[test] + fn test_lru_pop_lru_multi_shard() { + let cache = LruLockMap::::with_options(100, 0, 4); + for i in 0..10 { + cache.insert(i, i * 10); + } + + let mut popped = std::collections::BTreeSet::new(); + while let Some((k, v)) = cache.pop_lru() { + assert_eq!(v, k * 10); + assert!(popped.insert(k), "duplicate key {k}"); + } + assert_eq!(popped.len(), 10); + assert!(cache.is_empty()); + } + + #[test] + fn test_lru_pop_lru_concurrent() { + let cache = Arc::new(LruLockMap::::with_options(1 << 16, 0, 4)); + #[cfg(not(miri))] + const N: u32 = 1 << 10; + #[cfg(miri)] + const N: u32 = 1 << 5; + const M: usize = 4; + + for i in 0..N { + cache.insert(i, i); + } + + let total = Arc::new(AtomicU32::new(0)); + let threads: Vec<_> = (0..M) + .map(|_| { + let cache = cache.clone(); + let total = total.clone(); + std::thread::spawn(move || { + while let Some((k, v)) = cache.pop_lru() { + assert_eq!(k, v); + total.fetch_add(1, Ordering::AcqRel); + } + }) + }) + .collect(); + threads.into_iter().for_each(|t| t.join().unwrap()); + + assert_eq!(total.load(Ordering::Acquire), N); + assert!(cache.is_empty()); + } + #[test] fn test_lru_clear_with_held_entry() { let cache = Arc::new(LruLockMap::::new(100));