DEV Community

Cover image for The "Optimal" Order Book Data Structure Lost by 288 on Real Market Data
Umang Pokhriyal
Umang Pokhriyal

Posted on

The "Optimal" Order Book Data Structure Lost by 288 on Real Market Data

While learning about limit order books, I kept coming across two ideas. The first was the textbook answer: a flat array indexed by price is the optimal solution for a dense book because updates are O(1). The second came from low-latency systems and HFT discussions, where people repeatedly stress that contiguous memory and cache locality often matter more than algorithmic complexity.

That got me wondering how these ideas compare in practice, so I implemented four different order book data structures and benchmarked each implementation using both synthetic workloads and a real BTCUSDT replay.

The setup

A limit order book is the core data structure of an exchange: for one symbol, it tracks resting buy and sell orders at each price level, and the hot operation is applying a stream of updates (add, cancel, modify) as fast as possible.

In Rust, this is implemented using a common OrderBook trait. I also wrote a differential test harness that replays the same event stream through every implementation and verifies that they all produce identical results before benchmarking them.

I chose four implementations that represent the trade-offs you'll commonly see discussed in systems programming: pointer-based trees, contiguous vectors, linear scans that exploit locality, and direct indexing with arrays.

BTreeMap, Sorted Vec, Reverse-sorted Vec, FlatBook

  • BTreeMap the straightforward implementation. Memory usage grows only with occupied price levels, but updates involve tree traversal and pointer chasing.
  • Sorted Vec of (price, level). stores price levels in contiguous memory and locates them with binary search. Better cache locality than a tree, but insertions require shifting elements.
  • Reverse-sorted Vec with a linear scan. keeps the best prices at the front and relies on a simple linear scan. This sounds inefficient, but if most updates happen near the inside market, it can outperform binary search.
  • FlatBook (flat array indexed by price tick) indexes directly by price tick, giving constant-time access without searching.

Going into the project, I expected the flat array to come out ahead. It has constant-time lookups, avoids tree traversal entirely, and is the implementation that's usually recommended for dense order books. The benchmarks were mainly meant to measure how much faster it was than the alternatives.

The order book isn't the whole system, though. In a market data engine, updates also need to be distributed to multiple consumers, so each implementation plugs into the same lock-free pipeline shown below.

Architecture: a pinned producer runs book.apply, seqlock.store for the latest top-of-book, and ring.push for the full broadcast stream; K independent pinned consumers read from the seqlock and ring, resyncing from the seqlock on overrun.

One pinned producer, two lock-free primitives, K independent consumers. The book is the hot path; the seqlock and ring are how its output fans out.

On synthetic data, the textbook answer wins. On real data, it comes in last by 288x

To avoid drawing conclusions from a single workload, I benchmarked each implementation under two very different conditions. The first was a synthetic workload with a relatively narrow, uniformly distributed price range. The second replayed a real BTCUSDT market session, where updates arrive with the same characteristics as they did on the exchange.

Grouped bar chart, log scale: on the synthetic narrow book FlatBook is fastest at 7.46 ns/event; on the real BTCUSDT book FlatBook collapses to 10,896 ns/event while BTreeBook leads at 37.79 ns/event, a 288x gap.<br>

The synthetic benchmark matches the textbook expectation. The real market replay tells a completely different story. Every bar is re-derived from throughput.csv.

The surprising part isn't that FlatBook slows down—it's that the entire ranking reverses once the workload becomes realistic.

The result wasn't just that the ranking changed—it completely flipped. The FlatBook implementation, which was consistently the fastest on the synthetic benchmark, became the slowest on the real replay, while the BTreeMap implementation ended up on top by a wide margin.

If I had only benchmarked the synthetic workload, I would've concluded that the flat array was the obvious choice. Replaying real market data completely changed that conclusion.

At this point, the obvious question was why. The algorithms hadn't changed, only the workload had. The answer turned out to have very little to do with Big-O complexity and everything to do with how much memory the data structure actually touches.

Why the FlatBook slowed down

The biggest difference between the synthetic benchmark and the real replay wasn't the number of updates—it was the range of prices those updates covered.

A FlatBook allocates memory for every possible price tick in that range, whether there's an order there or not. That means its memory footprint grows with the price span, not with the number of price levels that actually contain orders.

Memory layout: the real BTC/USDT book spans about 88 MiB as one dense FlatBook array, which is 2.74x the 32 MiB per-CCD L3, so most accesses miss; BTreeBook's memory is proportional to occupied levels and stays small.

The flat array's span on the real book is ~88 MiB, about 2.74x the 32 MiB L3. Every "O(1)" index is now a main-memory round trip.

This was the point where it became clear that memory footprint mattered more than lookup complexity.

Once the array grows beyond what the processor can comfortably keep in cache, the cost of an "O(1)" lookup changes dramatically. The indexing itself is still constant time, but the data you're indexing often has to be fetched from lower levels of the memory hierarchy.

The BTreeMap has the opposite trade-off. Tree traversal involves pointer chasing, but memory usage stays proportional to the number of occupied price levels instead of the total price span. On the real replay, that smaller working set outweighed the extra traversal cost.

In this workload, the deciding factor wasn't algorithmic complexity—it was the working set size. Once the FlatBook grew to roughly 88 MiB, cache locality became the dominant cost.

The same idea explains another result from the benchmarks. I originally expected the binary-search implementation to consistently outperform the linear scan, but that wasn't always true either.

If most updates happen near the best bid and ask, a short linear scan often finishes before a binary search has a chance to pay off. If updates are spread uniformly across the book, the scan eventually becomes too expensive and binary search wins instead.

