← Back to roadmap
Phase 3Week 27~115 min read
0%·0/4

Allocation-Free Hot Paths

Arenas, slabs and object pools.

Performance · Measure, Profile, Then Go Fast

Why this week

Last week's profile almost certainly had malloc, free or RawVec::grow_one somewhere near the top. That is normal for Rust written the idiomatic way — Vecs that grow, Strings in events, Box<dyn Trait>, a HashMap keyed by order id — and it is also the most common reason a matching engine with a 300 ns median has a 30 µs p99.9.

The fix is not "use a faster allocator" (though that helps the median). It is to decide, for every object on the order path, who owns its memory and for how long, and then to allocate all of it before the market opens. Orders live in a slab: a preallocated array of slots with a free list and stable integer keys. Short-lived buffers come from pools and go back to them. Per-batch scratch lives in an arena that is reset, not freed. And because "I'm pretty sure it doesn't allocate" is not engineering, you prove it with a counting global allocator in a test that fails the build the day someone adds a format! to the hot path.

Day 1 — Where allocation hurts

Day 01

The median is fine; the tail is not

What malloc really costs on a trading hot path

Bookby Nicholas Nethercote et al.
The Rust Performance Book

Read the 'Heap Allocations' chapter: which std types allocate and when, Vec growth, reusing collections, and how to find allocation hot spots with a heap profiler (dhat-rs, heaptrack).

A small malloc/free pair from glibc's per-thread cache (tcache) or from jemalloc/mimalloc costs tens of nanoseconds in a microbenchmark. If that were the whole story, nobody would bother. The trouble is the distribution:

  • Slow paths inside the allocator. When the thread cache is empty or full, the allocator falls back to shared arenas with locks, coalesces free chunks, or asks the kernel for memory (brk, mmap) or gives it back (madvise, munmap) — a syscall, sometimes with a TLB shootdown that interrupts other cores.
  • Page faults. Fresh heap memory is not backed by physical pages until it is first touched. Each first touch of a 4 KiB page is a minor fault: a kernel entry, a page zeroed, a page-table update — on the order of a microsecond, often more.
  • Cross-thread frees. In a pipeline, the engine allocates an event and the publisher thread frees it. Memory migrates between threads' caches, and allocator metadata cache lines bounce between cores.
  • Cache pollution. Allocator metadata and freshly allocated memory evict the book's hot data.

None of these show up in the median. All of them show up at p99.9 — exactly where week 25 taught you to look. So the hot-path rule is simple: no allocation and no deallocation between "order received" and "events published", after warmup.

Recall#malloc-tail

A malloc/free pair benchmarks at 20 ns. Why do low-latency teams still ban allocation on the hot path?

Day 2 — Slabs and generational keys

Day 02

A Vec that never moves, with a free list inside it

Drill 1: insert, get, remove in O(1) with stale-key detection

Docs
slab crate

The canonical Rust slab (used inside tokio). Look at the Entry enum: vacant entries store the index of the next vacant entry, so the free list costs no extra memory. Note its keys are plain usize with no generation.

Docs
slotmap crate

A slab whose keys carry a version, so a key to a removed element can never accidentally reach the element that later reuses its slot. That is the design you build today.

A slab is a Vec<Entry<T>> sized once, where each entry is either occupied or vacant, and vacant entries form a singly linked free list through their own storage. Insert pops the free-list head; remove pushes onto it. Both are O(1), and nothing ever moves, so a key (an index) stays valid for the life of the element — unlike Vec::remove or swap_remove, which shift or relocate elements.

Two details turn this into a production structure:

  • Generations. An order id maps to a slab key. The order is cancelled, its slot is reused by a new order — and then a late, duplicate cancel for the old id arrives. With a bare index you would cancel the wrong order. Store a generation counter in each slot, bump it on remove, and put it in the key: a stale key no longer matches. This is the ABA problem again, in index form.
  • LIFO reuse. Pushing freed slots on the front of the free list means the next insert reuses the most recently freed slot — the one most likely still in L1. A FIFO free list cycles through the whole slab and touches cold memory.

