Skip to content
Open
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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 58 additions & 3 deletions src/counters.rs
Original file line number Diff line number Diff line change
@@ -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<usize> = pathmap::PathMap::new();
/// # map.set_val_at(b"example", 0);
/// pathmap::counters::print_traversal::<usize, _>(&map.read_zipper());
/// let counters = pathmap::counters::Counters::count_ocupancy(&map);
/// counters.print_histogram_by_depth();
/// counters.print_run_length_histogram();
Expand Down Expand Up @@ -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());
}
}
}
/// 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<usize> = 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);
}
}
6 changes: 6 additions & 0 deletions src/trie_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3048,13 +3048,19 @@ 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();

//The decrement of the old `self` refcount will happen at this assignment
*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);
}
Expand Down
42 changes: 42 additions & 0 deletions tests/cow_counters.rs
Original file line number Diff line number Diff line change
@@ -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<usize> = 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));
}