diff --git a/Cargo.toml b/Cargo.toml index 8b9d32cf..4009be66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,10 @@ harness = false name = "restrict" harness = false +[[bench]] +name = "shared_subtries" +harness = false + [[bench]] name = "act_paths" harness = false diff --git a/benches/shared_subtries.rs b/benches/shared_subtries.rs new file mode 100644 index 00000000..09493e1f --- /dev/null +++ b/benches/shared_subtries.rs @@ -0,0 +1,182 @@ +//! Algebraic operations on tries that share subtries +//! +//! A subtrie that both operands hold as the same node is its own join, meet and restriction, and +//! subtracting it leaves nothing, so an operation should settle it by pointer equality without +//! descending into it, and without copying it. Every benchmark here gives both operands a shared +//! subtrie of `n` random keys, so a cost that grows with `n` means the shared part is walked or +//! copied. Ideally all of them are flat. +//! +//! "Slot" benchmarks put the shared subtrie `M` at `P` in two otherwise small maps: `a` has values +//! at three sibling bytes, so `P` is a slot of a byte node, and `b` holds `M` alone or with fewer +//! siblings. "Edited copy" benchmarks pair a map with a clone of itself that has had `EDITS` keys +//! added, so all but the edited paths are shared; their cost should follow `EDITS`, not `n`. + +use divan::{Bencher, Divan, black_box}; +use pathmap::PathMap; +use pathmap::ring::Lattice; +use pathmap::zipper::*; +use rand::{Rng, SeedableRng, rngs::StdRng}; + +fn main() { + Divan::from_args().main(); +} + +const SIZES: &[usize] = &[1_000, 10_000, 100_000]; + +/// Where the shared subtrie sits in the slot benchmarks +const P: &[u8] = b"p"; + +/// Keys added to the edited copy +const EDITS: usize = 16; + +fn random_keys(rng: &mut StdRng, n: usize) -> Vec> { + (0..n).map(|_| (0..8).map(|_| rng.random::()).collect()).collect() +} + +/// `n` random 8 byte keys +fn subtrie(n: usize) -> PathMap { + random_keys(&mut StdRng::seed_from_u64(1), n).iter().map(|k| (k, 0)).collect() +} + +/// A map holding `m` at `P`, with a value at each of `siblings` +fn holding(m: &PathMap, siblings: &[&[u8]]) -> PathMap { + let mut map = PathMap::new(); + for key in siblings { + map.set_val_at(key, 0); + } + map.write_zipper_at_path(P).graft_map(m.clone()); + map +} + +/// `a` with `M` in a byte node's slot at `P`, and `b` with `M` in the same slot of a smaller byte node +fn slot_pair(n: usize) -> (PathMap, PathMap) { + let m = subtrie(n); + (holding(&m, &[b"a", b"b", b"c"]), holding(&m, &[b"a", b"b"])) +} + +/// A map of `n` random keys and a clone of it with `EDITS` more +fn edited_pair(n: usize) -> (PathMap, PathMap) { + let x = subtrie(n); + let mut y = x.clone(); + for key in random_keys(&mut StdRng::seed_from_u64(2), EDITS) { + y.set_val_at(&key, 1); + } + (x, y) +} + +/// [PathMap::join_into] where both maps hold `M` in a byte node's slot +#[divan::bench(args = SIZES)] +fn join_into_shared_slot(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| (a.clone(), b.clone())).bench_local_values(|(mut a, b)| { + black_box(a.join_into(b)); + a + }); +} + +/// [PathMap::join_into] where the other map's root is a list node holding `M`, so the join merges +/// it into a byte node child by child +#[divan::bench(args = SIZES)] +fn join_into_shared_list_child(bencher: Bencher, n: usize) { + let m = subtrie(n); + let a = holding(&m, &[b"a", b"b", b"c"]); + let b = holding(&m, &[]); + bencher.with_inputs(|| (a.clone(), b.clone())).bench_local_values(|(mut a, b)| { + black_box(a.join_into(b)); + a + }); +} + +/// [PathMap::join_into] of a map and its own clone, which share the root +#[divan::bench(args = SIZES)] +fn join_into_own_clone(bencher: Bencher, n: usize) { + let x = subtrie(n); + bencher.with_inputs(|| (x.clone(), x.clone())).bench_local_values(|(mut a, b)| { + black_box(a.join_into(b)); + a + }); +} + +/// [PathMap::join_into] of a map and an edited copy of it +#[divan::bench(args = SIZES)] +fn join_into_edited_copy(bencher: Bencher, n: usize) { + let (x, y) = edited_pair(n); + bencher.with_inputs(|| (x.clone(), y.clone())).bench_local_values(|(mut a, b)| { + black_box(a.join_into(b)); + a + }); +} + +/// [ZipperWriting::join_into_take] with both foci at `M` +#[divan::bench(args = SIZES)] +fn join_into_take_shared_focus(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| (a.clone(), b.clone())).bench_local_values(|(mut a, mut b)| { + black_box(a.write_zipper_at_path(P).join_into_take(&mut b.write_zipper_at_path(P), false)); + (a, b) + }); +} + +/// [PathMap::restrict] of a map by itself +#[divan::bench(args = SIZES)] +fn restrict_by_itself(bencher: Bencher, n: usize) { + let x = subtrie(n); + bencher.bench_local(|| black_box(&x).restrict(black_box(&x))); +} + +/// [PathMap::restrict] of a map by an edited copy of it +#[divan::bench(args = SIZES)] +fn restrict_by_edited_copy(bencher: Bencher, n: usize) { + let (x, y) = edited_pair(n); + bencher.bench_local(|| black_box(&x).restrict(black_box(&y))); +} + +/// [PathMap::meet] of a map and an edited copy of it +#[divan::bench(args = SIZES)] +fn meet_edited_copy(bencher: Bencher, n: usize) { + let (x, y) = edited_pair(n); + bencher.bench_local(|| black_box(&x).meet(black_box(&y))); +} + +/// [PathMap::subtract] of a map from an edited copy of it +#[divan::bench(args = SIZES)] +fn subtract_from_edited_copy(bencher: Bencher, n: usize) { + let (x, y) = edited_pair(n); + bencher.bench_local(|| black_box(&y).subtract(black_box(&x))); +} + +/// [ZipperWriting::join_into] with both foci at `M` +#[divan::bench(args = SIZES)] +fn zipper_join_into_shared_focus(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| a.clone()).bench_local_refs(|a| { + black_box(a.write_zipper_at_path(P).join_into(&b.read_zipper_at_path(P))) + }); +} + +/// [ZipperWriting::meet_into] with both foci at `M` +#[divan::bench(args = SIZES)] +fn zipper_meet_into_shared_focus(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| a.clone()).bench_local_refs(|a| { + black_box(a.write_zipper_at_path(P).meet_into(&b.read_zipper_at_path(P), false)) + }); +} + +/// [ZipperWriting::subtract_into] with both foci at `M` +#[divan::bench(args = SIZES)] +fn zipper_subtract_into_shared_focus(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| a.clone()).bench_local_refs(|a| { + black_box(a.write_zipper_at_path(P).subtract_into(&b.read_zipper_at_path(P), false)) + }); +} + +/// [ZipperWriting::restrict] with both foci at `M` +#[divan::bench(args = SIZES)] +fn zipper_restrict_shared_focus(bencher: Bencher, n: usize) { + let (a, b) = slot_pair(n); + bencher.with_inputs(|| a.clone()).bench_local_refs(|a| { + black_box(a.write_zipper_at_path(P).restrict(&b.read_zipper_at_path(P))) + }); +} diff --git a/src/trie_map.rs b/src/trie_map.rs index 5c0b1dfd..fccc9bc4 100644 --- a/src/trie_map.rs +++ b/src/trie_map.rs @@ -744,12 +744,8 @@ impl Lattice for PathMap let (other_root_node, other_root_val) = other.into_root(); let root_node_status = if let Some(other_root) = other_root_node { - let (status, result) = self.get_or_init_root_mut().make_mut().join_into_dyn(other_root); - match result { - Ok(()) => {}, - Err(replacement) => { *self.get_or_init_root_mut() = replacement; } - } - status + //Through `TrieNodeODRc::join_into`, for its x ∨ x = x shortcut + self.get_or_init_root_mut().join_into(other_root) } else { if self.is_empty() { AlgebraicStatus::None diff --git a/src/trie_node.rs b/src/trie_node.rs index 19b7031b..e2722d83 100644 --- a/src/trie_node.rs +++ b/src/trie_node.rs @@ -1554,7 +1554,14 @@ mod tagged_node_ref { } } + //With the check below, the compiler stops inlining this dispatch, which slows deep restricts by about 10% + #[inline(always)] pub fn prestrict_dyn(&self, other: TaggedNodeRef) -> AlgebraicResult> { + //x <| x = x. Every empty sentinel shares one address, and an empty node restricts to nothing, + // so an empty node takes the ordinary path + if self.shared_node_id() == other.shared_node_id() && !self.node_is_empty() { + return AlgebraicResult::Identity(SELF_IDENT); + } match self { Self::DenseByteNode(node) => node.prestrict_dyn(other), Self::LineListNode(node) => node.prestrict_dyn(other), @@ -2220,7 +2227,14 @@ mod tagged_node_ref { } } + //With the check below, the compiler stops inlining this dispatch, which slows deep restricts by about 10% + #[inline(always)] pub fn prestrict_dyn(&self, other: TaggedNodeRef) -> AlgebraicResult> { + //x <| x = x. Every empty sentinel shares one address, and an empty node restricts to nothing, + // so an empty node takes the ordinary path + if self.ptr == other.ptr && !self.node_is_empty() { + return AlgebraicResult::Identity(SELF_IDENT); + } let (ptr, tag) = self.ptr.get_raw_parts(); match tag { EMPTY_NODE_TAG => AlgebraicResult::None, @@ -3308,6 +3322,11 @@ impl TrieNodeODRc { *self = node; return AlgebraicStatus::Element } + //x ∨ x = x. `make_mut` would clone a node shared with itself, and the joins below would then + // clone every shared descendant in turn + if self.ptr_eq(&node) { + return AlgebraicStatus::Identity + } let (status, result) = self.make_mut().join_into_dyn(node); match result { Ok(()) => {}, @@ -3340,7 +3359,12 @@ impl TrieNodeODRc Quantale for TrieNodeODRc { fn prestrict(&self, other: &Self) -> AlgebraicResult where Self: Sized { - self.as_tagged().prestrict_dyn(other.as_tagged()) + //x <| x = x, as in `prestrict_dyn`, but before the tagged refs are built + if self.ptr_eq(other) && !self.as_tagged().node_is_empty() { + AlgebraicResult::Identity(SELF_IDENT) + } else { + self.as_tagged().prestrict_dyn(other.as_tagged()) + } } } diff --git a/tests/shared_subtrie_ops.rs b/tests/shared_subtrie_ops.rs new file mode 100644 index 00000000..fb71b91b --- /dev/null +++ b/tests/shared_subtrie_ops.rs @@ -0,0 +1,198 @@ +//! Algebraic operations on tries that share a subtrie. A node shared by both operands is its own +//! join, meet and restriction, so those operations must leave it shared, and must not copy it. + +use std::sync::{Mutex, MutexGuard}; + +use pathmap::PathMap; +use pathmap::ring::{AlgebraicStatus, Lattice}; +use pathmap::zipper::*; + +/// The `counters` feature's copy-on-write counters are process-wide, so the tests in this file run +/// one at a time +static SERIAL: Mutex<()> = Mutex::new(()); + +fn serial() -> MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The path the shared subtrie sits at, in the tests that give it byte-node siblings +const P: &[u8] = b"p"; + +/// A subtrie that branches at several depths, so copying it takes several node clones +fn subtrie() -> PathMap { + let mut m = PathMap::new(); + for (i, key) in [&b"alpha"[..], b"alps", b"beta", b"bet", b"gamma", b"gam", b"delta", b"del"].iter().enumerate() { + m.insert(key, i as u64); + } + m +} + +/// A map holding `m` at `path`, with a value at each of `siblings`. Three or more first bytes make +/// the root a byte node, so `m` sits in one of its slots +fn holding(m: &PathMap, path: &[u8], siblings: &[&[u8]]) -> PathMap { + let mut map = PathMap::new(); + for (i, key) in siblings.iter().enumerate() { + map.insert(key, i as u64); + } + map.write_zipper_at_path(path).graft_map(m.clone()); + map +} + +/// `Some` only while the node at `path` is shared and has no value at `path` +fn node_id(map: &PathMap, path: &[u8]) -> Option { + map.read_zipper_at_path(path).shared_node_id() +} + +/// The id of `m`'s root, which it reports only while shared, so this takes it through a second handle +fn root_id(m: &PathMap) -> Option { + let id = node_id(&m.clone(), b""); + assert!(id.is_some()); + id +} + +fn entries(map: &PathMap) -> Vec<(Vec, u64)> { + map.iter().map(|(path, val)| (path, *val)).collect() +} + +/// Runs `op` and checks that it cloned no node, when the `counters` feature is on +fn without_clones(case: &str, op: impl FnOnce() -> R) -> R { + #[cfg(feature = "counters")] + let before = pathmap::counters::cow_counters(); + let result = op(); + #[cfg(feature = "counters")] + assert_eq!(pathmap::counters::cow_counters().cow_clones, before.cow_clones, "{case}: cloned a node"); + #[cfg(not(feature = "counters"))] + let _ = case; + result +} + +/// Joining a map into one that already holds the same subtrie is the identity and keeps the subtrie +/// shared: the byte-node child join, the list-node merge and the focus join all meet the shared node +/// as `TrieNodeODRc::join_into(x, x)` +#[test] +fn join_of_a_shared_subtrie_is_identity() { + let _serial = serial(); + let m = subtrie(); + let m_id = root_id(&m); + + // Byte node into byte node, through the `CoFree` join + let mut a = holding(&m, P, &[b"a", b"b", b"c"]); + let b = holding(&m, P, &[b"a", b"b"]); + assert_eq!(node_id(&b, P), m_id); + let status = without_clones("byte node into byte node", || a.join_into(b)); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + // List node into byte node, through `join_child_into` + let mut a = holding(&m, P, &[b"a", b"b", b"c"]); + let b = holding(&m, P, &[]); + assert_eq!(node_id(&b, P), m_id); + let status = without_clones("list node into byte node", || a.join_into(b)); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + // The write zipper's focus join + let mut a = holding(&m, P, &[b"a", b"b", b"c"]); + let mut b = holding(&m, P, &[]); + { + let mut a_wz = a.write_zipper_at_path(P); + let mut b_wz = b.write_zipper_at_path(P); + let status = without_clones("focus join", || a_wz.join_into_take(&mut b_wz, false)); + assert_eq!(status, AlgebraicStatus::Identity); + } + assert_eq!(node_id(&a, P), m_id); + assert_eq!(entries(&a), entries(&holding(&m, P, &[b"a", b"b", b"c"]))); +} + +/// The value at a node boundary lives in the parent's slot, so a shortcut on the node below must not +/// decide it. `b` is `a` plus a value at `p`; the subtrie under `p` is the same node in both +#[test] +fn shared_subtrie_under_a_boundary_value() { + let _serial = serial(); + let m = subtrie(); + let m_id = root_id(&m); + // `p` in a byte node's slot, then `p` in a list node + for (p, siblings) in [(P, &[&b"a"[..], b"b", b"c"][..]), (&b"pre"[..], &[][..])] { + let a = holding(&m, p, siblings); + let mut b = holding(&m, p, siblings); + b.insert(p, 7); + // `b` has a value at `p`, so only `a` can witness the sharing there + assert_eq!(node_id(&a, p), m_id); + assert_eq!(node_id(&b, p), None); + + let mut joined = b.clone(); + assert_eq!(joined.join_into(a.clone()), AlgebraicStatus::Identity, "{p:?}: b ∨ a"); + assert_eq!(entries(&joined), entries(&b)); + + let mut joined = a.clone(); + assert_eq!(joined.join_into(b.clone()), AlgebraicStatus::Element, "{p:?}: a ∨ b"); + assert_eq!(joined.val_at(p), Some(&7)); + assert_eq!(entries(&joined), entries(&b)); + + let restricted = a.restrict(&b); + assert_eq!(entries(&restricted), entries(&a), "{p:?}: a <| b"); + assert_eq!(node_id(&restricted, p), m_id); + + // The value `b` has at `p` is not validated by `a`, so it goes; the subtrie stays shared + let restricted = b.restrict(&a); + assert_eq!(entries(&restricted), entries(&a), "{p:?}: b <| a"); + assert_eq!(node_id(&restricted, p), m_id); + } +} + +/// x <| x = x, and the result is x's own root +#[test] +fn restrict_by_itself_is_identity() { + let _serial = serial(); + let m = subtrie(); + let mut x = holding(&m, P, &[b"a", b"b", b"c"]); + x.insert(P, 3); + let restricted = without_clones("x <| x", || x.restrict(&x)); + assert_eq!(entries(&restricted), entries(&x)); + let x_id = node_id(&x, b""); + assert!(x_id.is_some(), "the result must share x's root"); + assert_eq!(node_id(&restricted, b""), x_id); +} + +/// The write zipper's algebraic operations, with the focus of each operand at the same node +#[test] +fn zipper_ops_on_a_shared_focus() { + let _serial = serial(); + let m = subtrie(); + let m_id = root_id(&m); + let fresh = || (holding(&m, P, &[b"a", b"b", b"c"]), holding(&m, P, &[])); + + let (mut a, b) = fresh(); + let status = without_clones("join_into", || a.write_zipper_at_path(P).join_into(&b.read_zipper_at_path(P))); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + let (mut a, _) = fresh(); + let status = without_clones("join_map_into", || a.write_zipper_at_path(P).join_map_into(m.clone())); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + let (mut a, b) = fresh(); + let status = without_clones("meet_into", || a.write_zipper_at_path(P).meet_into(&b.read_zipper_at_path(P), false)); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + let (mut a, b) = fresh(); + let status = without_clones("meet_2", || a.write_zipper_at_path(b"q").meet_2(&b.read_zipper_at_path(P), &m.read_zipper())); + assert_eq!(status, AlgebraicStatus::Element); + assert_eq!(node_id(&a, b"q"), m_id); + + let (mut a, b) = fresh(); + let status = without_clones("restrict", || a.write_zipper_at_path(P).restrict(&b.read_zipper_at_path(P))); + assert_eq!(status, AlgebraicStatus::Identity); + assert_eq!(node_id(&a, P), m_id); + + let (mut a, b) = fresh(); + assert!(without_clones("restricting", || a.write_zipper_at_path(P).restricting(&b.read_zipper_at_path(P)))); + assert_eq!(node_id(&a, P), m_id); + + let (mut a, b) = fresh(); + let status = without_clones("subtract_into", || a.write_zipper_at_path(P).subtract_into(&b.read_zipper_at_path(P), false)); + assert_eq!(status, AlgebraicStatus::None); + assert_eq!(entries(&a), entries(&holding(&PathMap::new(), P, &[b"a", b"b", b"c"]))); +}