Two-panel line chart: under concentrated touches the linear-scan RevVec never loses to the binary-search SortedVec; under uniform touches RevVec degrades to 519 ns at depth 2048 while SortedVec holds at 29 ns.

Under uniform touches the linear scan degrades ~18x by depth 2048; under concentrated touches it never loses. Source: service_sweep.csv.

The choice between linear scan and binary search depends more on access patterns than on theoretical complexity.

At this point the benchmark results made sense, but I still wanted evidence that the processor was actually spending time where I thought it was. That's where the hardware performance counters came in.

One of the reasons low-latency systems often favor contiguous data structures is cache locality—but this benchmark also shows the other half of that advice. Contiguous memory only helps when the working set still fits in cache. Once it doesn't, the advantage can disappear surprisingly quickly.

What the hardware counters showed

The benchmarks told me which implementation was faster, but they couldn't explain why. For that, I needed hardware performance counters (PMUs), which expose where the processor is actually spending its time.

Before I had access to server hardware, I tried to explain each benchmark result just from its behavior. Looking at how throughput changed under different workloads, I formed a hypothesis about what each implementation was bottlenecked on.

Later, I reran the benchmarks on a rented AMD EPYC system and collected the native Zen 4 PMU counters. They matched those hypotheses surprisingly well. Here's a summary of what the counters showed.

The table below isn't meant to compare performance again—it summarizes where each implementation spends its time.

impl IPC bad-spec backend-mem verdict
SortedVec 2.50 0.1% 50.5% memory-bound (branchless locate)
BTreeBook 1.33 28.3% 9.1% pointer chase (frontend + memory)
RevVec 6.10 1.3% 2.3% core-bound (scan length)
FlatBook 2.43 25.5% 13.4% mispredict-bound at wide depth

The interesting part isn't which implementation is "best." It's that each one spends its time differently, even though they're solving exactly the same problem.

  • SortedVec spends most of its time waiting on memory. Its binary search uses partition_point, which is already branchless, so branch prediction barely shows up.
  • BTreeBook pays the expected cost of pointer chasing through the tree.
  • RevVec is mostly limited by the work done in the scan itself.
  • FlatBook, surprisingly, spends much more time recovering from branch mispredictions than waiting on memory. This isn't something I'd be comfortable concluding from the benchmark alone. The PMU data is what made the explanation convincing.

A quick note: these measurements come from AMD Zen 4's native pipeline-utilization counters. They're conceptually similar to Intel's Top-Down methodology, but they're not the same thing, so I've intentionally kept the terminology specific to AMD.

Earlier I mentioned that low-latency engineers often emphasize cache locality and real measurements over theoretical complexity. This is exactly why. The benchmark tells you what happened. The PMU counters are what let you build a credible explanation for why it happened.

The two primitives, and the seqlock that never blocks its writer

The order book wasn't the only component I built. Rather than benchmark the order book in isolation, I embedded it in a small market-data pipeline. That's where the other two components in the architecture diagram come in: a seqlock for publishing the latest top-of-book snapshot and an SPMC ring buffer for broadcasting every update. Both implementations are entirely safe Rust (#![forbid(unsafe_code)]) and were verified with Loom before benchmarking.

A seqlock uses a simple version counter. Even values mean the data is stable, while odd values indicate that a write is in progress. Readers optimistically copy the snapshot and then check whether the version changed while they were reading. If it did, they simply retry. Because readers never acquire a lock, the writer is never blocked.

pub fn load_counted(&self) -> (TopOfBook, u32) {
    loop {
        let s1 = self.seq.load(Acquire);      // (R1) pairs with the writer's Release
        if s1 & 1 == 0 {                      // even => no write in progress; snapshot it
            let t = TopOfBook { /* payload: Relaxed loads */ };
            fence(Acquire);                   // (R2) order payload reads before the re-check
            let s2 = self.seq.load(Relaxed);
            if s1 == s2 { return (t, retries); } // unchanged => no write straddled the read
        }
        // else: odd, or straddled => discard and retry
    }
}
Enter fullscreen mode Exit fullscreen mode

Sequence diagram: the version counter goes even to odd (write in progress) to even; one reader whose snapshot straddles a write retries, another reader whose snapshot is clean accepts.

In practice, a load() stays around 10 ns (p50) and remains essentially flat even as the number of readers increases. Across six million timed reads, no torn snapshots were observed.

The ring buffer did expose one limitation. As more consumers are added, producer throughput drops from 12.17 to 8.46 Mev/s. My first suspicion was false sharing, but perf c2c showed something different. The payload slots remain isolated on separate cache lines; the contention comes from the write cursor itself, which every consumer must observe. In other words, this is true sharing, not false sharing. It's an inherent cost of broadcasting to multiple readers rather than a bug in the implementation.

I left that behavior as-is because eliminating it would require changing the design rather than fixing an implementation issue. The producer remains wait-free; only the throughput changes.

These primitives weren't the focus of the benchmark, but they complete the data path and are reusable outside the order book itself.

What I learned

I started this project wanting to compare different order book implementations. What I ended up with was a much better appreciation for how easily synthetic benchmarks can hide real bottlenecks.

The FlatBook wasn't "wrong"—it was simply optimized for a workload that didn't resemble the one I eventually tested. Once the working set grew beyond cache, a data structure with theoretically better complexity became the slowest implementation in the benchmark.

This project is also serving as a stepping stone toward a larger goal: building low-latency infrastructure for microVM-based systems. The same approach—benchmark on realistic workloads, measure with hardware counters, and avoid relying on intuition alone—is the one I'll carry into that work.

The repository contains all benchmark inputs, raw CSVs, plotting scripts, and source code, so every figure in this article can be reproduced.

Repository: https://github.com/umangPokhriyall/low-latency-lob

Top comments (0)