rsx-book
The orderbook. Matching an order costs the same whether the book holds a hundred orders or ten million.← All CratesDemo -- real bench, recorded live
the real cargo bench, recorded live: matching one order against a book of 100 K, then 10 M resting orders.
why it matters: the match stays ~60 ns at both depths -- matching cost does not grow with book depth (single core, Criterion lab microbenchmark).
Why it's built this way
Why rsx-book is built this way
An order book is the live list of every resting buy and sell order for one market; matching pairs an incoming order against the best ones already sitting there. A busy market can hold millions of resting orders at once, so the match has to stay fast no matter how full the book gets — that is the whole job of this crate.
One goal: matching an order costs the same whether the book holds a hundred orders or ten million. A textbook book slows as it fills; this one stays flat. Here is the shape that buys that.
In one breath (if you already know orderbooks)
Not a BTreeMap. A flat, pre-quantised price grid — ~120 000 tick slots —
so every hot operation is array arithmetic, not a tree walk:
- price → level: a sawtooth fold of the raw range onto the grid — O(1), no search, no pointers to chase.
- next-best level: a 3-tier occupancy bitmap + count-trailing-zeros — O(1), not O(price-range); a match that clears a level stays ~145 ns, flat (the isolated next-best find is ~25 ns).
- orders: a slab arena — ~1–5 ns alloc/free, zero heap on the hot path.
- layout:
#[repr(C, align(64))]with a hot/cold field split — a resting-order match touches one cache line.
Net: every operation is O(1) and cache-resident, so match latency is flat
from 100 to 10 M resting orders (measured below). i64 fixed-point
throughout — no floats, exact prices.
The sections below are the why behind each choice — the cost each one removes that a naive orderbook pays on every order.
The price map — arithmetic instead of a search
Problem. An order arrives at some price. Where does it go? The obvious
answer is a tree keyed by price (BTreeMap<price, level>), but a tree walk
is O(log n): the more prices are live, the more hops to find the right one,
on the hottest path in the system.
Fix. Prices don't need a tree — they live on a fixed grid (the tick size). So we pre-quantise the whole tradeable range into a fixed array of ~120 000 slots and fold the huge raw price range into it with plain arithmetic (a "sawtooth" of ± bands around the mid price, dense where it matters). Price → slot is now a couple of subtractions and a compare — O(1), no search, no pointers to chase.
Cost it removes. Every insert, cancel, and match starts by locating a price. Making that arithmetic instead of a tree walk takes the depth term out of the most frequent operation there is.
The occupancy bitmap — finding the next level without looking
Problem. After a trade clears the best price level, matching needs the next resting level. With slots in a flat array, the naive way is to scan forward until you hit a non-empty one. When the nearby book is thin, that scan walks thousands of empty slots — O(price-range) — and it lands right on the level-clearing hot path, where the cost is most visible.
Fix. Keep one bit per slot: 1 = something resting here, 0 = empty. Pack them 64 to a word, then put a second layer of bits summarising which words are non-zero, and a third summarising the second. To find the next filled level you read a word, and the CPU's count-trailing-zeros instruction hands you the answer in one step; if the word is empty you climb one level and repeat. Three levels cover all 120 000 slots, so next-best-level is ~3 word reads regardless of how far the next order sits — O(depth=3), not O(price-range).
Why not a tree here either? A BTreeMap would also answer "next key,"
but it re-earns the search every time and scatters nodes across the heap.
The domain is already a dense pre-quantised grid — the slot index is the
key — so a bitmap over that grid is both smaller and faster than a tree
laid on top of it. (A single "pointer to the next filled slot" can't help:
inserts arrive in any order and matching seeks in both directions, so you'd
still need the search the bitmap gives you for free.) The find itself is
~25 ns; a full match that has to clear the touch level and find the next
stays ~145 ns, flat — no matter how far the next order sits.
The slab — allocation that isn't allocation
Problem. Orders and levels are created and destroyed constantly.
Calling malloc/free for each one costs 20–80 ns and can grab a global
lock under load — unacceptable when the whole match budget is a few hundred
nanoseconds, and a hard "no" for the zero-heap-on-hot-path rule.
Fix. Grab one big block at startup and hand out fixed-size slots from it. Allocating is bumping a counter or popping a free-list head (~1–5 ns); freeing is pushing it back (O(1)). Slots get reused, so there's no fragmentation and no trip to the system allocator ever happens mid-trade. As a bonus the slots sit contiguously, so the CPU's prefetcher works with us instead of chasing scattered heap pointers.
Cache layout — not straddling the line
Problem. x86 memory moves in 64-byte cache lines. A struct that isn't line-aligned can sit across two lines, so reading one field drags in two lines — half the effective L1 bandwidth — and neighbouring order slots can share a line and fight over it (false sharing).
Fix. Every hot struct is #[repr(C, align(64))]: fixed C field order
(so we can count bytes and it survives compiler upgrades) and a guaranteed
64-byte start. In a slab of 128-byte slots each order is exactly two lines,
perfectly aligned, never straddling, never sharing a line with its
neighbour.
Hot/cold split. Within a slot, the fields touched on every match — price, quantity, side, the linked-list pointers — go in the first cache line. The fields only touched on insert or cancel — order id, user id, original quantity, timestamps — go in the second. Matching a resting order then pulls exactly one line, not two, and never pays to load audit data it won't read.
The through-line
Every choice above replaces a variable cost with a fixed one: a tree walk
becomes arithmetic, a linear scan becomes three word reads, a malloc
becomes a pointer bump, a two-line read becomes one. That's the whole
reason the numbers stay flat as the book grows — and the benchmark
(reports/ + the live demo) is just that, measured. Everything is i64
fixed-point throughout: no floats, no rounding, exact prices.
Description
rsx-book Architecture
The data structures behind a depth-invariant matching book, and how
they compose. This is "how it is"; the "why" lives in
notes/ and the formal spec specs/2/21-orderbook.md.
Module layout (rsx-book/src/)
| File | Purpose |
|---|---|
book.rs |
Orderbook struct; insert_resting, unlink_order, cancel_order, modify_*, best-bid/ask scan, occupancy maintenance, price_asc build. |
matching.rs |
process_new_order, match_at_level, can_fill_fully — GTC / IOC / FOK / post-only / reduce-only, event emission. |
slab.rs |
Generic Slab<T> arena: bump + free-list, O(1) alloc/free. |
compression.rs |
CompressionMap — 5-zone sawtooth price→index bisection. |
occupancy.rs |
Occupancy — hierarchical set-bit bitmap; set/clear/find_next/find_prev in O(depth). |
level.rs |
PriceLevel — 24-byte level head (head/tail slab handle, total_qty, order_count). |
order.rs |
OrderSlot — 128-byte #[repr(C, align(64))] order, hot fields in cache line 0. |
event.rs |
Event enum + reason constants; MAX_EVENTS = 65_536. |
user.rs |
UserState — per-user net position + active order count, tracked inside the book. |
migration.rs |
Lazy incremental recentering when mid drifts. |
snapshot.rs |
Binary save/load (magic RXSN + version). |
How the pieces compose
Orderbook
┌──────────────┬──────────────┬───────────────────────────┐
│ active_levels│ orders │ bid_occ / ask_occ │
│ Vec<PriceLevel> Slab<OrderSlot> Occupancy (per side) │
│ (dense, │ (arena, │ (bitmap over the same │
│ compressed) │ 128B slots) │ compression slots) │
└──────┬───────┴──────┬────────┴────────────┬──────────────┘
│ index by │ head/tail handle │ bit set =
│ compression │ into slab; orders │ level non-empty
│ price→slot │ doubly-linked (FIFO) │ (next-best find)
▼ ▼ ▼
CompressionMap OrderSlot chain find_next / find_prev
(price → u32) head → … → tail (O(depth) skip-empty)
- A price maps to a slot index via
CompressionMap::price_to_index(bisection, ~2 ns). active_levels[slot]is aPriceLevel: head/tail handles into the slab, plustotal_qtyandorder_count.- Orders at a level are a doubly-linked list threaded through the
slab (
next/prevare slab handles), head→tail = time priority. bid_occ/ask_occmark which slots are non-empty, so next-best-level is a bitmap find, never a scan.
Slab arena (slab.rs)
Slab<OrderSlot> is a preallocated Vec<OrderSlot> with a
free-list head and a bump cursor.
- alloc: pop
free_head(reuse) or bumpbump_next(fresh). O(1). Asserts on exhaustion. - free: push the slot onto
free_head. O(1). The free-list chains through each slot'snextfield. - no-leak invariant:
live = bump_next − |freelist|. Everyallocpairs with at most onefree.free_count()walks the list for test/introspection cross-checks (not the hot path).
OrderSlot is 128 bytes, #[repr(C, align(64))] (compile-time
asserted): cache line 0 is hot (price, remaining_qty, side, flags,
tif, next/prev/tick_index), cache line 1 is cold (user_id, sequence,
original_qty, timestamp_ns, order ids). Cancel and match touch only
line 0.
Compression map (compression.rs)
A price→slot quantization centered on mid_price, so the whole
tradeable range fits a bounded, dense level array instead of one
slot per tick. Five zones by absolute distance from mid:
| zone | distance from mid | ticks per slot |
|---|---|---|
| 0 | 0–5% | 1 (1:1 near mid) |
| 1 | 5–15% | 10 |
| 2 | 15–30% | 100 |
| 3 | 30–50% | 1000 |
| 4 | 50%+ | catch-all, 1 slot per side |
price_to_index is a 2–3 comparison bisection over the four raw-price
thresholds, then an in-zone offset. Each zone is a symmetric ± band
around mid laid out at ascending index, so the index is a
sawtooth: it is not globally price-monotonic across zone
boundaries. Two consequences enforced everywhere:
- BBA is tracked by raw price, never by index.
best_bid_px/best_ask_pxare compared as prices; the tick index is only a slot address. - Next-best walks
price_asc, not the raw bitmap.build_price_ascprecomputes the ≤10 zone-half index sub-ranges ordered by price band (within each, ascending index == ascending price). Recomputed only on construction / recenter.
Orders sharing a slot (zones 1–4) store their exact price; the matcher checks the real price per order.
Occupancy bitmap (occupancy.rs)
Per-side hierarchical set-bit index over the compression slots: bit
set = "this level holds ≥1 resting order of that side". levels[0]
is one bit per slot; each higher level is one bit per word of the
level below. ~120k slots ⇒ 3 levels (1929 + 31 + 1 words), ~15 KB.
set/clear— O(depth); climb up only while a word flips empty↔non-empty, so a deep cancel is a couple of word writes.find_next/find_prev— O(depth); climb summary words to find a candidate, then descend viatrailing_zeros/leading_zeros.find_first_in/find_last_in— bounded to aprice_ascsub-range.
Maintained in lockstep with PriceLevel::order_count at every site
that crosses the 0 boundary: insert_resting (0→1 sets),
unlink_order (→0 clears — covers cancel and maker-fill),
migrate_single_level, trigger_recenter (reset), and snapshot
load (rebuild_occupancy). A stale bit would be a phantom or
skipped level, i.e. a matching bug; scan_reference_test.rs
cross-checks the bitmap path against a brute-force scan. Deep why:
notes/occupancy.md.
Best-bid / best-ask
Cached as (best_bid_tick, best_bid_px) / (best_ask_tick,
best_ask_px). Updated on the fly during insert (compare raw price)
and match. When a level empties, scan_next_bid / scan_next_ask
walk price_asc in price order and take the extreme set bit per
sub-range — the common near-BBO case returns after touching a few
zone-0 summary words.
Matching algorithm (matching.rs)
process_new_order(book, order) — one aggressor in, events out.
Event buffer is reset at entry, so book.events() afterward is
exactly this order's events.
process_new_order(book, order):
event_buf.reset()
validate tick/lot -> OrderFailed(VALIDATION) on fail
reduce-only: clamp qty to net position,
or OrderFailed(REDUCE_ONLY)
post-only: OrderCancelled(POST_ONLY) if would cross
FOK: can_fill_fully()? else OrderFailed(FOK)
(pre-check — no partial, no rollback)
cross loop (Buy vs asks / Sell vs bids):
while remaining > 0 and touch level exists:
match_at_level(book, touch, order)
if touch emptied: best = scan_next_*()
if no progress or book side empty: break
residual:
remaining > 0 and IOC -> OrderDone(CANCELLED, filled/remaining)
remaining > 0 otherwise -> insert_resting + OrderInserted
remaining == 0 -> OrderDone(FILLED)
if best bid/ask tick changed: emit BBO
match_at_level(book, tick, aggressor) walks the level from head
(FIFO), and per maker:
skip if maker price doesn't cross aggressor limit
fill_qty = min(aggressor.remaining, maker.remaining)
decrement both; decrement level.total_qty
emit Fill <-- fill precedes any OrderDone
update_positions_on_fill(taker, maker)
if maker fully filled:
unlink_order (clears occupancy if level empties)
emit OrderDone(FILLED) for the maker
slab.free(maker)
Event ordering upholds invariant #1 (fills precede ORDER_DONE): the
maker's Fill is emitted before its OrderDone, and both before
the taker's terminal event at the end of process_new_order.
Invariant #2 (exactly-one completion): every return path emits
exactly one terminal event for the aggressor.
can_fill_fully (FOK) walks only the crossing levels in price
order via price_asc + occupancy, summing each level's maintained
total_qty and early-exiting the instant the running total reaches
the order size — O(levels crossed), never a per-order or full-slot
scan. A whole level shares one price, so total_qty counts it
exactly.
Incremental recentering (migration.rs)
When mid drifts past 50% of zone-0 half-width, the book rebuilds its compression map without a stop-the-world pause:
trigger_recenter(new_mid)— swap in a fresh empty active array and newCompressionMap; keep the old array asold_levels; reset occupancy +price_asc; enterMigrating.resolve_level(price)— lazy: migrate the old level coveringpriceon first access past the frontier.migrate_batch(n)— proactive: migratenlevels per idle call, advancing bid/ask frontiers until both cover the old range, thencomplete_migrationback toNormal.
During migration two arrays are live; migrate_single_level
re-links each order into the new array and re-sets occupancy / BBA.
Event buffer
Fixed Box<[Event; 65_536]> on the Orderbook, reset per
process_new_order. emit asserts on overflow (invariant: ME never
drops events). Worst case is a market order sweeping every resting
order at ~3 events per fill (Fill + maker OrderDone + final
BBO), so 65,536 slots covers ~21k fills in one cascade. The caller
drains book.events() after each order and fans out to two
transports (fills/BBO/done → risk; inserts/cancels/fills →
marketdata); that fan-out lives in the ME process, not in this crate.
How it plugs into the ME tile
rsx-book is a library of pure data structures — no runtime, no
threads, no I/O. The matching-engine process (rsx-matching) owns
one Orderbook per symbol and drives it from a pinned tile loop:
decode an incoming order off the transport, call
process_new_order, drain book.events(), and publish. Because
each book is single-owner state on one thread, it needs no locking;
scale-out is one book (one core) per symbol. rsx-marketdata runs
the same book as a shadow (fed inserts/cancels/fills) to serve L2 /
BBO. The book makes no thread-safety claims because no caller shares
it across threads.
Trust boundary. The book is internal, single-owner, and never touches the network. Authentication, margin, and risk limits are enforced upstream (gateway JWT, risk tile) before an order reaches it — the book trusts that boundary and does not re-check caller identity or solvency on the hot path. It validates only structural well-formedness (tick / lot multiples, reduce-only / post-only semantics), the checks it needs to keep its own state consistent.
Memory layout (config-driven)
| Component | Sizing | Memory |
|---|---|---|
| Order slab | capacity × 128 B (constructor arg) |
e.g. 78M slots ≈ 10 GB virtual; pages fault in on use |
| Price levels (×2: active + staging) | ~120k slots × 24 B × 2 | ~6 MB (grows with range/tick) |
| Occupancy (×2 sides) | ~15 KB each | negligible |
| Event buffer | [Event; 65_536] heap-boxed |
~8 MB |
Slab capacity is a caller choice; level-array and occupancy sizes follow from mid/tick via the compression map.
Operation complexity
| operation | cost |
|---|---|
| Insert (rest) | O(1) — bisect + slab alloc + tail link + bit set |
| Cancel by handle | O(1) — slab unlink + bit clear (+ O(depth) next-best only if the touch emptied) |
| Match, touch survives | O(fills) — depth-invariant (~60–65 ns) |
| Match, touch clears | O(fills) + O(depth) next-best find (~145 ns) |
| Deep sweep of K levels | O(K · depth) — linear in levels swept, not in slots |
| Best bid/ask read | O(1) — cached |
| Recenter | amortized via lazy + batched migration; no global pause |
Architectural decisions
Runtime: none — pure data structures. rsx-book is a library,
not a process: no async runtime, no threading primitives, no I/O.
The caller owns the loop and the threading model. Consumers today
are rsx-matching (the ME tile) and rsx-marketdata (shadow book),
both single-owner on whatever thread drives them.
Benchmarks
rsx-book benchmark — 2026-07-04 (clean quiet-box run)
What: the full rsx-book Criterion suite (book_bench + deep_book_bench),
the matching/orderbook library in isolation. Box: AMD Ryzen 9 5950X (4-vCPU
slice), single core, 1 thread/core. Method: the RSX cluster was STOPPED
first (its ME+Risk busy-spin at ~90% CPU otherwise poisons the numbers — a
contaminated earlier run showed +757% noise). Criterion, closed-loop, n=50-100
per case. Commit f45e0a0. Source: cargo bench -p rsx-book.
Headline — matching is O(1) in book depth
Matching a marketable order stays ~65 ns whether the book holds 100 thousand or 10 MILLION resting orders. Best case 28 ns at depth 1. Depth-invariant because the compression map + slab arena make level lookup and order pop constant-time, not a tree walk.
| resting orders in book | match latency (median) |
|---|---|
| 1 | 28.1 ns |
| 100 | 60.0 ns |
| 1,000 | 61.0 ns |
| 10,000 | 60.2 ns |
| 100,000 | 64.5 ns |
| 1,000,000 | 66.3 ns |
| 10,000,000 | 65.5 ns |
(match_depth/* + deep_flat_match/* — the same op across depths.)
Primitives (per-op, single core)
| op | median |
|---|---|
| slab alloc (bump) | 556 ps |
| slab alloc (freelist) | 1.44 ns |
| slab free | 8.30 ns |
| compression price→index (near) | 1.91 ns |
| compression price→index (far) | 2.18 ns |
| price→index bisection | 1.99 ns |
| compression map build | 12.7 ns |
| lazy recenter (per access) | 1.95 ns |
| modify order qty-down | 2.12 ns |
| post-only reject | 5.96 ns |
| deep flat insert (100k–10M book) | 238–260 ns |
Bulk / amortized
| op | median | note |
|---|---|---|
| insert+cancel, depth 1k–100k | 160–350 ns | amortized per pair |
| cancel, depth 1k–100k | 15–170 ns | |
| recenter 10k orders | 308 µs | bulk compression-map rebuild |
| best-bid scan after cancel | 50 µs | the known O(n) scan (BUGS.md) |
Caveats (honest)
- Lab microbenchmark, not system TPS. Single core, in-process, no I/O, no network — this is the algorithm, not the exchange round-trip (that's the transport-bound ~1.1 ms cross-process figure, a different story).
- Criterion closed-loop, quiet box, single run — re-run before quoting elsewhere.
match_by_type/fok_fullwas a separate finding (bugs.mdFOK-AVAILABLE-LIQUIDITY-ON-SCAN): FOK's old pre-check was its own O(N-resting) full-book scan, ~296 µs at depth 10k, not touched by the occupancy-bitmap fix. Also fixed 2026-07-04 — see the FOK section below; now ~118 ns.
Post-scan-fix — occupancy bitmap (2026-07-04, commit da9a2b4)
The MATCHING-BENCH-ORDERTYPE-FIXTURE finding above was WRONG about root
cause. The 32-224 µs match_by_type/insert_cancel_depth numbers were NOT
fixture alloc/drop bleed — post_only_reject ran on the exact same depth-10k
fixture and measured 5.96 ns, which is only possible if the fixture itself was
cheap. The real cause: scan_next_bid/scan_next_ask did an O(compression-
slots) linear scan (~100k slots) whenever a price level actually emptied
(match-that-clears-the-touch, or cancel-that-empties-a-level) — post_only_
reject never clears a level, so it never paid the scan; the match_depth/
deep_flat_match numbers above dodged it too, because their taker is always
replenished onto the SAME touch level before it can go empty. Every op whose
fixture design clears a level (match_by_type's taker_fill, cancel-driven
depth sweeps) hit the scan and paid 30-1000x the true match cost.
Fixed by a hierarchical occupancy bitmap (rsx-book/src/occupancy.rs): 1
bit/compression-slot + a u64 summary tree, find_next/find_prev via
trailing/leading-zeros, O(depth=3) instead of O(slots). Re-measured 2026-07-04
on the same quiet box, same commit family:
| bench | before da9a2b4 |
after | speedup |
|---|---|---|---|
match_ioc_vs_1k_asks (clears touch level) |
4.37 µs | 145 ns | 30x |
match_by_type/ioc_full |
~80 µs | 79.4 ns | ~1000x |
match_by_type/gtc_full_cross |
~80 µs | 79.7 ns | ~1000x |
match_by_type/sweep_10_levels |
~1 ms | 700 ns | ~1400x |
match_by_type/post_only_reject |
5.96 ns (unaffected, never clears) | 6.17 ns | — |
match_by_type/fok_full |
in the quarantined 99-224 µs / 1 ms range | 296 µs → 118 ns | fixed separately (FOK section below) |
Happy path is unaffected, confirming it was never the fixture:
match_depth/1000 61.0 ns → 61.3 ns, match_depth/10000 60.2 ns →
63.5 ns — same ~60-65 ns band as the original headline table above,
within run-to-run noise.
Budget claim, corrected: the exchange's <500 ns ME-match budget was previously met only on the path that never clears a resting level. Any real match that empties the touch (a common case — the whole point of matching is to consume liquidity) cost 32-224 µs, 200x over budget. It is now genuinely met on both paths: 60-65 ns when the touch survives, 145 ns when it clears.
rsx-book vs. the obvious baseline (BTreeMap<price, VecDeque<order>>)
Per the CEO-audit "so what, vs the obvious thing" ask (.ship/34-COMPARE-
RESEARCH/PLAN.md): a textbook order book — BTreeMap<i64, VecDeque<Order>>
per side, HashMap<order_id, (side, price)> to locate an order for cancel
(linear scan within its level's VecDeque — no slab, no compression map, no
occupancy bitmap). Same Criterion harness (rsx-book/benches/harness.rs),
same box, same RNG seed per depth so both books hold statistically-identical
content. Source: rsx-book/benches/compare_naive_bench.rs, cargo bench -p
rsx-book --bench compare_naive_bench.
| op | depth | rsx-book | naive BTreeMap | speedup |
|---|---|---|---|---|
| match, clears touch level | 100 | 72.1 ns | 106.5 ns | 1.5x |
| match, clears touch level | 1,000 | 71.7 ns | 110.2 ns | 1.5x |
| match, clears touch level | 10,000 | 71.6 ns | 117.8 ns | 1.6x |
| insert + cancel (pair) | 100 | 160.0 ns | 241.7 ns | 1.5x |
| insert + cancel (pair) | 1,000 | 162.2 ns | 286.8 ns | 1.8x |
| insert + cancel (pair) | 10,000 | 171.1 ns | 349.1 ns | 2.0x |
| cancel | 100 | 18.4 ns | 101.0 ns | 5.5x |
| cancel | 1,000 | 17.8 ns | 146.4 ns | 8.2x |
| cancel | 10,000 | 17.9 ns | 178.4 ns | 10.0x |
Honest reading: BTreeMap was never O(book-size) for this — tree removal
and next-best lookup are both O(log n), so it never had rsx-book's pre-fix
O(slots) bug; the gap here is constant-factor (slab handle vs. hash lookup +
tree traversal + heap alloc/dealloc per level), not asymptotic. The gap is
narrowest on match (1.5-1.6x, both O(1)-ish at these depths) and widest on
cancel (5.5x→10x, growing with depth) — rsx-book's cancel is a pure slab
unlink (O(1), no tree, no hash lookup), while the naive cancel pays a
HashMap lookup plus a BTreeMap tree descent plus a VecDeque scan, and that
tree descent cost grows with depth. insert+cancel sits between the two
(1.5x→2.0x, growing) since it's dominated by the same insert-side BTreeMap
entry-or-default cost at both ends.
FOK fill-or-kill — no map, just "try to match it" (2026-07-04)
FOK (fill-or-kill) must fill the whole order immediately or reject it. The
old check (available_liquidity) answered "is there enough crossable
liquidity?" with a SEPARATE O(N) pass: it iterated all ~100k active levels
AND every resting order on each, summing crossable qty, before matching.
At depth 10k that pre-check was the entire cost — fok_full sat at ~296 µs
while every other order type was 60-145 ns.
The fix is not a new structure (no histogram, no per-side liquidity index).
FOK is just "try to match it, take it or don't", so can_fill_fully walks
only the crossing levels in price order — the same traversal a real match
performs, via the book's existing best-level index (price_asc +
occupancy) — and sums each level's already-maintained total_qty, stopping
the instant the running total reaches the order size. A whole price level
shares one price, so it either crosses or it doesn't; total_qty counts it
exactly with no per-order walk. Complexity: O(levels crossed, early-exit)
instead of O(slots + orders).
| bench | before | after | speedup |
|---|---|---|---|
match_by_type/fok_full (depth 10k) |
296 µs | ~118 ns | ~2500x (−99.95%) |
Correctness is pinned by rsx-book/tests/fok_liquidity_test.rs: 3000 FOK
probes over multi-zone random flow, each compared to an independent
brute-force sum over every resting order — the fast walk must fail (no
fills) exactly when brute-force liquidity < order size, and fully fill
otherwise. Caveat: the ~118 ns figure is from a lightly-contended box (a
parallel bench was running); the −99.95% magnitude is unambiguous, but
re-run on a fully quiet box before quoting the exact ns elsewhere.
Distribution robustness (2026-07-04, distribution_bench.rs)
Does the occupancy bitmap hold O(depth) regardless of how orders are laid out? Next-best / match-that-clears / cancel measured under dense (packed zone 0), sparse (gaps across zones), and concentrated (single wall) shapes, at depth 1k and 10k. Quiet box, single core, medians (ns):
| op | dense 1k / 10k | sparse 1k / 10k | concentrated 1k / 10k |
|---|---|---|---|
| next_best (pure scan) | 21.7 / 21.8 | 29.0 / 26.6 | 25.3 / 25.0 |
| match_clears (clear touch + scan) | 73.8 / 71.9 | 81.2 / 77.8 | 77.9 / 77.8 |
| cancel_touch (cancel best → scan) | 41.8 / 43.3 | 46.0 / 48.7 | 42.9 / 43.2 |
| cancel_deep (far level, no scan) | 26.6 / 26.8 | 26.3 / 25.8 | 26.2 / 25.9 |
Verdict: O(depth), not O(slots), in every shape. Every op is flat 1k→10k
(10× the orders, ~same latency). Sparse adds only a small additive constant to
the scan-bearing ops (+5-7 ns) from skipping empty summary words — additive, not
proportional to the gap, the O(depth) signature. cancel_deep is a dead-flat
~26 ns baseline (pure slab unlink + O(1) bit clear), so the scan is the only
distribution-sensitive part and it stays bounded. No shape degrades.
Tail latency (2026-07-04, tail_bench.rs)
Closes the last CEO-eval gap: every figure above is a Criterion median
(p50). This section measures p50/p99/p99.9 on the hot ops, with special
attention to match_clears — the level-clearing path the occupancy
bitmap fixed (see the "Post-scan-fix" section above). Quiet box (RSX
cluster stopped), single core (pinned, same harness::pin() as every
other bench in this suite), dense-shape book (contiguous levels behind
a 1-order touch), depths 1k and 10k. Source: rsx-book/benches/
tail_bench.rs. Run: cargo bench -p rsx-book --bench tail_bench.
Methodology — and why a naive per-op timer would lie here
These ops run in 25-100 ns. Instant::now() on this box (vDSO
clock_gettime(CLOCK_MONOTONIC)) costs ~20-30 ns per call — a large
fraction of the op — so timing every single op individually mostly
measures the timer, not the op. Measured directly (back-to-back
Instant::now() calls, nothing timed in between, n=100,000):
| mean | p50 | p99 | p99.9 | max | |
|---|---|---|---|---|---|
| timer floor | 24.0 ns | 20.0 ns | 31.0 ns | 31.0 ns | 114,485 ns |
The floor's own max (114 µs) is OS scheduling jitter on an otherwise
idle box — a reminder that any single huge max reading below could be
noise, not the algorithm, and is called out per-op rather than
overclaimed.
Given that floor, this harness reports two numbers per op, both printed by the harness, only one of them quoted as authoritative:
- Batch-amortized (the number in the table below). Batches of
BATCH=64consecutive ops are timed as one span and divided by 64, so timer overhead contributes <1 ns to the per-op figure. 2,000,000 ops per op/depth combo -> 31,250 batch samples, so p99.9 has ~31 points above it (a real quantile, not a handful of outliers). - Single-op raw timer (context only, NOT quoted as fact). Every op timed individually, n=100,000. Included in the raw harness output so the reader can see it sits close to the timer floor (p50 50-90 ns vs. the 20-31 ns floor) — i.e. its distribution is partially timer noise, and its p99/p99.9 should not be read as the op's true tail.
Both runs are preceded by 20,000 discarded warmup iterations (cache /
branch-predictor warmup) and every op result is passed through
std::hint::black_box so the compiler cannot elide it. Single run,
quiet box, commit f45e0a0+tail_bench.rs — re-run before quoting
elsewhere, per the standing caveat on every table in this report.
Results (batch-amortized, ns/op)
| op | depth | mean | p50 | p99 | p99.9 | max |
|---|---|---|---|---|---|---|
| match (partial fill, touch survives) | 1,000 | 29.4 | 28.3 | 35.7 | 154.8 | 1,949.9 |
| match (partial fill, touch survives) | 10,000 | 29.0 | 28.2 | 41.3 | 118.5 | 266.9 |
| match_clears (empties touch → occupancy scan) | 1,000 | 71.1 | 70.5 | 76.5 | 175.2 | 1,289.4 |
| match_clears (empties touch → occupancy scan) | 10,000 | 70.2 | 68.2 | 109.4 | 216.5 | 542.3 |
| cancel_touch (cancel best → scan) | 1,000 | 46.4 | 43.2 | 90.0 | 175.6 | 1,406.1 |
| cancel_touch (cancel best → scan) | 10,000 | 40.8 | 39.6 | 74.4 | 173.3 | 583.1 |
| cancel_deep (far level, no scan) | 1,000 | 26.4 | 26.1 | 28.0 | 89.2 | 232.9 |
| cancel_deep (far level, no scan) | 10,000 | 28.0 | 26.8 | 40.5 | 97.8 | 303.2 |
Reading it
match_clears has a tight tail, not a fat one. p99/p50 is
1.08-1.60× (76.5/70.5 at depth 1k, 109.4/68.2 at depth 10k) — nowhere
near the 30-1000× blowups the pre-fix O(slots) scan produced (see the
"Post-scan-fix" section: 4.37 µs, ~80 µs, ~1 ms before). The
occupancy-bitmap find (O(depth=3) trailing/leading-zeros) does not
have a data-dependent slow path at these depths; the p99.9 bump
(~175-217 ns, ~2.5-3× p50) is consistent with occasional branch
mispredicts / cache misses on the summary-word walk, not an
asymptotic blowup, and stays flat 1k→10k (matching the O(depth)
verdict from the distribution-robustness section above).
match (happy path, touch survives) is the tightest of the four —
p99/p50 ~1.1-1.5×, as expected: no scan, no bitmap walk, just slab pop
+ level update.
cancel_touch has the widest p99/p99.9 spread of the four
(p99/p50 up to 2.1×, p99.9/p50 up to 4.4×) — it does two things per
op (insert + cancel-that-empties-and-scans), so it accumulates two
sources of jitter instead of one; cancel_deep's single-op, no-scan
baseline is correspondingly the tightest apart from match.
Caveats. Single run, quiet box, dense shape only (the
distribution-robustness section above already covers sparse/
concentrated — this section is about the tail, not the shape). The
single-op numbers exist in the raw harness output for context but are
NOT reported here as fact — they sit too close to the ~20-30 ns timer
floor to trust their tail. max columns include rare multi-hundred-ns
to ~2 µs batch averages, i.e. one op inside that batch of 64 likely
stalled for tens of µs (OS scheduling, not algorithm) — consistent
with the timer floor's own 114 µs max on an idle box. Re-run before
quoting an exact ns elsewhere.
Comparisons
rsx-book vs. the obvious baseline (BTreeMap<price, VecDeque<order>>)
Per the CEO-audit "so what, vs the obvious thing" ask (.ship/34-COMPARE-
RESEARCH/PLAN.md): a textbook order book — BTreeMap<i64, VecDeque<Order>>
per side, HashMap<order_id, (side, price)> to locate an order for cancel
(linear scan within its level's VecDeque — no slab, no compression map, no
occupancy bitmap). Same Criterion harness (rsx-book/benches/harness.rs),
same box, same RNG seed per depth so both books hold statistically-identical
content. Source: rsx-book/benches/compare_naive_bench.rs, cargo bench -p
rsx-book --bench compare_naive_bench.
| op | depth | rsx-book | naive BTreeMap | speedup |
|---|---|---|---|---|
| match, clears touch level | 100 | 72.1 ns | 106.5 ns | 1.5x |
| match, clears touch level | 1,000 | 71.7 ns | 110.2 ns | 1.5x |
| match, clears touch level | 10,000 | 71.6 ns | 117.8 ns | 1.6x |
| insert + cancel (pair) | 100 | 160.0 ns | 241.7 ns | 1.5x |
| insert + cancel (pair) | 1,000 | 162.2 ns | 286.8 ns | 1.8x |
| insert + cancel (pair) | 10,000 | 171.1 ns | 349.1 ns | 2.0x |
| cancel | 100 | 18.4 ns | 101.0 ns | 5.5x |
| cancel | 1,000 | 17.8 ns | 146.4 ns | 8.2x |
| cancel | 10,000 | 17.9 ns | 178.4 ns | 10.0x |
Honest reading: BTreeMap was never O(book-size) for this — tree removal
and next-best lookup are both O(log n), so it never had rsx-book's pre-fix
O(slots) bug; the gap here is constant-factor (slab handle vs. hash lookup +
tree traversal + heap alloc/dealloc per level), not asymptotic. The gap is
narrowest on match (1.5-1.6x, both O(1)-ish at these depths) and widest on
cancel (5.5x→10x, growing with depth) — rsx-book's cancel is a pure slab
unlink (O(1), no tree, no hash lookup), while the naive cancel pays a
HashMap lookup plus a BTreeMap tree descent plus a VecDeque scan, and that
tree descent cost grows with depth. insert+cancel sits between the two
(1.5x→2.0x, growing) since it's dominated by the same insert-side BTreeMap
entry-or-default cost at both ends.