Expanse: Modernized Judy Arrays

For the past few months, I have been deep in the weeds of sparse data structures. What started as a focused effort to cut worker memory in PHP with Judy arrays quickly turned into a major rabbit hole: vendorizing, fixing, and hardening libJudy itself after finding latent compiler bugs in the 20-year-old upstream C code.

Diving back into Doug Baskins’s original 20,000-line C codebase reminded me of the exact visceral reaction I had when I first read it twenty years ago: my eyes were bleeding.

It is a monument of early-2000s low-level wizardry—dense macros, custom chunk allocators, manual SWAR (SIMD Within A Register) bit-twiddling, and cryptic pointer tagging. The core algorithmic ideas behind Judy arrays were brilliant, but the implementation was so notoriously complex that almost the entire software engineering field eventually walked away from it.

Over the last two decades, when engineers needed high-performance ordered collections or bitsets, they reached for B-Trees, Adaptive Radix Trees (ART), or Roaring Bitmaps instead. Not because those structures were fundamentally more cache-efficient than Judy, but because nobody wanted to maintain, debug, or port 20,000 lines of arcane C macro soup to new CPU microarchitectures. The clearest proof: ART, the field’s go-to in-memory index since 2013, is itself an adaptive radix trie — its Node4/16/48/256 layouts revisit the same idea as Judy’s linear, bitmap, and uncompressed branches. The industry never refuted Judy’s design; it rebuilt a simpler subset of it and moved on.

With today’s AI agentic coding harnesses and formal verification tools, that cognitive barrier is largely gone. I found myself asking a question that had lingered in the back of my mind for years:

What would Judy arrays look like if designed from scratch for modern hardware?

The result of that exploration is Expanse: a clean-room, pure-Rust, memory-safe, hardware-vectorized digital trie engine, along with libexpanse—a drop-in C ABI replacement for libjudy.


Why “Expanse”?

In tree search literature, most data structures partition their keys by population (the number and distribution of keys currently stored). A B-Tree or Red-Black tree dynamically rebalances, splits, and merges nodes as keys are inserted, keeping the tree balanced relative to its population.

Judy arrays do something fundamentally different: they partition by expanse.

“Expanse, population, and density are not commonly used terms in tree search literature, so let’s define them here: Expanse is a range of possible keys […] A digital tree divides up the population (index set) uniformly by expanse (dividing and redividing the initial expanse evenly), while other methods, such as b-trees, divide up the population by the distribution of the population itself.”
— Doug Baskins & Alan Silverstein, Judy IV Shop Manual (2002)

Population Partitioning (B-Trees) vs Expanse Partitioning (Digital Tries)Both columns look up the same key, 715,064,296, which is 0x2A9F03E8 in hex. The B-tree walks three pages, binary-searching stored pivot keys at each hop. The trie decodes the key's own bytes: 2A picks a fixed slot in the top 256-slot range, 9F picks a slot in the 0x2A000000 subexpanse, byte 1 (03) is folded into the edge as a narrow-pointer skip, and E8 lands on the match in the terminal leaf — no stored key is ever compared.◆ Population Partitioning (B-Trees)Boundaries follow the stored key distributionRoot Page (binary search stored keys):10M250M700M ≤ K < 1.8B1.8BInternal Page (search subrange bracket):700M700M ≤ K < 725M725M850MLeaf Page (find exact key):715,010,040715,064,296 ✔715,190,800● Full keys stored explicitly inside every node● Traversal requires binary search loops per hop● Insertions trigger cascading page splits & rebalances● Tree layout is dependent on insertion order◆ Expanse Partitioning (Judy / Expanse)Fixed byte-range boundaries • same key: 0x2A9F03E8 = 715,064,296Byte 3: 0x00000000 … 0xFFFFFFFF (256 slots):0x2Adirect positional offset (no search)Byte 2: 0x2A000000 … 0x2AFFFFFF (256 slots):0x9Fbyte 1 (0x03) skipped— narrow pointerByte 0: 0x2A9F0300 … 0x2A9F03FF (Terminal Leaf):0xE8 → Match ✔● No full keys in index nodes — the path infers the prefix● Direct positional indexing (SWAR digit find, popcount rank)● Writes affect only local subexpanse (zero tree rebalance)● Invariant layout for ANY key insertion order
Both columns look up the same key — 715,064,296 is 0x2A9F03E8. The B-tree binary-searches stored pivot keys at every hop; the trie reads the key's own bytes as positions, and byte 1 never even costs a hop — its single-child level is folded into the edge as a narrow-pointer skip.

