Building a Low-Latency
Order Book from Scratch

One beginner's exploration of C++, systems programming,
and how high-frequency trading infrastructure actually works
A learning journal, not a textbook — written while building the thing
to sharpen C++ and understand a domain I'd never touched
Why this exists (read me first) I'm not a quant and I'd never written low-latency C++ before this. I built the order book to learn — and wrote this booklet to make the learning defensible, so I could explain every decision out loud rather than wave at code I half-understood. It's deliberately thorough: I'd rather over-explain than fake fluency. Treat it as "here's what I explored and why," not "here's the one true way." Where I made a beginner's simplification, I say so.

Contents

  1. C++ Foundations You Actually Need
  2. What Is an Order Book?
  3. Representing Money — Fixed-Point Arithmetic
  4. STL Containers — What They Hide from You
  5. Memory: The Stack, the Heap, and Why new Is Slow
  6. Building a Memory Pool
  7. Intrusive Data Structures
  8. Cache Lines and Struct Layout
  9. Flat Arrays vs. Trees
  10. Putting It All Together — The Order Book
  11. Benchmarking — Proving It Works
  12. System Architecture — Why HTTP Doesn't Belong Here
  13. Binary Protocols & Zero-Copy Parsing (ITCH)
  14. Measuring the Tail — Latency Histograms & Replay
  15. Lock-Free Programming — The SPSC Ring Buffer
  16. The Pipeline — UDP and Threads
  17. Finding the Tail — Profiling
  18. Production Hardening
  19. The Interview — The Grill Sheet
  20. What's Next
  21. Why This, Not That — The Decision Log
  22. C++ From Scratch — Every Concept Used

Chapter 01C++ Foundations You Actually Need

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.

Types and Their Sizes

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.

int32_t 4 bytes Exact integer, always 32 bits int64_t 8 bytes Our Price & Quantity type T* 8 bytes A memory address (64-bit) bool 1 byte true or false enum:uint8_t 1 byte

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.

Pointers

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.

value 42 addr: 0x1000 ptr 0x1000 int* ptr = &value; *ptr == 42 (dereference) int value = 42; int* ptr = &value; int x = *ptr; // x == 42

Pointers are the foundation of everything in this project: linked lists, memory pools, and direct data access without copying.

Structs — Grouping Data Together

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

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

static_assert — Compile-Time Safety Nets

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.

RAII — Automatic Cleanup

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.

Chapter 02What Is an Order Book?

Every exchange — NYSE, NASDAQ, CME — runs an order book at its core. It's the data structure that matches buyers with sellers.

The Basic Idea

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 (Buyers) Asks (Sellers) $50.03 100 shares BEST BID $50.02 350 shares $50.01 500 shares $50.00 250 shares $50.05 200 shares BEST ASK $50.06 150 shares $50.07 400 shares $50.08 180 shares Spread = $50.05 - $50.03 = $0.02 The "cost of immediacy" — buy-then-immediately-sell loses $0.02

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.

Price-Time Priority (FIFO)

When multiple orders sit at the same price, who gets filled first? Whoever arrived first. This is called FIFO matching.

Price level $50.03 — three orders queued, FIFO order Order A 100 shares · 09:30:01 FULLY FILLED Order B 50 shares · 09:30:02 20 FILLED, 30 LEFT Order C 200 shares · 09:30:05 UNTOUCHED HEAD TAIL Incoming sell of 120 shares at $50.03: A filled (100) + B partially filled (20) = 120 total. C is untouched.

Three Operations — That's All

An order book processes exactly three event types. On a busy exchange, these arrive millions of times per second.

EventWhat HappensOur Target
AddNew order arrives. Insert at the correct price level, back of the queue.O(1)
CancelTrader withdraws an order. Remove it from wherever it sits.O(1)
ExecuteA trade happens. Reduce the front order's quantity.O(1)

Chapter 03Representing Money — Fixed-Point Arithmetic

Your first instinct for a price like $50.03 is double price = 50.03;. This is a trap.

Why Doubles Lie

The Floating-Point Trap 0.1 + 0.2 = 0.30000000000000004 ✘ 50.03 == 50.03? Maybe. Depends on how you got there. Most decimal fractions (0.1, 0.2, 0.03) can't be stored exactly in binary IEEE 754 format.
Real-world consequence If two orders at "the same price" end up at slightly different floating-point values, they're placed at different price levels. Your book has phantom levels, your BBO is wrong, and matching produces incorrect fills. Debugging this is a nightmare because it only happens for certain values.

