ZeroPool
A user-space byte allocator that recycles buffers so the kernel work happens once.
About
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 keeps a pool of reusable byte buffers organized by size class. Buffers are handed out through thread-local caches backed by a lock-free shared queue, so the hot path is a TLS pop with no locks or atomics. When you're done, the buffer goes back to the pool instead of the OS.
The safe default, alloc(size), returns zero-initialized readable bytes. For full-overwrite workloads, alloc_uninit(size) skips zeroing and uses a BufUninit type that cannot be read from safe code until initialized.
Features
- Size-class bucketing: 8 power-of-two classes (4 KB to 64 MB), O(1) class selection
- Lock-free shared pool:
crossbeam::ArrayQueueper class, no mutexes - Thread-local caching: per-class LIFO caches with magazine-style batch transfer
- Pool isolation: unique pool IDs prevent TLS cache cross-contamination
- Safe by default:
alloc()zeroes visible bytes so recycled contents are not exposed - Explicit fast path:
alloc_uninit()avoids zeroing for callers that fully overwrite - Pluggable allocator: custom buffer creation via the
Allocatortrait - Pinned memory: optional
mlockto keep buffers out of swap - Opt-in stats: atomic counters for hit rates, allocations, and discards (disabled by default)
Installation
Requires Rust 2024 edition (1.85+).
Usage
use ZeroPool;
let pool = new;
let mut buf = pool.alloc; // 1 MB, zero-initialized RAII guard
buf = 42; // Deref<Target = [u8]>
// returned to pool on drop
For the fastest path, initialize through BufUninit and convert after all bytes are written:
use ZeroPool;
let pool = new;
let buf = pool.alloc_uninit.write_from_slice;
assert_eq!;
Sharing across threads
ZeroPool is Send + Sync. Each thread gets its own TLS cache automatically.
use Arc;
use thread;
use ZeroPool;
// Scoped threads: borrow directly
let pool = new;
scope;
// Owned threads: wrap in Arc
let pool = new;
let p = clone;
spawn;
Buf<'_> is lifetime-bound to the pool. Use buf.into_vec() when you need an owned Vec<u8>.
Configuration
use ZeroPool;
let pool = new
.tls_cache_size // per-class per-thread cache depth
.max_buffers_per_class // shared pool capacity per class
.min_buffer_size // skip pooling for small buffers
.batch_size // TLS <-> shared transfer size
.track_stats // opt into atomic counters
.pinned_memory; // mlock buffers (no swap)
Pluggable allocator
Implement the Allocator trait to control how buffers are created:
use ;
;
let pool = new.allocator;
Stats
Stats are disabled by default because even Relaxed atomics are measurable in tight allocation loops (~10 ns overhead). Enable them when you need counters:
use ZeroPool;
let pool = new.track_stats;
let _buf = pool.alloc;
let s = pool.stats;
println!;
// gets: 1 | puts: 0 | hit_rate: 0.0%
// tls_hits: 0 (0.0%) | shared_hits: 0 | allocations: 1 | discards: 0 | oversize: 0
Performance
Every "realistic" benchmark writes to every page of the buffer, paying the page-fault cost that dominates real workloads (networking, serialization, file I/O). Pure alloc/drop microbenchmarks hide this cost. Use alloc_uninit() for the historical non-zeroing fast path when your workload fully overwrites the buffer.
Realistic 64 KiB write, 200 ops/thread
| Threads | ZeroPool | Vec |
opool |
object_pool |
|---|---|---|---|---|
| 2 | 35 us | 272 us | 40 us | 45 us |
| 4 | 54 us | 299 us | 84 us | 102 us |
| 8 | 89 us | 388 us | 199 us | 295 us |
| 16 | 157 us | 624 us | 371 us | 1,476 us |
ZeroPool is 4x-8x faster than raw Vec and scales linearly while object_pool collapses under contention.
Single-thread hot path (no page writes, 64 KiB)
| Crate | 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 wins single-thread microbenchmarks by ~5 ns. ZeroPool wins everywhere else.
Full methodology, sustained throughput, burst allocation, mixed-size, and contention sweep results are in BENCHMARKS.md.
&&
&&
How It Works
Thread 1 Thread 2 Thread N
+------------+ +------------+ +------------+
| TLS Cache | | TLS Cache | | TLS Cache | <-- lock-free
| [class 0] | | [class 0] | | [class 0] | per-class
| [class 1] | | [class 1] | | [class 1] | LIFO caches
| ... | | ... | | ... |
+-----+------+ +-----+------+ +-----+------+
| batch | batch | batch
+----------+--------+------------------+
|
+-------v--------+
| Shared Pool |
| (lock-free) |
| |
| [4KB queue] | ArrayQueue per class
| [16KB queue] | CAS-based push/pop
| [64KB queue] |
| [256KB queue] |
| [1MB queue] |
| [4MB queue] |
| [16MB queue] |
| [64MB queue] |
+----------------+
pool.alloc(size)rounds up to the next power-of-two size class- Check the thread-local cache for that class. Hot path (~20 ns), no synchronization
- On TLS miss, batch-transfer from the shared
ArrayQueue(CAS-only, no mutex) - On shared miss, allocate fresh from the system (or the custom
Allocator) - On drop, buffer returns to TLS cache; overflow spills back to the shared queue
Contributing
Contributions are welcome!
Development setup
Running benchmarks
Code quality
The project uses strict Clippy lints (clippy::pedantic, clippy::perf, clippy::correctness at deny level). Before submitting:
Documentation
License
Licensed under either of Apache License, Version 2.0 or MIT License at your option.