The B-tree binary-searches stored pivot keys at every hop; the Expanse trie reads the key’s own bytes as positions, and byte 1 never even costs a hop — its single-child level is folded into the edge as a narrow-pointer skip.

An expanse-partitioned 256-ary digital tree decodes keys byte-by-byte, from the most significant to the least significant byte. Because the branching boundaries are fixed a priori by numerical byte ranges:

  1. Tree structure is invariant to insertion order: Inserting keys in ascending, descending, or random order produces the same tree. There are zero tree rotations, zero cascading page splits, and zero rebalancing passes.
  2. Keys are inferred by position: Full 64-bit integer keys do not need to be duplicated and stored inside index nodes. The path through the tree is the key prefix; only the trailing uncompressed bytes are stored at the leaves.
  3. Predictable mutation latency: Writes affect only the local subexpanse, providing deterministic latency without write-stall spikes.

Here is that same decode again, level by level — each byte magnifies the slot it picked into the next level’s whole range:

Decoding the key 0x2A9F03E8 by re-dividing a fixed expanseFour bars, one per key byte. The top bar is the full range 0x00000000 to 0xFFFFFFFF; byte 3, value 2A, selects one of its 256 slots. That slot is magnified into the second bar, the range 0x2A000000 to 0x2AFFFFFF, where byte 2, value 9F, selects the next slot — and so on through 0x2A9F0000 to 0x2A9FFFFF and 0x2A9F0300 to 0x2A9F03FF, until byte 0, value E8, lands on the key itself.KEY 0x2A9F03E8 — EACH LEVEL MAGNIFIES THE CHOSEN SUBEXPANSE64-bit key • leading zero bytes (7–4) skipped via narrow-pointer compression0x00000000 … 0xFFFFFFFFByte 3 · 0x2ATop branch (256 slots)chosen slot expands into the next level's entire 256-slot range0x2A000000 … 0x2AFFFFFFByte 2 · 0x9FMid branch0x2A9F0000 … 0x2A9FFFFFByte 1 · 0x03Sub branch0x2A9F0300 … 0x2A9F03FFByte 0 · 0xE8Terminal leafkey match: 0x2A9F03E8 ✔
Each bar is the slot selected above it, magnified into the next level's whole range — the trie divides and re-divides a fixed key range instead of comparing against stored keys. The marker position is computed from the byte value alone, which is why the tree is identical for any insertion order and writes stay local: the properties population-partitioned trees pay rotations and page splits to keep.

The reason naive 256-ary tries are rarely used in practice is memory bloat: allocating 256 pointers (2 KB on 64-bit) at every branch node wastes 99% of memory when only a few keys are present.

Judy solved this with adaptive polymorphic compression: branches dynamically morph between packed linear arrays, 2-tier bitmap subexpanses, and uncompressed tables based on local density. Small populations are stored immediately inside the 16-byte parent pointer itself — up to 15 key-remainder bytes, so 15 keys at one byte each, or 2 at seven — bypassing heap allocations entirely.


The 2002 vs. 2026 Hardware Mismatch

When Doug Baskins designed Judy IV at HP around 2000–2002, the hardware assumptions were radically different from the CPUs powering our servers today:

