← Back to roadmap
Phase 3Week 28~120 min read
0%·0/5

Cache-Friendly Order Book II

Ladders, struct-of-arrays and branchless best-price.

Performance · Measure, Profile, Then Go Fast

Why this week

The week 6 book was designed around operations: O(log n) to find a level, O(1) to cancel. This week you redesign it around the cache. Asymptotic complexity hides the term that dominates a modern order book: how many cache lines each operation touches, and whether the CPU can predict which ones. A BTreeMap<Price, Level> lookup is a chain of dependent loads through heap nodes — each potentially a cache miss of ~100 ns — while a dense price ladder turns "find the level for this price" into one subtraction, one division by the tick, and one load from an array the prefetcher already understands.

Real books make this design work because order activity is concentrated: most adds, cancels and trades happen within a few ticks of the best bid and ask, with a long, thin tail of far-away orders. So you keep a dense array of levels around the touch, a cheap structure for the rare outliers, and you lay out the hot data so that the questions asked most often — is this level empty? what is the next non-empty level? how much is resting here? — are answered from as few cache lines as possible, without unpredictable branches.

Day 1 — What the CPU sees

Day 01

Count cache lines, not operations

Why the tree-based book is slow under real flow

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

Section 3 (CPU caches: lines, associativity, the cost of each level) and section 6 (what programmers can do: layout, prefetching). The absolute numbers are from 2007 hardware; the ratios and the reasoning still hold.

Videoby Mike Acton · ~1.5h
Data-Oriented Design and C++ (CppCon 2014)

The talk that made 'the data is the problem, not the code' mainstream. Watch for the exercise of counting how many bytes of each cache line are actually used.

Paperby WK Selph (blog post, 2011)
How to Build a Fast Limit Order Book

A classic, short write-up of the tree-of-levels + linked-list-of-orders + hash-of-ids design — essentially your week 6 book. Read it to see the baseline this week improves on, then ask how many cache lines each of its operations touches.

Walk through what the week 6 book does when an order arrives at a price that already has resting orders:

  1. Look up the level in a BTreeMap: a few node hops, each a dependent load (you cannot fetch node 2 until node 1 tells you where it is). With a large book, upper nodes may be cached but lower ones often are not. (In the repo, week 26 already replaced the tree with a sorted Vec of prices per side. That's one dense array and no allocation, but a level away from the touch is still a binary search: log₂ L dependent probes.)
  2. Append to the level's FIFO: touch the level and its tail order.
  3. Insert into the order-id map: hash, probe, write — another cache line or two.

Every one of those is fine in a microbenchmark that keeps the whole book in L1. Under realistic flow — thousands of levels, hundreds of thousands of orders, a market-data burst evicting everything — they are the profile you saw in week 26: low IPC, high LLC MPKI. The fix is to make the common operations touch fewer, more predictable lines.

Latency numbers · log scaleQuiz yourself →

Dot = typical modern x86 server; band = plausible range across hardware. Click a row for details.

Recall#lines-per-op

Roughly how many cache lines does 'add an order at an existing price' touch in a BTreeMap-of-levels book versus a dense ladder, and why does the dependency structure matter as much as the count?

Day 2 — Price ladders

Day 02

Price → index is arithmetic

Drill 1: map any price to a slot, safely

Paperby Jean-Philippe Bouchaud, Marc Mézard, Marc Potters (Quantitative Finance, 2002)
Statistical properties of stock order books: empirical results and models

Empirical evidence for the shape your ladder is designed around: new limit orders arrive most often near the best quotes, with a fat (power-law) tail of far-away placements. Dense window for the bulk, sparse fallback for the tail.

A ladder side is an array of len levels covering prices base, base + tick, …, base + (len − 1) · tick. The index of a price is (px − base) / tick. Everything that can go wrong with that one line will, eventually, in production:

  • Prices off the tick grid must be rejected before they get here (week 8), but the ladder should still classify them rather than silently rounding.
  • Negative prices exist. Calendar spreads, some energy futures (WTI front-month settled at about −$37 in April 2020) and many synthetic instruments trade below zero. Rust's / truncates toward zero and % keeps the dividend's sign, so naive code mis-indexes negative offsets — and (negative_i64) as usize is a gigantic positive index. Use div_euclid / rem_euclid, and check the sign before casting.
  • Hostile input. The price comes from a client message. px − base can overflow i64 for extreme values — a panic in debug builds, silent wrap-around in release. Do the subtraction in i128 (or with checked_sub) and classify.
  • The market moves. When the touch drifts toward the edge of the window, re-centre: compute a new base that keeps the same tick grid and puts the current mid near the middle. Moving levels is the cold path; how often it happens depends on window size and volatility.
Drill

Price → ladder index with bounds

Target: 7/7 cases passNot yet run
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Slot {
    In(usize),
    Below,
    Above,
    OffTick,
}

/// A dense window of `len` price levels: base, base + tick, ...,
/// base + (len - 1) * tick. Prices are integers and may be negative.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Ladder {
    pub base: i64,
    pub tick: i64,
    pub len: usize,
}