What do you do when the slab is full? On the hot path, never grow: growth means a reallocation and a copy of every order, which invalidates every pointer and takes milliseconds. Reject the order with a typed capacity reject and size the slab from configuration (week 22) with headroom.

Drill

Slab with stable keys and a free list

Target: 7/7 cases passNot yet run
/// A handle into the slab. `generation` makes stale handles detectable:
/// when a slot is freed its generation is bumped, so a key that still
/// carries the old generation no longer resolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Key {
    pub index: u32,
    pub generation: u32,
}

enum Entry<T> {
    Occupied { generation: u32, value: T },
    Vacant { generation: u32, next_free: u32 },
}

const NIL: u32 = u32::MAX;

/// Fixed-capacity slab: every slot is allocated up front, free slots form
/// an intrusive LIFO list threaded through the vacant entries.
pub struct Slab<T> {
    entries: Vec<Entry<T>>,
    free_head: u32,
    len: usize,
}

impl<T> Slab<T> {
    /// Allocate every slot up front (generation 0) and thread them onto the
    /// free list 0 -> 1 -> 2 -> ... -> NIL. The Vec must never grow after this.
    pub fn with_capacity(capacity: usize) -> Self {
        todo!()
    }

    /// Pop the head of the free list. Err(value) when full — never grow.
    pub fn insert(&mut self, value: T) -> Result<Key, T> {
        todo!()
    }

    /// None for out-of-range indices, vacant slots and stale generations.
    pub fn get(&self, key: Key) -> Option<&T> {
        todo!()
    }

    pub fn get_mut(&mut self, key: Key) -> Option<&mut T> {
        todo!()
    }

    /// Free the slot: bump its generation (wrapping) so old keys go stale,
    /// and push it on the FRONT of the free list. Hint: std::mem::replace.
    pub fn remove(&mut self, key: Key) -> Option<T> {
        todo!()
    }

    pub fn len(&self) -> usize {
        self.len
    }

    pub fn capacity(&self) -> usize {
        self.entries.len()
    }
}
Rust
Recall#generation-why

Why does a slab key used for order ids need a generation counter, and what bug does it prevent?

Recall#lifo-free-list

Why push freed slots on the front of the free list (LIFO) instead of the back?

Day 3 — Proving zero allocations

Day 03

A global allocator that counts

Drill 2: make 'no allocation' a test, not a belief

Docs
std::alloc::GlobalAlloc

Read the safety section carefully, and note which methods have default implementations (alloc_zeroed, realloc) — defaults call alloc/dealloc, which changes what your counts mean if you forget to override them.

#[global_allocator] lets you replace the allocator for the whole program. A counting wrapper forwards every call to the real allocator (System, or jemalloc in production) and increments a few counters. Then a test can say: after warmup, process 100,000 mixed orders; assert zero allocations.

The rules for code inside an allocator are strict, because it is called from everywhere, including from inside println! and the panic machinery:

  • It must not allocate, directly or indirectly — no format!, no Vec, no printing. Recursion into yourself is a stack overflow or a deadlock.
  • It must not panic or unwind.
  • Keep it cheap: Relaxed atomic adds are enough for counters that you read after the fact.

What surprises people about what allocates in Rust: Vec::new(), String::new() and Vec::with_capacity(0) do not; vec![0; n] goes through alloc_zeroed; growing a Vec is a realloc; format!, to_string(), clone() of a String, collect(), Box<dyn Trait>, Arc::new and every HashMap insert that crosses the load factor do. std::mem::take(&mut vec) silently throws your capacity away; vec.clear() keeps it.

The pattern the drill ends with is the one you will use everywhere: the caller owns a buffer, the hot function fills it. encode_fills(&fills, &mut out) clears and refills a Vec whose capacity was reserved at startup — zero allocations per call, forever.