The Fix: Multiply, Store as Integer

DANGEROUS $50.03 × 10,000 SAFE 500,300 ✓ Comparison is exact ✓ Arithmetic is exact ✓ Can use as array index using Price = int64_t; // all prices in 1/10000ths of a dollar
Rule of thumb If it represents money, it's an integer. Every exchange, every trading system, every bank uses integer arithmetic for prices. Floating point is for analytics and display, never for order matching.

Chapter 04STL Containers — What They Hide from You

The C++ Standard Library gives you std::map, std::vector, std::deque. They're correct and general-purpose. That last part is the problem.

std::map — The Elegant Trap

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.

std::map internal structure (red-black tree) $50.03 $50.01 $50.06 $50.00 $50.02 $50.07 CACHE MISS ~100ns CACHE MISS ~100ns CACHE MISS Each node is a separate heap allocation at a random address. Every hop is a potential cache miss. 1000 price levels = ~10 pointer chases = ~1 μs just in memory stalls

What We Replaced Them With

WasBecameWhy
std::map<Price, Level>Flat std::vector<PriceLevel>O(1) indexed by price, contiguous memory
std::deque<Order>Intrusive doubly-linked listO(1) cancel via direct pointer
new / deleteMemory pool with free listO(1) alloc, zero syscalls

Chapter 05Memory: The Stack, the Heap, and Why new Is Slow

Two Places to Store Data

THE STACK int x = 42; Order o; double price = 50.03; ✓ Instant allocation (~0 ns) ✓ Automatic cleanup ✓ Contiguous in memory Limited size, dies with the function THE HEAP Order* int* char* ✘ Slow alloc (50-5000 ns) ✘ Manual cleanup (or leak) ✘ Scattered across memory Flexible, lives as long as you want

What Happens Inside new

What happens when you call: Order* o = new Order(); 1. ACQUIRE LOCK Heap is shared between threads 2. SEARCH FREE LIST Find a block of the right size 3. MAYBE SYSCALL Ask OS for memory 1-10 μs ! 4. BOOKKEEPING Write headers, track block size Total: 50 - 5,000 ns with unpredictable variance Our pool: 0.96 ns (1 pointer swap)

Chapter 06Building a Memory Pool

If every object is the same size, you don't need a general-purpose allocator. Pre-allocate a slab, hand out fixed-size slots.

How the Free List Works