Architectural DimensionJudy IV (2002)Expanse (2026)
Target CPU GeometryHP PA-RISC / Itanium class enterprise servers.Commodity 64-bit: x86-64, ARM64, RISC-V servers (64-byte L1/L2 lines).
Cache Line GeometrySized for 128-byte lines; straddles 64-byte boundaries.Every node sized to exact 64-byte and 128-byte multiples — zero straddles.
Bit Scanning & RankSWAR bit-twiddling and unrolled lookup tables.Native hardware bit ops: ARM cnt+addv, x86-64 POPCNT (x86-64-v2+), TZCNT (x86-64-v3+).
Search EngineScalar unrolled comparison loops over byte arrays.SWAR broadcasts & runtime-dispatched SIMD (SSE2 → AVX2/BMI2, ARM NEON).
Memory ManagementCustom chunk allocator (2, 4, 6, 8-word chunks).Slab page pools, intrusive freelists, and zero-allocation immediates.
ConcurrencySingle-threaded; external coarse-grained locks.Lock-free Optimistic Concurrency Control (OCC) with epoch reclamation.
Memory Safety~20,000 lines of macro-heavy C with segfault risks.Pure Rust #![no_std] core verified under Miri and Loom.

Judy IV was tuned for the wide cache lines of its era’s HP enterprise hardware: its own design documents size nodes around 16-word cache line fills — 128 bytes on a 64-bit machine.

On modern 64-bit server microarchitectures (x86-64, ARM64, and 64-bit RISC-V), the standard L1 and L2 cache line size is 64 bytes. When data structures engineered around 128-byte boundaries are traversed without 64-byte alignment awareness, child pointers and node metadata frequently straddle two adjacent 64-byte cache lines. Every straddle forces a second cache line fetch, doubling the memory cost of that hop.

Furthermore, calculating popcounts and ranks in bitmap nodes in 2002 required multi-step SWAR math. Today ARM64 counts bits with a NEON cnt+addv pair, and x86-64 does it in one POPCNT at x86-64-v2 (TZCNT needs BMI1, so x86-64-v3) — with a portable SWAR fallback retained for base targets.

BranchL3: a complete branch node in one 64-byte cache lineA 64-byte cache line drawn byte 0 to byte 63. The first 16 bytes are the branch header: version counter, child count, level, presence filter, and a sorted 8-byte digit word. The remaining 48 bytes are three full 16-byte edges. Finding the digit 8C is one SWAR pass over the digit word, selecting edge 2 in the same cache line.BRANCHL3 — A COMPLETE BRANCH NODE IN ONE 64-BYTE CACHE LINE▸ Find digit 0x8C: one SWAR pass over the sorted digit wordmatch index 2 → edge 2 (zero cache miss)ver (4B)nlvpres0A428C·····edge 016 B pointeredge 116 B pointeredge 2 ✔16 B target edgebyte 0byte 63 (64 Bytes Total)16 B header · sorted digit word3 full 16-byte packed child edges16 B header + 3 × 16 B edges =64 B exactly— the whole branch resolves in a single cache line fill.Larger nodes are exactly two lines (BranchL7: 7 edges • BranchB: 256-bit bitmap) — a node never straddles memory lines.
Judy IV sized its nodes for the 128-byte cache lines of 2002 hardware, so on modern 64-byte lines they straddle — and every straddle is a second memory fetch. Expanse sizes every node to exactly one or two lines: the one-line BranchL3 above resolves a digit with one SWAR pass over the header's digit word and lands on a full 16-byte edge without leaving the line.

Inside Expanse: Architecture of a Modernized Digital Trie

Expanse is a clean-room build: published descriptions and black-box differential testing against a compiled stock binary, never the LGPL source. Transliterating Judy IV into Rust was never the interesting version of this project anyway. In 2002 C, Judy relied heavily on upper-bit pointer stealing, unaligned word reads, and bespoke chunk allocators that assumed an isolated, single-threaded world.

To build a data structure that survives modern server workloads, I had to rethink the engine around three modern hardware primitives: strict 64-byte cache lines, virtual address sanity (supporting 57-bit PML5 without corrupting pointers), and lock-free multi-core read scaling.

You can inspect the live tree structure, node transitions, and memory layouts interactively in the Expanse Architecture & Benchmark Visualizer.