Drill

Counting global allocator

Target: 7/7 cases passNot yet run
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicU64, Ordering};

static ALLOCS: AtomicU64 = AtomicU64::new(0);
static REALLOCS: AtomicU64 = AtomicU64::new(0);
static DEALLOCS: AtomicU64 = AtomicU64::new(0);
static BYTES: AtomicU64 = AtomicU64::new(0);

/// Forwards to the system allocator and counts every call. Must never
/// allocate, lock or print itself — it IS the allocator.
pub struct Counting;

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // TODO: count one allocation and layout.size() bytes.
        System.alloc(layout)
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        // TODO: same as alloc — vec![0; n] lands here.
        System.alloc_zeroed(layout)
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        // TODO: count one realloc (not an alloc) and new_size bytes.
        System.realloc(ptr, layout, new_size)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // TODO: count one deallocation.
        System.dealloc(ptr, layout)
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AllocStats {
    pub allocs: u64,
    pub reallocs: u64,
    pub deallocs: u64,
    pub bytes: u64,
}

/// Current totals since process start.
pub fn snapshot() -> AllocStats {
    todo!()
}

/// Run `f` and report what it allocated: the difference between a snapshot
/// taken before and one taken after. Only meaningful while no other thread
/// allocates.
pub fn measure<R>(f: impl FnOnce() -> R) -> (R, AllocStats) {
    todo!()
}

#[derive(Clone, Copy, Debug)]
pub struct Fill {
    pub maker: u64,
    pub taker: u64,
    pub px: i64,
    pub qty: u64,
}

/// The slow way: one String per fill. Kept for comparison.
pub fn render_fills(fills: &[Fill]) -> Vec<String> {
    fills
        .iter()
        .map(|f| format!("{}:{}@{}x{}", f.maker, f.taker, f.px, f.qty))
        .collect()
}

/// Hot-path encoder: clear `out` and append one 32-byte record per fill
/// (maker, taker, px, qty — each 8 bytes little-endian). Must not allocate
/// when `out` already has capacity for 32 * fills.len() bytes.
pub fn encode_fills(fills: &[Fill], out: &mut Vec<u8>) {
    todo!()
}
Rust
Recall#counting-flaky

Your zero-allocation test passes alone but fails intermittently under cargo test. Why, and what are two fixes?

Day 4 — Pools and arenas

Day 04

Recycle what you cannot avoid

Lifetimes decide the strategy

Docsby Robert Nystrom, Game Programming Patterns
Object Pool

Games have the same constraint as exchanges — a hard frame/latency budget and no tolerance for allocator hiccups. The chapter's design decisions (who owns pooled objects, what happens when the pool is empty) map directly onto event buffers.

Docs
bumpalo

A bump arena: allocation is a pointer increment, and reset() frees everything at once. Note what it does not do by default — run destructors — and why that is fine for plain-data scratch.

Sort every object on the order path by its lifetime, and the strategy picks itself:

  • Lives as long as the order (the order itself, its level membership): a slab slot. Freed explicitly on fill or cancel.
  • Lives for one message or one batch (match results, scratch vectors, decoded fields): reused buffers owned by the engine — a Vec<Fill> that is cleared, never dropped — or an arena reset after each batch.
  • Crosses threads (event buffers handed from engine to publisher): a pool with a return path. The consumer does not free the buffer; it sends it back on an SPSC queue (week 5) and the producer reuses it. If the pool is empty you have discovered backpressure (week 24) — block, drop, or reject, but do not silently allocate.
  • Lives forever (instrument reference data, configuration): allocate freely at startup.

Inline storage helps for small, bounded collections: arrayvec::ArrayVec<Fill, 8> never touches the heap, and smallvec::SmallVec spills to the heap only past its inline capacity — fine if the spill is rare and you have measured that it is.

Design note#lifetime-audit

