From 3955f51829f769d3037b027a81fe9e2cea1b6f85 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Sun, 5 Jul 2026 04:43:54 +1000 Subject: [PATCH 1/2] Fix the counters feature build The counters module rotted while the feature was off: it imports ZipperPriv, which no longer exists (get_focus moved to ZipperInfallibleSubtries, already in the zipper glob), and its doc example references an undefined map and leaves print_traversal's free V parameter uninferred. With the import dropped and the example given a real map and a turbofish, cargo build/test/doc all pass with --features counters. --- src/counters.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/counters.rs b/src/counters.rs index 140e1e36..193c0c98 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(); From ce17bb17d0da1eb6e57827b403ca726b189ea9a6 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Sun, 5 Jul 2026 04:43:54 +1000 Subject: [PATCH 2/2] Add copy-on-write write-path counters Extends the counters feature with the write-side split it was missing: every structural write funnels through TrieNodeODRc::make_unique, which either finds the node unshared or pays the copy-on-write clone. Two process-wide relaxed atomics record that split (make_unique_calls / cow_clones), exposed as a CowCounters snapshot with a reset, so a workload's write amplification under aliasing is directly measurable. The hook is two lines in make_unique, compiled out entirely without the feature (the default build is unchanged). An integration test pins the semantics in its own process: unshared writes record zero clones, writing an aliased trie records at least one and never more clones than calls, the aliased handle stays untouched, and dropping the alias stops the cloning. --- Cargo.toml | 4 ++++ src/counters.rs | 55 ++++++++++++++++++++++++++++++++++++++++++- src/trie_node.rs | 6 +++++ tests/cow_counters.rs | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/cow_counters.rs diff --git a/Cargo.toml b/Cargo.toml index d9622490..2576a3a6 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 193c0c98..7746e1d9 100644 --- a/src/counters.rs +++ b/src/counters.rs @@ -194,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 82a8b165..092ef3a6 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 00000000..cceffa50 --- /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)); +}