Persistent data structures with structural sharing for Rust.
Forked from imbl (itself a fork of
im) with different design priorities:
performance over compatibility, Merkle hashing for O(1) equality checks,
SIMD-accelerated HAMT nodes, and no_std support.
Add to your Cargo.toml:
[dependencies]
pds = "1.0"Optional features can be enabled as needed:
[dependencies]
pds = { version = "1.0", features = ["serde", "rayon"] }See Feature flags for the full list.
All collections use structural sharing: cloning is O(1) and modified copies share unchanged subtrees with the original.
| Type | Algorithm | Constraints | Order | Insert | Lookup |
|---|---|---|---|---|---|
Vector<A> |
RRB tree | Clone |
insertion | O(1)* | O(log n) |
*Amortised O(1) push; O(log n) split/concat.
| Type | Algorithm | Constraints | Order | Insert | Lookup |
|---|---|---|---|---|---|
HashMap<K, V> |
SIMD HAMT | Clone + Hash + Eq |
undefined | O(log n) | O(log n) |
OrdMap<K, V> |
B+ tree | Clone + Ord |
sorted | O(log n) | O(log n) |
| Type | Algorithm | Constraints | Order | Insert | Lookup |
|---|---|---|---|---|---|
HashSet<A> |
SIMD HAMT | Clone + Hash + Eq |
undefined | O(log n) | O(log n) |
OrdSet<A> |
B+ tree | Clone + Ord |
sorted | O(log n) | O(log n) |
| Type | Algorithm | Constraints | Description |
|---|---|---|---|
Bag<A> |
SIMD HAMT | Clone + Hash + Eq |
Persistent multiset — tracks element counts |
OrdBag<A> |
B+ tree | Clone + Ord |
Sorted multiset — Ord, Hash, range queries |
OrdMultiMap<K, V> |
B+ tree | Clone + Ord |
Sorted key → sorted value-set multimap — Ord, Hash, range queries |
OrdSymMap<A> |
2× B+ tree | Clone + Ord |
Sorted symmetric bidirectional map — Ord, Hash |
OrdBiMap<K, V> |
2× B+ tree | Clone + Ord |
Sorted bidirectional map — bijection, Ord, Hash |
OrdTrie<K, V> |
B+ tree of B+ trees | Clone + Ord |
Sorted prefix tree — lexicographic path iteration |
OrdInsertionOrderMap<K, V> |
2× B+ tree | Clone + Ord |
Insertion-ordered map — Ord-only, O(log n) delete |
OrdInsertionOrderSet<A> |
2× B+ tree | Clone + Ord |
Insertion-ordered set — Ord-only, O(log n) delete |
HashMultiMap<K, V> |
SIMD HAMT | Clone + Hash + Eq |
Key → set of values multimap |
InsertionOrderMap<K, V> |
SIMD HAMT + B+ tree | Clone + Hash + Eq |
Map that iterates in insertion order — O(log n) per-op (B+ tree bottleneck, not O(1) like a plain hash map) |
InsertionOrderSet<A> |
SIMD HAMT + B+ tree | Clone + Hash + Eq |
Set that iterates in insertion order — O(log n) per-op (B+ tree bottleneck, not O(1) like a plain hash set) |
BiMap<K, V> |
2× SIMD HAMT | Clone + Hash + Eq |
Bidirectional map — bijection between two types |
SymMap<A> |
2× SIMD HAMT | Clone + Hash + Eq |
Symmetric bidirectional map with O(1) swap |
Trie<K, V> |
HAMT of HAMTs | Clone + Hash + Eq |
Persistent prefix tree — paths to values |
UniqueVector<A> |
RRB tree + SIMD HAMT | Clone + Hash + Eq |
Persistent sequence with uniqueness — dedup queue/stack with O(log n) index access |
All collection types implement Display for human-readable output.
Debug is also implemented on all types for use in format strings and test assertions.
Vector exposes VectorRange — a borrowed, range-bounded view with O(1) construction.
OrdMap and OrdSet will gain equivalent view types (OrdMapRange, OrdSetRange)
following the same design.
A VectorRange holds a reference to the original vector plus two integer bounds.
Construction records only those three values — no tree walk, no element copy:
| Method | Returns | Time |
|---|---|---|
vector.take(n) |
first n elements |
O(1) |
vector.skip(n) |
all but the first n elements |
O(1) |
vector.split_at(i) |
(prefix, suffix) pair |
O(1) |
vector.subrange(lo..hi) |
arbitrary sub-slice | O(1) |
view.take(n) / view.skip(n) / view.subrange(r) |
narrower view | O(1) |
The view borrows the original. Both the view and the vector share the same backing tree — no extra memory is allocated.
Most operations work directly on the view:
let data: Vector<i32> = (0..1000).collect();
// Iterate — O(n) work, O(1) view construction.
for x in data.take(100) { /* ... */ }
// Check length — O(1).
assert_eq!(data.skip(900).len(), 100);
// Chain narrowing operations — all O(1).
let middle = data.skip(200).take(600); // elements 200..800
println!("{}", middle.len()); // 600Call .to_vector() when you need an owned copy:
// Materialise — O(log n) two-split on the underlying tree.
let owned: Vector<i32> = data.take(100).to_vector();Before range views, working with a prefix meant clone-and-split:
// O(log n): clone is O(1) but split_off walks the tree.
let mut prefix = data.clone();
let _rest = prefix.split_off(100);VectorRange costs strictly less: take(100) is pure integer arithmetic.
Views are the right default for any operation that reads a subrange without
needing to own it.
The standard library provides HashMap, BTreeMap, and Vec as mutable, owned
containers. Every clone() allocates fresh memory and copies every element — O(n)
in both time and space. pds collections use structural sharing: clone is always O(1),
and a modification touches only the path from the root to the changed node.
| Operation | std::HashMap |
pds::HashMap |
std::BTreeMap |
pds::OrdMap |
|---|---|---|---|---|
clone() |
O(n) | O(1) | O(n) | O(1) |
| Lookup | O(1) avg | O(log n) | O(log n) | O(log n) |
| Insert | O(1) avg | O(log n) | O(log n) | O(log n) |
| Remove | O(1) avg | O(log n) | O(log n) | O(log n) |
| Iterate | O(n) | O(n) | O(n) | O(n) |
| Equality | O(n) | O(1)† | O(n) | O(1)‡ |
† Merkle hash fast-path — same-lineage maps with equal length and equal Merkle hash compare in O(1).
‡ Cached content hash (ord-hash feature, on by default) — O(1) when the hash is valid.
The trade-off is clone cost versus point-lookup speed. std::HashMap wins on random
lookups (roughly 2× faster than pds::HashMap). pds wins on any operation that involves
copying: every clone that would cost O(n) with a standard map becomes O(1).
When a single thread owns a map and mutates it in a tight loop with no snapshotting,
std::HashMap is the right tool. When you need snapshots, undo/redo, versioning, or
shared state between threads — pds collections win.
| Operation | std::Vec |
pds::Vector |
|---|---|---|
clone() |
O(n) | O(1) |
| Push (back) | O(1) avg | O(1) avg |
| Random access | O(1) | O(log n) |
| Insert (middle) | O(n) | O(log n) |
| Split | O(n) | O(log n) |
| Concat | O(n) | O(log n) |
std::Vec is unbeatable for purely sequential workloads: appending and reading by
index in a tight loop. pds::Vector trades a constant factor on random access (the
RRB tree depth) for dramatically cheaper structural operations — split and concat are
O(log n) rather than O(n), and clone is O(1). Use pds::Vector when you need to branch
on a sequence: taking a snapshot before a speculative edit, passing an independent view
to another thread, or producing multiple output variants from a single input.
Rust's ownership model prevents data races at compile time. pds extends this advantage: because clone is O(1), you can hand a complete, independent snapshot to another thread with no synchronisation overhead.
With a standard library map:
// Every reader must acquire the lock — even for read-only access.
let shared: Arc<Mutex<std::collections::HashMap<K, V>>> = ...;
let guard = shared.lock().unwrap();
let value = guard.get(&key);With a pds map:
// Clone the current snapshot in O(1) — no lock held during processing.
let snapshot: pds::HashMap<K, V> = current_state.clone();
let value = snapshot.get(&key);Because each modification produces a new root without touching the old one, multiple threads can hold snapshots at different points in time — all sharing structure, all independent, none blocking the others. Common patterns:
- Worker pools — distribute independent snapshots to workers; merge results back
with
par_union. - Speculative execution — clone before a tentative operation; discard the clone on rollback, keep it on commit.
- Event sourcing — each state transition produces a new snapshot; prior states are retained cheaply because unchanged subtrees are shared.
- Read scale-out — any number of readers hold the latest snapshot with zero contention; the writer atomically publishes a new root.
The Hash-backed and Ord-backed variants of every collection type share the same
persistent semantics, the same structural-sharing clone model, and the same API shape.
The choice between them is driven first by key constraints, then by which operations
dominate your workload.
This section covers the primary HashMap / OrdMap pair. The same reasoning applies
to every derived type: Bag / OrdBag, HashMultiMap / OrdMultiMap,
BiMap / OrdBiMap, SymMap / OrdSymMap, Trie / OrdTrie,
InsertionOrderMap / OrdInsertionOrderMap, and
InsertionOrderSet / OrdInsertionOrderSet.
These are hard constraints, not preferences:
HashMap / Hash variants |
OrdMap / Ord variants |
|
|---|---|---|
| Key constraint | Clone + Hash + Eq |
Clone + Ord |
| Iteration order | arbitrary (HAMT layout) | sorted by key |
| Range queries | — | get_range, iter_from, split_at_key |
get_min / get_max |
— | O(log n) |
without_min / without_max |
— | O(log n), structural sharing |
split_at_key |
— | O(log n) |
| Parallel join algorithm | filter+reduce (O(n)) | Blelloch join (O(m log(n/m))) |
Used as a HashMap key |
yes (Hash via ord-hash) |
yes (Hash via ord-hash) |
no_std without foldhash |
— | yes |
If you need sorted iteration, range queries, or access to the minimum/maximum key,
OrdMap is the only option. If your keys lack Ord, HashMap is the only option.
When both constraints are satisfied the choice is a performance question.
All numbers from cargo bench --bench compare --features rayon (release profile).
Full results in docs/baselines.md.
| Size | HashMap | OrdMap | Faster |
|---|---|---|---|
| 100 | 549 ns | 630 ns | HashMap ×1.15 |
| 1,000 | 5.76 µs | 10.6 µs | HashMap ×1.84 |
| 10,000 | 74.6 µs | 157 µs | HashMap ×2.11 |
| 100,000 | 1.17 ms | 2.27 ms | HashMap ×1.94 |
HAMT gives O(1) amortised lookup (fixed trie depth). OrdMap is O(log n) with a small constant from B+ node binary search. HashMap is consistently ~2× faster for random point queries, and this is the only operation where HashMap wins.
| Size | HashMap insert | OrdMap insert | OrdMap faster |
|---|---|---|---|
| 100 | 2.20 µs | 1.26 µs | ×1.74 |
| 1,000 | 30.4 µs | 16.2 µs | ×1.88 |
| 10,000 | 236 µs | 230 µs | ≈ equal |
| 100,000 | 3.97 ms | 2.01 ms | ×1.98 |
OrdMap wins under sole-owner writes: copy-on-write detects the sole reference and
mutates in-place without allocating. HashMap rewrites HAMT nodes on every insert
regardless of ownership. The same pattern holds for from_iter (OrdMap ×1.4–2.0×
faster) and remove_mut at small sizes (OrdMap ×1.7–2.2× faster at ≤1K).
| Size | HashMap iter | OrdMap iter | OrdMap faster |
|---|---|---|---|
| 100 | 199 ns | 145 ns | ×1.37 |
| 1,000 | 1.89 µs | 1.35 µs | ×1.40 |
| 10,000 | 33.3 µs | 14.3 µs | ×2.33 |
| 100,000 | 553 µs | 155 µs | ×3.57 |
B+ tree leaves are contiguous arrays; iterating a leaf scans cache-linearly. HAMT traversal follows pointer chains through bitmapped nodes — poor spatial locality. The gap widens with size as the HAMT grows deeper.
| Operation | Size | HashMap | OrdMap | OrdMap faster |
|---|---|---|---|---|
par_union |
10,000 | 1.08 ms | 267 µs | ×4.0 |
par_union |
100,000 | 13.0 ms | 840 µs | ×15.5 |
par_intersection |
10,000 | 929 µs | 437 µs | ×2.1 |
par_intersection |
100,000 | 9.55 ms | 1.49 ms | ×6.4 |
OrdMap uses the O(m log(n/m)) parallel join algorithm (split → recurse → concat); HashMap uses filter+reduce, which has a sequential O(n) bottleneck. The gap grows with size and is the dominant reason to choose OrdMap for any set-merge workload.
From cargo bench --bench memory (dhat, 100,000 i64 entries):
| HashMap | OrdMap | OrdMap fewer | |
|---|---|---|---|
| Allocations | 29,633 | 6,641 | ×4.5 |
| Bytes | 13.9 MB | 3.6 MB | ×3.9 |
OrdMap packs up to 16 key-value pairs per leaf allocation. HAMT nodes are per-trie-level and multiply with tree depth.
Use HashMap / Hash variants when:
- Keys are not
Ord(e.g. unordered tuples, custom types without a natural order). - Your workload is dominated by random point lookups and writes are infrequent. The ~2× lookup advantage compounds when lookups are the overwhelming majority of operations.
- You rely on
ptr_eqstructural-sharing fast-paths:HashMap::par_unionshort-circuits to O(1) when both operands share the same root (common afterclone()with no mutation). This benefits patterns like "start from a common snapshot, make one change, union back". - You accumulate incremental Merkle hashes without rescan. HAMT root hashes update
atomically on each insert;
OrdMap(ord-hash) recomputes lazily. If you clone a map, mutate it once, and compare it immediately, HAMT avoids the deferred rescan.
Use OrdMap / Ord variants when:
- You need sorted order at any point — sorted output, priority processing, stable serialisation, or deterministic comparison. Iteration is sorted by definition and ×2–4× faster than HashMap at large sizes.
- You need range queries: "all keys between X and Y", "everything from key K onwards".
This is only available on
OrdMap. - You need minimum / maximum access without a full scan (
get_min,get_max,without_min,without_max). These are O(log n). - You perform parallel set operations (
par_union,par_intersection, etc.). At 100K entries OrdMap's join algorithm is ~15× faster than HashMap's filter+reduce. - Your workload is write-heavy or bulk-construction-heavy. Sole-owner in-place
mutation gives OrdMap a consistent ×1.5–2× advantage over HashMap for inserts,
removes, and
from_iter. - You are in a
no_stdenvironment without thefoldhashfeature. OrdMap requires no hasher. - You want lower memory pressure. At 100K entries OrdMap uses 4.5× fewer allocations and 3.9× less memory than HashMap.
- Keys are expensive to hash.
Ordcomparison is often cheaper than hashing for numeric or short-string keys, and OrdMap's B+ tree stops as soon as a comparison resolves the branch.
Use either when:
- Immutable snapshots / structural sharing: both types share subtrees on clone and write only the changed path.
- Serde round-trips: both implement
Serialize/Deserialize(behindserdefeature). - Rayon parallel iteration: both support
par_iter()andFromParallelIterator. - As a key in another map: both implement
Hash(viaord-hashforOrdMap; built-in forHashMap) and can be used as keys inHashMap<OrdMap<_,_>, _>etc.
Every Hash-variant / Ord-variant pair follows the same pattern:
| Hash variant | Ord variant | Primary addition in Ord variant |
|---|---|---|
Bag<A> |
OrdBag<A> |
sorted element order, range count queries |
HashMultiMap<K,V> |
OrdMultiMap<K,V> |
sorted keys and values, range scans |
BiMap<K,V> |
OrdBiMap<K,V> |
sorted forward and reverse iteration |
SymMap<A> |
OrdSymMap<A> |
sorted pair iteration |
Trie<K,V> |
OrdTrie<K,V> |
lexicographic prefix iteration in sorted order |
InsertionOrderMap<K,V> |
OrdInsertionOrderMap<K,V> |
no Hash needed on keys |
InsertionOrderSet<A> |
OrdInsertionOrderSet<A> |
no Hash needed on elements |
For each pair: the Hash variant requires Hash + Eq, the Ord variant requires only Ord.
Performance characteristics mirror the HashMap / OrdMap comparison above: the Ord
variant is faster for writes, iteration, and parallel ops; the Hash variant is faster for
random point lookups on large collections. The OrdInsertionOrder* types have no Hash
variant analogue for the iteration-order guarantee when keys lack Hash.
dhat measures exact heap allocation counts per operation. The table below is from
cargo bench --bench memory on an M5 Max (Rust 1.95.0, release profile).
from_iter allocations and bytes — HashMap<i64,i64> vs OrdMap<i64,i64>:
| Entries | HashMap allocs | OrdMap allocs | Ratio | HashMap bytes | OrdMap bytes | Ratio |
|---|---|---|---|---|---|---|
| 1,000 | 226 | 68 | 3.3× | 120,288 | 36,576 | 3.3× |
| 10,000 | 1,134 | 666 | 1.7× | 528,216 | 358,224 | 1.5× |
| 100,000 | 29,633 | 6,641 | 4.5× | 13,874,968 | 3,572,024 | 3.9× |
from_iter allocations — HashSet<i64> vs OrdSet<i64>:
| Entries | HashSet allocs | OrdSet allocs | Ratio |
|---|---|---|---|
| 1,000 | 248 | 68 | 3.6× |
| 10,000 | 1,147 | 666 | 1.7× |
| 100,000 | 29,709 | 6,641 | 4.5× |
The gap opens with scale because HAMT promotes nodes through three tiers (SmallSimdNode → LargeSimdNode → HamtNode) and each trie level adds allocations. OrdMap's B+ tree is bounded by the tree height: at 100,000 entries, roughly one allocation per 15 entries (100,000 / 16 ≈ 6,250 leaves; 6,641 includes internal branch nodes).
Small-map behaviour: for ≤ 16 entries, HashMap uses a SmallSimdNode — a single
flat allocation with SIMD lookup. For ≤ 32 entries it promotes to LargeSimdNode. At
these sizes HashMap's allocation count is comparable to OrdMap.
For your own workload, run cargo bench --bench memory or instrument with:
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
let _profiler = dhat::Profiler::new_heap();
// ... operations under test ...Full results are in docs/baselines.md.
pds is forked from imbl, which is itself a fork of the unmaintained im. The API is largely compatible with imbl 7.x, but pds prioritises performance and capability over strict backward compatibility.
rpds is an independent implementation with a different collection set and design philosophy.
| pds | imbl | im | rpds | |
|---|---|---|---|---|
| Version | 1.0.0 | 7.0.0 | 15.1.0 | 1.2.0 |
| Last release | 2026 | Jan 2026 | Apr 2022 | Nov 2025 |
| Vector | RRB tree | RRB tree | RRB tree | — (indexable sequence) |
| HashMap / Set | SIMD HAMT | SIMD HAMT | HAMT | HAMT |
| OrdMap / Set | B+ tree | B+ tree | B-tree | Red-black tree |
| Bag | yes | — | — | — |
| OrdBag | yes | — | — | — |
| HashMultiMap | yes | — | — | — |
| OrdMultiMap | yes | — | — | — |
| InsertionOrderMap | yes | — | — | — |
| InsertionOrderSet | yes | — | — | — |
| OrdInsertionOrderMap | yes | — | — | — |
| OrdInsertionOrderSet | yes | — | — | — |
| BiMap | yes | — | — | — |
| OrdBiMap | yes | — | — | — |
| SymMap | yes | — | — | — |
| OrdSymMap | yes | — | — | — |
| Trie | yes | — | — | — |
| OrdTrie | yes | — | — | — |
| UniqueVector | yes | — | — | — |
| List / Stack / Queue | — | — | — | yes |
| Merkle hashing | O(1) equality | — | — | — |
| SIMD node ops | yes | yes | — | — |
no_std |
yes (default-features = false; type aliases via foldhash) |
— | — | yes |
triomphe::Arc |
yes | — | — | — |
| Hash consing | yes (InternPool) |
— | — | — |
| SSP serialisation | yes (HashMapPool) |
— | — | — |
| serde | yes | yes | yes | yes |
| rayon | yes | yes | yes | yes (hash maps only) |
| Par set ops | yes (Hash + OrdMap/OrdSet)† | — | — | — |
| proptest / quickcheck | yes | yes | yes | — |
†par_union, par_intersection, par_difference, par_symmetric_difference are
available on all Hash-backed types and on OrdMap/OrdSet. Trie, InsertionOrderMap,
InsertionOrderSet, and all Ord-derived types (OrdBag, OrdMultiMap, OrdBiMap,
OrdSymMap, OrdTrie, OrdInsertionOrderMap, OrdInsertionOrderSet) do not have
parallel set operations.
Key differences from imbl:
- Merkle hashing on all collections for O(1) structural equality checks
- Fifteen additional collection types: Bag, OrdBag, HashMultiMap, OrdMultiMap, InsertionOrderMap, InsertionOrderSet, OrdInsertionOrderMap, OrdInsertionOrderSet, BiMap, OrdBiMap, SymMap, OrdSymMap, Trie, OrdTrie, UniqueVector
- Hash consing via
InternPool— deduplicates identical HAMT subtrees across collections - Structural-sharing-preserving serialisation via
HashMapPool— serialises/deserialises trees with node deduplication and cross-session interning no_stdsupport viadefault-features = false;foldhashfeature provides type aliases in no_std environmentstriomphe::Arcsupport (no weak count, 8 bytes smaller per node)- Deprecated API aliases removed; breaking changes for correctness accepted
- API docs — build locally with
rm -rf rustdocs && cargo doc --no-deps --all-features --target-dir rustdocs --open - Architecture — internal data structure design
- Decision log — architectural choices and rationale
- Glossary — project terminology
- Implementation plan — phased improvement roadmap
- References — papers and external resources
| Feature | Default | Description |
|---|---|---|
std |
Yes | Enables std-dependent type aliases (HashMap, HashSet, etc.), From<std::collections::*> conversions, and Mutex-based locking. Disable for no_std + alloc environments. |
triomphe |
Yes | Use triomphe::Arc as the default shared pointer — no weak count, 8 bytes smaller per node, one fewer atomic op per clone/drop. |
proptest |
No | Proptest strategies for all 20 collection types. |
quickcheck |
No | Arbitrary implementations for all 20 collection types. |
rayon |
No | Parallel iterators, parallel set operations (par_union, par_intersection, par_difference, par_symmetric_difference), and parallel transform operations (par_filter, par_map_values, par_map_values_with_key) for all eligible collection types. See "Parallel support" below for full coverage. |
serde |
No | Serialize / Deserialize for all collection types |
arbitrary |
No | Arbitrary implementations for fuzzing — all 20 collection types. |
foldhash |
No | Enables HashMap/HashSet/etc. type aliases in no_std via foldhash::fast::RandomState |
atom |
No | Thread-safe atomic state holder via arc-swap (requires std) |
hash-intern |
No | Hash consing / node interning for HAMT collections via InternPool — deduplicates identical subtrees for memory savings and O(1) pointer equality |
persist |
No | Structural-sharing-preserving serialisation via HashMapPool — serialises HAMT trees with node deduplication, reconstructs with hash consing. Requires hash-intern |
ord-hash |
Yes | Cached content hash on OrdMap and OrdSet — O(1) PartialEq fast-path, content_hash() method, and Hash impl when K: Hash, V: Hash. One atomic store per mutation; overhead is unmeasurable for typical workloads. |
small-chunks |
No | Reduces internal chunk sizes so tree structures can be exercised with small collections. For testing only — not intended for production use. |
debug |
No | Enables internal invariant-checking methods on Vector (RRB tree validation). For testing and debugging only. |
The core types and their Hash-backed derived types gain parallel capabilities under
the rayon feature flag. Ord-backed derived types (OrdBag, OrdMultiMap,
OrdBiMap, OrdSymMap, OrdTrie, OrdInsertionOrderMap, OrdInsertionOrderSet)
do not currently have rayon support — only sequential operations.
| Type | par_iter |
FromParallelIterator |
ParallelExtend |
Notes |
|---|---|---|---|---|
HashMap<K, V> |
✓ | ✓ | ✓ | |
HashSet<A> |
✓ | ✓ | ✓ | |
OrdMap<K, V> |
✓ | ✓ | ✓ | |
OrdSet<A> |
✓ | ✓ | ✓ | |
Vector<A> |
✓ | ✓ | ✓ | |
Bag<A> |
✓ | ✓ | ✓ | Also par_elements() for flat expansion |
OrdBag<A> |
— | — | — | No rayon support |
HashMultiMap<K, V> |
✓ | ✓ | ✓ | Default hasher only |
OrdMultiMap<K, V> |
— | — | — | No rayon support |
BiMap<K, V> |
✓ | ✓ | ✓ | Default hasher only |
OrdBiMap<K, V> |
— | — | — | No rayon support |
SymMap<A> |
✓ | ✓ | ✓ | Default hasher only |
OrdSymMap<A> |
— | — | — | No rayon support |
InsertionOrderMap<K, V> |
✓ | — | — | Parallel collection loses insertion order |
OrdInsertionOrderMap<K, V> |
— | — | — | No rayon support |
InsertionOrderSet<A> |
✓ | — | — | Parallel collection loses insertion order |
OrdInsertionOrderSet<A> |
— | — | — | No rayon support |
Trie<K, V> |
— | — | — | No rayon support |
OrdTrie<K, V> |
— | — | — | No rayon support |
Every collection type that exposes union, intersection, difference, and
symmetric_difference also has parallel counterparts named with the par_
prefix. These work identically to the sequential versions but use rayon to
parallelise the computation.
| Type | par_union |
par_intersection |
par_difference |
par_symmetric_difference |
|---|---|---|---|---|
HashMap<K, V> |
✓ | ✓ | ✓ | ✓ |
HashSet<A> |
✓ | ✓ | ✓ | ✓ |
OrdMap<K, V> |
✓ | ✓ | ✓ | ✓ |
OrdSet<A> |
✓ | ✓ | ✓ | ✓ |
Bag<A> |
✓ | ✓ | ✓ | ✓ |
HashMultiMap<K, V> |
✓† | ✓ | ✓ | ✓ |
BiMap<K, V> |
✓† | ✓ | ✓ | ✓ |
SymMap<A> |
✓† | ✓ | ✓ | ✓ |
All Ord-backed derived types (OrdBag, OrdMultiMap, OrdBiMap, OrdSymMap,
OrdTrie, OrdInsertionOrderMap, OrdInsertionOrderSet): no parallel set operations.
† par_union delegates to the sequential implementation for these types.
BiMap and SymMap maintain bijection/symmetry invariants that require
sequential conflict resolution on each insert; HashMultiMap value-set merging
has the same constraint. The other three par ops are fully parallelised via
parallel filter + collect.
Fast paths for HashMap / HashSet: The HAMT-backed types also exploit
structural sharing for O(1) short-circuits:
ptr_eq— if both collections share the same root pointer they are identical; union returns one copy, difference returns empty, intersection returns one copy.- Merkle hash — same-lineage maps with equal length and equal Merkle hash are definitively equal; the fast-path fires without comparing individual entries.
Join algorithm for OrdMap / OrdSet (B+ tree): These types use a
fundamentally different parallel strategy based on Blelloch et al., "Joinable
Parallel Balanced Binary Trees" (ACM TOPC 2022) and "PaC-trees" (PLDI 2022).
A single structural split at the root's median key divides both inputs into
independent halves, which are merged recursively in parallel via rayon::join,
then concatenated with a height-aware concat. This gives:
- Work: O(m log(n/m + 2)) — optimal for set operations on inputs of size m ≤ n
- Span: O(log² n) — polylogarithmic, scales with thread count
Both the split and concat are O(log n) structural operations on the B+ tree spine — no per-entry hashing or re-insertion required. This is believed to be the first implementation of the Blelloch join algorithm on a blocked-leaf persistent B+ tree in any language. No other Rust persistent map library implements join-based parallel set operations.
Rayon-join parallelism for symmetric_difference: par_symmetric_difference
on all types uses rayon::join to compute the two halves (self \ other and
other \ self) simultaneously on separate threads.
use pds::{Bag, HashMap, HashSet};
use rayon::iter::ParallelIterator;
// Parallel iteration
let map: HashMap<i32, &str> = (0..10_000).map(|i| (i, "x")).collect();
let sum: i32 = map.par_iter().map(|(&k, _)| k).sum();
// Parallel set operations
let mut a = Bag::new();
let mut b = Bag::new();
a.insert_many("apple", 5);
a.insert_many("banana", 3);
b.insert_many("banana", 7);
b.insert_many("cherry", 2);
let union = a.par_union(&b); // apple:5, banana:10, cherry:2
let intersection = a.par_intersection(&b); // banana:3
let difference = a.par_difference(&b); // apple:5# Development (requires Nix)
nix develop # enter devShell with stable Rust + sccache
bash test.sh # run full quality gate (tests + clippy + doc)
bash bench.sh # run criterion benchmarks
bash bench.sh vector # run a single benchmark suite
# Nightly tools (miri, fuzzing)
nix develop .#nightly # enter nightly devShell
cargo miri test # run tests under miri
cd fuzz && cargo fuzz list # list fuzz targetsThis crate supports Rust 1.85 and later.
Copyright 2017–2021 Bodil Stokke Copyright 2021 Joe Neeman
This software is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.