zeropool 0.6.0

A user-space byte allocator with size-class bucketing, lock-free shared pool, and thread-local caching
Documentation

ZeroPool

A user-space byte allocator for Rust.

Crates.io Docs CI

Why

Every Vec::with_capacity(n) asks the kernel for memory, and every drop gives it back. Under load — especially multi-threaded — this becomes the bottleneck: page faults, mmap syscalls, allocator contention.

ZeroPool recycles buffers so the kernel work happens once.

Benchmarks

Every "realistic" benchmark writes to every page of the buffer. This is what networking, serialization, and file I/O actually do. Pure alloc/drop microbenchmarks hide the page-fault cost that dominates real workloads.

Full transparent numbers and reproducibility steps live in BENCHMARKS.md.

Headline: realistic 64 KiB write, 200 ops/thread

Threads ZeroPool Vec opool object_pool
2 35 µs 272 µs 40 µs 45 µs
4 54 µs 299 µs 84 µs 102 µs
8 89 µs 388 µs 199 µs 295 µs
16 157 µs 624 µs 371 µs 1,476 µs

ZeroPool is 2.2×9.4× faster than object_pool and within 1.1×2.4× of opool as contention grows. Compared to raw Vec, ZeroPool is 4.0×7.7× faster across the same range.

Sustained: 10 000 ops/thread, 64 KiB write, 8 s measurement

Threads ZeroPool Vec object_pool
1 558 µs 10.79 ms 527 µs
4 656 µs 11.51 ms 5.27 ms
8 677 µs 13.29 ms 22.81 ms
16 1.01 ms 18.11 ms 118.3 ms
32 2.35 ms 39.67 ms 286.1 ms

object_pool collapses past 4 threads because its Mutex-guarded shared queue serializes all producers. ZeroPool's lock-free ArrayQueue + per-thread cache scales linearly up to 16 threads on this 20-thread box.

Mixed sizes: one pool handles many buffer sizes

Crate Pools needed 7 sizes, round-robin Zipf-like 1 KiB–256 KiB
ZeroPool 1 144 ns 1.95 µs
object_pool (one per size) 7 140 ns 2.08 µs
opool (one per size) 7 107 ns
Vec (no pool) 0 96 µs 2.47 µs

ZeroPool is the same order of magnitude as N typed pools while managing a single object — and 660× faster than raw Vec on the round-robin case.

Single-thread hot path (no page writes)

64 KiB Time
opool 15.2 ns
ZeroPool 20.2 ns
object_pool 21.1 ns
bytes::BytesMut 45.8 ns
Vec::with_capacity 44.5 ns

opool is still the single-thread microbenchmark winner. ZeroPool is within 5 ns of it and is now within 1 ns of object_pool. Stats tracking adds ~10 ns — opt in only when you need it (see stats_overhead).

Burst: allocate N × 64 KiB, write, drop all

N ZeroPool Vec opool object_pool
10 327 ns 11.1 µs 214 ns 250 ns
50 2.14 µs 58.4 µs 1.07 µs 1.25 µs
100 5.58 µs 127 µs 2.09 µs 2.39 µs
1000 92.9 µs 6.85 ms 21.6 µs 24.7 µs

Burst is where raw Vec falls furthest behind. For server workloads that grab a batch of buffers, ZeroPool is ~74× faster than Vec at N=1000.

Reproduce

cargo bench                     # ZeroPool-only timing
cargo bench --features bench    # full comparison suite
cargo bench -- realistic        # just the write workloads
cargo bench -- hot_path         # pure alloc/drop microbenchmarks
cargo bench -- scale            # sustained throughput
cargo bench -- mixed            # mixed-size workloads
cargo bench -- burst            # burst allocation
cargo bench -- contention       # TLS depth × thread count
cargo bench -- stats            # stats overhead

Usage

use zeropool::ZeroPool;

let pool = ZeroPool::new();

let mut buf = pool.alloc(1024 * 1024); // 1 MB — RAII guard
buf[0] = 42;                            // Deref<Target = [u8]>
// returned to pool on drop

How it works

8 power-of-two size classes (4 KB → 64 MB). Each thread keeps a local cache per class; on miss, a batch is moved from the lock-free shared queue (crossbeam::ArrayQueue). Pool IDs prevent TLS cross-contamination.

Hot path (~20 ns): TLS pop — no locks, no atomics by default.

Configuration

let pool = ZeroPool::new()
    .tls_cache_size(8)           // per-class per-thread cache depth
    .max_buffers_per_class(64)   // shared pool capacity per class
    .min_buffer_size(4096)       // skip pooling for small buffers
    .batch_size(4)               // TLS ↔ shared transfer size
    .track_stats(true)           // opt into atomic counters
    .pinned_memory(true);        // mlock buffers (no swap)

Pluggable allocator

use zeropool::{Allocator, ZeroPool};

struct HugePageAllocator;
impl Allocator for HugePageAllocator {
    fn allocate(&self, capacity: usize) -> Vec<u8> {
        let mut buf = Vec::with_capacity(capacity);
        buf.resize(capacity, 0); // pre-fault
        buf.clear();
        buf
    }
}

let pool = ZeroPool::new().allocator(HugePageAllocator);

Stats

let pool = ZeroPool::new().track_stats(true);
let s = pool.stats();
println!("{s}");
// gets: 1 | puts: 1 | hit_rate: 0.0%
//   tls_hits: 0 (0.0%) | shared_hits: 0 | allocations: 1 | discards: 0 | oversize: 0

Stats are disabled by default because even Relaxed atomics are measurable in tight allocation loops. Enable them with .track_stats(true) when you need counters.

Thread safety

ZeroPool is Send + Sync. Share it across threads via &pool (scoped threads) or Arc<ZeroPool>:

use std::sync::Arc;
use std::thread;
use zeropool::ZeroPool;

// Scoped threads — borrow directly
let pool = ZeroPool::new();
thread::scope(|s| {
    s.spawn(|| { let buf = pool.alloc(4096); });
    s.spawn(|| { let buf = pool.alloc(4096); });
});

// Owned threads — wrap in Arc
let pool = Arc::new(ZeroPool::new());
let p = Arc::clone(&pool);
thread::spawn(move || { let buf = p.alloc(4096); });

Each thread gets its own TLS cache automatically. Buf<'_> is lifetime-bound to the pool — use buf.into_vec() when you need an owned Vec<u8>.

License

Apache-2.0 OR MIT