At Startup — all slots free, linked together: free_head Slot 0 Slot 1 Slot 2 Slot 3 Slot 4 null Each free slot stores a pointer to the next free slot (overlaid on the Order's memory) After allocate() × 2 — slots 0 and 1 hold Orders: Order A Order B free_head Slot 2 Slot 3 ... After deallocate(Order A) — slot 0 returned to front of free list: free_head Slot 0 Order B Slot 2 Slot 3 ... Slot 0 was reused — it jumps to the head. The free list is not sequential, and that's fine. Placement new: ::new (slot) Order(...) "Don't allocate memory — just construct the Order at this address I'm giving you"
0.96 ns
Pool Alloc
209 ns
System new
217×
Speedup

Chapter 07Intrusive Data Structures

Each price level is a FIFO queue. With std::deque, cancelling an order means scanning to find it — O(K). We need O(1).

Non-Intrusive vs. Intrusive

Non-Intrusive (std::list) — 6 allocations for 3 orders prev | next data* Order A prev | next data* Order B prev | next data* Order C 3 wrapper nodes + 3 order objects = 6 allocations Intrusive (our design) — 3 allocations for 3 orders Order A id, price, qty prev | next ──────► Order B id, price, qty prev | next ──────► Order C id, price, qty prev | next ► null prev/next pointers live INSIDE the Order. No wrapper nodes. No extra allocations. Cancel = look up pointer + rewire 3 pointers = O(1) regardless of queue depth

The Cancel Operation — Three Pointer Rewires

Cancelling Order B — just rewire the pointers around it Order A next ──────────► Order B ✕ REMOVING Order C ◄────────── prev A.next = C C.prev = A

Chapter 08Cache Lines and Struct Layout

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.

The Memory Hierarchy

L1 32 KB · ~1 ns Your desk L2 1 MB · ~5 ns Filing cabinet L3 16 MB · ~15 ns Supply room MAIN MEMORY (RAM) 16 GB · ~100 ns Warehouse 100× slower!

Cache Line Alignment

Without alignas(64) — Order may straddle two cache lines: cache line 0 (bytes 0-63) Order (48 bytes) cache line 1 (bytes 64-127) 2 fetches! With alignas(64) — Order always starts at a cache-line boundary: Order (64 bytes, padded) |id|price|qty|side|prev|next|level_idx| Next Order (64 bytes) 1 fetch! Our fields only need 40 bytes. The remaining 24 bytes of padding store level_idx — free real estate!

Chapter 09Flat Arrays vs. Trees

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?

std::map — O(log N) 50.03 50.01 50.06 50.00 Find $50.05: 3 pointer chases = 3 cache misses = ~300 ns Each node at a random address. Every hop probably misses the cache. Flat Array — O(1) $50.00 $50.01 $50.02 $50.03 $50.04 $50.05 $50.06 $50.07 Find $50.05: idx = (500500 - 490000) / 100 idx = 105 → levels[105] ✓ One subtraction + one division No pointers. No cache misses. O(1). All levels contiguous in memory. Neighbors prefetched automatically.

Chapter 10Putting It All Together — The Order Book

OrderBook Architecture MemoryPool<Order> Pre-allocated slab, 0.96 ns alloc unordered_map<ID, Order*> O(1) lookup by order ID vector<PriceLevel> bids Flat array, O(1) by price index vector<PriceLevel> asks Flat array, O(1) by price index BBO Tracker Pre-computed, 1.5 ns query add_order: pool.allocate() → levels[idx].add() → id_map[id] = ptr → update BBO cancel: id_map[id] → level.remove(ptr) → update BBO → pool.deallocate()

Operation Summary

OperationComplexityHeap AllocsCache Misses
AddO(1) amortized0~1 (hash map)
CancelO(1)0~1 (hash map)
ExecuteO(1)0~1 (hash map)
Get BBOO(1)00 (pre-computed)

Chapter 11Benchmarking — Proving It Works

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.

Common Traps

Optimizer deletes your code If result isn't used, compiler removes the computation entirely Debug build benchmarks Debug includes bounds checks, no optimizations. Meaningless. Single measurement One run = noise. Need hundreds of iterations for stability. Google Benchmark handles all of these automatically. Auto-scales iterations, prevents dead-code elimination, reports stable statistics.

Our Results

1.5 ns
BBO Query
~10 ns
Add Order
~12 ns
Cancel Order
8.9M
Ops / Second
The benchmarking mindset Measure → Understand → Fix → Measure again. Never optimize without a before/after comparison. This discipline separates systems engineers from people who cargo-cult patterns they read about online.

Chapter 12System Architecture — Why HTTP Doesn't Belong Here

The Cost of HTTP

TCP HEADERS JSON PARSE SYSCALLS JSON SERIALIZE ~35,000 ns framework overhead ~10 ns your code HTTP overhead is 3,500× larger than the actual work. You'd be optimizing the 0.03% while 99.97% is spent in plumbing.

The Pipeline Model — No Network Between Components

Exchange NASDAQ, NYSE UDP only network hop YOUR MACHINE — SAME PROCESS UDP Socket Reader PHASE 6 ring buffer shared memory, zero copies Deserializer PHASE 5 function call direct, same address space Order Book PHASES 1-4 BBO Output 1.5 ns Dashboard HTTP is fine here (not on critical path)
The key mental shift In web development, the network is between every component. In HFT, the network exists only at the edges — data arriving from the exchange, orders going out. Everything in between is function calls, shared memory, and lock-free queues.

Chapter 13Binary Protocols & Zero-Copy Parsing (ITCH)

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.

Why binary, and why it changes everything

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.

1. Struct layout — padding is the enemy

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.

Naive struct { uint8_t type; uint32_t id; } — 8 bytes, padded: type pad pad pad id (4 bytes) ← 3 bytes the wire never sent #pragma pack(1) — 5 bytes, exact wire match: type id (4 bytes) ← matches the 5 bytes on the wire Our ITCH structs use #pragma pack(1); static_assert pins each to its real wire size (36, 31, 23, 19, 35 bytes).

2. Endianness — which end of the number goes first

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
}

3. Zero-copy — and the trap inside it

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.

The senior answer Use 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.

4. Framing & dispatch — where does one message end?

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
}
Single-symbol focus (an honest simplification) A real ITCH feed interleaves every listed symbol. Add messages carry a symbol; Execute/Cancel/Delete/Replace carry only an order id. We track the ids whose Add matched our target symbol, and act on later messages only if the id is one we own. Replace carries neither side nor symbol, so we recover the side from the order we're tracking. Verified end-to-end: build a message → parse it → assert the book state matches.

