One file — no account, no server, nothing to install. Open it from disk on any machine you own. Tick boxes save into whichever browser you tick them in. The ladder tells you what to build next; the chapters explain why it works; each rung links straight to the chapters it needs.
Reading produces recognition, not retrieval — which is exactly why most of those 109 LeetCode hards aren't there when you reach for them. A better-written document would fail the same way. So each rung is locked into a four-step loop, and the explanation genuinely will not open until you have committed to an answer:
You do not need to internalise 22 chapters. You need what you use — six for rung 1, two for rung 2, none for rung 3. A chapter no rung names is reference, not curriculum. Never read this front to back; raid it. And keep the ratio near one part reading to four parts typing, because the typing is the part that sticks.
It is almost never three hours, and it is almost always one of three gaps. Name which one and an intimidating paragraph becomes four or five specific questions, most of which resolve in minutes:
counter & (Capacity-1)”. One fact: for powers of two, AND with n-1 is modulo n. Twenty minutes with a calculator, permanently.tail - head”. Why is that true? Because both counters only ever increase — which also makes full and empty distinguishable.Ten of these chapters were originally a title and a diagram, with no prose at all — Ch 5 had twenty-one words. Those have been written out properly, so if a chapter still reads as a summary rather than an explanation, that is the chapter's problem and not yours. Say so and it gets fixed.
Roughly five to six weeks at most evenings. Rungs 1–3 are ten to twelve of them — comfortable before 5 October even while working, and they are the rungs where a second opinion is worth most. Rungs 4–7 grade themselves: a replay either reproduces the state hash or it doesn't, the reference model names the diverging sequence, the benchmark either got faster or it didn't. That is the half built to be done alone. If the window compresses, cut scope inside rung 6 by shipping fewer optimisations — never by skipping rung 5.
Save as PDF
Press Ctrl+P → Destination: "Save as PDF" → Margins: "Minimum"
Add and cancel resting limit orders. Query best bid, best ask, and depth at a price. Match nothing — this rung is representation, and every hard decision in the project is made here.
Step 1 — answer these in writing, before you read or code
double breaks level identity, not just that it does.order_id map to, and what invalidates that mapping? Ch 4 is about exactly this. Look up the invalidation rules; don't recall them.What to take away — check your answers against this
Price–time priority forces a queue per level, not a set — that single rule is where every FIFO problem in rung 2 comes from. Integer ticks are a correctness requirement, not an optimisation. And your choice of level container is a bet on access locality: nearly every operation touches the touch, almost none the far end. Write the bet down — rung 6 tells you if you were right.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
double and float.std::map<Price, std::deque<Order>> plus unordered_map<OrderId, Price>, and scan the deque to cancel. Not the fast answer — you replace it at rung 6 — but a correct slow engine you understand beats a fast one you're stuck on. Log that you took the hatch; that entry is rung 6's opening move.Log these in docs/decisions/ before you climb
An order that crosses consumes resting liquidity in price–time priority, produces fills, and rests any remainder. Partial fills at both ends. This is where “order book” becomes “matching engine”.
Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
Matching is one loop: while the incoming order has quantity and the opposite touch is acceptable, consume from the front of that level. The rule most home-made engines get wrong — a trade executes at the resting order's price, never the incoming one. Bid 101 into an ask at 100 and you pay 100; that's price improvement, and the resting order earned it by being there first. Conservation of quantity is your strongest debugging tool: assert it after every operation and a wrong answer becomes a precise operation number.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
std::vector<Trade>& out-parameter, and unlink eagerly on cancel with a full level scan. Both are replaceable and neither can be subtly wrong. The invariant assertions matter far more at this rung than the shape of the code — get them green, then refactor with the net in place.Log these in docs/decisions/ before you climb
Four types, each with a precise end state including the refusals. Fill-or-kill is the interesting one: it must know whether it can fill completely before it changes anything.
Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
A limit price says what you'll accept; time-in-force says how long you'll wait, and they are independent axes. GTC rests. IOC takes what's there and cancels the rest. FOK is all-or-nothing. Post-only would rather be rejected than cross the spread.
Fill-or-kill is different in kind, not degree. Every other type decides as it goes, mutating while it discovers liquidity. FOK must know the answer before it changes anything — so it needs a read-only pass: walk the opposite side while the price is acceptable, accumulate live quantity, stop as soon as you have enough. Only then match.
That read-then-act shape is the transferable idea, and you'll see it everywhere once you notice it: check-then-commit, validate-then-apply, two-phase anything. The tempting alternative — match, then undo — is where bugs live, because undo must be perfect and never is. Prefer a design where the failure case never mutates over one that mutates and rolls back.
And rejection is an outcome, not an error. Exchanges reject constantly — unacceptable price, thin book, post-only would cross. A rejection carries a reason and rides the same stream as fills rather than being thrown. Rung 4 has to replay rejections too.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
INT64_MAX and something overflowed, or it inserted a level there. If a market order can ever rest, the model is wrong — mark it never-rest, whatever price you give it.Log these in docs/decisions/ before you climb
Every input becomes an ordered, persisted event. Feed the same log into a fresh engine and get identical state and an identical trade sequence. This comes before correctness and performance work because both need it: a bug becomes a seed you can replay, a benchmark becomes a workload you can rerun.
Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
The claim is simple and strong: state is a fold over an ordered log of events. If that holds, the log is the system of record and the in-memory book is only a cache — so you can throw it away and rebuild, replay into a second process, or replay under a debugger a year later.
Determinism is what makes the claim true: the fold must be a pure function of the events. In C++ the leaks are a short, memorisable list — a clock read, a pointer or address-derived value, hash container iteration order, and uninitialised padding bytes. When a replay diverges, check those four before reading any logic.
Time becomes data: the engine never asks what time it is, the event says what time it was.
This is the rung whose ideas travel furthest beyond exchanges — it is how a database recovers from its write-ahead log, how a stream consumer rebuilds after a crash, how a durable-execution engine resumes a half-finished workflow. If you get one thing off this ladder into an interview, make it this one: you'll be asked about crash recovery and idempotency far more often than about order books.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
unordered_map to build the hash; (2) you hash a struct with uninitialised padding; (3) a pointer or clock read is part of the state. It is one of those three far more often than a logic bug.-fsanitize=address,undefined and rerun — don't reason about this one by reading code.Log these in docs/decisions/ before you climb
Write a second engine that is obviously correct and far too slow — std::multimap, no cleverness. Then generate random operation sequences and assert the fast engine and the slow one agree on every trade, fill and resting quantity. This is how exchange and database teams actually work.
Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
Hand-written tests check the cases you imagined. The bugs that survive are, by definition, in the cases you didn't — so more hand-written tests have sharply diminishing returns.
A reference model is a second implementation written for obvious correctness and nothing else: std::multimap, linear scans, readable end to end in one sitting. Two implementations written to different priorities rarely share a bug, so disagreement means one is wrong and you hold the sequence that proves it.
Seeded randomness turns a failure into a value you can carry around — a failing seed reproduces it exactly, forever, on any machine. Without rung 4's determinism this collapses entirely, which is why that rung came first.
Shrinking exists because a 4,000-operation failure is unreadable and a 20-operation failure is a bug report. The naive algorithm works: delete a chunk, and if it still fails, keep the deletion.
Two kinds of check, and you want both: an invariant must hold of any single state on its own; an oracle is an independent implementation you compare against. Invariants catch corruption, oracles catch states that are internally consistent but still wrong.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
Log these in docs/decisions/ before you climb
Benchmark harness first, then optimise — and only against rung 5's differential suite, so you can't ship a fast wrong engine. Report p50, p99 and p99.9 per operation, on your own Mac, with the workload committed so anyone can rerun it.
docs/decisions/. Its twelve decisions are effectively this project's finished state — treat that chapter as the target, and each entry you write as one of its rows earned rather than copied.Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
Report percentiles, never a mean. One operation in a thousand at ten milliseconds and the rest at fifty nanoseconds gives you an excellent average and an unusable system — the slow ones are what people experience. Quoting a mean is a tell.
Tail latency is a memory story, not an arithmetic one: cache misses, allocation, page faults, branch mispredicts. An L1 hit costs a few cycles; a miss out to DRAM costs on the order of a hundred times that. Memory moves in 64-byte cache lines, so one miss drags in several neighbouring orders for free when they're contiguous — and nothing useful when they're scattered nodes.
That's the mechanism behind contiguity beats asymptotics at small n. A binary search over a sorted vector does more comparisons than a hash lookup and still wins at book-sized data, because it touches two or three cache lines while the hash touches a random one then chases a pointer. Saying why in terms of cache lines is the whole difference between having read that and understanding it.
Predict, then measure. Write down which change you expect to help, and by how much, before you run anything. A documented wrong prediction with the measurement that corrected it is stronger material than a lucky win — it shows method rather than instinct.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
Log these in docs/decisions/ before you climb
A receive thread reads UDP and decodes zero-copy; a lock-free single-producer/single-consumer ring hands work to the matching thread; the engine itself stays single-threaded. Then the production edges: warm-up, no allocation or logging on the hot path, backpressure, graceful shutdown.
Step 1 — answer these in writing, before you read or code
What to take away — check your answers against this
The matching core stays single-threaded, and that's a design decision rather than a limitation. Real exchanges run one writer per book precisely because it removes every lock, atomic and ordering question from the hot path — and a single thread is also what makes rung 4's determinism achievable at all. Two threads matching one book would destroy replay.
So concurrency lives at the boundary: one thread receives and decodes, one matches, and a lock-free SPSC ring connects them. Exactly one producer and one consumer is what keeps the ring simple — head and tail each have a single writer, so you need release/acquire between them and nothing stronger. Reaching for seq_cst everywhere is the common mistake and buys no correctness here.
False sharing is the failure that looks like a mystery: head and tail in the same 64-byte line, so each thread's write invalidates the other's and throughput collapses though the code is correct. Pad them apart and measure — that measurement is one of the best answers to “tell me about a performance problem you found”.
And why UDP, not TCP: market data is a broadcast of perishable values. TCP's retransmission delivers a stale price late, which is worse than not delivering it — so real feeds use UDP with sequence numbers and let the receiver detect gaps. Explaining why a reliable transport is the wrong choice sounds genuinely senior.
Step 3 — build it. Acceptance gates, ticked when they actually pass
Step 4 — make it retrievable
If you get stuck — find your symptom
alignas. Measure before and after; the delta belongs in the README.std::mutex-protected queue, get the whole pipeline working end to end, and measure it. Then replace it with the lock-free ring and measure again. Strictly better than starting lock-free: a working system sooner, and a before/after number that makes the ring's value concrete rather than assumed.Log these in docs/decisions/ before you climb
You don't need all of C++ to build high-performance systems. You need a specific subset, and you need to understand it deeply. This chapter covers the pieces that matter for our order book.
Every variable in C++ has a type, and every type has a fixed size in bytes. In systems programming, you must know exactly how much space each thing occupies.
Use int32_t and int64_t (from <cstdint>) for guaranteed sizes. Plain int could be 2, 4, or 8 bytes depending on the platform. In systems code, "it depends" is unacceptable.
A pointer stores a memory address — literally a number that says "the data I point to lives at location 0x7ff4a2b3c000." That's all it is.
Pointers are the foundation of everything in this project: linked lists, memory pools, and direct data access without copying.
A struct groups related fields into one unit. The compiler lays them out sequentially in memory:
struct Order {
int64_t id; // 8 bytes, offset 0
int64_t price; // 8 bytes, offset 8
int64_t quantity; // 8 bytes, offset 16
Side side; // 1 byte, offset 24
Order* prev; // 8 bytes, offset 32 (after padding!)
Order* next; // 8 bytes, offset 40
};
Notice the gap: side ends at byte 25, but prev starts at byte 32. The compiler inserts 7 invisible padding bytes to align the pointer to an 8-byte boundary. We'll exploit this in Chapter 8.
Templates let you write code that works with any type. The compiler generates a specialized version for each type you use — zero runtime cost.
template <typename T>
class MemoryPool {
T* allocate(); // returns a T-sized slot
void deallocate(T*); // reclaims a slot
};
MemoryPool<Order> pool(10000); // compiler generates MemoryPool_Order
Order* o = pool.allocate(); // returns an Order-sized slot
If the condition is false, the build fails. Encode your assumptions so they can never silently break:
static_assert(sizeof(Order) == 64, "Order must be exactly one cache line");
static_assert(alignof(Order) == 64, "Order must be cache-line aligned");
If someone adds a field and Order grows past 64 bytes, the compiler catches it immediately. Compare this to a runtime bug that only surfaces in production at 3 AM.
Acquire resources in constructors, release them in destructors. Since C++ guarantees destructors run when objects leave scope, cleanup is automatic:
class MemoryPool {
MemoryPool(size_t n) { storage_ = allocate_slab(n); } // acquire
~MemoryPool() { free(storage_); } // release
};
No try/finally. No garbage collector. No "did I forget to call close()?" Just scoping rules.
A book is two sorted collections of resting intentions. Buy orders (bids) sorted high-to-low, sell orders (asks, or offers) sorted low-to-high. The best bid and best ask together are the touch; the gap between them is the spread; the total live quantity resting at one price is the depth at that level. That is the entire data model.
An order is a standing offer: “I will buy up to 500 at 100 or better.” It sits in the book until someone takes it, or the owner cancels it, or it expires. Nothing happens on a timer — a book is entirely event-driven, and every state change is caused by an incoming message.
If the best bid were 101 and the best ask 100, someone would be willing to buy at 101 while
someone else is willing to sell at 100 — so a trade must happen. The engine's job is to make
that trade immediately, which means a resting book is always in a state where
best_bid < best_ask. That is your first and most important invariant, and asserting
it after every operation will find more bugs than any test you write deliberately.
When two orders compete, the rule is: better price wins; at equal price, earlier arrival wins. Price first, time as the tie-break. This is what makes an exchange defensibly fair — you cannot jump a queue by being bigger or better connected, only by offering a better price.
The consequence is structural, and it dictates your design: because ties are broken by arrival time, the orders at a single price level must be a FIFO queue, not a set or a bag. Every FIFO-related bug you hit later comes from this rule, not from your choice of container. And it is why cancellation is awkward: removing an order from the middle of a queue while preserving the order of everything behind it is the central mechanical problem of rungs 1 and 2.
Everything an exchange does decomposes into three operations:
The rule that home-made engines get wrong: a trade executes at the resting order's price, never the incoming order's. Bid 101 into an ask resting at 100 and you pay 100, not 101. The resting order is entitled to that better price because it was there first — the same time-priority rule again, viewed from the other side. The difference is called price improvement, and getting it right is a five-second signal to an interviewer that you understand the domain rather than the data structure.
The two sides also have names worth using: the incoming order is the aggressor or taker; the order it consumes is the maker. Exchanges usually charge takers and rebate makers, because makers supply the liquidity that makes the book worth trading against at all.
Every exchange — NYSE, NASDAQ, CME — runs an order book at its core. It's the data structure that matches buyers with sellers.
Traders submit limit orders: "I want to buy 100 shares at $50.03" or "I want to sell 200 shares at $50.05." The order book collects these and organizes them by price.
Bids are sorted highest-first. If you're selling, you want the buyer paying the most. Asks are sorted lowest-first. If you're buying, you want the cheapest seller. The spread is the gap between them.
When multiple orders sit at the same price, who gets filled first? Whoever arrived first. This is called FIFO matching.
An order book processes exactly three event types. On a busy exchange, these arrive millions of times per second.
| Event | What Happens | Our Target |
|---|---|---|
| Add | New order arrives. Insert at the correct price level, back of the queue. | O(1) |
| Cancel | Trader withdraws an order. Remove it from wherever it sits. | O(1) |
| Execute | A trade happens. Reduce the front order's quantity. | O(1) |
Money is decimal and double is binary, and that mismatch will corrupt your book in a
way no test catches unless you go looking. This chapter is short because the fix is short — but
the reasoning matters, because “use integers” without the why is a rule you will
break under pressure.
A double stores a number as a binary fraction: sums of 1/2, 1/4, 1/8 and so on. Some
decimals land exactly on such a sum — 0.5, 0.25, 0.75. Most do not. 0.1 in binary
is 0.0001100110011... repeating forever, exactly the way 1/3 repeats in decimal. Stored
in 53 bits of mantissa, it is truncated, so what you actually hold is approximately 0.1.
Try it and watch: 0.1 + 0.2 == 0.3 is false. The left side is
0.30000000000000004. That single fact is enough to break an order book, and here is the precise
mechanism — this is the part worth internalising:
Your book finds a price level by comparing prices. If one order's price arrived as a
literal 100.10 and another's was computed as 100.00 + 0.10, those two
doubles differ in the last bit. They are not equal. So two orders that a human would say are
at the same price land at two different levels in your book. Depth is wrong, the touch is
wrong, and matching silently skips liquidity that is sitting right there. Nothing crashes. The book
is just quietly incorrect, and it will stay incorrect until someone reconciles against an exchange
and cannot work out why.
Accumulated error compounds the problem: add a hundred fills together and the drift becomes visible in the cents column, which in a real system means money that does not add up.
An exchange never allows arbitrary prices. It defines a minimum increment — the tick — and every valid price is an integer multiple of it. A tick might be one cent, or a hundredth of a cent for a liquid instrument. So stop storing currency and store how many ticks, as a signed 64-bit integer:
using Price = std::int64_t; // ticks, not dollars
// with a tick of 1/10000 of a unit:
constexpr std::int64_t TICKS_PER_UNIT = 10'000;
Price from_decimal_string(std::string_view s); // parse at the edge
std::string to_decimal_string(Price p); // format at the edge
Now every price comparison is exact integer comparison. Two orders at the same price are at the same level, always, with no tolerance and no epsilon. Addition and subtraction are exact. Equality means equality.
Does int64 have the range? Worth checking rather than assuming, since this is the one question an interviewer will ask. A signed 64-bit integer holds up to about 9.2 × 1018. At 10,000 ticks per unit that is prices up to roughly 9.2 × 1014 currency units — nine hundred trillion. There is no instrument on earth that needs more, so the range question is settled and you can say why.
Where conversion is allowed: at the edges only — parsing an inbound message, and
formatting for display or logging. Between those two points, no floating point touches a price. Write
a test that greps your own price path for double and float and fails if it
finds any; that test will save you a week at some point.
Why not a decimal float type? Because you do not need one. Decimal floating point solves representation, but you would still be paying for a general-purpose numeric type when the real insight is that prices are not continuous quantities at all — they are a lattice. Ticks are the natural unit, and integers are the natural representation of a lattice. This is the same reason you count pixels rather than storing inches.
Your first instinct for a price like $50.03 is double price = 50.03;. This is a trap.
Big-O tells you how an operation scales. It does not tell you what it costs, and for a book-sized container those two answers can point in opposite directions. This chapter is about what the standard containers actually do to memory, because that — not the complexity class — is what decides your design at rung 1.
Every standard container is one of two shapes. Contiguous ones (vector, and
deque in chunks) store elements next to each other in memory. Node-based ones
(map, set, list, and the buckets of
unordered_map) allocate a separate node per element and link them with pointers.
That difference decides three things at once: how many separate allocations you perform, whether walking the container is a sequential memory read or a scatter of random ones, and which operations invalidate what.
std::map<Price, Level> looks like the obvious answer for price levels. It is
sorted, so begin() is your touch; lookup is O(log n); insertion keeps everything
ordered. The interface is exactly the shape of the problem.
What it hides: it is a balanced binary tree with one heap allocation per level, and those nodes are scattered wherever the allocator put them. So an O(log n) lookup over 1,000 levels is about ten steps, and each step is a pointer dereference to an unpredictable address — which means each step is a potential cache miss costing on the order of a hundred times an L1 hit. Ten misses to answer one lookup. Meanwhile a binary search over a sorted vector of the same 1,000 levels does the same ten comparisons but touches only two or three cache lines, because the elements it probes are near each other.
Same complexity class. Very different cost. That is the whole reason Ch 9 exists, and it is the mechanism behind the phrase contiguity beats asymptotics at small n.
Your order index has to map an order_id to a resting order in O(1). The obvious
implementation stores a pointer or an iterator. Whether that is safe depends entirely on rules you
must look up rather than recall, because they are not intuitive:
| Container | What invalidates references |
|---|---|
vector | Any reallocation invalidates everything. Any insert or erase invalidates iterators at and after that position. Growth is the killer. |
deque | Insert/erase at either end keeps references to existing elements valid, but invalidates all iterators. Insert in the middle invalidates both. |
map, set, list | Node-stable. References and iterators survive every insert, and survive erase of other elements. This is their real selling point. |
unordered_map | Rehashing invalidates all iterators but not references to elements. Erase invalidates only what was erased. |
The practical conclusion: if you choose a contiguous level container, your order index cannot store a pointer or an iterator. It must store a stable handle — a price plus a slot number, say — that you resolve on each use. If you skip this, you get the classic rung 1 symptom: the book works perfectly until an insert happens to trigger reallocation, and then you are reading freed memory and the crash lands somewhere unrelated.
unordered_map's iteration order is unspecified by the standard. It depends on
bucket count, hash function and insertion history, and it is not something you may rely on. In
practice a given build often reproduces the same order for the same insertion sequence — which
is worse than if it never did, because it means the bug hides. If you ever iterate an
unordered_map to compute a hash of your engine's state, rung 4's deterministic replay
will fail intermittently and you will spend an evening blaming your matching logic. Iterate something
ordered, or sort before hashing.
The C++ Standard Library gives you std::map, std::vector, std::deque. They're correct and general-purpose. That last part is the problem.
A std::map<Price, PriceLevel> seems perfect: keys are sorted, lookup is O(log N). Under the hood, it's a red-black tree where each node is a separate heap allocation, scattered across memory.
| Was | Became | Why |
|---|---|---|
std::map<Price, Level> | Flat std::vector<PriceLevel> | O(1) indexed by price, contiguous memory |
std::deque<Order> | Intrusive doubly-linked list | O(1) cancel via direct pointer |
new / delete | Memory pool with free list | O(1) alloc, zero syscalls |
new Is SlowThere are exactly two places your program puts data, they behave completely differently, and the difference is the entire reason Ch 6 exists. This is short, and it is load-bearing.
Local variables live on the stack. Allocating one costs a single register adjustment — the stack pointer moves down by the size you need. Freeing costs the same in reverse, and it happens automatically when the variable goes out of scope. There is no bookkeeping, no search, no failure mode short of running out of stack entirely.
It is also the fastest memory you will ever touch, for a reason that has nothing to do with the allocation: the top of the stack was used moments ago, so it is almost certainly already in L1 cache. Stack data is cache-hot by construction.
The constraints are that the size must be known at compile time, and the lifetime ends with the scope. Everything that outlives its scope or is sized at runtime has to go elsewhere.
new looks like one operation. It is a call into a general-purpose allocator that is
maintaining its own data structures on your behalf, and here is roughly what happens inside:
delete knows the size later.mmap or sbrk syscall, which is a different order of cost entirely,
and the fresh pages it returns will each take a page fault the first time you touch them.Why this rules out new on the hot path. The average cost is not the problem
— a well-warmed allocator serves most requests in tens of nanoseconds. The problem is the
distribution. Most calls are fast; occasionally one splits a block, or trips a syscall, or
faults a page, or waits on a lock, and that call costs microseconds. On a path budgeted in tens of
nanoseconds, a microsecond is a hundred times your budget.
That is a tail-latency argument, and it is the shape of nearly every performance argument in this book: you are not trying to make the average faster, you are trying to remove the rare disaster. An allocator you cannot predict is a rare disaster generator, and a p99.9 measurement is where it shows up. A mean will hide it completely.
Which sets up the fix: if you cannot afford unpredictable allocation, take the allocation out of the hot path entirely. Grab all the memory once, at startup, and hand out fixed-size pieces yourself in constant time. That is a pool, and it is the next chapter.
newIf every object is the same size, you don't need a general-purpose allocator. Pre-allocate a slab, hand out fixed-size slots.
A general-purpose allocator is slow because it is general: it must serve any size, in any
order, forever, from a structure that reflects everything your program has ever allocated. Drop the
generality and almost all the cost disappears. Every object in the hot path here is an
Order, and they are all identical in size — so you never need a search at all.
Allocate one contiguous slab of N × sizeof(Order) at startup. Now you need to
track which slots are free, and the trick is that you store that information inside the free slots
themselves. A slot is either holding a live order, or it is free — and if it is free, its
bytes are yours to use. So put the index of the next free slot in the first few bytes of each
free slot, and keep one head index pointing at the first.
The result is a linked list threaded through unused memory, costing zero extra bytes. And both operations become three or four instructions with no branching on data:
// allocate: pop the head
std::uint32_t slot = free_head_;
free_head_ = next_of(slot); // read the index stored in the slot
return &slab_[slot];
// free: push onto the head
next_of(slot) = free_head_;
free_head_ = slot;
No search. No splitting. No syscall. No lock. No bookkeeping header written beside your object. That is the whole difference between 0.96 ns and 209 ns — the 217× in this chapter is not a clever optimisation, it is the removal of work that was never necessary for this case.
The slab is contiguous, so orders allocated near each other in time sit near each other in memory
— and per Ch 8, that means walking them pays one cache miss per line rather than one per object.
A general allocator gives you no such guarantee; consecutive new calls can land anywhere.
So the pool improves the average by 217× and improves the tail by removing the
syscall and lock paths entirely, which is the improvement you actually care about.
Reserving address space is not the same as having memory. The first write to each fresh page takes a page fault — a trap into the kernel to map a physical page — and that is microseconds. Ten thousand orders means potentially hundreds of faults scattered through your first minute of trading.
So touch every page at construction: write one byte per page across the whole slab, once, before anything is timed. The cost is identical; you have simply moved it off the latency-critical path. This is the same reasoning as Ch 18's warm-up, and it is worth stating as a general principle: a cost you cannot remove, you relocate.
Three things, and an interviewer will ask for at least one:
The invariant that catches the bugs: a slot is either in the free list, or owned by exactly one live order — never both, never neither. Assert it in debug builds by walking the free list and checking it does not contain any slot the book believes is live. That single check catches double-free and use-after-free, which are otherwise the hardest class of bug in this whole project.
newEach price level is a FIFO queue. With std::deque, cancelling an order means scanning to find it — O(K). We need O(1).
Cancellation is the most frequent operation in real order flow — cancels vastly outnumber trades — so an O(K) scan to find the order inside its price level is not a small inefficiency, it is the wrong data structure. This chapter is how you get to O(1), and the idea generalises far beyond order books.
std::list<Order> does not store your Order. It allocates a
node containing a prev pointer, a next pointer, and your
Order copied inside it. Two consequences follow, and both hurt:
Order*, but the links live in a node you
cannot see, so you cannot get from your order to its neighbours. To erase, the container must
find the node — and finding means scanning.Put the pointers in Order itself:
struct Order {
std::int64_t price;
std::int64_t id;
std::uint32_t qty;
Order* prev; // the list lives here
Order* next;
};
Now the object is its own node, and three things change at once. There is no allocation
at all to link or unlink — the pointers already exist inside memory the pool already gave
you. The order and its links share a cache line, so reaching a neighbour is one hop instead of two.
And most importantly: an Order* is a complete position. Given the pointer, you
know where the order sits in the queue, because the queue's structure is stored in the order.
That is what unlocks O(1) cancel. Your order_id → Order* hash map hands you the
pointer directly, and from the pointer you can unlink without ever touching the level, the head, or
any other order except the two neighbours.
if (o->prev) o->prev->next = o->next; else level.head = o->next;
if (o->next) o->next->prev = o->prev; else level.tail = o->prev;
level.total_qty -= o->qty;
That is the entire operation: two conditional stores, plus a depth update. No search, no
comparison, no allocation, no iteration. Roughly four memory writes to two cache lines. The
conditionals exist only to handle the ends — if the order was the head, the level's head pointer
must move instead of a predecessor's next; likewise for the tail. Forgetting either end
case is the single most common bug here, and it produces a level that looks correct until you cancel
the first or last order in it.
prev/next can be
in exactly one intrusive list. Two lists means two pairs of pointers, declared deliberately.std::list corrupts the
container's private nodes. Here it corrupts your Order, which is also holding a price and
a quantity — so the symptom can appear as a wrong number rather than a crash.The invariant that finds those bugs immediately: for every node in every level,
x->next->prev == x and x->prev->next == x, with the head's
prev and the tail's next both null. Walk each level in a debug build after
every operation and assert it. This converts “the book went wrong somewhere” into
“the links broke at operation 12,443,” which is a completely different problem.
Your CPU does not read individual bytes from memory. It reads fixed-size blocks called cache lines, and once you know that, several design rules that look arbitrary become obvious.
Approximate costs, in CPU cycles, to fetch data that is already in each level:
| Where | Cost | Roughly |
|---|---|---|
| Register | 0 cycles | free |
| L1 cache | ~4 cycles | about 1 ns |
| L2 cache | ~12–15 cycles | a few ns |
| L3 cache | ~40–50 cycles | ~15 ns |
| Main memory (DRAM) | ~200–300 cycles | ~60–100 ns |
The number to carry in your head: a miss all the way to DRAM costs on the order of a hundred times an L1 hit. This is why performance work on data structures is mostly about memory access patterns rather than instruction counts. You are not trying to do less arithmetic; you are trying to touch fewer, closer lines.
A cache line is 64 bytes on x86-64. Note carefully: Apple Silicon uses 128-byte cache
lines, so a hardcoded 64 in your padding is wrong on the machine you are actually building on.
Use std::hardware_destructive_interference_size rather than a literal, and verify it
prints what you expect on your Mac before you rely on it.
When you touch one byte, the whole line comes into cache. So if the next thing you need is next to the thing you just read, it arrived free. If it is somewhere else, you pay again. That single asymmetry is the reason a contiguous array beats a linked structure of the same complexity class: walking an array pays one miss per line and gets several elements from it; chasing pointers pays one miss per element.
The compiler inserts invisible padding so each field sits at an address that is a multiple of its own size. Field order therefore changes the size of your type:
struct Bad { struct Good {
std::uint8_t side; std::int64_t price; // 8
std::int64_t price; std::int64_t id; // 8
std::uint8_t flags; std::uint32_t qty; // 4
std::int64_t id; std::uint8_t side; // 1
std::uint32_t qty; std::uint8_t flags; // 1
}; // 40 bytes }; // 24 bytes
Same five fields, same information, 40 bytes versus 24 — because Bad pays seven
bytes of padding after each uint8_t to realign the following int64_t. Order
fields largest first and the padding mostly disappears.
Why you should care about sixteen bytes: at 24 bytes you fit two orders per 64-byte line and five per 128-byte line; at 40 you fit one, and three. That is a two-to-three times difference in how many orders arrive per cache miss when you walk a price level — which is the single most frequent memory access pattern in the whole engine.
Do not estimate this — print it. Run
clang -Xclang -fdump-record-layouts -c order.cpp and the compiler tells you every
field's offset and exactly where the padding sits. Run it on your Order the moment you
define it, reorder the fields, and run it again. The difference in total size is your first
optimisation, it took four minutes, and you can explain the mechanism.
The flip side of “neighbours come along for free” is that neighbours also share a line's ownership. If two threads write to two different variables that happen to live in the same cache line, each write invalidates the other core's copy of the line, and throughput collapses even though the code is entirely correct. That is false sharing, and the fix is to pad the two variables apart so they land on separate lines. Remember that it exists; Ch 15 is where it bites.
Your CPU doesn't read individual bytes from RAM. It reads 64-byte chunks called cache lines. Understanding this changes how you design data structures.
You need to get from a price to its level on every single operation. This is the most frequently executed lookup in the engine, and the choice you make here is the one design decision that most affects your rung 6 numbers. There are three real options.
std::map<Price, Level>Sorted by construction, so begin() is the touch and iteration walks levels in price
order. Lookup, insert and erase are all O(log n), and it handles any price range without you thinking
about it. As Ch 4 covered, the cost is one heap-allocated node per level, scattered in memory, so each
of the ~10 steps of a lookup over 1,000 levels is a potential cache miss. Choose it when the
price range is unbounded or unknown, or when you want obviously-correct code and are not yet
optimising — which is exactly what rung 1's escape hatch says.
Allocate one slot per possible price and index directly: levels[price - min_price].
Lookup is O(1) with a single memory access, and there is no search, no comparison, no pointer
chasing. It is as fast as this problem gets.
The cost is memory, and it is worth actually computing rather than hand-waving. If a level is 64 bytes and your instrument trades between 90.0000 and 110.0000 at a tick of 1/10000, that is 200,000 ticks × 64 bytes = 12.8 MB — larger than L2, so a scan would miss constantly, but perfectly fine as a directly-indexed table where you only ever touch the few levels near the touch. Widen the range to 0–10,000 and it becomes 6.4 GB and the idea collapses.
Choose it when the price range is bounded and known in advance, which for a single instrument it usually is. This is what real matching engines do.
Keep only the levels that exist, in a contiguous array, sorted by price. Lookup is
O(log n) — the same complexity as the map — but the elements a binary search probes are
close together, so it touches two or three cache lines instead of ten scattered nodes. The cost moves
to insertion: adding a level in the middle means shifting everything above it, which is a
memmove.
The reason that memmove is usually acceptable is a property of the domain rather than
of the data structure: real order flow clusters near the touch. New levels almost always appear
just inside or just outside the current best price, so the shift is short. If flow were uniformly
distributed across the whole book, this option would be the worst of the three.
| Lookup | Insert level | Memory | Cache behaviour | |
|---|---|---|---|---|
std::map | O(log n), ~10 misses | O(log n) + allocation | one node per live level | poor — scattered |
| Flat array | O(1), one access | O(1) | whole range, always | excellent at the touch |
| Sorted vector | O(log n), 2–3 lines | O(n) memmove, short in practice | one slot per live level | good — contiguous |
Notice that every one of these is a bet about your data, not a fact about computer science.
The flat array bets the range is narrow. The sorted vector bets flow clusters. The map bets you would
rather not think about it yet. So write your bet down in
docs/decisions/ as a claim about the data — “ticks are dense and the live
range is narrow, therefore…” — and rung 6 will tell you whether the bet paid.
An interviewer who knows this material is not checking whether you picked the fashionable answer; they
are checking whether you know it was a bet at all.
Stock prices exist on a fixed grid. A stock between $49.00 and $51.00 with $0.01 ticks has exactly 201 possible levels. Why use a tree when you can use an array?
Three structures, one job each, and the reason every operation in the summary table below is O(1) with zero allocations is that each structure removed exactly one source of work. Nothing here is clever in isolation; the composition is the design.
Order*. Removes the search from cancel
— and note it is the only structure in the engine that costs a cache miss on a typical
operation, which is why the summary table says ~1 miss rather than zero.Plus the cached BBO: the best bid and best ask kept as two plain values, updated whenever an operation could change them. That is why Get BBO costs 1.5 ns and zero misses — it does not look anything up at all. It is the single cheapest and most-called query in the system, so it is worth caching even though the flat array could answer it directly.
Add. Take a slot from the pool (Ch 6, no allocation). If the incoming order crosses the opposite touch, match against the front of that level's FIFO until it stops crossing. Whatever remains: index the price array to get the level, link the order at the tail of its queue — tail, always, because arrival order is priority — insert into the id map, add to the level's depth, and refresh the cached BBO if this order created a new best or filled the old one.
Cancel. One hash lookup to get the Order*. Three pointer rewires to unlink it
(Ch 7). Subtract its quantity from the level's depth. Return the slot to the pool. Then — and
this is the step people forget — if the level is now empty and it was the touch, the
cached BBO is stale and must be recomputed by scanning outward for the next non-empty level.
Execute. Reduce the front resting order's quantity. If it reaches zero, unlink it and return its slot, exactly as cancel does. Update the level's depth and, again, the BBO if the level emptied. Emit the fill at the resting order's price, per Ch 2.
Every one of the following is a bug you will actually write, because they are all consequences of having three structures that must agree with each other:
total_qty is a cached sum. Update it on add,
cancel and partial fill, or it slowly diverges from the orders actually linked there. Prefer
deriving it in debug builds and asserting the cached value matches.Which is precisely why rung 5 exists. These are not bugs a hand-written test finds, because they need a specific interleaving of adds, cancels and fills to surface. A reference model that keeps one obvious structure instead of three cannot have them — so it disagrees with your fast engine the moment one appears, and hands you the sequence that caused it. Build the composition here; prove it there.
| Operation | Complexity | Heap Allocs | Cache Misses |
|---|---|---|---|
| Add | O(1) amortized | 0 | ~1 (hash map) |
| Cancel | O(1) | 0 | ~1 (hash map) |
| Execute | O(1) | 0 | ~1 (hash map) |
| Get BBO | O(1) | 0 | 0 (pre-computed) |
Every optimization was driven by a benchmark, not a hunch. "I think this will be faster" is worthless. "This benchmark shows 217x improvement" is a fact.
The results in this chapter — 1.5 ns for a BBO query, ~10 ns to add, ~12 ns to cancel, 8.9 M operations a second — are only worth anything if the measurement was honest. Microbenchmarking is unusually easy to get wrong, and every one of the traps below produces a number that looks plausible and means nothing.
If nothing consumes the result of the work, the optimiser is entitled to remove the work. You
measure an empty loop and report 0.3 ns per operation, and it is correct — you did
nothing very quickly. The fix is to make the result observable: accumulate into a
volatile sink, or use your benchmark library's do-not-optimise barrier. The sanity check
is cheap and you should run it every time: double the work and confirm the time roughly
doubles. If it doesn't, you are not measuring what you think.
This one specifically threatens the numbers above. Reading a high-resolution clock costs something
— typically on the order of 20 ns, sometimes more. If a single add takes 10 ns, then
timing each call individually means the timer is two-thirds of your measurement and your result
is mostly noise about the clock.
The fix is to time a batch: start the clock, do a million operations, stop, divide. The per-call cost then carries the timer's cost divided by a million, which is nothing. The consequence is that you get a reliable average but lose per-operation percentiles — so if you want p99.9, you need a different technique (a cycle counter, or sampling), and you should say which you used.
The first few thousand iterations measure page faults, an empty cache and an untrained branch predictor — the very costs Ch 6's pre-faulting and Ch 18's warm-up exist to move. Run a warm-up pass and discard it. Report what steady state looks like, and say separately what the first message costs, because in a real system that first message is a real event.
CPU frequency scaling, thermal throttling and other processes all move your numbers. Two runs of the same code can differ by tens of percent. Run long, run with the machine otherwise idle and on mains power, and repeat the whole benchmark rather than trusting one execution. On Apple Silicon also be aware that the scheduler can place you on an efficiency core, which will quietly halve your results.
An average hides exactly the events that matter. One operation in a thousand at ten microseconds barely moves a mean and completely defines what a user experiences. Report p50, p99 and p99.9. A mean is the number you quote when you have not looked at the distribution, and an interviewer will read it that way.
Add costs ~10 ns, and throughput is 8.9 M ops/sec. Those are not the same claim: 10 ns per operation would imply 100 M/sec if the operation were all there was. Be able to explain the gap. The microbenchmark times one call into the book; the throughput figure measures the whole path — parsing, dispatch, the ring handoff, the loop itself. Both are true and they measure different things. An interviewer who spots the discrepancy is testing whether you know what your own harness measured, and “the 10 ns is the matching call, the 8.9 M is end-to-end” is a complete answer. Not knowing is the failure, not the discrepancy.
Which is why the mindset in this chapter is the whole chapter: measure, understand, fix, measure again — with understand carrying the weight. A before/after pair you cannot explain mechanically is a coincidence you are taking credit for.
The instinct from web development is to make each component a service and put a network between them. That instinct is right for web systems and catastrophic here, and it is worth being able to say why in numbers rather than as a preference.
Count the work in one request, none of which is your logic:
send and recv is a trap into the kernel,
and the data is copied between your buffer and kernel buffers.Put an order of magnitude on it: an HTTP round trip on the same machine is typically tens to hundreds of microseconds. A direct function call is a few nanoseconds. That is a factor of roughly 103 to 105. Against a book that adds an order in ~10 ns, a single localhost HTTP hop costs more than ten thousand operations.
The conclusion is not “HTTP is bad.” It is arithmetic: you cannot put a network between two components that must be fast, because the network costs more than the components do.
So the architecture inverts. Instead of services connected by networks, you have one process containing stages connected by function calls, shared memory and lock-free queues. The network exists at exactly two places — data arriving from the exchange, and orders going out — and those are unavoidable because the exchange is genuinely somewhere else.
Everything in between is in-process by construction. A stage hands work to the next by writing to a ring buffer in shared memory; no serialisation, because both sides are the same program and agree on the layout; no syscall, because nothing crosses into the kernel; no copy beyond the one write.
This is where the answer becomes senior rather than dogmatic, because a monolithic pipeline gives up real things:
Web systems accept microseconds of overhead to buy every one of those properties, and they are right to — their latency budget is tens of milliseconds, so a 200 µs hop is a rounding error. Here the budget is tens of nanoseconds and the same hop is the entire system. Same reasoning, opposite conclusion, because the budget differs by six orders of magnitude. Being able to say that is the difference between having read about HFT architecture and understanding why it looks the way it does.
The order book has a clean C++ API. But a real exchange never calls your function — it sends you bytes over a wire. This chapter is the translator: raw NASDAQ ITCH bytes in, book.add_order(...) out, with no parsing, no copying, and no allocation.
A JSON "add order" is ~200 bytes of text you must parse — walk each character, find the quotes, convert the string "50.03" into a number. The ITCH equivalent is 36 bytes with zero parsing: the number is already sitting there as raw bytes in the exact layout your CPU uses.
That's the whole trick. If the bytes on the wire match the memory layout of a C++ struct, you don't parse — you point a struct at the bytes. Making that safe forces you to understand four things cold, because interviewers drill every one.
The compiler aligns each field to a "nice" address and fills the gaps with invisible padding. A wire message has no padding. Point a padded struct at an unpadded message and every field past the first reads garbage.
The number 500,300 needs 4 bytes. In what order? Your x86 CPU stores least-significant-byte-first (little-endian). Network protocols traditionally send most-significant-first (big-endian). Read big-endian bytes as little-endian and the value is scrambled. Only multi-byte fields need care; a single byte has no order.
// C++20 lets us swap only when the host disagrees with the wire — at compile time.
inline std::uint32_t from_be(std::uint32_t v) {
if constexpr (std::endian::native == std::endian::little) return __builtin_bswap32(v);
else return v; // already big-endian host: nothing to do
}
The naive move is reinterpret_cast<const AddOrder*>(buffer): no copy, the struct just overlays the bytes. It's genuinely how fast systems work — and it's technically undefined behavior under the strict aliasing rule, which says you may only read memory through a pointer of its real type (with char/std::byte the exception). Those bytes were never "born" an AddOrder.
std::memcpy into a real struct: AddOrder m; std::memcpy(&m, buffer, sizeof(m));. It looks like a copy, but for a fixed small size the compiler folds it to the same load as the cast — identical speed, zero UB. C++20's std::bit_cast does the same. Saying "I know the cast is the naive move, I know exactly why it's UB, and I use memcpy for the same codegen safely" is what separates you from a Stack Overflow copy-paste.
The wire is a continuous stream; nothing visually separates messages. Three strategies exist: fixed size per type (ITCH: the type byte implies the length), length prefix (a 2-byte field says "I am N bytes" — ITCH's transport layer), and delimiters (text protocols only). Our parser reads the 2-byte big-endian length, slices one message, looks at the type byte, and switches to the right book call. Unknown types are skipped by their length — which is why framing matters.
while (off + 2 <= len) {
std::uint16_t msg_len = read_be16(data + off); // 1. frame
if (off + 2 + msg_len > len) break; // (truncated tail?)
dispatch(data + off + 2, msg_len); // 2. type byte -> book
off += 2 + msg_len; // 3. advance
}
| They ask | You say |
|---|---|
| "How do you parse the feed?" | Zero-copy: overlay a struct on the buffer, no allocation, no parse pass |
| "Isn't that cast UB?" | Yes — strict aliasing; I use memcpy/bit_cast, same codegen, no UB |
"What's #pragma pack for?" | Kills padding so my struct matches the tight wire layout exactly |
| "How do you handle endianness?" | Byte-swap multi-byte fields; std::endian makes it a compile-time no-op on matching hosts |
| "Where does a message end?" | Type-implied length (ITCH) or a 2-byte length prefix (transport) |
Verified: the parser sustains ~7.15 million ITCH messages/second (236 MiB/s) through the full frame → swap → dispatch → book pipeline.
This is the feature that changes the project's category. Anyone can benchmark an average. The showcase is: replay a full trading day and publish the tail-latency distribution — p50, p99, p99.9, max — because in trading the mean is a lie. What matters is the worst message, since that's the one that loses money.
Values are bucketed logarithmically. Each power-of-two "octave" is split into 32 linear sub-buckets, so relative error is bounded to ~3% across the whole range from 1 ns to years, using a fixed array and an O(1) record(). Percentiles are a single walk of the cumulative counts.
You cannot measure a ~10 ns book operation with a ~30 ns std::chrono call — the clock would dominate. So the replay uses the CPU timestamp counter (rdtsc, ~6 ns), calibrated against steady_clock at startup to convert ticks to nanoseconds. That calibration is itself a good interview line.
Replaying a synthesized million-step day, the first run showed a median of 162 ns but a hideous tail. Diagnosis: the order-id unordered_map was rehashing as it grew to ~470k entries. Fix: pre-reserve the map to pool capacity in the constructor. Re-measured:
| Percentile | Before (rehashing) | After (reserved) |
|---|---|---|
| mean | 405 ns | 279 ns |
| p50 | 162 ns | 81 ns |
| p99 | 1744 ns | 1200 ns |
| p99.9 | 6976 ns | 5056 ns |
| p99.99 | 29952 ns | 16256 ns |
To hand data from the network thread to the processing thread, you cannot use a mutex: a lock costs ~25 ns uncontended and blocks the thread entirely when contended — a stall the hot path can't afford. The answer is a lock-free single-producer / single-consumer ring buffer. This is the classic HFT interview topic, so understand every word.
Everything below the fold in this chapter is stated correctly and compressed hard. Here is the same content derived rather than asserted, because “understand every word” is only possible if each word was explained once.
head and tail only ever increase. Not once, ever, are they wrapped or
zeroed by your code. The producer writes a slot then increments tail; the consumer reads
a slot then increments head. Wrapping happens only when you index, never when
you store.
That sounds wasteful and is the source of all three of the design's nice properties, so it is worth
the paragraph. With 64-bit counters at 100 million operations per second, you would need something
like five thousand years to overflow, so overflow is not a practical concern. And even if it
were, unsigned arithmetic wraps modulo 264, so tail - head stays correct
across the wrap as long as the real difference is smaller than 264 — which it is,
because it can never exceed the capacity.
counter & (Capacity - 1) is the whole trick, and it is a single fact about binary
numbers. Take a capacity of 1024. Then Capacity - 1 is 1023, which in binary is
0b1111111111 — ten ones. ANDing with it keeps the low ten bits and discards
every bit above them. Discarding the high bits is exactly what dividing by 1024 and taking the
remainder does. So:
counter & (Capacity - 1) == counter % Capacity // powers of two ONLY
It is used because an AND is a single cycle and an integer division is tens of them. And the
“powers of two only” caveat is the reason capacity is not a free choice: with capacity
1000, 999 is 0b1111100111 and the identity collapses. Assert it in the
constructor: static_assert((Capacity & (Capacity - 1)) == 0).
Because both counters only ever increase, their difference is the number of items in
flight. There is no separate count to keep in sync, which deletes a whole family of bug. Empty is
tail == head; full is tail - head == Capacity.
This is the real payoff, and it is easy to miss: full and empty are distinguishable.
If you had instead stored wrapped indices — incrementing modulo capacity, as the naive design
does — then a full buffer and an empty buffer would both show head == tail and be
indistinguishable. The usual workarounds are to waste a slot, or to keep a separate size counter that
both threads must update, which reintroduces shared mutable state into a design whose whole point is
not having any. Free-running counters make the problem disappear instead of solving it.
Two separate things reorder your memory operations, and it helps to keep them apart. The compiler reorders at compile time, because as far as it can tell nothing observes the difference. The CPU reorders at run time — it executes out of order, and stores drain from a buffer in an order that is not necessarily program order. Both are invisible in single-threaded code, and both are catastrophic across two threads.
The concrete failure: the producer writes the message into the slot, then publishes the new
tail. If those two become visible to the other core in the opposite order, the
consumer sees a tail saying “there is a message” and then reads a slot that
is still half-written. Nothing crashes. You process a message that never existed.
A happens-before edge is the guarantee that forbids exactly this, and here is what it means operationally rather than formally: if the producer's release-store is what the consumer's acquire-load read, then everything the producer wrote before that store is visible to the consumer after that load. That is the entire contract, and it is why the pairing matters — a release with no matching acquire guarantees nothing.
So the code, with every ordering justified:
bool try_push(const T& v) {
const auto t = tail_.load(std::memory_order_relaxed); // I am the only writer of tail
const auto h = head_.load(std::memory_order_acquire); // published by the consumer
if (t - h == Capacity) return false; // full
slot_[t & MASK] = v; // 1. write the payload
tail_.store(t + 1, std::memory_order_release); // 2. publish it -- AFTER (1)
return true;
}
bool try_pop(T& out) {
const auto h = head_.load(std::memory_order_relaxed); // I am the only writer of head
const auto t = tail_.load(std::memory_order_acquire); // published by the producer
if (t == h) return false; // empty
out = slot_[h & MASK]; // 3. read the payload
head_.store(h + 1, std::memory_order_release); // 4. free the slot -- AFTER (3)
return true;
}
Why relaxed on your own counter is safe: you are its only writer, so no other
thread can surprise you with a value, and you need no ordering guarantee against yourself. This is
the single-writer property doing the work — and it is why SPSC is the easy case.
Why the release on head_ in try_pop is not optional: it stops the
producer overwriting a slot the consumer is still reading. Step 3 must be visible before step 4
advertises the slot as reusable. Both directions need the pairing, not just the publish direction
— which is the detail people drop.
Because on your machine it costs real instructions. On x86-64 the hardware is already strongly
ordered, so acquire loads and release stores compile to plain loads and stores —
acquire/release is free and only sequential consistency needs a fence. ARM64 is weakly ordered:
acquire becomes ldar, release becomes stlr, and sequential consistency adds
dmb barriers on top. So on Apple Silicon, reaching for seq_cst out of
caution is a measurable cost for a guarantee this design does not need. Put both versions into
Compiler Explorer with an ARM64 target and read the difference — it is a five-minute experiment
and it makes the whole topic concrete.
The chapter says multi-producer needs CAS and has an ABA problem. Briefly: with compare-and-swap
you read a value, compute a new one, then swap only if the old value is unchanged. Thread A reads
head pointing at node X and stalls. Other threads pop X, push several things, and
eventually push X back. A wakes, compares, sees X, and succeeds — even though the
structure changed completely underneath it. The value went A, then B, then A again, so the comparison
cannot tell. Fixes involve version tags or hazard pointers, and they are genuinely harder.
With SPSC, each counter has exactly one writer, so there is no compare-and-swap at all, and therefore no ABA. Knowing why SPSC avoids the problem, rather than knowing that it does, is the answer that lands.
The alignas(64) below is right on x86-64. Apple Silicon uses 128-byte cache
lines, so 64 bytes of alignment may leave head_ and tail_ on the same
line and you will still get the ping-pong you were trying to avoid. Prefer
std::hardware_destructive_interference_size, and then verify: print it on your
Mac, and if your standard library reports 64, pad to 128 explicitly. Do not trust the constant
without printing it — and either way, prove the padding worked with a before/after throughput
number rather than by inspection.
Two free-running counters, head (consumer) and tail (producer). The slot is counter & (Capacity-1) (capacity is a power of two, so the mask is one AND). Size is tail - head. No locks — coordination is two atomics.
Modern CPUs and compilers reorder memory operations for speed. Left unchecked, the consumer could see the new tail before the producer's write to the slot lands — and read a half-written message. Acquire/release ordering forbids exactly that.
Each thread reads its own counter relaxed (it's the only writer) and the other's counter with acquire. The producer's release store pairs with the consumer's acquire load to create a happens-before edge — the guarantee that makes the whole thing correct.
Cache coherence works at 64-byte cache-line granularity. If head and tail shared a line, every producer write to tail would invalidate the consumer's cached copy of head (and vice versa) — two cores ping-ponging one line, turning a lock-free design into a cache-thrash bottleneck. The fix is one keyword:
alignas(64) std::atomic<std::size_t> head_{0}; // its own cache line
alignas(64) std::atomic<std::size_t> tail_{0}; // its own cache line
| They ask | You say |
|---|---|
| "Why not a mutex?" | ~25 ns uncontended, unbounded blocking when contended; unacceptable on the hot path |
| "What does release/acquire do?" | Creates a happens-before edge so the consumer sees the slot write before it sees the published tail |
"Why alignas(64) on the counters?" | Stops false sharing — the two cores would otherwise thrash one cache line |
| "Why is SPSC easier than MPMC?" | Single writer per counter → no CAS, no ABA; MPMC needs compare-and-swap loops |
| "Power-of-two capacity?" | Turns the modulo into a single bitwise AND |
Now assemble the whole thing the way a real feed handler is built: the network at the edge, lock-free handoffs in the middle, the hot processing thread running undisturbed.
Market data is UDP multicast: connectionless, unordered, best-effort. That's the right choice — a lost packet is stale a microsecond later anyway, so you never want TCP stopping the world to retransmit ancient prices. The cost is that you must tolerate loss (real feeds add sequence numbers and a gap-fill channel).
SO_RCVBUF to absorb bursts, but some loss remains — and we print it. That's the honest demonstration of why exchanges attach sequence numbers to every message and run a separate gap-recovery channel: on a lossy transport, the application must detect and recover gaps itself.
The receive thread does one thing: pull datagrams off the socket, decode them in place, and push them into the ring. The matching thread does one thing: drain the ring and apply each message to the book. Nothing else runs on either.
Two, not more, for a reason worth stating: the book has exactly one writer. That is what removes every lock and every atomic from the matching path, and it is also what makes rung 4's deterministic replay achievable — two threads mutating one book would interleave differently on every run and destroy reproducibility. So parallelism goes across instruments (a book each, a thread each) and never inside one book. Adding threads to a single book is the one optimisation that cannot work.
The matching thread's rule follows from the same logic and is absolute: it must never do anything that can block. No allocation (Ch 6), no logging (Ch 18), no syscall, no lock. Its single-threadedness is fast because nothing can stop it, not merely because nothing contends with it.
The kernel holds arriving datagrams in a receive buffer. When a burst arrives faster than you
drain, that buffer fills and the kernel discards datagrams silently — there is no error
returned to you, because UDP promises nothing. So enlarge it deliberately with
SO_RCVBUF, and know that the OS may cap what it grants you: set it, then read it back
with getsockopt and log the value you actually received rather than the one you asked
for.
On the batching side, Linux offers recvmmsg to collect many datagrams in one syscall.
macOS does not have it — you have recvmsg and a loop. Build on the Mac, so
plan for the loop and do not copy a Linux feed handler's structure expecting it to compile.
Because loss is permitted, the application must detect it. Every real feed numbers its messages, so you keep the last sequence you saw and check:
if (seq != last_seq_ + 1) {
gaps_ += (seq - last_seq_ - 1); // how many vanished
gap_events_++; // how many times it happened
}
last_seq_ = seq;
Two counters, not one, and the distinction matters: how many messages were lost tells you about volume, how many separate gap events tells you whether you are seeing steady low-level loss or occasional catastrophic bursts. Those have different causes and different fixes. Exposing both, and printing them, is the honest instrument — and it is exactly why exchanges run a separate gap-recovery channel: detection is your job, and recovery needs a second path because the first one already failed.
On Linux you would pin each thread to a dedicated core so the scheduler cannot migrate it and throw away its warm caches. macOS gives you no equivalent hard affinity API — you can express quality-of-service hints, and the scheduler decides. In practice that means keep the machine idle, stay on mains power, run at a normal QoS so you are not placed on an efficiency core, and accept that your tail numbers will be noisier than a tuned Linux box's. Say so in the README rather than pretending otherwise; a stated limitation reads as competence, and an unexplained noisy p99.9 reads as carelessness.
You can't optimize what you can't see. Profiling is how you turn "it feels slow" into "the unordered_map rehash is 60% of the p99.9." The tools you should be able to name and use:
Read the tool table below with one correction in mind: perf and
cachegrind are Linux tools, and you are building on a Mac. The method in this chapter
is exactly right; the instruments are not the ones you have. Here is the same workflow in tools that
actually run on Apple Silicon, plus what the numbers mean.
| The chapter says | On your Mac | Notes |
|---|---|---|
perf stathardware counters |
Instruments → CPU Counters template | Real PMU access. This is the closest thing to perf stat and it is genuinely
good. |
perf record + flamegraph |
Instruments → Time Profiler, or xctrace record --template 'Time Profiler'
from the shell |
Sampling profiler with an inverted call tree, which is the flamegraph's information in a different shape. |
| — | sample <pid> |
Built in, no setup, prints a stack sample summary in seconds. Use it first, always. |
cachegrind |
Nothing equivalent. | Valgrind is not viable on Apple Silicon. Test cache hypotheses with CPU Counters plus an A/B measurement instead — see below. |
isolcpus + taskset |
Not available. | macOS exposes QoS hints, not hard affinity. This changes what you can promise about the far tail. |
One trap worth naming: running Linux perf inside Docker on your Mac will not save you.
Docker Desktop runs a Linux VM, and hardware performance counters are generally not exposed to a
guest, so perf stat there reports software events at best and nothing useful at worst.
This is the chapter's “first look” question, and it has a concrete answer. Get instructions and cycles from CPU Counters and divide:
Then confirm with the cache-miss counters: a high last-level-cache miss rate per instruction corroborates memory-bound. For this project the answer will almost always be memory-bound, which is why Ch 6, 7, 8 and 9 are all about layout rather than cleverness — but measure it rather than assuming it, because assuming is how you spend a weekend optimising the wrong half.
Three rules that stop the common misreadings:
You cannot simulate cache behaviour per line, so falsify the hypothesis by changing exactly one thing and measuring:
Order's fields drops it from 40 to 24 bytes, so a level walk should touch about half the
lines and p99 should improve by 20–40%.”clang -Xclang -fdump-record-layouts
proves the size actually changed.This is weaker than a cache simulator and it is strictly honest, which counts for more. And it forces the prediction step, which is where the understanding lives.
The callout below is the most valuable paragraph in this chapter and it was learned the hard way: the multi-millisecond maximum was the hypervisor, not page faults. Take the general form seriously — the median and p99 are yours to optimise; the far tail belongs to the machine.
On macOS you have none of the mitigations it lists. No isolcpus, no
taskset, no tickless kernel, no IRQ affinity, and the scheduler may place you on an
efficiency core whenever it likes. The practical consequences, which belong in your README rather
than being discovered by a reader:
And the thing to say if an interviewer pushes on it: “I optimised what the machine let me control, measured percentiles rather than a max, and documented why the far tail is environmental — on a tuned Linux box with core isolation I'd expect the p99.9 to tighten considerably.” That answer is stronger than a suspiciously clean number, because anyone who has done this work knows laptops do not produce clean tails.
| Tool | What it shows | When to reach for it |
|---|---|---|
perf stat | Cycles, instructions, cache misses, branch mispredicts (hardware counters) | First look: is it memory-bound or compute-bound? |
perf record + flamegraph | Which functions burn the cycles | Find the hot function to attack |
cachegrind | Simulated cache hit/miss per line | Prove a cache-locality hypothesis |
| Our replay histogram | The actual latency distribution & tail | Confirm a fix moved the percentile that matters |
isolcpus + taskset core pinning, disabled CPU frequency scaling, a tickless kernel (nohz_full), and IRQ affinity moved off the trading core. The median and p99 are yours to optimize; the far tail is the machine's.
The difference between a benchmark and a system is what happens at the edges: startup, shutdown, failure, and observability — without ever slowing the hot path.
The first message pays for everything cold: unmapped pages, empty caches, an untrained branch predictor. Real systems run a warmup pass before the market opens. We pre-fault the memory pool at construction (commit every page up front), moving that cost off the latency-critical path. It's opt-in so tests stay fast.
A single blocking printf or file write in the processing loop can cost microseconds and stall everything behind it. The discipline: the hot path writes structured events into a lock-free buffer (like our ring); a separate low-priority thread drains and formats them. The trading thread never touches I/O.
Threads coordinate teardown through atomic flags with release/acquire ordering — the producer signals "done," the consumer drains the ring, then exits. Under overload, backpressure (the receiver spinning when the ring is full) is a deliberate policy choice: block the slow source rather than silently lose data mid-book.
-Werror with -Wall -Wextra -Wpedantic so warnings can never rot into bugs.
The chapter above stops at the interesting question, so here it is. A bounded queue will fill, and you must choose in advance what happens then. There are exactly three policies, and the right one depends on what the data is:
The general principle, and it is the useful part: a full queue is a decision, not an error. Systems that treat it as an error end up blocking, and systems that block turn a bounded problem into an unbounded one.
You cannot log on the hot path, but you must know what happened. The resolution is that counters are not logs. A plain integer incremented in the processing loop — orders accepted, orders rejected, gaps detected, ring-full events, pool exhaustions — costs one increment to memory. No formatting, no allocation, no lock, no I/O.
A second, low-priority thread reads and reports them. It needs no synchronisation beyond relaxed atomics, because a counter read a few nanoseconds late is still true — you are looking at a rate over seconds, so tearing on an individual read simply does not matter. This is the rare place where the weakest possible ordering is not merely adequate but correct.
The same structure handles the events you genuinely need detail for: the hot path writes a small fixed-size record into a ring, and the drain thread formats it. The trading thread touches no I/O, ever.
Both are the parts that separate a benchmark from a system, and both are ordered sequences you should be able to recite:
Startup: allocate the pool and pre-fault every page; size and verify SO_RCVBUF;
join the multicast group; run a warm-up pass through the matching path with synthetic messages and
discard the results; only then begin accepting real traffic. Every one of those exists to move a
first-message cost off the live path.
Shutdown: the producer sets a done flag with release ordering and stops writing.
The consumer, seeing the flag with acquire ordering, drains the remaining ring entries and
only then exits. That order is the entire point: draining is a state you pass through, not a signal
you handle. A shutdown that exits on the flag loses every message still in flight — which,
for order entry, means losing orders you already told the client you had accepted.
Not “faster” and not “has more features”. It means the system behaves predictably at its edges: the first message, the last message, the overload, the missing packet, the exhausted pool. A benchmark only ever exercises the middle. Everything in this chapter is about the fact that production is mostly edges, and that each edge should have a named policy and a counter rather than an accident.
The consolidated toughest questions across the whole project, with crisp answers. If you can defend every row from memory, the project is bulletproof.
| Question | Answer |
|---|---|
Why not std::map for price levels? | Red-black tree = per-node heap allocations scattered in memory; every traversal is cache misses. A flat array indexed by price tick is O(1) with one cache line. |
Why a memory pool over new? | new is 50–200+ ns with lock/syscall variance. A free-list pool is a single pointer swap: 0.96 ns, no variance. Measured 217×. |
What is placement new? | Construct an object in memory you already own — separates allocation from construction. It's how the pool builds an Order in a pre-allocated slot. |
| Why intrusive lists? | prev/next live inside the Order, so cancel is O(1) pointer rewiring with zero extra allocations — vs std::list's per-node allocation. |
Why alignas(64) on Order? | Pins it to one cache line so no access straddles two lines; the spare padding stores the level index for free. |
| Question | Answer |
|---|---|
Why not double for price? | Binary floating point can't represent most decimals exactly; equal prices may compare unequal and split into phantom levels. Fixed-point int64 (×10000) is exact. |
| How do you track the BBO in O(1)? | Explicit best-bid/ask indices, updated on each change; on a level emptying, a short contiguous scan finds the next best (1–5 steps in liquid markets). |
| How do you know the book is correct? | 90 tests including round-trip (build→parse→assert), FIFO/price-time priority, and a 2M-item concurrent ring test. All under -Werror. |
| Question | Answer |
|---|---|
Why is reinterpret_cast on the buffer UB? | Strict aliasing — the bytes were never an object of that type. memcpy/bit_cast is the same codegen without UB. |
| Explain release/acquire. | The producer's release store and the consumer's acquire load form a happens-before edge, so the slot write is visible before the published index is. |
| What is false sharing and how do you avoid it? | Two cores thrashing one cache line because unrelated data shares it. Separate them with alignas(64). |
| Why UDP for market data? | Stale data is worthless; you never want TCP retransmitting old prices. Tolerate loss via sequence numbers + gap fill. |
| Your p99.9 is noisy — why? | Scheduling jitter on a non-realtime OS in a VM. Real deployments pin isolated cores and tune the kernel; median and p99 reflect the code. |
You have a single-instrument matching engine that is correct, measured and fast. Here is what a production exchange has that this does not — stated precisely, because naming your gaps exactly is a senior signal, and a README that implies completeness is a red flag. An interviewer will trust the numbers more if the limitations are specific.
List the four or five limitations you most consciously chose — single instrument, no auction, no risk checks, fixed pool capacity that rejects rather than grows, replay without snapshots — and for each one, a clause saying why it was the right call for this scope. That paragraph does more for you than another optimisation would. It demonstrates that you know where the edges of your own system are, which is the actual thing being assessed when someone asks about a project you built.
Interviewers rarely ask "what did you build." They ask "why did you build it that way, and what else did you consider?" Every choice below has a rejected alternative. If you can defend the whole table, you can defend the project.
int64| Alternative | Why not |
|---|---|
double | Binary float can't represent most decimals exactly; equal prices can compare unequal → phantom levels → wrong matches. |
| A decimal library (e.g. Boost.Multiprecision) | Correct but heap-allocating and slow; overkill when a scaled integer is exact and free. |
int32 scaled | Overflows: $1000 × 10000 = 10M fits, but intermediate math (price × qty for notional) blows 32 bits. 64 bits is safe headroom. |
Interview trap: "Why ×10000 specifically?" → four decimal places covers sub-penny US equity ticks with room to spare; it's a convention, not a law.
| Alternative | Why not |
|---|---|
std::map<Price,Level> | Red-black tree: per-node heap allocation, pointer-chasing, ~log N cache misses per lookup. Correct, but slow where it counts. |
std::unordered_map<Price,Level> | O(1) average but hashing + bucket cache misses + no natural ordering, so finding the next best price after a level empties is painful. |
Sorted std::vector | O(1) best price but O(N) insert in the middle when a new price appears. |
A flat array indexed by (price−min)/tick is O(1), contiguous, and the "next best" is a short cache-friendly scan. The cost is memory for the full price grid — fine for one symbol.
Interview trap: "What if the price range is huge or unknown (crypto)?" → then you'd use a hybrid: a hash map of active levels plus a coarse index, or a bounded window that recenters. The flat array is the right call for a bounded-tick equity; naming its limit is the point.
| Alternative | Why not |
|---|---|
std::deque | O(1) front/back, but cancel-from-the-middle is an O(K) scan — and cancels are constant on a busy book. |
std::list | O(1) splice, but a separate heap node per element: extra allocation and a cache miss to reach the data. |
std::vector | Cancel = O(K) erase + shifts. |
Embedding prev/next in the Order gives O(1) cancel with zero extra allocation. The trade-off: the Order is coupled to one list (fine — it lives at exactly one level).
| Alternative | Why not |
|---|---|
new/delete | 50–200+ ns, lock/syscall variance, unpredictable tail. Death on the hot path. |
std::pmr monotonic/pool | Good idea, same family — but a purpose-built fixed-size free list is simpler and we control every cycle. |
| tcmalloc / jemalloc | Faster general allocators, but still general: they solve a harder problem than "hand out fixed-size slots." We don't need general. |
Interview trap: "What happens when the pool is full?" → add_order returns false; the caller decides (reject / grow / alert). We never silently allocate and blow the latency budget.
unordered_map<id,Order*>, pre-reservedBy-id cancel/execute needs a fast id→Order map. A hash map is O(1) average; we reserve it to pool capacity so it never rehashes mid-session (that was the measured p99.9 spike). Alternative — a sorted structure — buys nothing since we never iterate ids in order.
memcpy, not the cast| Alternative | Why not |
|---|---|
reinterpret_cast | Strict-aliasing UB; also alignment risk on some ISAs. Works until it doesn't. |
union type-punning | Legal in C, UB in C++ (reading the inactive member). |
| Field-by-field parse | Safe but slower and verbose; unnecessary once memcpy gives the same result for free. |
std::memcpy into a real struct is well-defined and compiles to the same load; C++20 std::bit_cast is the modern spelling.
| Alternative | Why not |
|---|---|
Mutex + std::queue | ~25 ns uncontended, unbounded blocking when contended; the hot consumer must never wait on the producer. |
| MPMC lock-free queue | Needs compare-and-swap loops and ABA handling — materially harder. We only have one producer and one consumer, so SPSC is both sufficient and simpler. |
| LMAX Disruptor | Excellent, but it is a ring buffer with extra machinery; ours is the same core idea sized to the task. |
TCP guarantees delivery by retransmitting — but a retransmitted price is stale garbage. UDP's best-effort delivery is correct for market data; you handle loss with sequence numbers + a gap-fill channel, not by blocking. Shared memory beats UDP for same-box feeds (and real colocated systems use it), which is exactly the "what's next" direction.
rdtscstd::chrono::steady_clock is ~20–30 ns per call — it would dominate a 10 ns measurement. rdtsc is ~6 ns; we calibrate it against steady_clock at startup to get ns. clock_gettime is portable but still a function call. The honest caveat: even rdtsc can't cleanly resolve a single 10 ns op, which is why the histogram's value is the tail, not the median.
| Alternative | Why not |
|---|---|
| Store every sample, sort, index | O(N) memory and an O(N log N) sort — impossible for hundreds of millions of samples. |
| Fixed linear buckets | Either too coarse for ns or too many buckets for ms; can't span 1 ns–100 ms at good resolution. |
| t-digest | Great for streaming quantiles, but more complex; log-bucketing is enough here with bounded ~3% error. |
Deterministic destruction (no GC pauses — the reason Java is a hard sell for the hot path), manual memory control, zero-overhead abstractions, and direct hardware access (alignas, intrinsics). Rust offers the same performance with memory-safety guarantees and is a genuine modern alternative; C is lower-level but you lose RAII and templates. C++ is the incumbent because the ecosystem and the control are both there.
Header-only keeps the whole engine inlinable across translation units (the optimizer sees everything) and the project trivially buildable. Docker/Alpine makes it run identically on any machine with no toolchain install — the reason you can clone this and get numbers in one command. Bare metal with core pinning is where you'd go for real latency work (Chapter 17).
A ground-up reference for every language and library feature the code touches, basic to advanced. If a term in the project is unfamiliar, it's defined here — with why it appears and the nuance an interviewer probes.
<cstdint>: int64_t, uint32_t, uint8_t) — exact, guaranteed sizes. Plain int is "at least 16 bits," which is unacceptable when a struct must match a wire layout byte-for-byte.using Price = std::int64_t;) — give a domain meaning to a raw type. Zero runtime cost; pure readability and single-point-of-change.enum class Side : uint8_t — a scoped enum with an explicit 1-byte underlying type. Scoped means Side::Buy can't silently convert to int (catches bugs); the uint8_t keeps it one byte for packing.std::size_t — the unsigned type for sizes/indices; matches what containers return. Mixing it with signed int triggers warnings (we compile with -Werror, so we're forced to be correct).BookConfig{.min_price=400000}) — name fields at construction for clarity and safety against field reordering.alignof, alignas) — a type's required address multiple. alignas(64) pins an object to a cache-line boundary. Used on Order (one line per order) and on the ring's counters (false-sharing avoidance).sizeof / padding — the compiler inserts gaps to satisfy alignment. Understanding this is why wire structs need #pragma pack(1) and why our Order has spare bytes we repurpose.#pragma pack(1) — disables padding so a struct matches an external byte layout exactly. Essential for the ITCH wire structs; static_assert(sizeof(...) == N) proves it worked.prev/next) and the pool.OrderBook& into the parser to avoid copying and signal "this must exist."const-correctness — marking read-only methods/params const lets the compiler enforce intent and enables optimization. get_bbo() const promises it won't mutate the book.finally.= delete copy/move on the pool, ring, and sockets so nobody accidentally double-frees a slab or fd.explicit — stops a constructor from being used for silent implicit conversions. Small safety habit throughout.MemoryPool<T>, SpscRingBuffer<T,N>) — the compiler stamps out a specialized version per type, with the exact size/layout baked in. Generic and zero-overhead.allocate(Args&&...) → ::new(slot) T(std::forward<Args>(args)...)) — forward any constructor arguments through the pool to placement-new, preserving value categories.constexpr / if constexpr — computed at compile time. if constexpr (std::endian::native == little) compiles the byte-swap in or out with zero runtime branch.static_assert — a compile-time test. We pin wire sizes and the power-of-two ring capacity so a mistake fails the build, not production.operator new — new T() does two things: get memory, then construct. Separating them is the key idea behind the pool.new (::new(ptr) T(...)) — construct an object in memory you already own. How the pool builds an Order in a pre-allocated slot with no allocation.ptr->~T()) — the matching teardown for placement new; run the destructor without freeing the memory (the slot returns to the free list).static_cast — checked, related-type conversions (e.g. uint32→int64 price). The cast you should reach for by default.reinterpret_cast — "trust me, treat these bytes as that type." Powerful and dangerous; the source of the strict-aliasing trap.char/std::byte excepted). Breaking it is UB the optimizer may exploit. memcpy / bit_cast are the safe path.x & (N-1) equals x % N when N is a power of two, in one instruction. The ring buffer's wrap.__builtin_bswap32) — reverse byte order for endianness conversion, one instruction.__builtin_clzll) — find the highest set bit to compute a value's magnitude; the histogram's log-bucket index.std::thread — an OS thread; join() waits for it. The pipeline runs three.std::atomic — a type whose operations are indivisible and synchronizing. The ring's head/tail.relaxed: atomicity only. acquire (loads): nothing after it moves before. release (stores): nothing before it moves after. An acquire that reads a release's value establishes happens-before: the reader sees everything the writer did prior. That pairing is what makes the ring correct. (seq_cst, the default, is stronger and slower — we don't need it.)alignas(64).| Container | Used for | Watch out for |
|---|---|---|
std::vector | the flat price-level arrays | reallocation on growth (we size once, up front) |
std::array<T,N> | the ring's fixed storage | size is part of the type; lives inline (heap-allocate if large) |
std::unordered_map | id → Order* | rehashing latency spikes — reserve() up front |
std::chrono — type-safe durations/clocks. steady_clock (monotonic, for measuring intervals) vs system_clock (wall time, can jump). Always steady_clock for latency.std::mt19937_64 — a seeded, deterministic PRNG. A fixed seed makes the synthetic day reproducible — same input every run, so numbers are comparable.std::bad_alloc, std::runtime_error) — used at startup/setup (pool allocation, socket bind), never on the hot path (throwing is expensive and unpredictable).add_order returns bool, execute_order returns filled quantity. The caller branches; no exception cost per message.assert — a debug-build sanity check (compiled out in Release). Guards invariants like a positive tick size.-Wall -Wextra -Wpedantic -Werror — every warning is a build failure. This is why the project has zero warnings: they can't rot into bugs. (It's also why -Waddress-of-packed-member forced us to read packed fields by value, not by address.)static_assert as a test — the cheapest test there is: it runs at compile time and costs nothing at runtime.types.hpp for alignment, ring_buffer.hpp for memory order, memory_pool.hpp for placement new) and match the words to the code. You'll remember what you've seen work, not what you've read.
Built as part of the HFT Order Book Learning Project — June 2026
C++20 · CMake · GoogleTest · Google Benchmark · Docker
You were right that these exist. Use them the same way as the chapters: predict what you'll see, then look. A tool you watch passively is just a prettier document.
The one you described: write a snippet, step line by line, and watch stack frames, heap objects and pointer arrows redraw as they move. Point it at the intrusive-list cancel your Ch 7 calls “three pointer rewires” and you literally see the three arrows swing. Limitation: small programs, no deep STL internals — so use it for pointer mechanics, not for containers.
Source left, generated assembly right, live, any compiler and architecture. The highest-value
tool on this list. Rung 6: did the sorted-vector search actually inline, did the optimiser vectorise,
is the abstraction really zero-cost. Rung 7: compile one std::atomic load as
relaxed, then acquire, then seq_cst, and flip the target between
x86-64 and ARM64 — on x86 acquire is often free, on ARM64 you watch ldar appear,
or ldr plus a dmb barrier. That is the cost of memory ordering made
visible, on the architecture your Mac actually is.
Desugars your source into what the compiler really sees: templates instantiated and written out longhand, implicit conversions made explicit, the destructor calls you never wrote, range-for expanded, lambdas shown as the structs they are. This is Ch 1 and Ch 22 turned from prose into something you can poke.
Google Benchmark in the browser with a side-by-side bar chart. A/B std::map against a
sorted vector against a flat array in ten minutes, with no harness to build. Use it to
predict-then-measure cheaply before you commit to a structure — then reproduce the winner
properly in your own committed benchmark, because a shared web machine's numbers are not your Mac's.
Write a tiny two-thread snippet with atomics and it enumerates every execution the C++ memory
model permits, drawn as graphs. Academic-feeling, and it is nevertheless the actual tool for
“visualise memory ordering”. Search for “cppmem” — it is hosted at
Cambridge. Try it on your ring's head/tail publish and see which reorderings relaxed
allows that release forbids.
clang -Xclang -fdump-record-layouts -c foo.cppPrints every struct's exact layout — field offsets, sizes, and precisely where the padding
bytes sit. This is Ch 8's cache-line and struct-layout material answered exactly, for your own
types, rather than in general. Run it on your Order the moment you define it, and again
after you reorder the fields; the difference in total size is your first free optimisation.
-fsanitize=thread finds the ring's ordering bugs by running them, which beats reasoning
about them. Xcode's Instruments Time Profiler is already on your Mac for rung 6's flame graphs. Be aware
that false-sharing detection tooling is weak on macOS — Linux has perf c2c and
you don't — so for rung 7 the practical method is the A/B measurement the gate already asks for:
padded versus unpadded, with numbers.
There is no tool that animates your order book with live depth, because it's specific to your data. But rung 4 already hands it to you almost free: once you have a replayable event log, a small renderer that steps the log and draws the two sides of the book as bars is about one evening's work — terminal output or a tiny SVG, no framework. It is worth doing for two reasons that have nothing to do with learning C++: watching depth build and get swept is the fastest way to spot a matching bug you'd never find in a test, and a short recording of it is the best possible top line for the README. Treat it as an optional rung 8, after rung 6, never before rung 5.
Repo bootstrap — run this on the Mac
mkdir matching-engine && cd matching-engine
git init
# personal identity, NOT the work address --
# company-email commits on a personal
# project muddy ownership
git config user.name "Pratyush Kumar"
git config user.email "<personal@address>"
mkdir -p include/engine src tests bench \
docs/decisions tools
# keep this handbook with the project
cp ~/orderbook-handbook.html docs/
# first commit before any code: the plan.
# the history should show you thinking,
# then building.
git add docs
git commit -m "Handbook and decision log format"
git remote add origin \
git@github.com:<you>/matching-engine.git
git push -u origin main
Decision log entry — one per real fork
# docs/decisions/0003-price-levels.md
# 0003 -- Sorted vector for price levels,
# not std::map
Date: 2026-09-19
Status: accepted
## Context
Level lookup is on the hot path for every
add and every match. Ticks are dense and
the live price range is narrow.
## Options considered
1. std::map<Price, Level> -- O(log n),
node per level, pointer chasing, but
handles any price range.
2. Flat array indexed by tick -- O(1),
cache-friendly, allocates whole range.
3. Sorted vector + binary search --
O(log n), contiguous; mid-insert costs
a memmove.
## Decision
Option 3. Levels cluster near the touch,
so the memmove is short in practice, and
contiguity beats asymptotics at this size.
## Consequences
Iterators invalidate on insert, so the
order index stores (price, slot), not a
pointer. Revisit if the range widens.
## Measured
Level lookup p99: 41ns -> 12ns
(bench/level_lookup, 2026-09-19)
What leaves the work laptop — deadline 5 October 2026
Verified against the actual projects. Two of these were already solved by design, and two were not.
run.sh self-updates from lld-trainer.pages.dev, verifying digest and byte count, and
refuses to touch data/, workspace/ or .toolchain/. It runs on the Mac
with no work at all.
Its .gitignore already excludes .env, data/, out/ and
*.db — written to keep the repo publishable. Publish via git, never as a zip; a zip
ignores .gitignore and would ship your profile, blocklist, application history and keys.
ui-src/ — the frontend sourceNot in the published zip; only the built ui/ is. Lose it and all 14 views still run but can never
be changed again. Fix: publish a second lld-trainer-src.zip beside the runtime zip.
snapshots/ — the history substituteLocal only. With no git in that project, these are its history. Into the source zip, or onto a drive you keep.
deploy.ps1 reads it from your company OneDrive. That folder is revoked with your account,
so copy cloudflare.txt somewhere personal now or you lose the ability to deploy — the account
itself is yours, so only the token is at risk.
The SQLite DB and data/ are exactly what git excludes, so they move by hand — with a macOS
Tectonic binary in place of the Windows one, and ollama pull for qwen3:4b and 8b. Also copy
~/.claude/projects/C--Users-PratyushKumar-PycharmProjects/memory/.
Not “copied, assume fine”. Launch LLD Dojo, launch jobhunt, compile a résumé PDF end to end. Do it with a week to spare, because whatever breaks will need this machine to diagnose.