diff --git a/Cargo.toml b/Cargo.toml index d962249..2576a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,10 @@ tempfile = "3.19.1" pasture-core = "0.5.0" # For pointcloud benchmark pasture-io = "0.5.0" # For pointcloud benchmark +[[test]] +name = "cow_counters" +required-features = ["counters"] + [[bench]] name = "superdense_keys" harness = false diff --git a/src/counters.rs b/src/counters.rs index 140e1e3..7746e1d 100644 --- a/src/counters.rs +++ b/src/counters.rs @@ -1,11 +1,13 @@ use crate::PathMap; -use crate::zipper::{*, zipper_priv::ZipperPriv}; +use crate::zipper::*; use crate::trie_node::{TaggedNodeRef, TrieNode}; /// Example usage of counters /// /// ``` -/// pathmap::counters::print_traversal(&map.read_zipper()); +/// # let mut map: pathmap::PathMap = pathmap::PathMap::new(); +/// # map.set_val_at(b"example", 0); +/// pathmap::counters::print_traversal::(&map.read_zipper()); /// let counters = pathmap::counters::Counters::count_ocupancy(&map); /// counters.print_histogram_by_depth(); /// counters.print_run_length_histogram(); @@ -192,4 +194,57 @@ pub fn print_traversal<'a, V: 'a + Clone + Unpin, Z: ZipperIteration + Clone>(zi while zipper.to_next_val() { println!("{:?}", zipper.path()); } -} \ No newline at end of file +} +/// Copy-on-write write-path counters +/// +/// Every structural write goes through `TrieNodeODRc::make_unique`, which either finds the node +/// unshared (cheap) or clones it (the copy-on-write cost). These counters expose that split, so +/// a workload's write amplification can be measured directly: +/// +/// ``` +/// # use pathmap::PathMap; +/// pathmap::counters::reset_cow_counters(); +/// let mut map: PathMap = PathMap::new(); +/// map.set_val_at(b"hello", 42); +/// let shared = map.clone(); +/// map.set_val_at(b"help", 43); // writing while `shared` aliases the trie forces clones +/// let counters = pathmap::counters::cow_counters(); +/// assert!(counters.cow_clones >= 1); +/// assert!(counters.cow_clones <= counters.make_unique_calls); +/// # drop(shared); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CowCounters { + /// Number of times a write required a unique reference to a node + pub make_unique_calls: usize, + /// How many of those calls found the node shared and cloned it + pub cow_clones: usize, +} + +static MAKE_UNIQUE_CALLS: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); +static COW_CLONES: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + +/// Returns a snapshot of the counters accumulated since the last [reset_cow_counters] +pub fn cow_counters() -> CowCounters { + use core::sync::atomic::Ordering::Relaxed; + CowCounters { + make_unique_calls: MAKE_UNIQUE_CALLS.load(Relaxed), + cow_clones: COW_CLONES.load(Relaxed), + } +} + +/// Resets the copy-on-write counters to zero +pub fn reset_cow_counters() { + use core::sync::atomic::Ordering::Relaxed; + MAKE_UNIQUE_CALLS.store(0, Relaxed); + COW_CLONES.store(0, Relaxed); +} + +/// Internal. Records one `make_unique` call and whether it had to clone +pub(crate) fn record_make_unique(cloned: bool) { + use core::sync::atomic::Ordering::Relaxed; + MAKE_UNIQUE_CALLS.fetch_add(1, Relaxed); + if cloned { + COW_CLONES.fetch_add(1, Relaxed); + } +} diff --git a/src/trie_node.rs b/src/trie_node.rs index 82a8b16..092ef3a 100644 --- a/src/trie_node.rs +++ b/src/trie_node.rs @@ -3048,6 +3048,9 @@ mod opaque_dyn_rc_trie_node { let (ptr, _tag) = self.ptr.get_raw_parts(); if unsafe{ &*ptr }.compare_exchange(1, 0, Acquire, Relaxed).is_err() { + #[cfg(feature = "counters")] + crate::counters::record_make_unique(true); + // Another pointer exists, so we must clone. let cloned_node = self.as_tagged().clone_self(); @@ -3055,6 +3058,9 @@ mod opaque_dyn_rc_trie_node { *self = cloned_node; } else { + #[cfg(feature = "counters")] + crate::counters::record_make_unique(false); + // We were the sole reference so bump back up the ref count. unsafe{ &*ptr }.store(1, Release); } diff --git a/tests/cow_counters.rs b/tests/cow_counters.rs new file mode 100644 index 0000000..cceffa5 --- /dev/null +++ b/tests/cow_counters.rs @@ -0,0 +1,42 @@ +//! Exercises the copy-on-write counters in isolation (an integration test binary runs as its own +//! process, so the process-wide counters see only this test's writes). +#![cfg(feature = "counters")] + +use pathmap::counters::{cow_counters, reset_cow_counters}; +use pathmap::PathMap; + +#[test] +fn cow_counters_split_unshared_and_shared_writes() { + reset_cow_counters(); + + // Writes into an unshared trie never clone: every make_unique call finds a unique node. + let mut map: PathMap = PathMap::new(); + for (i, key) in [&b"romane"[..], b"romanus", b"romulus", b"rubens"].iter().enumerate() { + map.set_val_at(key, i); + } + let unshared = cow_counters(); + assert!(unshared.make_unique_calls > 0, "writes must route through make_unique"); + assert_eq!(unshared.cow_clones, 0, "an unshared trie must never clone on write"); + + // Once another handle aliases the trie, a write must clone every shared node on its path. + let shared_handle = map.clone(); + map.set_val_at(b"romanes", 4); + let shared = cow_counters(); + assert!(shared.cow_clones >= 1, "writing an aliased trie must record at least one clone"); + assert!(shared.cow_clones <= shared.make_unique_calls); + assert_eq!(shared_handle.get_val_at(b"romane"), Some(&0), "the aliased handle is unaffected"); + assert_eq!(shared_handle.get_val_at(b"romanes"), None); + + // After the aliasing handle is gone, fresh writes stop cloning. + drop(shared_handle); + map.set_val_at(b"ruber", 5); + let reunified = cow_counters(); + assert_eq!( + reunified.cow_clones, shared.cow_clones, + "writes after the alias is dropped must not clone (the path was already un-shared by the previous write, and sole ownership needs no copies)" + ); + + reset_cow_counters(); + let zeroed = cow_counters(); + assert_eq!((zeroed.make_unique_calls, zeroed.cow_clones), (0, 0)); +}