From 6d79a7e287adbb9a8e43ddf96a956b80a156dfd0 Mon Sep 17 00:00:00 2001 From: Lef Ioannidis Date: Wed, 5 Aug 2026 19:09:06 +0000 Subject: [PATCH] feat(arena): add arena-backed hashconsing behind `bumpalo` feature Adds `BHConsed`/`BHConsign`, the arena counterpart of `HConsed`/`HConsign`, in a new `arena` module gated on the optional `bumpalo` feature. The existing `Arc` path is untouched. Values are bump-allocated in a caller-owned `bumpalo::Bump`, so handles are `Copy` (`&'bump T` + `u64` uid) rather than refcounted, and the consign keys its table on the interned reference itself, dropping the `T: Clone` bound the `Arc` consign needs. The trade-off is documented prominently: `Bump` never runs destructors, and there are no weak refs, hence no `collect` and no `consign!` analogue (`Bump` is `Send` but not `Sync`). Verified by unit tests (`src/test/arena.rs`), two module doctests, a rayon integration test proving handles are `Send + Sync`, and a new `arena_vs_arc` bench that replays one generated term DAG through both consigns and fails unless the arena wins. Measured, 200k instructions: phase Arc (HConsed) Arena (BHConsed) speedup build 30.989 ms 18.200 ms 1.70x re-intern 22.953 ms 11.956 ms 1.92x traverse 15.506 ms 6.697 ms 2.32x teardown 10.282 ms 0.002 ms 5411.73x total 79.730 ms 36.855 ms 2.16x Assistant-model: Claude Opus --- Cargo.toml | 12 +- benches/arena_vs_arc.rs | 287 +++++++++++++++++++++++++++++++ src/arena.rs | 368 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 15 ++ src/test.rs | 3 + src/test/arena.rs | 98 +++++++++++ tests/send_sync.rs | 64 +++++++ 7 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 benches/arena_vs_arc.rs create mode 100644 src/arena.rs create mode 100644 src/test/arena.rs diff --git a/Cargo.toml b/Cargo.toml index cd7a9d9..c95faef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,9 +38,10 @@ features = ["unstable_docrs"] [features] with_ahash = ["ahash"] -unstable_docrs = ["with_ahash"] +unstable_docrs = ["with_ahash", "bumpalo"] weak-table = ["dep:weak-table"] derive = ["hashconsing-derive"] +bumpalo = ["dep:bumpalo"] [dependencies] lazy_static = "1.*" @@ -54,7 +55,16 @@ optional = true version = "^0.3.0" optional = true +[dependencies.bumpalo] +version = "^3.16" +optional = true + [dev-dependencies] crossbeam-utils = "^0.8" rayon = "^1.5" rand = "0.8" + +[[bench]] +name = "arena_vs_arc" +harness = false +required-features = ["bumpalo"] diff --git a/benches/arena_vs_arc.rs b/benches/arena_vs_arc.rs new file mode 100644 index 0000000..3a3fd31 --- /dev/null +++ b/benches/arena_vs_arc.rs @@ -0,0 +1,287 @@ +//! Compares the `Arc`-backed (`HConsed`) and arena-backed (`BHConsed`) consigns on the same +//! generated lambda-calculus term DAG. +//! +//! Run with: `cargo bench --features bumpalo --bench arena_vs_arc` + +use std::{ + collections::HashSet, + hint::black_box, + time::{Duration, Instant}, +}; + +use hashconsing::{ + arena::{bumpalo::Bump, BHConsed, BHConsign}, + hash_coll::{hashers::p_hash::Builder as PHash, p_hash::HConSet}, + HConsed, HConsign, HashConsign, +}; + +/// Instructions per run. +const N: usize = 200_000; +/// Repetitions; the minimum over repetitions is reported. +const REPS: usize = 5; +/// Distinct variables the generator draws from. +const VARS: usize = 16; +const SEED: u64 = 0x5EED_1234_ABCD_9876; + +type ATerm = HConsed; + +#[derive(Hash, Clone, PartialEq, Eq)] +enum ArcTerm { + Var(usize), + Lam(ATerm), + App(ATerm, ATerm), +} + +type BTerm<'b> = BHConsed<'b, BumpTerm<'b>>; + +#[derive(Hash, PartialEq, Eq)] +enum BumpTerm<'b> { + Var(usize), + Lam(BTerm<'b>), + App(BTerm<'b>, BTerm<'b>), +} + +#[derive(Clone, Copy)] +enum Instr { + Var(usize), + Lam(usize), + App(usize, usize), +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +/// Generates the instruction sequence replayed against both consigns. +/// +/// Every operand index is `< i`, so replay never refers to an unbuilt term. +fn program(n: usize) -> Vec { + let mut rng = Rng(SEED); + let mut prog = Vec::with_capacity(n); + for i in 0..n { + prog.push(if i < VARS { + Instr::Var(i) + } else { + match rng.below(3) { + 0 => Instr::Var(rng.below(VARS)), + 1 => Instr::Lam(rng.below(i)), + _ => Instr::App(rng.below(i), rng.below(i)), + } + }); + } + prog +} + +fn build_arc(prog: &[Instr]) -> (HConsign, Vec) { + let mut factory = HConsign::with_capacity(prog.len()); + let mut terms: Vec = Vec::with_capacity(prog.len()); + for instr in prog { + let t = match *instr { + Instr::Var(i) => factory.mk(ArcTerm::Var(i)), + Instr::Lam(a) => factory.mk(ArcTerm::Lam(terms[a].clone())), + Instr::App(a, b) => factory.mk(ArcTerm::App(terms[a].clone(), terms[b].clone())), + }; + terms.push(t); + } + (factory, terms) +} + +fn reintern_arc(prog: &[Instr], factory: &mut HConsign, terms: &[ATerm]) { + for instr in prog { + let t = match *instr { + Instr::Var(i) => factory.mk(ArcTerm::Var(i)), + Instr::Lam(a) => factory.mk(ArcTerm::Lam(terms[a].clone())), + Instr::App(a, b) => factory.mk(ArcTerm::App(terms[a].clone(), terms[b].clone())), + }; + black_box(&t); + } +} + +fn traverse_arc(terms: &[ATerm]) -> usize { + let mut visited: HConSet = HConSet::with_capacity(terms.len()); + let mut stack: Vec = Vec::with_capacity(64); + for root in terms { + stack.push(root.clone()); + while let Some(t) = stack.pop() { + if !visited.insert(t.clone()) { + continue; + } + match t.get() { + ArcTerm::Var(_) => (), + ArcTerm::Lam(b) => stack.push(b.clone()), + ArcTerm::App(f, a) => { + stack.push(f.clone()); + stack.push(a.clone()); + } + } + } + } + visited.len() +} + +fn build_arena<'b>( + arena: &'b Bump, + prog: &[Instr], +) -> (BHConsign<'b, BumpTerm<'b>>, Vec>) { + let mut factory = BHConsign::with_capacity(arena, prog.len()); + let mut terms = Vec::with_capacity(prog.len()); + for instr in prog { + let t = match *instr { + Instr::Var(i) => factory.mk(BumpTerm::Var(i)), + Instr::Lam(a) => factory.mk(BumpTerm::Lam(terms[a])), + Instr::App(a, b) => factory.mk(BumpTerm::App(terms[a], terms[b])), + }; + terms.push(t); + } + (factory, terms) +} + +fn reintern_arena<'b>( + prog: &[Instr], + factory: &mut BHConsign<'b, BumpTerm<'b>>, + terms: &[BTerm<'b>], +) { + for instr in prog { + let t = match *instr { + Instr::Var(i) => factory.mk(BumpTerm::Var(i)), + Instr::Lam(a) => factory.mk(BumpTerm::Lam(terms[a])), + Instr::App(a, b) => factory.mk(BumpTerm::App(terms[a], terms[b])), + }; + black_box(&t); + } +} + +fn traverse_arena(terms: &[BTerm<'_>]) -> usize { + let mut visited: HashSet, PHash> = + HashSet::with_capacity_and_hasher(terms.len(), PHash::new()); + let mut stack: Vec> = Vec::with_capacity(64); + for root in terms { + stack.push(*root); + while let Some(t) = stack.pop() { + if !visited.insert(t) { + continue; + } + match t.get() { + BumpTerm::Var(_) => (), + BumpTerm::Lam(b) => stack.push(*b), + BumpTerm::App(f, a) => { + stack.push(*f); + stack.push(*a); + } + } + } + } + visited.len() +} + +/// Timings for `build`, `re-intern`, `traverse` and `teardown`. +fn run_arc(prog: &[Instr]) -> [Duration; 4] { + let t = Instant::now(); + let (mut factory, terms) = build_arc(prog); + let build = t.elapsed(); + + let t = Instant::now(); + reintern_arc(prog, &mut factory, &terms); + let reintern = t.elapsed(); + + let t = Instant::now(); + black_box(traverse_arc(&terms)); + let traverse = t.elapsed(); + + let t = Instant::now(); + drop(terms); + drop(factory); + let teardown = t.elapsed(); + + [build, reintern, traverse, teardown] +} + +/// Same phases as [`run_arc`]; arena creation is charged to `build`, arena release to `teardown`. +fn run_arena(prog: &[Instr]) -> [Duration; 4] { + let t = Instant::now(); + let arena = Bump::new(); + let (mut factory, terms) = build_arena(&arena, prog); + let build = t.elapsed(); + + let t = Instant::now(); + reintern_arena(prog, &mut factory, &terms); + let reintern = t.elapsed(); + + let t = Instant::now(); + black_box(traverse_arena(&terms)); + let traverse = t.elapsed(); + + let t = Instant::now(); + drop(terms); + drop(factory); + drop(arena); + let teardown = t.elapsed(); + + [build, reintern, traverse, teardown] +} + +fn main() { + let prog = program(N); + let (mut arc, mut arena) = ([Duration::MAX; 4], [Duration::MAX; 4]); + + for _ in 0..REPS { + let (a, b) = (run_arc(&prog), run_arena(&prog)); + for i in 0..4 { + arc[i] = arc[i].min(a[i]); + arena[i] = arena[i].min(b[i]); + } + } + + let ms = |d: Duration| d.as_secs_f64() * 1e3; + let speedup = |a: Duration, b: Duration| a.as_secs_f64() / b.as_secs_f64(); + let names = ["build", "re-intern", "traverse", "teardown"]; + + println!("{N} instructions, min of {REPS} reps\n"); + println!( + "{:<12}{:>14}{:>18}{:>10}", + "phase", "Arc (HConsed)", "Arena (BHConsed)", "speedup" + ); + for i in 0..4 { + println!( + "{:<12}{:>11.3} ms{:>15.3} ms{:>9.2}x", + names[i], + ms(arc[i]), + ms(arena[i]), + speedup(arc[i], arena[i]) + ); + } + let (ta, tb): (Duration, Duration) = (arc.iter().sum(), arena.iter().sum()); + println!( + "{:<12}{:>11.3} ms{:>15.3} ms{:>9.2}x", + "total", + ms(ta), + ms(tb), + speedup(ta, tb) + ); + + assert!( + speedup(ta, tb) >= 2.0, + "total speedup {:.2}x < 2.0x", + speedup(ta, tb) + ); + assert!( + speedup(arc[2], arena[2]) >= 1.5, + "traverse speedup {:.2}x < 1.5x", + speedup(arc[2], arena[2]) + ); + assert!( + speedup(arc[3], arena[3]) >= 3.0, + "teardown speedup {:.2}x < 3.0x", + speedup(arc[3], arena[3]) + ); +} diff --git a/src/arena.rs b/src/arena.rs new file mode 100644 index 0000000..2c99fad --- /dev/null +++ b/src/arena.rs @@ -0,0 +1,368 @@ +//! Arena-backed hashconsing: the counterpart of [`crate::HConsed`] / [`crate::HConsign`]. +//! +//! Values live in a caller-owned [`bumpalo::Bump`] arena, and handles ([`BHConsed`]) are `Copy`: +//! a `&'bump T` plus a `u64` uid. Cloning a term is thus a register copy instead of an atomic +//! increment, and the consign ([`BHConsign`]) needs **no `T: Clone`** bound — the `Arc` consign +//! clones each element to use it as its table key, this one keys the table on the interned +//! reference itself. +//! +//! # Destructors never run +//! +//! Values are bump-allocated with [`bumpalo::Bump::alloc`], which never runs destructors, so +//! **`T`'s destructor never runs**. Any heap owned by `T` (`String`, `Vec<_>`, ...) is leaked for +//! as long as the arena lives. +//! +//! This is forced, not incidental: handles are `Copy` and carry the arena lifetime `'bump`, so no +//! owner could drop a value earlier without leaving handles dangling. Prefer arena-friendly +//! payloads: allocate `&'bump str` / `&'bump [T]` in the same arena, reachable through +//! [`BHConsign::arena`]. If you genuinely need destructors, use the `Arc`-backed +//! [`crate::HConsign`] instead. +//! +//! # Differences with the `Arc` path +//! +//! - no weak references, hence no `collect`/`collect_to_fit`: arena memory is reclaimed only when +//! the [`bumpalo::Bump`] is dropped or reset; +//! - no [`crate::consign!`] analogue: [`bumpalo::Bump`] is `Send` but **not** `Sync`, so a lazy +//! static arena consign is impossible. [`BHConsign`] is `!Send`; individual [`BHConsed`] +//! handles are `Send + Sync` whenever `T: Sync`; +//! - [`crate::hash_coll`]'s `HConSet`/`HConMap` are specific to [`crate::HConsed`] and do not +//! accept [`BHConsed`]. Use `std`'s `HashSet`/`HashMap`/`BTreeSet`/`BTreeMap` directly. +//! [`BHConsed::hash`](BHConsed#impl-Hash-for-BHConsed<'_,+T>) writes exactly one `u64`, so this +//! crate's fast hasher applies: +//! `HashSet`. +//! +//! # Examples +//! +//! Lambda calculus, mirroring the crate-level example. Note that the term type does **not** derive +//! `Clone`, and that handles are copied around without any `.clone()`. +//! +//! ```rust +//! use hashconsing::arena::{bumpalo::Bump, BHConsed, BHConsign}; +//! +//! type Term<'b> = BHConsed<'b, ActualTerm<'b>>; +//! +//! #[derive(Debug, Hash, PartialEq, Eq)] +//! enum ActualTerm<'b> { +//! Var(usize), +//! Lam(Term<'b>), +//! App(Term<'b>, Term<'b>), +//! } +//! use ActualTerm::*; +//! +//! let arena = Bump::new(); +//! let mut factory: BHConsign<'_, ActualTerm<'_>> = BHConsign::new(&arena); +//! assert_eq!(factory.len(), 0); +//! +//! let v = factory.mk(Var(0)); +//! assert_eq!(factory.len(), 1); +//! +//! let v2 = factory.mk(Var(3)); +//! assert_eq!(factory.len(), 2); +//! +//! let lam = factory.mk(Lam(v2)); +//! assert_eq!(factory.len(), 3); +//! +//! let v3 = factory.mk(Var(3)); +//! // `v2` and `v3` are the same term: nothing new was allocated. +//! assert_eq!(factory.len(), 3); +//! assert_eq!(v2.uid(), v3.uid()); +//! assert_eq!(v2, v3); +//! assert!(std::ptr::eq(v2.get(), v3.get())); +//! +//! let lam2 = factory.mk(Lam(v3)); +//! assert_eq!(factory.len(), 3); +//! assert_eq!(lam, lam2); +//! +//! let app = factory.mk(App(lam2, v)); +//! assert_eq!(factory.len(), 4); +//! ``` +//! +//! Sets of handles, using this crate's uid-keyed hasher: +//! +//! ```rust +//! use std::collections::HashSet; +//! use hashconsing::{arena::{bumpalo::Bump, BHConsed, BHConsign}, hash_coll::hashers::p_hash}; +//! +//! #[derive(Hash, PartialEq, Eq)] +//! struct Var(usize); +//! +//! let arena = Bump::new(); +//! let mut factory: BHConsign<'_, Var> = BHConsign::new(&arena); +//! let a = factory.mk(Var(0)); +//! let b = factory.mk(Var(0)); +//! +//! let mut set: HashSet, p_hash::Builder> = HashSet::default(); +//! assert!(set.insert(a)); +//! assert!(!set.insert(b)); +//! assert_eq!(set.len(), 1); +//! ``` + +use std::{ + borrow::Borrow, + cmp::Ordering, + collections::{hash_map::RandomState, HashMap}, + fmt, + hash::{BuildHasher, Hash, Hasher}, + ops::Deref, +}; + +use bumpalo::Bump; + +use crate::HashConsed; + +pub use bumpalo; + +/// A hashconsed value allocated in a [`Bump`] arena. +/// +/// This is a `Copy` handle: cloning it copies a reference and a uid, no atomic operation is +/// involved. There is no weak-reference counterpart, since the arena — not the handles — owns the +/// value. +pub struct BHConsed<'bump, T> { + /// The actual element, allocated in the arena. + elm: &'bump T, + /// Unique identifier of the element. + uid: u64, +} +impl HashConsed for BHConsed<'_, T> { + type Inner = T; +} + +impl<'bump, T> BHConsed<'bump, T> { + /// The inner element. Can also be accessed *via* dereferencing. + /// + /// The result borrows the arena, not `self`: interned values outlive any handle borrow. + #[inline] + pub fn get(&self) -> &'bump T { + self.elm + } + /// The unique identifier of the element. + #[inline] + pub fn uid(&self) -> u64 { + self.uid + } +} + +impl Clone for BHConsed<'_, T> { + fn clone(&self) -> Self { + *self + } +} +impl Copy for BHConsed<'_, T> {} + +impl PartialEq for BHConsed<'_, T> { + #[inline] + fn eq(&self, rhs: &Self) -> bool { + self.uid == rhs.uid + } +} +impl Eq for BHConsed<'_, T> {} +impl PartialOrd for BHConsed<'_, T> { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for BHConsed<'_, T> { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.uid.cmp(&other.uid) + } +} +impl Hash for BHConsed<'_, T> { + #[inline] + fn hash(&self, state: &mut H) + where + H: Hasher, + { + self.uid.hash(state) + } +} + +impl Deref for BHConsed<'_, T> { + type Target = T; + #[inline] + fn deref(&self) -> &T { + self.elm + } +} +impl Borrow for BHConsed<'_, T> { + fn borrow(&self) -> &T { + self.elm + } +} + +impl fmt::Debug for BHConsed<'_, T> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "{:?}", self.elm) + } +} +impl fmt::Display for BHConsed<'_, T> { + #[inline] + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + self.elm.fmt(fmt) + } +} + +/// The consign storing arena-allocated hashconsed elements. +/// +/// Elements are allocated in the [`Bump`] the consign was created with, and never freed before +/// that arena is dropped or reset. See the [module-level documentation](self) for the destructor +/// caveat. +pub struct BHConsign<'bump, T: Hash + Eq, S = RandomState> { + /// Arena the elements are allocated in. + arena: &'bump Bump, + /// Maps interned elements to their uid. Keys point into `arena`. + table: HashMap<&'bump T, u64, S>, + /// Counter for uids. + count: u64, +} + +impl<'bump, T: Hash + Eq> BHConsign<'bump, T, RandomState> { + /// Creates an empty consign over `arena`. + #[inline] + pub fn new(arena: &'bump Bump) -> Self { + BHConsign { + arena, + table: HashMap::new(), + count: 0, + } + } + + /// Creates an empty consign over `arena`, with a capacity. + #[inline] + pub fn with_capacity(arena: &'bump Bump, capacity: usize) -> Self { + BHConsign { + arena, + table: HashMap::with_capacity(capacity), + count: 0, + } + } +} + +impl<'bump, T: Hash + Eq, S> BHConsign<'bump, T, S> { + /// The arena the elements are allocated in. + /// + /// Handy to allocate arena-friendly payloads (`&'bump str`, `&'bump [_]`, ...) for the values + /// about to be interned. + #[inline] + pub fn arena(&self) -> &'bump Bump { + self.arena + } + + /// The number of elements stored. + #[inline] + pub fn len(&self) -> usize { + self.table.len() + } + + /// True if the consign is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.table.is_empty() + } + + /// Capacity of the underlying lookup table. + #[inline] + pub fn capacity(&self) -> usize { + self.table.capacity() + } + + /// Iterator over the interned elements. + #[inline] + pub fn iter(&self) -> impl ExactSizeIterator + '_ { + self.table.keys().copied() + } + + /// Iterator over the interned elements, as hashconsed handles. + #[inline] + pub fn consed_iter(&self) -> impl ExactSizeIterator> + '_ { + self.table.iter().map(|(elm, uid)| BHConsed { + elm: *elm, + uid: *uid, + }) + } +} + +impl<'bump, T: Hash + Eq, S: BuildHasher> BHConsign<'bump, T, S> { + /// Creates an empty consign over `arena`, with a custom hash. + #[inline] + pub fn with_hasher(arena: &'bump Bump, build_hasher: S) -> Self { + BHConsign { + arena, + table: HashMap::with_hasher(build_hasher), + count: 0, + } + } + + /// Creates an empty consign over `arena`, with a capacity and a custom hash. + #[inline] + pub fn with_capacity_and_hasher(arena: &'bump Bump, capacity: usize, build_hasher: S) -> Self { + BHConsign { + arena, + table: HashMap::with_capacity_and_hasher(capacity, build_hasher), + count: 0, + } + } + + /// Hashconses `elm` and returns the hashconsed version. + /// + /// The boolean is `true` iff `elm` was not in the consign, meaning it was just allocated in + /// the arena. + #[inline] + pub fn mk_is_new(&mut self, elm: T) -> (BHConsed<'bump, T>, bool) { + if let Some((elm, uid)) = self.table.get_key_value(&elm) { + return ( + BHConsed { + elm: *elm, + uid: *uid, + }, + false, + ); + } + // The arena, not the consign, owns the value, so handles can carry `'bump`. `T`'s + // destructor never runs, see module doc. + let elm: &'bump T = self.arena.alloc(elm); + let uid = self.count; + self.count += 1; + self.table.insert(elm, uid); + (BHConsed { elm, uid }, true) + } + + /// Creates a hashconsed element. + #[inline] + pub fn mk(&mut self, elm: T) -> BHConsed<'bump, T> { + self.mk_is_new(elm).0 + } + + /// True if the consign contains `elm`. + #[inline] + pub fn contains(&self, elm: &T) -> bool { + self.table.contains_key(elm) + } + + /// Reserves capacity for at least `additional` more elements. + /// + /// Only affects the lookup table; the arena is untouched. + #[inline] + pub fn reserve(&mut self, additional: usize) { + self.table.reserve(additional) + } + + /// Shrinks the capacity of the lookup table as much as possible. + /// + /// Only affects the lookup table; arena memory is never reclaimed this way. + #[inline] + pub fn shrink_to_fit(&mut self) { + self.table.shrink_to_fit() + } +} + +impl fmt::Display for BHConsign<'_, T, S> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "consign:")?; + for e in self.table.keys() { + write!(fmt, "\n | {e}")?; + } + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index e07ef09..abf1f14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -201,6 +201,13 @@ //! Another way to have efficient sets/maps of/from hashconsed things is to use the `BTree` sets //! and maps from the standard library. //! +//! # Arena-backed hashconsing +//! +//! Activating feature `bumpalo` exposes the `arena` module, which provides `BHConsed` and +//! `BHConsign`: the same hashconsing API backed by a `bumpalo` arena instead of `Arc`. Handles are +//! `Copy`, the consign needs no `Clone` bound, and memory is reclaimed only when the arena is +//! dropped — see that module's documentation for the destructor caveat. +//! //! [paper]: http://dl.acm.org/citation.cfm?doid=1159876.1159880 //! (Type-safe modular hash-consing) //! [`HConsed`]: trait.HashConsed.html (HConsed type) @@ -286,6 +293,14 @@ macro_rules! consign { pub mod coll; pub mod hash_coll; +/// Arena-backed hashconsing, on top of [`bumpalo`](https://crates.io/crates/bumpalo). +/// +/// > **NB:** requires feature `"bumpalo"`. +#[cfg(feature = "bumpalo")] +pub mod arena; +#[cfg(feature = "bumpalo")] +pub use arena::{BHConsed, BHConsign}; + /// Internal trait used to recognize hashconsed things. /// /// The only purpose of this trait (currently) is to simplify the type diff --git a/src/test.rs b/src/test.rs index 3c387b7..77cc7d2 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2,3 +2,6 @@ mod basic; mod collect; + +#[cfg(feature = "bumpalo")] +mod arena; diff --git a/src/test/arena.rs b/src/test/arena.rs new file mode 100644 index 0000000..fc0b66f --- /dev/null +++ b/src/test/arena.rs @@ -0,0 +1,98 @@ +//! Tests for the arena-backed consign. + +use std::{collections::HashSet, fmt}; + +use crate::{ + arena::{bumpalo::Bump, BHConsed, BHConsign}, + hash_coll::hashers::p_hash, +}; + +type Term<'b> = BHConsed<'b, ActualTerm<'b>>; + +#[derive(Hash, PartialEq, Eq)] +enum ActualTerm<'b> { + Var(usize), + Lam(Term<'b>), + App(Term<'b>, Term<'b>), +} + +impl fmt::Display for ActualTerm<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Var(i) => write!(fmt, "v{i}"), + Self::Lam(t) => write!(fmt, "({})", t.get()), + Self::App(u, v) => write!(fmt, "{}.{}", u.get(), v.get()), + } + } +} + +#[test] +fn run() { + let arena = Bump::new(); + let mut factory: BHConsign<'_, ActualTerm<'_>> = BHConsign::with_capacity(&arena, 100); + assert!(factory.is_empty()); + + let (v1, is_new) = factory.mk_is_new(ActualTerm::Var(0)); + assert!(is_new); + assert_eq!(factory.len(), 1); + assert!(!factory.is_empty()); + + let (v2, is_new) = factory.mk_is_new(ActualTerm::Var(3)); + assert!(is_new); + assert_eq!(factory.len(), 2); + + let (lam, is_new) = factory.mk_is_new(ActualTerm::Lam(v2)); + assert!(is_new); + assert_eq!(factory.len(), 3); + + let (v3, is_new) = factory.mk_is_new(ActualTerm::Var(3)); + assert!(!is_new); + assert_eq!(factory.len(), 3); + + let (lam2, is_new) = factory.mk_is_new(ActualTerm::Lam(v3)); + assert!(!is_new); + assert_eq!(factory.len(), 3); + + let (app, is_new) = factory.mk_is_new(ActualTerm::App(lam2, v1)); + assert!(is_new); + assert_eq!(factory.len(), 4); + + assert_ne!(v1.uid(), v2.uid()); + assert_eq!(v2.uid(), v3.uid()); + assert_eq!(lam.uid(), lam2.uid()); + + // Sharing is at the allocation, not just at the uid. + assert!(std::ptr::eq(v2.get(), v3.get())); + assert!(std::ptr::eq(lam.get(), lam2.get())); + + assert!(factory.contains(&ActualTerm::Var(3))); + assert!(!factory.contains(&ActualTerm::Var(7))); + + assert_eq!(format!("{app}"), "(v3).v0"); + + assert_eq!(factory.iter().count(), 4); + assert_eq!(factory.consed_iter().count(), 4); + for consed in factory.consed_iter() { + assert!(factory.contains(consed.get())); + } + + let mut set: HashSet, p_hash::Builder> = HashSet::default(); + assert!(set.insert(v2)); + assert!(!set.insert(v3)); + for term in [v1, v2, lam, v3, lam2, app] { + set.insert(term); + } + assert_eq!(set.len(), 4); +} + +#[test] +fn copy_handles_need_no_clone() { + fn takes(_: T) {} + + let arena = Bump::new(); + let mut factory: BHConsign<'_, ActualTerm<'_>> = BHConsign::new(&arena); + let v = factory.mk(ActualTerm::Var(0)); + takes(v); + takes(v); + assert_eq!(factory.len(), 1); +} diff --git a/tests/send_sync.rs b/tests/send_sync.rs index eedf3ab..30d40a7 100644 --- a/tests/send_sync.rs +++ b/tests/send_sync.rs @@ -56,3 +56,67 @@ fn rayon() { }) .collect::<()>(); } + +#[cfg(feature = "bumpalo")] +mod arena { + use hashconsing::arena::{bumpalo::Bump, BHConsed, BHConsign}; + + use rayon::prelude::*; + + type Tree<'b> = BHConsed<'b, RawTree<'b>>; + + #[derive(Debug, PartialEq, Eq, Hash)] + enum RawTree<'b> { + Node(Tree<'b>, usize, Tree<'b>), + Leaf(usize), + } + + #[test] + fn rayon() { + let arena = Bump::new(); + let mut consign: BHConsign<'_, RawTree<'_>> = BHConsign::new(&arena); + macro_rules! tree { + ($val:expr) => { + consign.mk(RawTree::Leaf($val)) + }; + ($lft: expr, $val:expr, $rgt:expr) => { + consign.mk(RawTree::Node($lft, $val, $rgt)) + }; + } + + let t_7 = tree!(7); + let t_5 = tree!(5); + let t_3 = tree!(3); + let t_2 = tree!(2); + let t_1 = tree!(1); + + let t_10 = tree!(t_1, 3, t_2); + let t_11 = tree!(t_10, 6, t_3); + let t_12 = tree!(t_11, 11, t_5); + let t_13 = tree!(t_12, 18, t_7); + + let forest = vec![t_7, t_5, t_3, t_2, t_1, t_10, t_11, t_12, t_13]; + let verbose = false; + + forest + .par_iter() + .map(|tree| { + if verbose { + println!("{tree:?}") + } + }) + .collect::<()>(); + + forest + .clone() + .into_par_iter() + .map(|tree| { + if verbose { + println!("{tree:?}") + } + }) + .collect::<()>(); + + assert_eq!(forest.len(), 9); + } +}