The interview scorecard

They askYou 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.

Chapter 14Measuring the Tail — Latency Histograms & Replay

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.

Why the average hides the danger

per-message latency (log scale) → p50 ~100ns p99 ~1.6μs p99.9 max The mean sits here-ish — and tells you nothing about the tail on the right.

The histogram: HdrHistogram in ~2 KB

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.

Timing a 10 ns operation honestly

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.

Two honesty caveats baked into the tool (1) This measures book-processing latency, not wire-to-wire — labeled precisely so it's never mistaken for end-to-end HFT latency. (2) We process a subset of ITCH message types and skip the rest by length. Both are stated openly; overselling is how you fail the interview.

The result — a real measure → diagnose → fix arc

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:

PercentileBefore (rehashing)After (reserved)
mean405 ns279 ns
p50162 ns81 ns
p991744 ns1200 ns
p99.96976 ns5056 ns
p99.9929952 ns16256 ns
The most important honesty point in the whole project The extreme tail (p99.9 and beyond) in this environment is dominated by OS/hypervisor scheduling jitter — the container thread getting descheduled for milliseconds on a Docker-on-Windows VM — not by the order book. The median (~80–100 ns) and p99 (~1.6 μs) reflect the real code. This is exactly why HFT firms pin threads to isolated cores, disable frequency scaling, and run tickless kernels (Chapter 17). Knowing that your measurement floor is the environment, not your code, is a senior-level insight.

Chapter 15Lock-Free Programming — The SPSC Ring Buffer

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.

The structure

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.

Memory ordering — the crux

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.

PRODUCER thread 1. buffer[tail & MASK] = item; 2. tail.store(tail+1, RELEASE); RELEASE = "everything I wrote before this store is visible to whoever ACQUIRE-loads this value" CONSUMER thread 1. t = tail.load(ACQUIRE); 2. item = buffer[head & MASK]; ACQUIRE = "I see every write the producer made before its RELEASE" — so the slot write in (1) is guaranteed visible happens-before

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.

False sharing — the silent killer

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
Verified under real concurrency Two threads, no locks, 2,000,000 items streamed producer→consumer: every value received exactly once, in order, in 14 ms. Why SPSC specifically? With exactly one reader and one writer, each counter has a single writer, so plain loads/stores with acquire/release suffice — no compare-and-swap, no ABA problem. Generalizing to multi-producer needs CAS and is materially harder; knowing why SPSC is the easy case is the insight.

The interview scorecard

They askYou 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

Chapter 16The Pipeline — UDP and Threads

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.

Why UDP, not TCP

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).

Feed thread pack msgs into UDP datagrams UDP Receiver thread recv → push into the ring buffer ring Processor thread pop → parse → apply to book OrderBook + BBO No lock and no syscall between receiver and processor — just the lock-free ring. backpressure: if the ring is full, the receiver spins until space frees Verified: 2.43 M messages/sec end-to-end across three threads. ~4% of datagrams dropped by the kernel under load — reported honestly, because UDP is lossy.
The drop is a feature, not a bug Under a send burst the kernel's receive buffer fills and it drops datagrams. We enlarge 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.

Chapter 17Finding the Tail — Profiling

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:

ToolWhat it showsWhen to reach for it
perf statCycles, instructions, cache misses, branch mispredicts (hardware counters)First look: is it memory-bound or compute-bound?
perf record + flamegraphWhich functions burn the cyclesFind the hot function to attack
cachegrindSimulated cache hit/miss per lineProve a cache-locality hypothesis
Our replay histogramThe actual latency distribution & tailConfirm a fix moved the percentile that matters

What actually causes tails

The jitter lesson (learned the hard way in this project) Our replay's multi-millisecond max didn't move when we pre-faulted the pool — because the max was never page faults. It was the hypervisor descheduling the container thread. On a general-purpose OS in a VM, the extreme tail is the environment. Production HFT eliminates it with 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.

Chapter 18Production Hardening

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.

Warm up before you trade

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.

Never log on the hot path

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.

Graceful shutdown & backpressure

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.

Talking points that signal maturity Warmup passes · pre-faulting · huge pages (2 MB) to cut TLB misses · off-hot-path logging · bounded queues with explicit overflow policy · graceful drain-then-exit · NUMA-aware allocation · -Werror with -Wall -Wextra -Wpedantic so warnings can never rot into bugs.

Chapter 19The Interview — The Grill Sheet

The consolidated toughest questions across the whole project, with crisp answers. If you can defend every row from memory, the project is bulletproof.