impl Ladder {
    pub fn new(base: i64, tick: i64, len: usize) -> Self {
        assert!(tick > 0 && len > 0);
        Ladder { base, tick, len }
    }

    /// Map a price straight off the wire (ANY i64) to a slot:
    /// OffTick if (px - base) is not a multiple of tick; otherwise Below /
    /// Above if outside the window; otherwise In(index).
    /// Must not overflow or panic for any input.
    pub fn index(&self, px: i64) -> Slot {
        todo!()
    }

    /// Price of level `idx` (idx < len).
    pub fn price(&self, idx: usize) -> i64 {
        todo!()
    }

    /// Same tick and len, re-centred so that `mid` — rounded DOWN onto this
    /// ladder's tick grid — lands at index len / 2. Keep the grid: the new
    /// base must differ from the old one by a multiple of tick.
    pub fn recenter(&self, mid: i64) -> Ladder {
        todo!()
    }
}
Rust
Recall#negative-index-bug

A ladder has base -250 and tick 5. What does the naive ((px - base) / tick) as usize return for px = -255, and what should happen?

Day 3 — Hot/cold splits and struct-of-arrays

Day 03

Put the bytes you read together

Layout for the questions you ask most

List the questions the book answers, by frequency: is this level non-empty (every best-price search), how much is resting at this level (market data, depth, FOK checks), which order is at the head of this level (matching), what are this order's client id and timestamps (only when reporting). Then lay memory out so the frequent questions read dense memory and the rare ones pay for the indirection.

// Array-of-structs: one level = 24 bytes (20 of data, padded to align 8).
// A 64-byte line holds 2.67 levels; a depth scan drags head/tail along.
#[repr(C)]
struct Level { qty: u64, head: u32, tail: u32, count: u32 }

// Struct-of-arrays: each question reads only its own array.
struct LadderSide {
    occupied: Vec<u64>, // 1 bit per level: 512 levels per cache line
    qty: Vec<u64>,      // 8 levels per line — what depth and aggregation read
    head: Vec<u32>,     // 16 per line — what matching reads
    tail: Vec<u32>,     // what adds read
    base: i64,
}

The occupancy bitmap is the trick that makes best-price maintenance cheap. When the best ask level empties, the next best is the lowest set bit at or above the old index: one trailing_zeros (a single tzcnt instruction on x86) per 64 levels. When a new bid improves the best, it is a comparison. No tree, no scan of empty levels.

Orders get the same treatment. The slab from week 27 can hold two arrays: a hot part touched on every match and cancel — remaining quantity, prev/next slab indices, level index, side — and a cold part touched only for reporting — client order id, account, entry timestamp, original quantity. Keeping the hot part to 16–24 bytes means two or three orders per cache line as you walk a level's FIFO.

One caution: cache lines are 64 bytes on current x86 and most Arm server cores, but 128 bytes on Apple's M-series cores. Reason about lines, but measure on the machine you deploy to.

Recall#levels-per-line

How many levels fit in one 64-byte cache line as (a) the 24-byte Level struct, (b) a Vec of u64 quantities, (c) an occupancy bitmap? Which operation uses each?

Design note#level-layout

Write down the hot and cold fields for your Level and Order types with their sizes. How many orders' hot parts fit in a cache line, and how many lines does matching an aggressive order against the first three resting orders at one level touch?

0 chars

Day 4 — Branchless updates

Day 04

When an if costs 15 cycles

Drill 2: select, min/max and bitmap search without branches

A branch the predictor gets right is almost free. A branch it gets wrong flushes the speculative work behind it — roughly 15–20 cycles on recent x86 cores, more than an L2 hit. Matching engines are full of data-dependent branches: which side is this order, does it cross, is this level now empty, is this price better than the best. On a mixed stream some of those are close to coin flips.

The branchless alternative computes both outcomes and selects with a mask, which compilers turn into a conditional move (cmov on x86, csel on Arm):

fn select(cond: bool, a: i64, b: i64) -> i64 {
    let mask = -(cond as i64);  // all ones or all zeros
    b ^ ((a ^ b) & mask)
}

Branchless is not automatically faster:

  • If the branch is predictable — most adds do not improve the best price — the branch costs almost nothing and a cmov adds a data dependency on both inputs to the critical path.
  • The compiler may turn your branchless source back into a branch, or your branchy source into a cmov. Recent Rust has std::hint::select_unpredictable to state the intent; either way, read the assembly (week 37 goes deeper) and measure with perf stat -e branch-misses.
  • Beware the classic arithmetic tricks. min = b + ((a - b) & ((a - b) >> 63)) overflows when a − b does — exactly for the i64::MIN/i64::MAX sentinels an empty book side uses. Masks built from comparisons do not overflow.