The four node layouts Expanse morphs betweenAs local key density rises, a branch changes shape: up to 15 key bytes stay inlined inside the 16-byte edge with no heap allocation; a linear branch packs 3 edges into one 64-byte cache line (7 edges into two) found by a SWAR scan of its sorted digit word; denser subexpanses become a 128-byte bitmap branch ranked with popcount; only a dense subexpanse grows into a 4 KiB uncompressed slab of 256 edges indexed directly. Because byte boundaries are fixed, each change is local and never rebalances the tree.◀ sparsedense ▶KEY DENSITY IN THE SUBEXPANSEkey 0key 116-byte edge (inlined)ImmediateUp to 15 key bytesInlined in the parent edge✔ zero heap alloc0A1F428CB3C0sorted 8-byte digit wordLinear branch3 edges • 64 B (1 line)7 edges • 128 B (2 lines)⚡ SWAR digit scan256-bit bitmap (8 subexpanses)Bitmap branch256-bit bitmap • 128 BPacked child pointer arrays⌘ popcount rank256-edge slab arrayUncompressed256 edges • 4 KiB slabDirect array indexing➤ direct O(1) index
A branch changes shape with the density of the keys under it — small subexpanses stay inline in the 16-byte edge, and only a dense one grows into a 4 KiB slab. Because the byte boundaries are fixed, each transition is local: the tree never rotates or rebalances.

1. 64-Byte Cache Line Node Geometry & Single-Line SWAR

Expanse re-engineers the digital trie geometry specifically around exact 64-byte and 128-byte cache-line multiples (with 32-byte alignment support for embedded profiles):

2. Lock-Free Optimistic Concurrency Control (OCC)

In multi-threaded server environments, traditional tree structures hit a hard concurrency wall. Coarse-grained mutexes bottleneck all cores, while read-write locks (RwLock) trigger massive cache-invalidation storms as readers contend for the lock word.

Expanse solves this with per-node versioned Optimistic Concurrency Control (SyncExpanseMap and SyncExpanseSet). Readers traverse the trie without taking a lock, validating generation version tags on descent. On 100%-read workloads, reader throughput scales near-linearly across cores—884.5 million ops/second for SyncExpanseSet and 424.1 million for SyncExpanseMap at 16 threads (11.4× scaling), ahead of DashMap (132.1 M, 8.5×) while keeping the ordered iteration a sharded hash map cannot offer.

The honest limit, stated plainly: this is a single-writer design. Under a 50/50 read/write mix every Expanse arm loses throughput as threads are added (0.12×–0.55×): writes serialize on one mutex, and the threads spend their time handing it off. Readers are not the bottleneck—the reader fallback never fires in these runs—so “lock-free” here means lock-free reads, precisely obstruction-free with a mutex fallback after 64 lost races. DashMap and SkipMap win that regime outright. Multi-writer support is the standing follow-up.

3. Pure Rust Core with 100% C ABI Drop-In Compatibility

The core engine is #![no_std] Rust, verified against memory leaks, data races, and undefined behavior using Miri and Loom.

At the same time, libexpanse exports the legacy C ABI:

Any existing C, C++, or PHP application linking against libJudy can replace -lJudy with -lexpanse with zero code modifications. In fact, libexpanse passes the entire 221-test regression suite of php-judy without a single failure.


Performance & Memory Benchmarks

If there is one thing I learned while hardening libjudy, it is that benchmarking low-level data structures is full of traps. It is shockingly easy to measure test-harness teardown or memory-allocator recycling instead of algorithmic speed, or to compare an inlined static Rust library against a dynamically linked C library.

To ensure apples-to-apples comparisons, every benchmark in Expanse is evaluated against explicit baseline implementations across identical key distributions (uniform random, sequential runs, clustered 256-key bursts, and Zipfian distributions).

Full benchmark datasets and Callgrind profiles are documented on the Expanse Benchmarks page.

