diff --git a/src/lib.rs b/src/lib.rs index 4db978a..f706b8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,7 +79,7 @@ //! | `parking_lot` | ✓ | Use [parking_lot](https://crates.io/crates/parking_lot) for synchronization primitives. Mutually exclusive with `sharded-lock`. | //! | `sharded-lock` | | Use [`crossbeam_utils::sync::ShardedLock`](https://docs.rs/crossbeam-utils/latest/crossbeam_utils/sync/struct.ShardedLock.html) for synchronization primitives. Mutually exclusive with `parking_lot`. | //! | `shuttle` | | Enable [shuttle](https://crates.io/crates/shuttle) testing support for concurrency testing. | -//! | `stats` | | Enable cache statistics tracking via the `hits()` and `misses()` methods. | +//! | `stats` | | Enable cache statistics tracking via the `hits()`, `misses()`, and per-item `item_stats()` methods. Overhead: adds an 8-byte per-item access counter (`AtomicU64`) to each resident item — raising per-entry memory by up to 8 bytes, depending on layout — and performs two atomic increments per cache hit and one per miss. | #![allow(clippy::type_complexity)] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -271,6 +271,25 @@ impl MemoryUsed { } } +/// Per-item statistics returned by `item_stats`. +/// +/// Only available with the `stats` feature enabled. Enabling that feature adds an +/// 8-byte per-item access counter to each resident item (raising per-entry memory +/// by up to 8 bytes, depending on layout) and performs two atomic increments per +/// cache hit and one per miss. +#[cfg(feature = "stats")] +#[non_exhaustive] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct ItemStats { + /// Number of times the item has been accessed (read) since it became resident. + /// + /// Incremented on every cache hit (`get`/`get_mut`/`get_value_or_guard`/`entry`). + /// Unlike the internal eviction counter, this is monotonic per residency and is + /// not bounded by the eviction policy. It resets to zero if the slot is reused for + /// a new value (e.g. after eviction and re-insertion). + pub access_count: u64, +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/shard.rs b/src/shard.rs index 11769fc..7b110a1 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -79,6 +79,11 @@ pub struct Resident { value: Val, state: ResidentState, referenced: AtomicU16, + /// Number of times this item has been accessed (read) since it became resident. + /// Incremented wherever a cache hit is recorded, unlike `referenced` which is + /// bounded by the eviction policy. Reset whenever the slot is reused for a new key. + #[cfg(feature = "stats")] + access_count: AtomicU64, } impl Clone for Resident { @@ -89,6 +94,8 @@ impl Clone for Resident { value: self.value.clone(), state: self.state, referenced: self.referenced.load(atomic::Ordering::Relaxed).into(), + #[cfg(feature = "stats")] + access_count: self.access_count.load(atomic::Ordering::Relaxed).into(), } } } @@ -176,12 +183,22 @@ macro_rules! record_hit { ($self: expr) => {{ $self.hits.fetch_add(1, atomic::Ordering::Relaxed); }}; + ($self: expr, $resident: expr) => {{ + $self.hits.fetch_add(1, atomic::Ordering::Relaxed); + $resident + .access_count + .fetch_add(1, atomic::Ordering::Relaxed); + }}; } #[cfg(feature = "stats")] macro_rules! record_hit_mut { ($self: expr) => {{ *$self.hits.get_mut() += 1; }}; + ($self: expr, $resident: expr) => {{ + *$self.hits.get_mut() += 1; + *$resident.access_count.get_mut() += 1; + }}; } #[cfg(feature = "stats")] macro_rules! record_miss { @@ -195,14 +212,15 @@ macro_rules! record_miss_mut { *$self.misses.get_mut() += 1; }}; } - #[cfg(not(feature = "stats"))] macro_rules! record_hit { ($self: expr) => {{}}; + ($self: expr, $resident: expr) => {{}}; } #[cfg(not(feature = "stats"))] macro_rules! record_hit_mut { ($self: expr) => {{}}; + ($self: expr, $resident: expr) => {{}}; } #[cfg(not(feature = "stats"))] macro_rules! record_miss { @@ -567,7 +585,7 @@ impl< // Even if that happens there's no impact correctness wise. resident.referenced.fetch_add(1, atomic::Ordering::Relaxed); } - record_hit!(self); + record_hit!(self, resident); Some((&resident.key, &resident.value)) } else { record_miss!(self); @@ -598,7 +616,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } - record_hit_mut!(self); + record_hit_mut!(self, resident); let old_weight = self.weighter.weight(&resident.key, &resident.value); Some(RefMut { @@ -645,6 +663,19 @@ impl< Some(&resident.value) } + /// Returns per-item statistics for a resident key without affecting its hotness + /// or access count. Returns `None` if the key is not resident. + #[cfg(feature = "stats")] + pub fn item_stats(&self, hash: u64, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + let (_, resident) = self.search_resident(hash, key)?; + Some(crate::ItemStats { + access_count: resident.access_count.load(atomic::Ordering::Relaxed), + }) + } + pub fn peek_mut(&mut self, hash: u64, key: &Q) -> Option> where Q: Hash + Equivalent + ?Sized, @@ -906,6 +937,8 @@ impl< value, state: enter_state, referenced: referenced.into(), + #[cfg(feature = "stats")] + access_count: Default::default(), }), ); match evicted { @@ -1034,6 +1067,8 @@ impl< value, state: placeholder_hot, referenced: (referenced as u16).into(), + #[cfg(feature = "stats")] + access_count: Default::default(), }); let list_head = if placeholder_hot == ResidentState::Hot { @@ -1116,6 +1151,8 @@ impl< value, state, referenced: Default::default(), + #[cfg(feature = "stats")] + access_count: Default::default(), })); if weight != 0 { *list_head = Some(self.entries.link(idx, *list_head)); @@ -1164,7 +1201,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } - record_hit_mut!(self); + record_hit_mut!(self, resident); unsafe { // Rustc gets insanely confused returning references from mut borrows // Safety: value will have the same lifetime as `resident` @@ -1215,7 +1252,6 @@ impl< return match action { EntryAction::Retain(t) => { - record_hit_mut!(self); let Some((Entry::Resident(resident), _)) = self.entries.get_mut(idx) else { // SAFETY: we had a mut reference to the Resident under `idx` until the previous line unsafe { unreachable_unchecked() }; @@ -1223,6 +1259,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } + record_hit_mut!(self, resident); EntryOrPlaceholder::Kept(t) } EntryAction::Remove => { @@ -1420,6 +1457,7 @@ impl, B, L, Plh: SharedPlaceholder> mod tests { use super::*; + #[cfg(not(feature = "stats"))] #[test] fn reserve_caps_ghost_headroom() { // A small reserve on a shard with a large estimated capacity (hence a @@ -1455,14 +1493,24 @@ mod tests { #[test] fn entry_overhead() { use std::mem::size_of; + // 8 bytes from the linked slab, 8 bytes from the entry enum. + // `stats` adds an 8-byte `access_count` to each `Resident` (24 -> 32 bytes). + // Whether the slab entry grows then depends on enum discriminant/niche layout: + // the sync entry's discriminant moves into the `Arc` niche, cancelling the + // growth (stays 32), while the unsync entry loses its niche and grows by 8. + // (Layout-dependent and not guaranteed stable across rustc versions.) assert_eq!( size_of::>>() - size_of::<[u64; 2]>(), - 16 // 8 bytes from linked slab, 8 bytes from entry + 16 ); + #[cfg(not(feature = "stats"))] + let unsync_overhead = 16; + #[cfg(feature = "stats")] + let unsync_overhead = 24; assert_eq!( size_of::>() - size_of::<[u64; 2]>(), - 16 // 8 bytes from linked slab, 8 bytes from entry + unsync_overhead ); } } diff --git a/src/sync.rs b/src/sync.rs index 363fde0..42da03a 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -359,6 +359,35 @@ impl< } } + /// Returns per-item statistics for `key`, or `None` if the key is not present. + /// Like peeks, this does not alter the key "hotness" or its access count. + #[cfg(feature = "stats")] + pub fn item_stats(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + let (shard, hash) = self.shard_for(key)?; + shard.read().item_stats(hash, key) + } + + /// Attempts to return per-item statistics for `key`. + /// Like peeks, this does not alter the key "hotness" or its access count. + /// Returns `Ok(Some(stats))` if the key is present, `Ok(None)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + #[cfg(feature = "stats")] + pub fn try_item_stats(&self, key: &Q) -> Result, LockContention> + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(None); + }; + match shard.try_read() { + Some(guard) => Ok(guard.item_stats(hash, key)), + None => Err(LockContention), + } + } + /// Remove an item from the cache whose key is `key`. /// Returns the removed entry, if any. pub fn remove(&self, key: &Q) -> Option<(Key, Val)> @@ -1698,6 +1727,29 @@ mod tests { assert!(cache.try_peek(&1).is_err()); } + #[cfg(feature = "stats")] + #[test] + fn test_item_stats() { + let cache = Cache::new(100); + // Missing key has no stats. + assert!(cache.item_stats(&1).is_none()); + + cache.insert(1, 10); + // Insert alone is not a hit. + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(0)); + + // Each get increments the per-item access count. + cache.get(&1); + cache.get(&1); + cache.get(&1); + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(3)); + + // Peeking (including item_stats itself) does not alter the count. + cache.peek(&1); + let _ = cache.item_stats(&1); + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(3)); + } + #[test] fn test_try_remove() { let cache = Cache::new(100); diff --git a/src/unsync.rs b/src/unsync.rs index 65af821..7e9a5c7 100644 --- a/src/unsync.rs +++ b/src/unsync.rs @@ -175,6 +175,16 @@ impl, B: BuildHasher, L: Lifecycle(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.shard.item_stats(self.shard.hash(key), key) + } + /// Peeks an item from the cache. Contrary to gets, peeks don't alter the key "hotness". /// /// Note: Leaking the returned RefMut might cause cache weight tracking to be inaccurate.