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
21 changes: 20 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]

Expand Down Expand Up @@ -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::{
Expand Down
62 changes: 55 additions & 7 deletions src/shard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ pub struct Resident<Key, Val> {
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<Key: Clone, Val: Clone> Clone for Resident<Key, Val> {
Expand All @@ -89,6 +94,8 @@ impl<Key: Clone, Val: Clone> Clone for Resident<Key, Val> {
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(),
}
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Q>(&self, hash: u64, key: &Q) -> Option<crate::ItemStats>
where
Q: Hash + Equivalent<Key> + ?Sized,
{
let (_, resident) = self.search_resident(hash, key)?;
Some(crate::ItemStats {
access_count: resident.access_count.load(atomic::Ordering::Relaxed),
})
}

pub fn peek_mut<Q>(&mut self, hash: u64, key: &Q) -> Option<RefMut<'_, Key, Val, We, B, L, Plh>>
where
Q: Hash + Equivalent<Key> + ?Sized,
Expand Down Expand Up @@ -906,6 +937,8 @@ impl<
value,
state: enter_state,
referenced: referenced.into(),
#[cfg(feature = "stats")]
access_count: Default::default(),
}),
);
match evicted {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -1215,14 +1252,14 @@ 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() };
};
if *resident.referenced.get_mut() < MAX_F {
*resident.referenced.get_mut() += 1;
}
record_hit_mut!(self, resident);
EntryOrPlaceholder::Kept(t)
}
EntryAction::Remove => {
Expand Down Expand Up @@ -1420,6 +1457,7 @@ impl<Key, Val, We: Weighter<Key, Val>, 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
Expand Down Expand Up @@ -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::<Entry<u64, u64, crate::sync_placeholder::SharedPlaceholder<u64>>>()
- 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::<Entry<u64, u64, crate::unsync::SharedPlaceholder>>() - size_of::<[u64; 2]>(),
16 // 8 bytes from linked slab, 8 bytes from entry
unsync_overhead
);
}
}
52 changes: 52 additions & 0 deletions src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Q>(&self, key: &Q) -> Option<crate::ItemStats>
where
Q: Hash + Equivalent<Key> + ?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<Q>(&self, key: &Q) -> Result<Option<crate::ItemStats>, LockContention>
where
Q: Hash + Equivalent<Key> + ?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<Q>(&self, key: &Q) -> Option<(Key, Val)>
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/unsync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ impl<Key: Eq + Hash, Val, We: Weighter<Key, Val>, B: BuildHasher, L: Lifecycle<K
self.shard.peek(self.shard.hash(key), key)
}

/// 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<Q>(&self, key: &Q) -> Option<crate::ItemStats>
where
Q: Hash + Equivalent<Key> + ?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.
Expand Down
Loading