Comparative benchmarks: point lookup vs BTreeMap, membership vs Roaring, memory densityPoint Lookup vs BTreeMap1M sequential keys (lower is better)▼ Latency (ns / op)12060011.9 nsExpanseMap9.2× faster108.9 nsBTreeMap1.0× baselineRange: 2.9×–14.5× across distributions at 1M keys.Membership vs Roaring100k sparse keys, contains() (lower is better)▼ Latency (ns / op)201008.5 nsExpanseSet2.2× faster19.0 nsRoaring1.0× baselineDense sets flip it: Roaring's bit containers win contains() ~1.9×.Memory DensityExpanseSet, 1M keys (lower is better)▼ Bytes / key8 B4 B00.07 BSequential0.36 BClustered7.66 BRandomDeterministic accounting • vs stock JudyL (map): 0.92×–1.03× B/key.Measured: i9-12900F reference host (lookups, commit 695b98d) • deterministic NodeAlloc accounting (memory) • docs/BENCHMARKING.md

1. Memory Density (Bytes per Key)

I measured memory footprint with deterministic allocator accounting (NodeAlloc)—the one benchmark that is machine-independent and reproduces byte-for-byte:

Key Distribution (ExpanseSet, 1M keys)Bytes / keyNote
Sequential integers0.07full-expanse + bitmap-leaf compression
Clustered (256-key runs)0.36was 1.34 before leaf-targeted narrow pointers
Clustered (4096-key runs)0.12was 0.19 before branch-targeted narrow pointers
Uniform random 64-bit7.92not part of the dense/clustered design target; density-dependent, see the sawtooth post
Sparse (i << 40)16.31one 16-byte edge per isolated key—the structural floor

Against stock libJudy through the identical C ABI, the map-flavor footprint is 0.92×–1.03× (smaller on random and clustered keys, 3% larger on sequential). On clustered and dense sets the 0.07–0.36 bytes/key matches Roaring Bitmaps’ run/bit-container compression while keeping ordered navigation; on genuinely sparse keys the trie’s floor is a 16-byte edge per isolated key, and other structures have regimes where they win.

2. Throughput Against Named Baselines

Lock-free OCC read scaling: SyncExpanseMap vs DashMap, 1 to 16 threadsLock-Free OCC Read Scaling (100% Read)SyncExpanseMap vs DashMap • bounded keyspace, ~50% hit rate • i9-12900F▲ Read throughput (M ops / sec)SyncExpanseMapDashMap450M225M037.2 M/s15.6 M/s1 Thread138.9 M/s50.4 M/s4 Threads424.1 M/s132.1 M/s16 Threads11.4× scaling vs DashMap's 8.5×Measured: run 33030152085 (ref 5fb03aa3), load average 0.00 • SyncExpanseSet reaches 884.5 M ops/s on the same sweep.Honest limit: at a 50/50 read/write mix the single-writer design loses throughput as threads are added (0.19×) — DashMap and SkipMap win that regime.
YCSB MemTable benchmarks: range-scan and read-modify-write workloadsYCSB E — Range Scan95% short scans / 5% insert (higher is better)▲ Throughput (M ops / sec)1.4M0.7M00.66 M/sExpanse1.96× slower1.29 M/sBTreeMapfastest0.31 M/sSkipMap4.2× slowerNote the axis: every arm here is ~10× below panel F. Sparse random keys pack ~1.43 records per leaf.YCSB F — Read-Modify-Write50% read / 50% RMW (higher is better)▲ Throughput (M ops / sec)16M8M014.7 M/sExpanse8.1× vs SkipMap4.35 M/sBTreeMap2.4×1.81 M/sSkipMap1.0× baselineAll arms carry identical 128-byte records. 50%-write blob arms show arena-growth p99 spikes (~39 µs).Measured: YCSB, 100k keys, Zipfian θ=0.99, 128-byte records • i9-12900F reference host, run 33037221608 • docs/BENCHMARKING.md

Across different access patterns, performance reflects the structural advantages of expanse partitioning:


Real-World Use Cases in Systems Engineering

Having spent years building high-throughput infrastructure at TubeMogul and Adobe—where a few nanoseconds of p99 latency or a 20% memory bloat cascades across thousands of servers—these are the specific patterns where digital tries shine:

