Canonical documentation for Expanse Python bindings, PyPI distribution, type stubs, and GIL-free concurrent architecture.
Architecture: ARCHITECTURE.md · Packaging: PACKAGING.md · CI Pipeline: CI.md
expanse-trie provides high-performance Python bindings for Expanse, the clean-room, pure-Rust reimplementation of Judy arrays and digital tries modernized for 64-bit hardware.
-
Zero-Overhead Memory Compaction: Consumes as low as 0.07–0.36 bytes/key on clustered integer sets (measured: Apple M1,
bytes_per_keyexample, commit 6c63826a — deterministic byte accounting), versus tens of bytes/key for Python's standardsetanddict. -
Cache-Line Aligned Digital Tries:
$O(\text{depth})$ traversals (at most 8 digit steps for 64-bit keys) keeping branch and leaf evaluations within 64-byte L1 cache lines. -
Ordered Traversal & Range Scans: Native sorted iteration,
$O(\text{depth})$ first(),last(),next_at_or_after(),prev_at_or_before(), rank (count_below), and select (by_count) without maintaining secondary index trees. -
GIL-Free Optimistic Concurrency Control (OCC):
SyncExpanseSet,SyncExpanseMapand the four shared maps of §4.1 release the Python GIL (py.detach, pyo3 0.29's renamedallow_threads) during queries, so multiple Pythonthreading/ThreadPoolExecutorworkers execute reads concurrently across cores without a read lock on the common path. Which API you call decides whether you get multi-core reads. Per-callgetreleases and reacquires the GIL around a lookup of tens of nanoseconds, so the handoff costs more than the work and concurrent callers convoy on reacquisition — pinned to the performance cores it measures 0.124x its own single-thread throughput at 16 threads.get_manyamortises one release across the batch and is the form to use for concurrent reads; on one thread it is 1.15x [1.04, 1.23] a plaindict, while per-callgetloses to one. Against thedictcontrol only the single-thread comparison is settled; at two threads and above the control is multi-modal and the comparison isINTERMEDIATE(#774). The earlier0.02xfigure for this bullet was measured unpinned and is superseded. Full table and provenance in § Concurrency. -
Strict Typing & IDE Support: Full PEP 561 compliance (
py.typedand__init__.pyistubs) for mypy, Pyright, and IDE autocompletion.
Precompiled binary wheels (built with Python abi3 for Python 3.8+) are distributed on PyPI for Linux (x86_64, aarch64), macOS (arm64, x86_64), and Windows (x86_64):
pip install expanse-trieTo compile from source using maturin:
# Install maturin build backend
pip install "maturin>=1.5,<2.0" pytest
# Build and install into active virtualenv
maturin develop --release
# Execute test suite
pytest tests/test_python_bindings.py -vgraph TD
A[expanse_trie] --> B[ExpanseSet (Judy1: 64-bit Integer Set)]
A --> C[ExpanseMap (JudyL: 64-bit Key-Value Map)]
A --> D[ExpanseStrMap (JudySL: String/Bytes Trie Map)]
A --> E[ExpanseBytesMap (JudyHS: Arbitrary Bytes Hash Map)]
A --> F[SyncExpanseSet (Thread-Safe OCC Set)]
A --> G[SyncExpanseMap (Thread-Safe OCC Map)]
A --> H[SyncExpanseStrMap / SyncExpanseBytesMap / SyncExpanseOrderedBytesMap / SyncExpanseBlobMap (Thread-Safe OCC Maps)]
ExpanseSet stores dynamic populations of 64-bit unsigned integers with adaptive compression:
from expanse_trie import ExpanseSet
# Create and populate
s = ExpanseSet([10, 20, 30, 40, 50, 1000])
# O(depth) Membership tests
assert 20 in s
assert 25 not in s
# Mutations
s.add(60)
s.remove(20)
assert len(s) == 6
# Ordered Navigation & Proximity Searches
assert s.first() == 10
assert s.last() == 1000
assert s.next_at_or_after(25) == 30 # Smallest key >= 25
assert s.next_after(30) == 40 # Smallest key > 30
assert s.prev_at_or_before(35) == 30 # Largest key <= 35
assert s.prev_before(30) == 10 # Largest key < 30
# Rank & Select (0-based)
assert s.count_below(40) == 2 # Keys strictly < 40 (10, 30)
assert s.count_range(10, 50) == 4 # Keys in [10, 50] inclusive
assert s.by_count(0) == 10 # 0-th key in sorted order
assert s.by_count(3) == 50 # 3-rd key in sorted order
# Range Scanning
assert s.range(25, 60) == [30, 40, 50, 60]
# Exact memory footprint in bytes
print(f"Memory used: {s.mem_used()} bytes")ExpanseMap maps 64-bit integer keys to 64-bit integer values with dict-like semantics:
from expanse_trie import ExpanseMap
# Create map from dict or pairs
m = ExpanseMap({1: 100, 2: 200, 5: 500, 10: 1000})
# Dict-like item access
assert m[1] == 100
m[20] = 2000
assert m.get(99, default=0) == 0
# Pop & Remove
old = m.pop(2)
assert old == 200
assert 2 not in m
# Ordered iteration & Range Queries
assert list(m) == [1, 5, 10, 20]
assert m.items() == [(1, 100), (5, 500), (10, 1000), (20, 2000)]
assert m.range(5, 15) == [(5, 500), (10, 1000)]
# Navigation
assert m.next_at_or_after(6) == (10, 1000)ExpanseStrMap indexes arbitrary variable-length string or byte keys into 64-bit values using digital expanse trie branching:
from expanse_trie import ExpanseStrMap
sm = ExpanseStrMap()
sm["http://api.internal/users"] = 1
sm["http://api.internal/orders"] = 2
sm["http://api.internal/payments"] = 3
# Exact lookups
assert sm["http://api.internal/orders"] == 2
assert "http://api.internal/catalog" not in sm
# Lexicographical range queries
routes = sm.range(start="http://api.internal/o", end="http://api.internal/p~")
# Returns [("http://api.internal/orders", 2), ("http://api.internal/payments", 3)]ExpanseBytesMap hashes arbitrary binary keys (including binary blobs with embedded \x00 null bytes):
from expanse_trie import ExpanseBytesMap
bm = ExpanseBytesMap()
raw_key = b"session\x00auth\x00token\xff\xfe"
bm[raw_key] = 8888
assert raw_key in bm
assert bm[raw_key] == 8888ExpanseBlobMap maps a 64-bit key to an arbitrary byte payload, packing small payloads inline in the value slot and bump-allocating larger ones in the arena (see large-values.md; live arena ceiling is 16 MiB). insert takes an optional 32-bit hot metadata word stored alongside the locator for predicate filtering without dereferencing the payload:
from expanse_trie import ExpanseBlobMap
blob = ExpanseBlobMap() # optional: ExpanseBlobMap(chunk_size=...)
# insert(key, data, hot_meta): hot_meta is a u32 (e.g. TTL / flags / tenant id)
blob.insert(1, b"arbitrary payload bytes", 0x01)
# get() returns (payload, hot_meta); get_bytes() / [] return just the payload
payload, meta = blob.get(1)
assert payload == b"arbitrary payload bytes" and meta == 0x01
assert blob[1] == b"arbitrary payload bytes"
# The dict-style setter stores with hot_meta = 0 (the inline-blob default contract):
blob[2] = b"no metadata"
assert blob.get(2) == (b"no metadata", 0)
# Persistence: save_to_file() writes an image; load_from_file() reads it back
# (full std::fs::read + index rebuild — not an mmap).
n = blob.save_to_file("blob.img")
reloaded = ExpanseBlobMap.load_from_file("blob.img")
assert reloaded[1] == b"arbitrary payload bytes"The arena's capacity cap counts allocated chunk bytes and defaults to 1 GiB; ExpanseBlobMap(chunk_size, max_capacity) sets another, clamped to [chunk_size, 64 GiB]. An insert the cap refuses may compact the arena under the reclaim rule (docs/design/large-values.md §6.3.1); set_reclaim_at_cap(False) turns that off. That compaction copies every live payload inside the one call and stalls the inserting thread, and on a shared map every other writer; §6.3.1 gives the measured stall and how to avoid it. insert raises RuntimeError on any refusal; insert_status returns the reason instead: "cap_refused" when nothing was compacted (dead bytes may remain, arena_stats() shows how many), "arena_full" when the insert compacted and the record still does not fit. A map loaded with load_from_file has the default cap.
ExpanseOrderedBytesMap binds the expanse_ordered_bytesmap_* C surface (include/expanse.h) for Python: an ordered map from arbitrary byte keys to 64-bit values. Keys compare as unsigned bytes in lexicographic order, so a key sorts before any longer key it prefixes. Keys may be empty and may contain any byte, including 0x00 and 0xFF. str keys are encoded as UTF-8 and address the same entry as their bytes.
| Python | C symbol |
|---|---|
| constructor / garbage collection | _new / _free |
map[key], insert, get, in, del map[key], remove |
_get, _insert, _contains, _remove |
slot, ins_slot |
_slot, _ins_slot |
len, mem_used, mem_held, shrink_to_fit, clear |
_len, _mem_used, _mem_held, _shrink_to_fit, _clear |
first, last |
_first, _last |
next_at_or_after, next_after |
_next_at_or_after, _next_after |
prev_at_or_before, prev_before |
_prev_at_or_before, _prev_before |
from expanse_trie import ExpanseOrderedBytesMap
om = ExpanseOrderedBytesMap()
om[b""] = 0
om[b"\x00\xff"] = 1
om["user:42"] = 2
assert list(om.keys()) == [b"", b"\x00\xff", b"user:42"]
assert om.first() == (b"", 0)
assert om.next_after(b"") == (b"\x00\xff", 1)
assert om.prev_before(b"user:42") == (b"\x00\xff", 1)- Navigation returns owned
bytes. The C ABI asks the caller to supply a destination buffer and retry onEXPANSE_ORDERED_BYTES_NAV_BUFFER_TOO_SMALL. The Python binding has no caller buffer: each key is returned whole, so a key is never truncated and no retry is needed. - Missing keys:
map[key]raisesKeyError;getreturnsNoneor the given default;first/lastand the navigation methods returnNonewhen no entry matches. slotandins_slotreturn values, not pointers. Python has no writable value pointer, soslot(key)returns the stored value orNone, andins_slot(key)inserts0when the key is absent (an existing value is kept) and returns the value. Store a new value withmap[key] = value.- Iteration (
for key in map,keys(),values(),items()) is in ascending key order over a snapshot taken when the iterator is created. Navigation methods read the map as it is at the call.
In standard CPython, multithreaded CPU-bound data lookups often serialize on the Global Interpreter Lock (GIL).
SyncExpanseSet and SyncExpanseMap solve this by implementing optimistic concurrency control (OCC) in Rust and wrapping calls with py.detach (pyo3 0.29's renamed allow_threads):
-
Optimistic Reads: Query operations (
contains,get,get_many,len,is_empty, and onSyncExpanseMapthe ordered readsfirst,last,nextandprev) validate version seqlocks and read concurrently without holding a mutex on the common path, and without the Python GIL. The protocol is blocking optimistic lock coupling, not lock-free: a read that exhausts its bounded retries falls back to the writer-excluding path, and writers wait for it. Rank, range and iteration (count_below,by_count,count_range,range,keys/values/itemsand__iter__) and the ordered reads ofSyncExpanseSetrun with writers excluded. The map's ordered reads moved to the optimistic path in #900; under quiescence they return what the writer-excluding reads returned. -
Serialized Writes: Mutations (
insert,remove) synchronize internally while allowing readers to proceed optimistically.SyncExpanseMapalso has atomic read-modify-write (#1194).compare_exchange(key, expected, new)storesnewonly if the key's current value isexpected, withNonemeaning absent on both sides ((None, v)inserts if absent,(e, None)removes if equal), and returns(stored, observed):observedis the value the compare saw, so a failed call hands back the value to retry against.get_or_insert(key, val)storesvalonly if the key is absent and returns the existing value, orNonewhen it stored (the convention ofinsert).update_with(key, fn)storesfn(current)(Noneremoves the key) and returns the replaced value; it is a loop overcompare_exchangein the extension that takes the GIL only to callfn, sofnmay run more than once per call and must be free of side effects, and an exception fromfnpropagates with the key unchanged. It is not namedupdatebecauseupdate(other)is the dict-style bulk merge. The C ABI exports no callback form (docs/COMPAT.md, Atomic RMW row); the other language bindings write the same loop overcompare_exchange. -
Multi-Core Reads — through
get_many, notget. Which API you call decides whether you get multi-core reads: per-callgetreleases and reacquires the GIL around a lookup of tens of nanoseconds, so the handoff costs more than the work and concurrent callers convoy on reacquisition, whileget_manyamortises one release across the batch.Re-measured on the reference host (#755, #774). The figures below replace the withheld ones. Two separate defects had to be cleared first, and the second was found by this re-run rather than by #755:
- the harness drew its miss half as
k ^ (1 << 63)of each hit key, which lands every miss in a different top-level expanse from the key it came from, so it terminates at a systematically different depth than a hit — the stream met its stated 50% hit rate while averaging two different descents into one number (section 8.6). Corrected: misses are rejection-sampled from the population's own generator and shuffled in. - the superseded run was taken unpinned on a hybrid host, and pin exposure grows with thread count — parity at one thread, 5.57x [5.31, 5.74] at sixteen (#774). That invalidated the within-arm scaling curve too, which #755 had retained on the reasoning that its contamination was constant along the thread axis. It was, for the miss shape; it was not, for the pin.
Throughput in Mops/s,
SyncExpanseMapthrough per-callget, pinned to the performance cores (workload:python_concurrency):threads get(per-call)scaling vs 1 thread 1 5.686 [5.565, 5.738] 1.000 2 2.338 [2.317, 2.372] 0.411 4 0.819 [0.812, 0.839] 0.144 8 0.689 [0.686, 0.693] 0.121 16 0.708 [0.696, 0.712] 0.124 The per-call arm does not scale: it is slowest at 8-16 threads, about an eighth of its single-thread throughput.
get_manyis the form that reaches more than one core, peaking at two threads (16.204 [15.519, 16.685], 1.622x its own single-thread rate) and falling off after — at 16 threads each worker runs about three batches, so there is little left to amortise, and assembling the result list is itself GIL-bound Python work.Against the
dictcontrol, only the single-thread comparison is settled.get_manyis 1.152x [1.040, 1.227] thedict, and per-callgetis 0.656x [0.637, 0.691] — that is, per-callgetloses to a plaindicton one thread. The earlier "1.64x faster single-threaded" and "beats thedictcontrol at every width" are formally retracted, not merely re-measured: the measurement contradicts them.At two threads and above the comparison is
INTERMEDIATEand no ratio is published. Thedictcontrol is multi-modal there — at W=4 and W=8, 30.0% of rounds run above 1.5x the median with a coefficient of variation near 60%, while both Expanse arms stay unimodal — and a mean over a bimodal distribution describes neither mode (section 8.4). Every cell carriesmean_is_valid_estimatorin the artifact.(measured: reference host — Intel i9-12900F, 8P+8E / 24 threads, benchmark pinned to CPUs 0-15; commit
eb404912; 15 rounds per cell, BCa 95% over 10,000 resamples; artifactresults/baseline_python_concurrency.json; load snapshotted before and after, busy-CPU delta 1.88 core-equivalents). The batch size is not tuned against any run — picking one to flatter the curve would be the mid-execution tuning section 8.7 warns about. - the harness drew its miss half as
Four more shared maps wrap the Rust SyncExpanse* types that the C expanse_sync_strmap_*, expanse_sync_bytesmap_*, expanse_sync_ordered_bytesmap_* and expanse_sync_blob_map_* handles expose (docs/COMPAT.md, Shared-map C handles). Every call releases the GIL, writers from any thread run beside each other and beside readers, and each class has atomic compare_exchange and get_or_insert plus update_with(key, fn), the retry loop over compare_exchange (it takes the GIL only to call fn, which may run several times and must be free of side effects; an exception from fn propagates with the key unchanged).
| Class | Keys | Values | Reads |
|---|---|---|---|
SyncExpanseStrMap |
str, bytes or bytearray, no NUL (ValueError) |
int (u64) |
get, contains, and the ordered first, last, next(key, inclusive), prev(key, inclusive), all optimistic. A key comes back as str when it is valid UTF-8 and as bytes otherwise. |
SyncExpanseBytesMap |
any bytes, NUL and empty allowed (str is UTF-8 encoded) |
int (u64) |
get, contains, optimistic. Unordered: no ordered reads. |
SyncExpanseOrderedBytesMap |
any bytes, NUL and empty allowed | int (u64) |
get, contains (a reader per call), and first, last, next_at_or_after, next_after, prev_at_or_before, prev_before, which exclude writers for their duration: a writer waits for a running ordered read and the reverse. |
SyncExpanseBlobMap |
int (u64) |
bytes payload of any length plus a hot_meta |
get returns (bytes, hot_meta), get_bytes, get_meta, contains, optimistic; the payload is an owned copy, so there is no buffer to size. |
The word-valued maps return what SyncExpanseMap returns: compare_exchange(key, expected, new) is (stored, observed) with None for absent on both sides, get_or_insert(key, val) returns the existing value or None when it stored, and insert/remove return the previous value. Point reads go through a per-thread cache of the Rust detached reader (one registration per thread and map); SyncExpanseOrderedBytesMap has no detached reader and registers one per point read.
The blob map returns a status string instead of a flag, as expanse_blob_status_t does: "ok", "mismatch", "meta_overflow", "allocation_failed", "cap_refused" (the capacity cap refused a chunk and nothing was compacted), "arena_full" (the call compacted and the record still does not fit) and "no_meta_field". compare_exchange(key, expected, new) takes (data, hot_meta) tuples or None and returns (status, observed); compare_exchange_meta(key, expected, new) swaps only the 24-bit metadata with no payload copy and is the form for a retry loop whose state lives in the metadata; get_or_insert(key, data, hot_meta=0) returns ("ok", None), ("mismatch", existing) or a refusal status. insert raises RuntimeError on a refusal and insert_status returns the status. A refusal is reported only when the comparison held, and leaves the map unchanged, so a retry loop retries on "mismatch" and stops on anything else. Only the low 24 bits of hot_meta are stored beside an arena payload, and a payload of 7 bytes or fewer reads back hot_meta == 0, so expecting the metadata it was inserted with reports "mismatch" (open issue #1548; the binding does not change the engine's behaviour).
from expanse_trie import SyncExpanseBlobMap, SyncExpanseStrMap
hits = SyncExpanseStrMap()
hits.update_with("home", lambda cur: (cur or 0) + 1) # atomic counter, no lost update
blobs = SyncExpanseBlobMap()
status, existing = blobs.get_or_insert(7, b"x" * 64, 3) # one caller wins per key
status, seen = blobs.compare_exchange(7, (b"x" * 64, 3), (b"y" * 64, 4))Not wrapped, as for the C handles: the closure forms with_locked / with_exclusive, stats, the collector census, and a cursor object. The shared maps have no iterator or keys() / items() view; walk a string or ordered-bytes map with first and next.
import concurrent.futures
from expanse_trie import SyncExpanseMap
# Shared thread-safe OCC map
cache = SyncExpanseMap()
for i in range(100_000):
cache.insert(i, i * 10)
def reader_worker(thread_id: int) -> int:
hits = 0
# Queries execute GIL-free in parallel across CPU cores
for k in range(thread_id, 100_000, 8):
if cache.get(k) is not None:
hits += 1
return hits
# Execute across 8 parallel OS threads
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(reader_worker, range(8)))
print(f"Total verified items across threads: {sum(results)}")| Feature / Metric | Python set / dict
|
roaring-bitmap |
expanse-trie |
|---|---|---|---|
| Memory (Dense/Clustered Integers) | ~64 bytes / key | 0.12–0.50 bytes / key | 0.07–0.36 bytes / key |
| Random Point Lookup | Hash table lookup | Container binary search | Direct Tagged Pointer / Trie |
Ordered Range Scan (range()) |
|
||
Rank / Select (count_below) |
Not natively supported | Bit-count traversal | |
| Multithreaded GIL Release | No (holds GIL) | Partial | Yes (SyncExpanseMap/Set) |
| Memory Safety | C / Python runtime | C / Rust backend | 100% Pure Rust (#![no_std] core) |
The
expanse-triememory figure (0.07–0.36 bytes/key) is deterministic byte accounting (measured: Apple M1,bytes_per_keyexample, commit 6c63826a); theset/dictandroaring-bitmapcolumns are approximate references. This table carries no timing measurements.
- Packaging Taxonomy: Python package metadata and PEP 561 typed source files live in
bindings/python/expanse_trie/(py.typedand__init__.pyi), configured viapyproject.tomlwithpython-source = "bindings/python". - PyO3 Extension: Native compiled extension module built from
crates/expanse-pyintoexpanse_trie._expanse. - Wheel Architecture: Compiled with stable
PyO3targeting Pythonabi3-py38for cross-version binary compatibility across Python 3.8 through 3.13+. - Type Stubs: Packaged with
py.typedand__init__.pyiproviding comprehensive annotations for modern Python type checkers (mypy --strict, Pyright, IDE linting).