Data structures & memory

QuestionAnswer
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.

Correctness & money

QuestionAnswer
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.

Systems & concurrency

QuestionAnswer
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.

The one meta-question

"Walk me through how you'd make it faster." Never guess. Measure (replay histogram + perf), find the dominant cost (rehash? cache miss? jitter?), fix the root cause (reserve / contiguous layout / core pinning), re-measure to prove the percentile that matters actually moved. Every optimization in this project has a before/after number. That discipline — not any single trick — is the job.

Chapter 20What's Next

The Road So Far

0-4 Core LOB Pool, intrusive list, flat array DONE ✓ 5 ITCH Feed Zero-copy parse 7.15M msg/s DONE ✓ 6 Replay + Tail HDR histogram p50/p99/p99.9 DONE ✓ 7-8 UDP + Ring Lock-free SPSC acquire/release DONE ✓ 9 Pipeline 3 threads 2.43M msg/s DONE ✓ 10-11 Profile + Harden reserve, prefault warmup, jitter DONE ✓ Topics for Further Study Real NASDAQ File Ingest a genuine ITCH capture (268M msgs/day) Matching Engine Cross bids/asks, emit trades (a live exchange) Kernel Bypass DPDK / io_uring — skip the OS network stack FPGA / Huge Pages Hardware deserialize; 2 MB pages cut TLB miss The Pattern Behind Everything Measure → Understand → Fix → Measure Again

Chapter 21Why This, Not That — The Decision Log

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.

1. Price = fixed-point int64

AlternativeWhy not
doubleBinary 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 scaledOverflows: $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.

2. Price levels = flat array, not a tree

AlternativeWhy 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::vectorO(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.

3. Order queue = intrusive doubly-linked list

AlternativeWhy not
std::dequeO(1) front/back, but cancel-from-the-middle is an O(K) scan — and cancels are constant on a busy book.
std::listO(1) splice, but a separate heap node per element: extra allocation and a cache miss to reach the data.
std::vectorCancel = 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).

4. Allocation = free-list pool

AlternativeWhy not
new/delete50–200+ ns, lock/syscall variance, unpredictable tail. Death on the hot path.
std::pmr monotonic/poolGood idea, same family — but a purpose-built fixed-size free list is simpler and we control every cycle.
tcmalloc / jemallocFaster 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.

5. Order lookup = unordered_map<id,Order*>, pre-reserved

By-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.

6. Deserialization = memcpy, not the cast

AlternativeWhy not
reinterpret_castStrict-aliasing UB; also alignment risk on some ISAs. Works until it doesn't.
union type-punningLegal in C, UB in C++ (reading the inactive member).
Field-by-field parseSafe 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.

7. Thread handoff = lock-free SPSC ring

AlternativeWhy not
Mutex + std::queue~25 ns uncontended, unbounded blocking when contended; the hot consumer must never wait on the producer.
MPMC lock-free queueNeeds 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 DisruptorExcellent, but it is a ring buffer with extra machinery; ours is the same core idea sized to the task.

8. Transport = UDP

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.

9. Timing = calibrated rdtsc

std::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.

10. Histogram = HdrHistogram-style log buckets

AlternativeWhy not
Store every sample, sort, indexO(N) memory and an O(N log N) sort — impossible for hundreds of millions of samples.
Fixed linear bucketsEither too coarse for ns or too many buckets for ms; can't span 1 ns–100 ms at good resolution.
t-digestGreat for streaming quantiles, but more complex; log-bucketing is enough here with bounded ~3% error.

11. Language = C++

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.

12. Build = header-only lib + Docker Alpine

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).

Chapter 22C++ From Scratch — Every Concept This Project Uses

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.

A. Types & values

B. Memory model & layout

C. Pointers, references, const

D. Classes & object lifetime

E. Templates & compile-time

F. Dynamic allocation internals

G. Casting & undefined behavior

H. Bit manipulation

I. Concurrency (the deep end)

J. Standard library containers used

ContainerUsed forWatch out for
std::vectorthe flat price-level arraysreallocation on growth (we size once, up front)
std::array<T,N>the ring's fixed storagesize is part of the type; lives inline (heap-allocate if large)
std::unordered_mapid → Order*rehashing latency spikes — reserve() up front

K. Time & randomness

L. Error handling

M. Tooling as correctness

How to use this chapter Don't memorize it. Read an entry, then open the file that uses it (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

Save as PDF

Press Ctrl+P → Destination: "Save as PDF" → Margins: "Minimum"