1. RocksDB Pluggable MemTable (rocksdb-expanse)

Standard LSM-tree storage engines, such as RocksDB, use SkipLists for their in-memory MemTables because SkipLists allow lock-free concurrent inserts. But SkipLists pay a variable-height tower of forward pointers per node — ~18.7 bytes of indexing overhead per entry against Expanse’s 13.2, a 1.42× density edge — and have poor spatial cache locality.

By implementing RocksDB’s MemTableRep interface on top of Expanse, the standardized YCSB suite (100k keys, Zipfian θ=0.99, 128-byte records) measures:

2. CIDR Subnet Routing & Network Filtering

IPv4 and IPv6 subnets (e.g., 192.168.1.0/24 or /48 IPv6 blocks) are natural prefix paths in a 256-ary digital trie. Longest Prefix Match wants exactly the descent the trie already does: bounded at 4 hops for IPv4 and 8 for 64-bit keys, no hash collisions, no tree rotations, no allocation on the read path. I have not built the LPM layer yet, so take this one as a design fit rather than a benchmark.

3. Database MVCC Visibility Maps

In modern transactional engines (PostgreSQL, MySQL InnoDB, CockroachDB), tracking active transaction IDs (xid) for Multi-Version Concurrency Control (MVCC) requires a low-overhead, concurrent bitset. SyncExpanseSet lets background transactions commit and register without stalling concurrent readers checking row visibility. Writers still serialize against each other on one mutex; readers never wait on them.


Getting Started with Expanse

Expanse is open source under dual MIT / Apache-2.0 licensing. Each release is also archived on Zenodo; to cite the software, use the concept DOI 10.5281/zenodo.22152112, which always resolves to the latest release.

Rust

Add expanse-trie to your Cargo.toml:

[dependencies]
expanse-trie = "0.6.0"
use expanse_trie::ExpanseMap;

let mut map = ExpanseMap::new();
map.insert(1042, 500);

if let Some(val) = map.get(1042) {
    println!("Found value: {}", val);
}

// Ordered range navigation in O(depth)
for (key, val) in map.range(1000..=2000) {
    println!("{}: {}", key, val);
}

C / Linux Packages (Debian, Ubuntu, Fedora, RHEL)

Install prebuilt packages directly from the official repositories:

# Debian / Ubuntu
echo "deb [trusted=yes] https://orieg.github.io/expanse/apt/ stable main" | sudo tee /etc/apt/sources.list.d/expanse.list
sudo apt-get update
sudo apt-get install -y libexpanse1 libexpanse-dev libjudy-compat

# Fedora / RHEL / AlmaLinux
sudo dnf config-manager --add-repo https://orieg.github.io/expanse/rpm/expanse.repo
sudo dnf install -y libexpanse libexpanse-devel libjudy-compat

Compile against the modern C API (expanse.h) or drop-in legacy API (Judy.h):

#include <stdio.h>
#include <expanse.h>

int main() {
    expanse_map_t *map = expanse_map_new();
    expanse_map_insert(map, 1042, 500, NULL);

    uint64_t val = 0;
    if (expanse_map_get(map, 1042, &val)) {
        printf("Retrieved key 1042 -> %llu\n", (unsigned long long) val);
    }
    
    expanse_map_free(map);
    return 0;
}
gcc -O3 main.c -lexpanse -o main
./main

Language bindings and target platforms are available across the ecosystem:


Looking Ahead

Building Expanse reminded me that good algorithmic ideas don’t expire—sometimes they just get trapped in the implementation constraints of the era in which they were born.

Doug Baskins’s insight that digital tries partitioned by expanse could outperform comparison-based trees was ahead of its time in 2002. Rebuilding that vision for modern 64-bit microarchitectures with memory safety, SIMD vectorization, and lock-free concurrency shows how much headroom remains when we design algorithms around the actual physics of modern silicon.

Check out the project on GitHub, explore the live Architecture Visualizer, and let me know your thoughts!