List every heap-allocated object your engine creates per order today (use last week's profile and grep for Vec, String, Box, Arc, HashMap, collect, clone, format!). For each one write its lifetime class — per order, per batch, cross-thread, forever — and the replacement you will use.

0 chars

Day 5 — Memory you allocated is not memory you have

Day 05

Page faults, prefaulting and huge pages

Preallocation only works if the pages are resident

Paperby Ulrich Drepper (2007)
What Every Programmer Should Know About Memory

Section 4 (virtual memory) explains page tables and TLBs; the TLB discussion is why huge pages matter for a large order book. Old hardware numbers, timeless model.

Vec::with_capacity(1 << 20) returns instantly — the allocator reserved address space, but for large sizes the kernel has not given you any physical memory yet. The first write to each 4 KiB page faults. So a slab that was "preallocated" at startup can still take a page fault the first time the market reaches an untouched slot — in the middle of the open, of course.

The startup checklist:

  • Prefault: write to every page of every preallocated structure (filling the free list in with_capacity does this for the slab), or mmap with MAP_POPULATE.
  • Lock it: mlockall(MCL_CURRENT | MCL_FUTURE) stops the kernel from paging hot memory out. Needs CAP_IPC_LOCK or a large enough RLIMIT_MEMLOCK.
  • Mind the TLB: the second-level TLB on many recent cores holds around 1.5 thousand entries — about 6 MiB of coverage with 4 KiB pages. A book plus order slab bigger than that misses the TLB as well as the cache. 2 MiB huge pages cover gigabytes with the same entries.
  • But beware transparent huge pages in always mode: background compaction and khugepaged can stall a thread. Many latency-sensitive deployments use madvise mode (opt in per region) or explicit hugetlbfs pages allocated at boot.
Recall#capacity-not-resident

You preallocate the order slab with Vec::with_capacity at startup but never write to it. Why can the first orders of the day still be slow?

Day 6 — Putting it together

Day 06

Warmup, then zero

What 'allocation-free' means precisely

"Allocation-free" needs a precise definition or it becomes an argument. Use this one: after a warmup phase that exercises every message type and every code path you care about, a run of N mixed messages performs zero allocations, zero reallocations and zero deallocations on the engine thread. Warmup exists because some structures legitimately reach their steady-state capacity on first use; the test proves they stop there.

Rejects count. In a real exchange a large fraction of inbound messages can be rejected or cancelled, so an error path that builds a String reason or a Box<dyn Error> is a hot path. Make reject reasons a Copy enum.

Design note#full-slab-policy

The order slab is full during a volatile open. What should the engine do, exactly? Consider: reject new orders with a capacity reason, cancel the oldest resting orders, grow on a background thread and swap, or refuse to start without enough headroom. Pick one, justify it, and say which metric pages someone before it happens.

0 chars

Capstone

In crates/book, build a book whose storage is allocated up front, so that adding, matching and cancelling orders touches no allocator, and prove it. In the repo this is a second book, SlabBook, behind the same OrderBook trait as the week 6 Book, which stays as it is.

What to build:

  • Orders in a slab. A fixed-capacity, generational slab (your drill, hardened) holds every resting order. The per-level FIFO becomes an intrusive doubly linked list of slab indices (prev/next as u32 in the order slot) — no VecDeque per level, no Box nodes. Cancel is: look up the key, unlink, free the slot. The repo's Slab<T: Copy> differs from the drill in two ways. insert returns a SlabKey (index plus generation, read with index() and generation(); odd generations mean occupied). And the free list is a separate Vec<u32> used as a stack, still LIFO, rather than a list threaded through the vacant entries. Levels live in a second slab, and each side keeps week 26's sorted price index.
  • Order ids that are cheap to resolve. If the exchange assigns order ids, it can encode the slab index and generation in the id, so cancel needs no hash lookup at all. Client-assigned ids still need a map; give it a fast hasher and preallocate it for the maximum number of live orders. The repo keeps the ids it is given. An IdMap<SlabKey> (the one-multiply hasher) is reserved for twice the order capacity, because hashbrown can clean up tombstones in place, without allocating, only while it is at most half full. key_of(id) hands out an order's generational key. get_by_key and cancel_by_key then use the key without hashing, and refuse a stale one with BookError::UnknownOrder.
  • Pooled event buffers. The engine writes events into buffers it owns (cleared and reused), or into a pool whose buffers come back from the consumer. The match result Vec<Fill> becomes a scratch buffer passed in by &mut. In the repo the book emits no events, and Engine::process already appends to a Vec<Event> the caller clears and reuses, so there's nothing to pool this week. Week 36's router pools its batches.
  • A counting-allocator test. A w27_ test installs a counting allocator (per-thread counters, so parallel tests cannot interfere), warms the book up, then runs a long mixed add/cancel/match stream and asserts zero allocations, reallocations and deallocations. The repo provides it in crates/book/tests/common; it counts allocations and reallocations. A SlabBook sized up front needs no warm-up, so the w27 tests measure it from the very first operation.

The w27_ tests in crates/book/tests/w27_slab.rs cover:

  • stale keys, including the reuse (ABA) case;
  • no allocation within capacity, and growth beyond it;
  • a differential run against Book through Box<dyn OrderBook>;
  • add, cancel, reduce and fill allocate nothing from the first operation;
  • a stale order key never touches a reused slot;
  • cancel by key agrees with cancel by id;
  • clear invalidates every key and reuses the storage.

The decisions that matter:

  • Capacity and the full policy. Size the slab from configuration with headroom, prefault it at startup, and make "slab full" a typed reject with a metric — not a panic, not growth. The repo does the opposite: past its capacity, Slab and SlabBook grow like a Vec. BookError, the error type of the trait every book shares, has no "full" variant. A typed reject is a change to that contract. with_capacity also only reserves, so it doesn't prefault.
  • Where the counting allocator lives. A binary has exactly one #[global_allocator]. Put the counting wrapper in the test binary (it can wrap jemalloc or System), not in the library.
  • What warmup must cover. Every order type, both sides, partial fills, cancels of the head, middle and tail of a level, level creation and deletion, rejects. If the test passes only because your stream never creates a new price level, it proves nothing.

On the benchmark: add-then-cancel at p99 over a large resting book. w27.add_cancel_slab runs the week 6 workload on a SlabBook sized up front: 100,000 resting orders spread over ±500 ticks, then pairs of "add a random order, cancel a random resting order", one timed sample per pair. Compared with a book that allocates per order, the slab removes that allocation and keeps orders dense in memory. Encoding slots in ids would also remove the hash lookup per cancel, but the benchmark cancels by id through the trait, so it still hashes. And the repo's week 6 Book already keeps its orders in a Vec arena with a free list, so once warm it doesn't allocate per order either. Don't expect a big difference from Book here: both books pay for cache misses on random cancels in a 100k-order book. If your p99 does not move but the median does, look for whatever still allocates or page-faults occasionally — the counting test should already have caught the former, so suspect the latter.

Capstone · in your exchange · crates/book

Zero allocations per order

Orders live in a fixed-capacity slab with generational keys, event buffers are pooled, and a counting-allocator test proves zero heap allocations per order after warmup.

cargo xtask test --week 27      # tests named w27_*
cargo xtask bench --week 27     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 27 tests pass w27_*
  • Add then cancel with slab storage w27.add_cancel_slabtarget p99 ≤ 450 ns

Feeds into

  • Week 28 builds the cache-friendly ladder book: the levels become a dense array around the touch, and the intrusive lists keep cancels O(1). The repo's ladder keeps its orders in the same kind of Vec arena as the week 6 Book, with no generations, because it hands out no keys.
  • Week 40 keeps the zero-allocation test in the final engine's CI, so the property survives every later optimisation.