Drill

Branchless best-price update

Target: 6/6 cases passNot yet run
/// Branch-free select: `a` if cond else `b`. No if/match/&&/|| — build a
/// mask from `cond as i64` and combine with bitwise operations.
pub fn select(cond: bool, a: i64, b: i64) -> i64 {
    todo!()
}

/// Correct for ALL i64 inputs, including i64::MIN and i64::MAX.
pub fn min_branchless(a: i64, b: i64) -> i64 {
    todo!()
}

pub fn max_branchless(a: i64, b: i64) -> i64 {
    todo!()
}

/// Sentinels for an empty side: no bid is below i64::MIN, no ask above i64::MAX.
pub const EMPTY_BID: i64 = i64::MIN;
pub const EMPTY_ASK: i64 = i64::MAX;

/// New best price after a resting order is added at `px`:
/// bids keep the highest price, asks the lowest. Branch-free.
pub fn best_after_add(best: i64, px: i64, is_bid: bool) -> i64 {
    todo!()
}

/// Occupancy bitmap: bit (i % 64) of word (i / 64) is set when level i has
/// resting quantity. Lowest non-empty level (best ask on an ascending
/// ladder). Hint: u64::trailing_zeros.
pub fn lowest_set(bits: &[u64]) -> Option<usize> {
    todo!()
}

/// Highest non-empty level (best bid). Hint: u64::leading_zeros.
pub fn highest_set(bits: &[u64]) -> Option<usize> {
    todo!()
}
Rust
Recall#branchless-loses

Give a case where replacing a branch with a cmov makes the book slower.

Day 5 — Prefetching

Day 05

Ask for the line before you need it

Hardware prefetchers, software prefetch, and timing

Hardware prefetchers watch your access stream and fetch ahead when they detect a pattern — sequential lines, constant strides, and on Intel the adjacent line of a 128-byte pair. Dense arrays benefit automatically: a depth scan over the qty array is prefetched for free. Pointer chasing is invisible to them: the next address is not known until the current load completes.

Software prefetch fills that gap when you know an address early. The best example in an exchange: a cancel or modify message carries the order id. As soon as you have decoded the id, prefetch the order's slab slot and its level; then do the remaining decoding, session checks and risk checks; by the time you touch the order, the line is (hopefully) in L1.

#[cfg(target_arch = "x86_64")]
#[inline(always)]
fn prefetch_read<T>(p: *const T) {
    use std::arch::x86_64::{_mm_prefetch, _MM_HINT_T0};
    // A prefetch is a hint: it never faults, even on a bad address.
    unsafe { _mm_prefetch::<_MM_HINT_T0>(p as *const i8) }
}

Prefetch only helps if it is issued early enough — a DRAM miss takes hundreds of cycles, so a prefetch issued twenty instructions before the load hides almost nothing — and not so early that the line is evicted again. It costs a load slot and cache capacity when it is wrong. Treat every prefetch as an experiment: keep it only if the open-loop p99 or the LLC-miss count on that path improves.

Recall#prefetch-when

When does a software prefetch in the order path pay off, and when is it useless or harmful?

Day 6 — Benchmarking a book honestly

Day 06

The workload decides the winner

Distributions, not a single price

A book benchmark is only as good as its price distribution. Benchmark every order at one price and both books live in L1 — the comparison measures nothing. Draw prices uniformly over thousands of ticks and the ladder spends its time in the sparse fallback — an equally fictional market. Use the shape from Day 2: most orders within a few ticks of a slowly moving mid, a fat tail of far orders, cancels biased toward recent orders, occasional aggressive orders that sweep a few levels, and a book large enough (tens to hundreds of thousands of resting orders) that it does not fit in L1/L2.

Run both books under the same generator, seed and machine, and compare open-loop latency (week 25) as well as throughput. Use the differential test from week 12 to prove they produce identical events.

Design note#window-size

Choose the ladder window size for an instrument with a 1-cent tick that typically trades in a 50-cent daily range. How much memory does each side take in your layout, which cache level does that fit in on your machine, how often do you expect to re-centre, and what happens to orders outside the window?

0 chars

Capstone

Build a second order book in crates/book: a price-ladder book that implements the same trait as the week 6 book, so the engine, the tests and the benchmarks can swap one for the other.

