From 5d0297eaeb5439361a00281a10913420d8ca65a6 Mon Sep 17 00:00:00 2001 From: adamv-symbolica Date: Mon, 2 Nov 2026 14:34:35 +0000 Subject: [PATCH 1/2] Add pointer-equality shortcuts to TrieNodeODRc::join_into and prestrict_dyn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrieNodeODRc::join_into went straight to make_mut, so joining a node into itself cloned it (its refcount is at least 2) and then cloned every shared descendant through the recursive joins, leaving the subtrie unshared. It now returns Identity when both operands are the same node (x ∨ x = x). This is the path of the CoFree join, DenseByteNode::join_child_into and the write zipper's focus join, so join_into_take and the list/byte-node merges stop copying shared subtries. PathMap::join_into called make_mut().join_into_dyn on the root directly; it now goes through TrieNodeODRc::join_into as well. TaggedNodeRef::prestrict_dyn now returns Identity(SELF_IDENT) for the same non-empty node (x <| x = x), in both dispatch variants, as pjoin_dyn, pmeet_dyn and psubtract_dyn already do for their laws, and TrieNodeODRc::prestrict checks first, as pjoin, pmeet and psubtract do. Restricting a shared subtrie by itself used to walk all of it. Empty nodes are excluded: every empty sentinel shares one address, and an empty node restricts to nothing. PathMap::restrict, the node-level recursions and the write zipper's restrict and restricting all reach the dispatch, so none of them needs a check of its own. The check made the compiler outline prestrict_dyn, which it had inlined into the node recursions, and that slowed the restrict benchmarks by about 10%, so it is now #[inline(always)]. The wrapper's check spares the byte-node recursion building tagged refs for each shared child. Co-Authored-By: Claude Opus 5.5 --- src/trie_map.rs | 8 +- src/trie_node.rs | 26 ++++- tests/shared_subtrie_ops.rs | 198 ++++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 tests/shared_subtrie_ops.rs 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"]))); +} From c6d809d16b00efddb75e995b5b7e227697859c07 Mon Sep 17 00:00:00 2001 From: adamv-symbolica Date: Mon, 2 Nov 2026 15:25:27 +0000 Subject: [PATCH 2/2] Add benchmarks of algebraic operations on tries that share subtries Each benchmark gives both operands a shared subtrie of n random keys and sweeps n, so an operation that walks or copies the shared part instead of settling it by pointer equality shows up as cost growing with n: joins through a byte-node slot, a list-node child, a map's own clone, the focus join and an edited copy; restrict by itself and by an edited copy; meet and subtract with an edited copy; and the write zipper's join_into, meet_into, subtract_into and restrict at a shared focus. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 4 + benches/shared_subtries.rs | 182 +++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 benches/shared_subtries.rs 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))) + }); +}