What to build:

  • Dense ladders per side: a window of levels around the touch in struct-of-arrays form (quantity, head, tail, count), plus an occupancy bitmap for best-price search. Orders stay in the week 27 slab with intrusive FIFO links.
  • A sparse fallback for prices outside the window — the week 6 tree structure is fine, because these orders are rare and far from the touch. They must still be correct: a far order becomes relevant when the market moves to it.
  • Re-centring: when the best price gets within some margin of the window edge, move the window. Decide whether you shift the arrays (simple, O(window) on a cold path) or use a ring-indexed ladder where re-centring only migrates the levels that rotate out and in. Either way, re-centring must preserve price-time priority and must not allocate.
  • Branchless best-price maintenance where the branch is unpredictable, and bitmap search when the best level empties.

The repo's LadderBook (crates/book/src/ladder.rs) makes some different choices:

  • Index slots, not struct-of-arrays. Each side's window is an array of u32 level indices, slots[price - base], plus an occupancy bitmap with 64 prices per word. The level records (price, total, count, head, tail, side: 32 bytes) live in an arena, not inline in the window. Re-centring then moves only 4-byte indices, and each order keeps a stable link to its level.
  • Plain arenas. Orders sit in a Vec arena with a free list, as in Book, rather than the week 27 slab. The ladder hands out no keys, so generations would only add another cache line to every random cancel.
  • Raw price units. LadderBook::new(window, orders) covers window consecutive prices, rounded up to a multiple of 64, with no tick parameter and so no division. With a tick of 5, only every fifth slot is used.
  • Re-centring on the best. When the best price leaves the window, the window is re-centred on it (#[cold], O(window / 64 + levels moved)). Dense levels that fall outside move to the sparse BTreeMap, and sparse levels that fall inside move into the window. That can allocate B-tree nodes, so the repo's no-allocation guarantee covers adds and cancels inside the window, not re-centring.
  • Comparisons, not cmov. A new best price is a plain comparison, which is predictable because most adds don't improve the best. When the best level empties, a leading_zeros or trailing_zeros scan of the bitmap finds the next one.

The w28_ tests (crates/book/tests/w28_ladder.rs) cover:

  • differential runs against Book, with narrow prices and with far prices on both sides of a small window;
  • a market that drifts 10,000 ticks up and back, far beyond a 256-price window;
  • extreme prices, i64::MIN and i64::MAX included, going to the sparse side;
  • the next best level found within one bitmap word, in the next word and many words away;
  • no allocation in steady state near the touch;
  • depth and visits that merge sparse and dense levels in priority order.

The decisions that matter:

  • Window size and memory. Bigger windows mean fewer fallbacks and re-centres but more memory per side; compute which cache level your hot arrays fit in.
  • Index safety. Every price that reaches the ladder has been validated, but the ladder still classifies hostile values without overflow — your drill is the unit test.
  • Equivalence. Run the week 12 differential test between the two books on millions of random operations, including streams that force re-centring and fallback traffic. A fast book that disagrees with the slow one is a bug, not a speedup.

On the benchmarks: one target is an absolute p99 for add-then-cancel, the other is a speedup ratio against the week 6 book on the same workload. Both use the week 6 add/cancel stream: 100,000 resting orders spread uniformly over ±500 ticks, then pairs of "add a random order, cancel a random resting order". w28.ladder_add_cancel times each pair on a LadderBook whose 2,048-price window covers the whole book, so nothing goes to the sparse side. w28.speedup divides the ladder's pairs per second by those of the week 6 Book, as week 26 left it (the sorted price index; the slab book isn't in this comparison). Both books get the same 1M pairs, and the reported ratio is the median of 5 alternating measurements. Random cancels in a 100k-order book miss the cache on any book, so expect a modest ratio on this workload. The ratio is only meaningful if both run the same distribution on the same machine — write the distribution down in the report. If the ladder is not clearly faster, profile both (week 26): the usual culprits are a level struct that is too wide, fallback traffic from a window that is too small, or re-centring far more often than you assumed.

Capstone · in your exchange · crates/book

A ladder book behind the same trait

A dense price-ladder book (array of levels around the touch, occupancy bitmap, sparse fallback, re-centring) implementing the week 6 book trait, differential-tested against it and benchmarked on a realistic price distribution.

cargo xtask test --week 28      # tests named w28_*
cargo xtask bench --week 28     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 28 tests pass w28_*
  • Ladder book add then cancel w28.ladder_add_canceltarget p99 ≤ 400 ns
  • Ladder vs w06 book w28.speeduptarget ≥ 1.15 ×

Feeds into

  • Week 29 applies the ladder idea to market data: a dense array of quantities per price (LadderDepth in crates/marketdata), with depth aggregation and level search vectorized over it.
  • Week 37 compares the engine with the book and risk policy as type parameters against a dyn twin. Engine<B> has been generic over the book since week 7, so Engine::with_book with a LadderBook already selects the ladder at compile time.
  • Week 40 uses the ladder book in the final engine configuration (FastestEngine in the repo).