LOB / BUILD LOGSource >>>
Random feed / seed
C++20 limit order book / mean time per message2026-10-30 to 2026-11-14

73.410.9NS

Every optimisation, its commit and its benchmark row, including the ones that did nothing.

Step ledger

Replay of 1M messages / Apple M2 Pro
CommitChangeMean nsDeltaAllocs/msgDateMean, to scale
5f8e243Baseline: map, list, hash73.41.0510-30
af6e254Hot-path cleanup68.7−6%1.0510-31
ad0066cpmr experiment, not merged38.5−44%0feat/pmr
6629407Order pool, intrusive list51.6−25%0.4911-02
841cfd3Flat hash map27.3−47%0.01311-03
41dae74Level pool, 40-byte node27.30%0.01311-04
fed2dc0Sorted price ladder26.6−3%011-05
6dea803Banded price array22.4−16%011-08
0d97d55Book-assigned ids15.2−32%011-09
ba56d49Sides by index12.2−20%011-10
57a062aCrossing modify trades (fix)13.3+9%011-11
7c62d0dInlining restored12.4−7%011-12
3c869d4Thin LTO10.9−12%011-14
Red: slower than the row aboveDashed: measured on a branch, never merged

Tail latency / 1 box = 1 clock tick = 41.67 ns

Timed per message, so read the shape, not the mean
Changep99p99.9
Baseline: map, list, hash4 ticks6 ticks
Hot-path cleanup4 ticks6 ticks
pmr experiment, not merged2 ticks3 to 5 ticks
Order pool, intrusive list3 ticks5 ticks
Flat hash map2 ticks3 ticks
Level pool, 40-byte node2 ticks3 ticks
Sorted price ladder1 tick2 ticks
Banded price array1 tick2 ticks
Book-assigned ids1 tick2 ticks
Sides by index1 tick2 ticks
Crossing modify trades (fix)1 tick2 ticks
Inlining restored1 tick2 ticks
Thin LTO1 tick2 ticks

The log

Before > after, measured in alternating runs on the same day

Each entry gives the reasoning, the code and what the benchmark said. Code is quoted from the repository at the commit shown beside it. Some excerpts are trimmed, and a few comments were added for this page.

THE TEXTBOOK BOOK, AND A WAY TO MEASURE IT

A limit order book keeps every resting order, grouped by price, and matches incoming orders against the best price on the other side, oldest order first. Three questions decide its design. Where is the best price? Who is first in the queue at a price? And where is order 123, so it can be cancelled?

The first version answers each with the standard container that fits: a std::map from price to level for each side, a std::list of orders inside each level, and a std::unordered_map from order id to where the order lives.

include/lob/OrderBook.h, include/lob/CoreTypes.h (excerpt)5f8e243
class OrderBook {
    // ...
   private:
    std::unordered_map<OrderId, OrderLocation> order_lookup;
    std::map<Price, Level, std::greater<Price>> bid_map;   // best bid is begin()
    std::map<Price, Level, std::less<Price>> ask_map;      // best ask is begin()
};

struct Level {
    std::list<Order> orders;   // time priority: front() is the oldest
    Qty total = 0;             // running sum, so FOK and depth don't walk the list
};

struct OrderLocation {         // what the index stores, so cancel doesn't search
    Side side;
    Price price;
    Tif tif;
    Level* level;              // std::map nodes never move, so this stays valid
    std::list<Order>::iterator iter;
};

Two details were already in place before any measuring. Each level keeps a running total, so a fill-or-kill check can add up levels without walking every order. And the index stores a pointer to the level and an iterator into its list, so a cancel unlinks the order directly and only touches the tree when the level empties.

What one add costs

src/OrderBook.cpp5f8e243
template <typename Book>
OrderLocation add_to_level(Book& book, const Order& order) {
    Level& level = book[order.price];        // may allocate a 72-byte tree node
    level.orders.push_back(order);           // allocates a 64-byte list node
    level.total += order.remaining;
    return {.side = order.side,
            .price = order.price,
            .tif = order.tif,
            .level = &level,
            .iter = std::prev(level.orders.end())};
}

void OrderBook::add(const Order& order) {
    if (!checkOrder(order)) {                // one hash lookup, to reject duplicates
        return;
    }
    if (order.side == Side::Buy) {
        order_lookup[order.id] = add_to_level(bid_map, order);   // a second lookup,
    } else {                                                     // and a 64-byte hash node
        order_lookup[order.id] = add_to_level(ask_map, order);
    }
}

Every resting order lives in two separate heap blocks, and a new price level adds a third. The blocks come from malloc, so they land wherever the allocator has room, and each hop from one structure to the next is a pointer to a different cache line.

std::list node / 64 B

std::unordered_map node / 64 B

std::map node / 72 B

One add at a new price: three heap blocks. 1 column = 1 byte

Heap allocations per operation, counted by replacing operator new

Add to an existing level
2 allocs, 128 B
Add that creates a level
3 allocs, 200 B
Cancel of the last order at a price
0 allocs, 3 frees
Apple Clang, -O2. The last case is the common one at the touch, where levels appear and vanish constantly

What a cancel touches, in order. Each box is a separate heap block

bucket arrayhash(id)
hash nodeOrderLocation
list nodethe order
prev noderelink
next noderelink
tree nodeLevel.total
Six dependent loads, each a possible cache miss, then two calls to free

The matcher

Matching was written test first. An incoming order trades against the best level on the other side, from the front of its queue, until it is filled or the next price no longer crosses. Fills are reported through a callback, so the matcher itself never builds a container.

include/lob/OrderBook.h5f8e243
template <typename Book, typename OnFill>
void OrderBook::match_against(Book& book, Order& incoming, OnFill& on_fill) {
    while (incoming.remaining > 0 && !book.empty()) {
        const auto best = book.begin();
        const Price price = best->first;
        if (!crosses(incoming, price)) {
            return;
        }

        Level& level = best->second;
        while (incoming.remaining > 0 && !level.orders.empty()) {
            Order& maker = level.orders.front();
            const Qty qty = std::min(incoming.remaining, maker.remaining);
            maker.remaining -= qty;
            incoming.remaining -= qty;
            level.total -= qty;
            on_fill(Fill{.maker = maker.id, .taker = incoming.id, .price = price, .qty = qty});

            if (maker.remaining == 0) {
                order_lookup.erase(maker.id);   // frees a hash node
                level.orders.pop_front();       // frees a list node
            }
        }
        if (level.orders.empty()) {
            book.erase(best);                   // frees a tree node
        }
    }
}

submit wraps it with the order types. A GTC or Day order rests whatever is left. An IOC order drops it. A FOK order is checked against the levels' running totals first and is turned away whole if it cannot fill. A market order has no limit price and never rests.

The benchmark

The workload is a seeded stream of messages around a mid price of 10,000 ticks. Passive orders sit a geometrically distributed number of ticks from the mid, so most of the book is near the touch. Takers are IOC orders that cross by one to six ticks. Once the book holds 10,000 orders, adds turn into cancels, which holds it steady at about 9,400.

Message mix of the replay / share of 1,000,000 messages

Resting adds
48.0%
Cancels
37.0%
IOC takers
10.0%
Modifies
5.0%
Seed 42. 100,000 warm-up messages, then 1,000,000 timed

Two numbers come out of it. The mean is the time for the whole timed replay divided by a million, with nothing but the book in the loop. The percentiles come from a second run that reads the clock around every message. On this machine that clock ticks every 41.67 ns, so the percentiles move in whole ticks and the mean is the number to trust.

The allocation test

A separate test binary replaces the global operator new with one that counts, replays the same workload and asserts that the count does not move after warm-up. At this commit it fails by design, reporting 1.05 allocations per message. It is registered as an expected failure until the step that makes it pass.

tests/no_alloc_test.cpp5f8e243
namespace {
std::size_t g_allocs = 0;
}

// The default operator new[] and the nothrow forms call this one, so it counts them too
void* operator new(std::size_t n) {
    ++g_allocs;
    if (void* p = std::malloc(n == 0 ? 1 : n)) {
        return p;
    }
    throw std::bad_alloc{};
}

TEST(HotPath, NoAllocationAfterWarmUp) {
    const std::vector<bench::Message> messages = bench::make_workload(WARM_UP + MEASURED, 42);
    OrderBook book;
    std::size_t fills = 0;
    auto on_fill = [&fills](const Fill&) { ++fills; };   // only counts: storing would allocate

    for (std::size_t i = 0; i < WARM_UP; ++i) {
        bench::apply(book, messages[i], on_fill);
    }
    const std::size_t before = g_allocs;
    for (std::size_t i = WARM_UP; i < messages.size(); ++i) {
        bench::apply(book, messages[i], on_fill);
    }
    const std::size_t allocs = g_allocs - before;

    EXPECT_EQ(allocs, 0u) << static_cast<double>(allocs) / MEASURED << " allocations per message";
}

CLEAN THE HOT PATH BEFORE CHANGING ITS SHAPE

The hot path is the code that runs for every message: add, cancel, modify, match and the best-price reads. Before replacing any container, this step removed what plainly did not belong on it. No structure changed.

Return codes instead of printing

The first version reported problems by writing to std::cout. Formatting text is slow and can end in a system call, and one of those prints sat in best_bid(), which fired whenever the bid side was empty. That is a normal state, not an error. Every operation now returns a one-byte Result, and the best-price reads return std::optional.

src/OrderBook.cpp, before5f8e243
bool OrderBook::checkOrder(const Order& order) {
    if (order_lookup.find(order.id) != order_lookup.end()) {         // lookup 1
        std::cout << "Error: Order ID " << order.id << " already exists.\n";
        return false;
    }
    if (order.remaining <= 0) {
        std::cout << "Error: Order Id " << order.id
                  << " Can't create an order with quantity smaller or equal to zero.\n";
        return false;
    }
    // ...
    return true;
}

void OrderBook::add(const Order& order) {
    if (!checkOrder(order)) {
        return;
    }
    if (order.side == Side::Buy) {
        order_lookup[order.id] = add_to_level(bid_map, order);       // lookup 2
    } else {
        order_lookup[order.id] = add_to_level(ask_map, order);
    }
}
src/OrderBook.cpp, afteraf6e254
enum class Result : std::uint8_t { Ok, DuplicateId, UnknownId, BadQty, BookFull };

Result OrderBook::add(const Order& order) {
    if (const Result result = validate(order); result != Result::Ok) {
        return result;
    }

    const auto [iter, inserted] = order_lookup.try_emplace(order.id);   // the only lookup
    if (!inserted) {
        return Result::DuplicateId;
    }

    iter->second =
        order.side == Side::Buy ? add_to_level(bid_map, order) : add_to_level(ask_map, order);
    return Result::Ok;
}

One hash lookup per add

The old add hashed the id twice: once in checkOrder to reject a duplicate, and again in operator[] to insert. try_emplace does both in one probe. It inserts an empty entry if the id is new and says whether it did.

Smaller records

Side was a plain enum, which takes four bytes and drags four more of padding behind it in front of an eight-byte field. Giving it a one-byte underlying type and moving the small fields to the end took eight bytes off both the order and its location record.

include/lob/CoreTypes.haf6e254
enum class Side : std::uint8_t { Buy, Sell };   // was a plain enum: 4 bytes

struct Order {
    OrderId id;
    Price price;
    Qty original;
    Qty remaining = original;
    Side side;            // the three one-byte fields now sit together at the end
    Tif tif;
    OrderType type = OrderType::Limit;
};
static_assert(sizeof(Order) == 40);

Order, before / 48 B

Order, after / 40 B

s = side, t = tif, y = type. Hatched: padding the compiler inserts for alignmentstatic_assert(sizeof(Order) == 40)

What was measured

Mean per message, same day

Baseline, rerun
74.2 ns
After the cleanup
68.7 ns
Run-to-run variation 0.26%, so a 7.4% change is well clear of the noise

The workload never reaches an error path, so removing the printing is not what helped. The gain is the saved lookup, the smaller records, or both. They were not measured separately, and the log says so rather than guessing.

AN EXPERIMENT: SAME CONTAINERS, NO MALLOC

The plan was to replace all three containers. Before doing that, one branch kept them and changed only where their memory comes from. It is a small diff, and it separates two costs that the later steps remove together: what malloc costs, and what the layout costs.

Three resources, stacked

std::pmr containers take a memory resource at construction and ask it for every node instead of calling new. The book stacks three:

  • One std::vector of bytes, the arena, allocated once at start-up.
  • A monotonic_buffer_resource over it, which hands out memory by bumping a pointer and never takes any back.
  • An unsynchronized_pool_resource on top, which keeps freed blocks in per-size free lists and reuses them, with no locking.
include/lob/OrderBook.h, src/OrderBook.cppad0066c
class OrderBook {
   public:
    static constexpr std::size_t DEFAULT_ARENA_BYTES = std::size_t{16} << 20;
    static constexpr std::size_t EXPECTED_ORDERS = 16'384;
    explicit OrderBook(std::size_t arena_bytes = DEFAULT_ARENA_BYTES);
    // ...
   private:
    // Declared in this order, so the resources outlive the containers that use them
    std::vector<std::byte> arena_;
    std::pmr::monotonic_buffer_resource upstream_;
    std::pmr::unsynchronized_pool_resource pool_;
    std::pmr::unordered_map<OrderId, OrderLocation> order_lookup{&pool_};
    std::pmr::map<Price, Level, std::greater<Price>> bid_map{&pool_};
    std::pmr::map<Price, Level, std::less<Price>> ask_map{&pool_};
};

OrderBook::OrderBook(const std::size_t arena_bytes)
    : arena_(arena_bytes),
      upstream_(arena_.data(), arena_.size(), std::pmr::null_memory_resource()),
      pool_(&upstream_) {
    order_lookup.reserve(EXPECTED_ORDERS);
}

Who asks whom for memory

map, list, hash mapask for a node
pool resourcereuses freed blocks by size
monotonic resourcebumps a pointer
arena16 MB, allocated once
null resourcethrows if the arena runs out
After start-up nothing in this chain calls malloc

Three things that are easy to get wrong

A level's list has to be told about the resource, or it quietly goes back to calling new. Making Level allocator-aware does that: the map passes its allocator to each level it constructs, and the level passes it to its list.

include/lob/CoreTypes.had0066c
struct Level {
    using allocator_type = std::pmr::polymorphic_allocator<>;

    explicit Level(const allocator_type& alloc) : orders(alloc) {}

    std::pmr::list<Order> orders;
    Qty total = 0;
};

The hash map is reserved up front, because a rehash allocates a new bucket array and the monotonic resource never gets the old one back. And the upstream of the arena is null_memory_resource(), so running out throws std::bad_alloc instead of silently falling back to malloc and spoiling the measurement.

What it showed

Mean per message, same day, same containers

main (af6e254)
73.5 ns
feat/pmr
38.5 ns
A second run gave 38.8 ns. p99.9 fell from 250 ns to between 125 and 208 ns

The mean nearly halved. That figure combines four things that were not measured apart: no malloc and free, nodes packed next to each other in the arena, the arena's pages touched up front, and no rehash.

The branch was never merged, on purpose. pmr removes the allocator from the hot path but keeps the layout: every order is still two separate nodes joined by pointers. Its row stays in the table as a yardstick. If the hand-built structures only matched it, the extra code would not be worth it. Whatever they gain beyond it is what the layout is worth.

ONE ARRAY OF ORDERS, LINKED BY INDEX

std::list went first. It allocates on every add and frees on every cancel, and those two are 85% of the traffic. The tree, by comparison, was already cheap where it mattered: the best price is begin(), and matching only ever touches that.

A pool with the free list inside it

All orders now live in one std::vector sized at construction. A slot is an index into it. The slots that are not in use form a singly linked list threaded through the nodes' own next field, so the free list costs no memory at all.

include/lob/OrderPool.h (excerpt)6629407
class OrderPool {
   public:
    explicit OrderPool(std::uint32_t capacity) : nodes_(capacity) {
        for (Slot s = 0; s < capacity; ++s) {            // thread every node onto the free list
            nodes_[s].next = (s + 1 == capacity) ? NIL : s + 1;
        }
        free_head_ = capacity == 0 ? NIL : 0;
    }

    bool full() const { return free_head_ == NIL; }

    Slot acquire() {
        const Slot s = free_head_;
        if (s != NIL) {
            free_head_ = nodes_[s].next;                 // pop
        }
        return s;
    }

    void release(Slot s) {
        nodes_[s].next = free_head_;                     // push
        free_head_ = s;
    }

    OrderNode& operator[](Slot s) { return nodes_[s]; }

   private:
    std::vector<OrderNode> nodes_;
    Slot free_head_ = NIL;
};

free_head_ = 1. Free slots chain through next

0orderlive
1freenext 4
2orderlive
3orderlive
4freenext 6
5orderlive
6freenext NIL
7orderlive

After release(3): slot 3 is pushed on the front, so it is the next one handed out

0orderlive
1freenext 4
2orderlive
3freenext 1
4freenext 6
5orderlive
6freenext NIL
7orderlive
Inverted: the head of the free list

Taking a slot is one load and one store. Returning one is two stores. The list is last-in, first-out, so the slot a cancel just freed is the slot the next add gets, while it is still in the cache.

The queue lives inside the nodes

A level no longer owns a container. It holds the slot of its first and last order, and each order holds the slots of its neighbours. The links are 32-bit indices rather than 64-bit pointers, which halves their size, and they stay valid as plain integers whatever happens to the vector.

include/lob/CoreTypes.h6629407
using Slot = std::uint32_t;
constexpr Slot NIL = UINT32_MAX;

struct Level {
    Price price;
    Qty total = 0;
    Slot head = NIL;
    Slot tail = NIL;
    std::uint32_t count = 0;
};

// Step 7 swaps Level* for a 4-byte level slot, which brings this to 40 bytes
struct OrderNode {
    OrderId id;
    Qty remaining;
    Qty original;
    Level* level = nullptr;
    Slot prev = NIL;
    Slot next = NIL;  // also links the free list while the slot is free
    Side side;
    Tif tif;
};
static_assert(sizeof(OrderNode) == 48);

OrderNode at this step / 48 B

One block per order, where there were two of 64 Bstatic_assert(sizeof(OrderNode) == 48)

A level at 99.98 with three orders. Level: head 5, tail 2, count 3, total 210

slot 5qty 90prev NIL next 0
slot 0qty 70prev 5 next 2
slot 2qty 50prev 0 next NIL
Inverted: the head, which trades first. A new order links in after the tail
src/OrderBook.cpp6629407
void OrderBook::link_back(Level& level, const Slot slot) {
    OrderNode& node = order_pool[slot];
    node.level = &level;
    node.prev = level.tail;
    node.next = NIL;
    if (level.tail == NIL) {
        level.head = slot;
    } else {
        order_pool[level.tail].next = slot;
    }
    level.tail = slot;
    ++level.count;
    level.total += node.remaining;
}

void OrderBook::unlink(const Slot slot) {
    const OrderNode& node = order_pool[slot];
    Level& level = *node.level;
    if (node.prev == NIL) {
        level.head = node.next;
    } else {
        order_pool[node.prev].next = node.next;
    }
    if (node.next == NIL) {
        level.tail = node.prev;
    } else {
        order_pool[node.next].prev = node.prev;
    }
    --level.count;
    level.total -= node.remaining;
}

Cancelling from the middle of a queue is still constant time, as it was with the list iterator. What changed is that it frees nothing. A modify that moves an order to another price relinks the same slot into the new level, with no allocation and no change to the index.

What it costs

Capacity is fixed. The default pool holds 65,536 orders, and when it is full add returns BookFull. A real deployment would size it for the symbol.

What was measured

Mean per message, alternating runs on the same day

main (af6e254)
68.8 ns
Order pool
51.6 ns

Heap allocations per message

Before
1.05
Order pool
0.49
What is left is the hash index: one node per resting add

One caution about reading the table. The pmr branch ran at 38.5 ns and this step at 51.6 ns, but main itself read 73.5 ns on the day pmr was measured and 68.8 ns on this day. The honest comparison is each one's change against main: 48% for pmr, 25% for the pool, with the index and the tree still allocating.

A FLAT HASH MAP FOR THE ID INDEX

With the orders pooled, the index from order id to slot was the last structure allocating on every add. It was also the smallest change in the whole log: one type alias and one call to reserve.

include/lob/OrderBook.h, src/OrderBook.cpp841cfd3
#include <ankerl/unordered_dense.h>

class OrderBook {
    // ...
    using OrderIndex = ankerl::unordered_dense::map<OrderId, Slot>;   // was std::unordered_map
    OrderIndex order_lookup;
};

// add() stops at a full pool, so the index never grows past this
OrderBook::OrderBook(const std::uint32_t max_orders) : order_pool(max_orders) {
    order_lookup.reserve(max_orders);
}

Why the standard map is slow here

std::unordered_map is required to keep references to its elements valid across inserts, which in practice forces every entry into its own heap node. A lookup hashes the key, loads a bucket pointer, then follows it to a node somewhere else on the heap, and possibly on to more nodes. An insert allocates and an erase frees.

std::unordered_map: a bucket array of pointers, one heap node per entry

bucket[h]pointer
nodenext*, hash, id, slot
nodeon a collision

ankerl::unordered_dense: a bucket array of small records, and all entries in one vector

bucket[h]fingerprint + index
values[index]id, slot
values[index + 1]id, slot
Inverted: where the answer is. Both arrays on the second row are reserved once, at start-up

The flat map keeps its entries packed in one vector and its buckets in another. Nothing is allocated per entry, and because the book can never hold more than the pool's capacity, reserving that many up front means it never rehashes either.

The experiment pays off

Mean per message, all three built and run on the same day

Order pool + std::unordered_map
51.7 ns
feat/pmr (std containers, no malloc)
38.0 ns
Order pool + flat index
27.3 ns
pmr rerun gave 38.0 and 37.9 ns. Flat index gave 27.3 and 27.5 ns

This is the comparison the pmr branch was kept for. The pooled book with the flat index beats it by 27%, and it does so while still allocating a tree node whenever a price level is created. pmr removed malloc entirely but kept the layout. So getting rid of scattered nodes and pointer chasing is worth at least that 27% on top of removing the allocator.

The 0.013 allocations per message that remain are those std::map nodes: levels are created far less often than orders.

A cost that shows up later

Erasing from a dense map is its dearest operation. To keep the entries packed, it moves the last entry into the hole and then probes again to repoint that entry's bucket. A later profile put the index at 31% of the whole loop, with erases the largest part, and the answer then was not a faster map but no map.

POOL THE LEVELS, THEN DROP THE TREE

The tree was the last thing allocating. Replacing it took two commits, and they were measured separately, which turned out to matter.

First: levels in a pool of their own

Levels moved out of the tree's nodes into a second pool. The order pool was turned into a template for this, so both pools are the same code, and the level pool threads its free list through head. An order now names its level by a four-byte slot, not an eight-byte pointer, and with the padding that takes the node from 48 bytes to 40. Eight 40-byte nodes fit in five cache lines where eight 48-byte nodes need six.

include/lob/CoreTypes.h, include/lob/Pool.h41dae74
using Slot = std::uint32_t;
using LevelSlot = std::uint32_t;

struct Level {
    Price price;
    Qty total = 0;
    Slot head = NIL;  // also links the free list while the level is free
    Slot tail = NIL;
    std::uint32_t count = 0;
};
static_assert(sizeof(Level) == 32);

// Matching reads the first two fields on every fill
struct OrderNode {
    OrderId id;
    Qty remaining;
    Qty original;
    Slot prev = NIL;
    Slot next = NIL;  // also links the free list while the slot is free
    LevelSlot level = NIL;     // was Level*: 8 bytes, now 4
    Side side;
    Tif tif;
};
static_assert(sizeof(OrderNode) == 40);

// The order pool became a template over the node type and the field its free list uses
template <typename Node, Slot Node::* Link>
class Pool { /* acquire() and release() as before, through nodes_[s].*Link */ };

using OrderPool = Pool<OrderNode, &OrderNode::next>;
using LevelPool = Pool<Level, &Level::head>;

OrderNode / 40 B

Level / 32 B

Inverted: read or written on every fillsizeof(OrderNode) == 40, sizeof(Level) == 32

Mean per message, alternating runs

Flat index (841cfd3)
27.6 ns
Level pool, 40-byte node
27.7 to 27.9 ns
No measurable difference

That change did nothing. A smaller node ought to help, and on this workload it did not. Why was not investigated. It stays in the log as a reminder that a change which ought to help still has to be measured.

Then: a sorted vector instead of a tree

The price structure now only has to map a price to a level slot. The ladder is two parallel vectors, sorted so that the best price is at the back: bids ascending, asks descending. The best level is back(), removing it is pop_back(), and finding any other price is a linear scan from the best end.

include/lob/PriceLadder.h (excerpt)fed2dc0
template <typename Better>
class PriceLadder {
   public:
    // Index size() - 1 is the best price, and index 0 the worst
    Price best_price() const { return prices_.back(); }
    LevelSlot best_slot() const { return slots_.back(); }

    void pop_best() {
        prices_.pop_back();
        slots_.pop_back();
    }

    std::pair<LevelSlot&, bool> try_emplace(const Price price) {
        const std::size_t i = past_better(price);
        if (holds(i, price)) {
            return {slots_[i - 1], false};
        }
        prices_.insert(prices_.begin() + static_cast<std::ptrdiff_t>(i), price);
        slots_.insert(slots_.begin() + static_cast<std::ptrdiff_t>(i), NIL);
        return {slots_[i], true};
    }

   private:
    // Scans from the best end: most activity is within a few levels of the touch
    std::size_t past_better(const Price price) const {
        std::size_t i = prices_.size();
        while (i > 0 && Better{}(prices_[i - 1], price)) {
            --i;
        }
        return i;
    }

    bool holds(const std::size_t i, const Price price) const {
        return i > 0 && prices_[i - 1] == price;
    }

    std::vector<Price> prices_;     // two parallel arrays: the scan reads only prices
    std::vector<LevelSlot> slots_;
};

Bid ladder. prices_ ascending, so the best bid is at the back

[0]99.93slot 12
[1]99.95slot 4
[2]99.96slot 9
[3]99.98slot 1
[4]99.99slot 7

A new bid at 99.97 scans two entries from the back, then inserts

[0]99.93slot 12
[1]99.95slot 4
[2]99.96slot 9
[3]99.97new
[4]99.98slot 1
[5]99.99slot 7
A linear scan beats a tree here because orders cluster near the best price and the prices are contiguous in memory

Inserting into the middle of a vector moves the entries after it. Near the back that is a handful of eight-byte moves, which is cheaper than allocating and rebalancing a tree node. Both vectors are reserved at construction, so they never reallocate.

What was measured

Mean per message, alternating runs on the same day

Flat index (841cfd3)
27.6 ns
Level pool only
27.8 ns
Level pool + ladder
26.6 ns

The mean moved 4%. Only 0.013 allocations per message were left to remove, which may be why. The tail moved more: p99 fell from 83 to 42 ns and p99.9 from 125 to 84 ns, one clock tick each, in both rounds. Occasional slow allocations are exactly what a tail percentile sees and a mean hides.

From this commit the book makes no allocation after construction. The allocation test passes, and its expected-failure marker came out of the build.

INDEX BY PRICE INSTEAD OF SEARCHING

A profile of the ladder build split the replay loop like this: the ladder 26%, the hash index 27%, matching 19%, reading messages 18%. The ladder's share was about 7 ns per message, most of it add looking for its level. A sorted vector still has to search. An array indexed by price does not.

One slot per tick

Prices are integers, counted in ticks. So within a fixed band of prices, the price itself can be the index. The array holds one four-byte level slot for every tick in the band, NIL where there is no level. Find, insert and erase are each a subtraction and one array access.

include/lob/PriceArray.h (excerpt)6dea803
template <typename Better>
class PriceArray {
   public:
    PriceArray(const PriceBand band, std::size_t)
        : base_(band.lo), slots_(static_cast<std::size_t>(band.hi - band.lo), NIL) {}

    Price best_price() const { return base_ + best_; }
    LevelSlot best_slot() const { return at(best_); }
    void pop_best() { remove(best_); }

    LevelSlot find(const Price price) const { return in_band(price) ? at(price - base_) : NIL; }

    std::pair<LevelSlot&, bool> try_emplace(const Price price) {
        assert(in_band(price) && "the book checks the band first");
        const Price i = price - base_;
        LevelSlot& slot = slots_[static_cast<std::size_t>(i)];    // one index, no search
        if (slot != NIL) {
            return {slot, false};
        }
        if (count_ == 0 || Better{}(price, best_price())) {
            best_ = i;                                            // a new best price
        }
        ++count_;
        return {slot, true};
    }

   private:
    static constexpr Price WORSE = Better{}(Price{1}, Price{0}) ? -1 : 1;

    void remove(const Price i) {
        slots_[static_cast<std::size_t>(i)] = NIL;
        --count_;
        if (i != best_ || count_ == 0) {
            return;
        }
        do {                                  // the best level emptied:
            best_ += WORSE;                   // walk towards worse prices to the next one
        } while (at(best_) == NIL);
    }

    Price base_;
    Price best_ = 0;  // offset from base_; meaningless while empty
    std::size_t count_ = 0;
    std::vector<LevelSlot> slots_;
};

Ask side. base_ = 0, so the index is the price in ticks. best_ = 10001

10000NIL
10001L3best
10002L8
10003NIL
10004NIL
10005L1
10006L5
10007NIL

The level at 10001 empties, then the one at 10002: the cursor walks past two empty ticks

10000NIL
10001NIL
10002NIL
10003NILscan
10004NILscan
10005L1best
10006L5
10007NIL
L3 means level slot 3 in the level pool. Inverted: the cached best price

The one operation that is not constant time is finding the next best price when the best level empties. It costs the gap, in ticks, to the next level. In a liquid book that gap is one or two ticks. A textbook fix is a bitmap over the array, and it was on the plan as the next step.

The price of an array

The array needs to know its range in advance. The book takes a PriceBand at construction, and a limit order priced outside it is turned away with a new result code. The default band is 65,536 ticks, which at a tick of 0.01 covers prices from 0 to 655.35 and costs 256 KiB per side. Nothing re-centres the band if the market leaves it.

include/lob/CoreTypes.h, include/lob/OrderBook.h6dea803
struct PriceBand {
    Price lo;
    Price hi;
    constexpr bool contains(const Price price) const { return lo <= price && price < hi; }
};

class OrderBook {
   public:
    static constexpr std::uint32_t DEFAULT_MAX_ORDERS = 1 << 16;
    static constexpr PriceBand DEFAULT_BAND{.lo = 0, .hi = 1 << 16};
    // ...
};

// in submit():
if (order.type == OrderType::Limit && !band.contains(order.price)) {
    return {.result = Result::OutOfBand};
}

A three-way race

The ladder was not deleted. Both structures have the same interface, a CMake option picks one, and the same 18 tests run against both. That made it possible to race three builds on one day in alternating runs: the previous commit, this tree with the ladder, and this tree with the array.

Mean per message, three rounds each

Previous commit (ladder)
26.5 to 27.8 ns
This tree, ladder
27.2 to 27.7 ns
This tree, array
22.4 to 22.5 ns
The ladder builds showed p99 at 83 ns that day; the array showed 42 ns in all three rounds

The array is 16% faster than the previous commit and 18% faster than the ladder built from the same tree. It saved about 4.3 ns of the ladder's 7 ns; the rest is the array's own work, the index and the occasional scan. The array became the default and the ladder stayed as a build option for another race.

PROFILE FIRST. THEN DELETE THE HASH MAP

Up to here the order of work came from reasoning about the design. From this step on it came from a profiler. The first profile changed the plan twice: it cancelled the step that was next, and it pointed at a structure that had already been optimised.

What the profile showed

The replay was sampled every millisecond on a build with debug information and frame pointers, which ran within 2% of the release build. Each of 6,406 samples was assigned to the innermost function on its inlined stack.

Share of the replay loop, by component

Hash index
31.0%
Reading messages, dispatch
21.7%
Matching
17.3%
Add, cancel, modify, submit
16.8%
Price array
8.1%
Link and unlink
4.6%
Pool acquire, release
0.4%
Next-best-price scan
1 sample
6,406 samples at 1 ms. The profile build ran 21.9 ns per message against 22.4 ns for release

The scan for the next best price, the thing a bitmap would speed up, showed up in one sample. The bitmap was dropped. The flat hash index, already the fast one, was the largest thing the book owned: 31% of the loop, about 6.8 ns per message.

The hash index by call site / share of the whole loop

Erase on cancel
9.5%
Insert on add
8.9%
Erase filled makers
4.0%
contains() in submit
3.9%
Find on cancel
3.5%
Other
1.3%
Erases together are 13.5%: the map moves its last entry into the hole, then probes again to repoint it

An id that is its own address

A hash map exists to turn an arbitrary key into a location. But the book could choose the key. If the book hands out the order id, the id can simply be the order's slot in the pool, and finding the order is an array index.

A bare slot number is not enough, because slots are reused: cancel order A, and the next order B gets A's slot, so A's old id would now cancel B. Each slot therefore carries a generation counter, and the id is the slot plus the generation at the time the order was placed.

An order id, 64 bits

bits 63 to 320x0000000Fgeneration 15: odd, so live
bits 31 to 00x0000005Fslot 95 in the order pool
Inverted: the part that is the address
include/lob/OrderBook.h, src/OrderBook.cpp0d97d55
// An id is the slot in the low 32 bits and the slot's generation in the high 32
static OrderId make_id(const Slot slot, const std::uint32_t generation) {
    return (static_cast<OrderId>(generation) << 32) | slot;
}

Slot OrderBook::find(const OrderId id) const {
    const auto slot = static_cast<Slot>(id);
    const auto generation = static_cast<std::uint32_t>(id >> 32);
    // An even generation is never handed out, and a free slot's generation is always even
    if (generation % 2 == 0 || slot >= order_pool.capacity() ||
        order_pool[slot].generation != generation) {
        return NIL;
    }
    return slot;
}

// The generation turns odd, which brings the slot's next id to life
Slot OrderBook::open_slot() {
    const Slot slot = order_pool.acquire();
    assert(slot != NIL && "the order pool keeps a slot spare for submit");
    ++order_pool[slot].generation;
    return slot;
}

// The generation turns even, so the id stops matching before the slot can be reused
void OrderBook::close_slot(const Slot slot) {
    ++order_pool[slot].generation;
    order_pool.release(slot);
}

The generation goes up by one when the slot is taken and by one again when it is freed. So it is odd exactly while the slot holds a live order, and one compare answers two questions at once: is this slot live, and is it still the same order?

The life of slot 95

generation 14freeon the free list
15order Aid 0x0000000F0000005F
16freeA cancelled: its id is dead
17order Bid 0x000000110000005F
18freeB filled
A's id carries generation 15. Once the slot reads 16 or more, find() returns NIL for it, for good

What changed around it

include/lob/CoreTypes.h, src/OrderBook.cpp0d97d55
// What add() and submit() return. id is NO_ORDER only if the order was turned away before it
// got one. submit() gives the id before matching, so fills can name it, and returns it even
// when the order then doesn't rest
struct Placed {
    Result result;
    OrderId id = NO_ORDER;
};

// What a client sends. It has no id: the book assigns one
struct Order {
    Price price;
    Qty original;
    Qty remaining = original;
    Side side;
    Tif tif;
    OrderType type = OrderType::Limit;
};
static_assert(sizeof(Order) == 32);

Result OrderBook::cancel(const OrderId id) {
    const Slot slot = find(id);          // was: order_lookup.find(id), a hash probe
    if (slot == NIL) {
        return Result::UnknownId;
    }
    erase_order(slot);                   // was: also order_lookup.erase(iter)
    return Result::Ok;
}
  • add and submit return the id. The client no longer supplies one, so DuplicateId cannot happen and that result code is gone.
  • Fills name the taker, so submit takes a slot before it matches. The pool holds one node more than the book's capacity, so a full book still has a slot for an incoming order and still trades.
  • An order that never rests, such as an IOC, gets its slot back and still returns its id, because its fills carried it.
  • The generation moved into the node where the id used to be, so the node stays at 40 bytes, and Order lost eight.
  • The hash library is no longer fetched or linked.

OrderNode, as it still is / 40 B

Inverted: the two fields matching reads on every fill, now firststatic_assert(sizeof(OrderNode) == 40)

What was measured, and a control

Mean per message, alternating runs

Price array, hash index
21.7 ns
Book-assigned ids, padded message
15.4 to 15.6 ns
Book-assigned ids
15.2 ns
The saving is 6.5 ns. The profile had put the index at 6.8 ns

Dropping the id shrank Order from 40 to 32 bytes, which made the benchmark's own message struct exactly one cache line. That alone could have explained a speed-up. So a copy of the benchmark padded the message back to 72 bytes and was run in the same rounds. It was 0.15 ns slower. The other 6.3 ns is the book.

More than half the messages now start and finish inside one tick of the clock, so the p50 reads zero. The percentiles cannot resolve anything smaller than a tick.

What it costs

The ids belong to the book. Replaying an exchange feed, where the exchange has already numbered the orders, would need the hash index back behind find(). And the generation is 32 bits and rises by two per order, so after 231 orders through a single slot it wraps and an id that old would match again.

THE BOTTLENECK MOVES FROM MEMORY TO BRANCHES

After the index went, a second profile was taken with no code change at all, only to see where the time now was.

Samples pile up after branches

Share of the replay loop after book-assigned ids

Matching
29.2%
Reading messages, dispatch
26.9%
Add, cancel, modify, submit
23.1%
Price array
8.1%
Id helpers
5.9%
Link and unlink
5.6%
Pool acquire, release
1.1%
7,551 samples at 1 ms. The id helpers cost about 0.9 ns where the hash index cost 6.8 ns

The shares are flatter, and no structure stands out. What stood out was where inside the functions the samples sat. They piled up on the first instruction after a branch, on whichever side the branch went. That is the signature of a mispredicted branch: the processor guessed, ran ahead, found out it was wrong, and the wait for the right instructions is charged to the first of them.

In the matcher, 7.0% and 6.8% of all samples sat on the first instruction on either side of the test of the incoming order's side. Resting the order tested the side again, for another 5.9%.

Asking the processor directly

Sampling shows where time lands, not why. The CPU's own counters split every slot in which the core could have done work into four kinds. The profiler's export does not name its columns, so three small test programs pinned them down first: a pointer chase over 64 MB read 99.8% processing, a loop of random branches read 70% delivery, and a megabyte of straight-line code read 48% delivery.

Before / ns per message by where the core spent it

Useful
6.3 ns
Discarded (mispredicted)
5.8 ns
Delivery (refetching)
2.7 ns
Processing (incl. memory)
0.7 ns
As shares: useful 40.5%, discarded 37.4%, delivery 17.7%, processing 4.4%

Processing, the bucket that includes waiting on memory, was 4%. Six steps of work on layout had done their job: the book was no longer waiting on memory. More than half its time went on work done down a wrong path and thrown away, and on refetching afterwards.

Why the side is unpredictable, and how to stop asking

A branch predictor learns patterns. Whether the next order is a buy or a sell has none: in the workload, as in a market, it is close to a coin flip, so about half of all submits mispredicted that branch. The branch existed only because bids and asks were two different types, one sorted downwards and one upwards, so every function that touched a side had to choose which.

include/lob/OrderBook.h, src/OrderBook.cpp0d97d55
PriceLevels<std::greater<Price>> bid_levels;    // two different types,
PriceLevels<std::less<Price>> ask_levels;       // so every use has to pick one

template <typename OnFill>
void OrderBook::match(Order& incoming, const OrderId taker, OnFill& on_fill) {
    if (incoming.side == Side::Buy) {                         // a coin flip
        match_against(ask_levels, incoming, taker, on_fill);
    } else {
        match_against(bid_levels, incoming, taker, on_fill);
    }
}

bool OrderBook::crosses(const Order& incoming, const Price price) {
    if (incoming.type == OrderType::Market) {
        return true;
    }
    return incoming.side == Side::Buy ? incoming.price >= price : incoming.price <= price;
}

LevelSlot OrderBook::level_for(const Side side, const Price price) {
    return side == Side::Buy ? find_or_add_level(bid_levels, level_pool, price)   // and again,
                             : find_or_add_level(ask_levels, level_pool, price);  // to rest it
}

The fix makes the two sides the same type, so choosing one is arithmetic. Bids are stored under the key -price and asks under price. On both sides a lower key is then a better price, both can use one ascending structure, and the two live in an array indexed by side.

Asks: key = price. The best ask has the lowest key

price 100.01key 10001best
100.02key 10002
100.03key 10003
100.05key 10005

Bids: key = -price. The best bid, the highest price, also has the lowest key

price 99.99key -9999best
99.98key -9998
99.97key -9997
99.95key -9995
A buy limited at 100.02 trades with asks whose key is at most 10002. A sell limited at 99.98 trades with bids whose key is at most -9998. Same compare
include/lob/OrderBook.hba56d49
// levels is indexed by side
static_assert(static_cast<int>(Side::Buy) == 0 && static_cast<int>(Side::Sell) == 1);

using PriceLevels = PriceArray<std::less<Price>>;   // one type for both sides
std::array<PriceLevels, 2> levels;                  // bids, then asks

// A level's place in its side. Bids are keyed by -price and asks by price, so on both
// sides a lower key is better. key() is its own inverse: key(side, key(side, p)) == p
static constexpr Price key(const Side side, const Price price) {
    return side == Side::Buy ? -price : price;
}
PriceLevels& levels_of(const Side side) { return levels[static_cast<std::size_t>(side)]; }

// The highest maker key the order trades with
static Price limit_key(const Order& incoming) {
    return incoming.type == OrderType::Market ? std::numeric_limits<Price>::max()
                                              : key(opposite(incoming.side), incoming.price);
}

template <typename OnFill>
void OrderBook::match(Order& incoming, const OrderId taker, OnFill& on_fill) {
    PriceLevels& book = levels_of(opposite(incoming.side));   // an index, not a branch
    const Price limit = limit_key(incoming);                  // computed once
    while (incoming.remaining > 0 && !book.empty()) {
        if (book.best_price() > limit) {                      // one compare for both sides
            return;
        }
        // ... walk the level's queue exactly as before
    }
}

In the release build, choosing the side compiles to a conditional set and a multiply-add for the address, and the limit key to a conditional negate. None of them is a jump, so there is nothing to mispredict. The level keeps the real price, so fills report it without converting back.

Negating prices invites off-by-one errors at the edges of the band, so a new test rests and sweeps orders at both edges on both sides. A bid band that is off by one fails it at once.

What was measured

After / ns per message by where the core spent it

Useful
5.8 ns
Discarded (mispredicted)
4.3 ns
Delivery (refetching)
1.4 ns
Processing (incl. memory)
0.6 ns
CPU counters on the profile build, same replay

Mean per message, alternating runs

Book-assigned ids
15.5 ns
Sides by index
12.2 ns

The saving was 3.2 ns, more than the two to three the samples had pointed to. About 2.8 ns of it is in the two misprediction buckets and the other half a nanosecond is fewer instructions. Sampling undercounts a misprediction, because the stall is spread over the instructions that follow it rather than charged to the branch.

Discarded work is still about a third of the total. Part of it belongs to the benchmark: its switch on message type is as unpredictable as the side was, and a real feed handler would pay it too.

A FIX OFF THE HOT PATH MADE EVERY MESSAGE SLOWER

This entry is a correctness fix, the slowdown it caused, and the search for where the time went. It is the step where the benchmark earned its keep, because nothing else would have noticed.

The bug

A modify that changed an order's price unlinked it and relinked it at the new price, without matching. If the new price crossed the spread, the book was left crossed: a bid resting above an ask, which a matching engine must never allow. The fix makes any modify that is not a simple reduction a cancel and replace under the same id. The order leaves its level but keeps its slot, trades as the taker if its new price crosses, and rests whatever is left at the back of its new queue.

include/lob/OrderBook.h57a062a
// In the header, because it now takes a fill callback like submit()
template <typename OnFill>
Result OrderBook::modify(const OrderId id, const Price newPrice, const Qty newQty,
                         OnFill&& on_fill) {
    const Slot slot = find(id);
    if (slot == NIL) {
        return Result::UnknownId;
    }
    if (newQty <= 0) {
        erase_order(slot);
        return Result::Ok;
    }
    if (!band.contains(newPrice)) {
        return Result::OutOfBand;
    }
    if (shrink_in_place(slot, newPrice, newQty)) {   // less at the same price: keep the queue place
        return Result::Ok;
    }

    // Like a new submit, but the order keeps its slot and id, and what it filled before
    const OrderNode& node = order_pool[slot];
    Order order{.price = newPrice,
                .original = node.original - node.remaining + newQty,
                .remaining = newQty,
                .side = node.side,
                .tif = node.tif};
    take_off_book(slot);
    match(order, id, on_fill);                       // the second caller of match()
    if (order.remaining == 0) {
        close_slot(slot);
        return Result::Ok;
    }
    rest(slot, order);
    return Result::Ok;
}

Four new tests cover it: a crossing modify trades, one that fills completely removes the order, the order keeps its time in force, and it can trade even when the book is full. All four fail if the matching call is taken out again.

The bill

Mean per message, alternating runs, two rounds

Sides by index
12.0 to 12.4 ns
With the modify fix
13.1 to 13.3 ns
+0.95 to +1.29 ns in every pair of runs. p99 and p99.9 unchanged

Modifies are 5% of the workload and none of them cross, so a few tenths of a nanosecond would have been a fair guess. The cost was 1.1 ns on every message. It cannot be the modifies themselves: 1.1 ns spread over 5% of messages would be 22 ns per modify, twice what an entire modify cost before.

Where it went

The symbol table of the benchmark binary gave the first half. Before the fix, the matcher did not exist as a function: it was inlined into submit. After it, the matcher was a separate function. It now had two callers, and the compiler stopped inlining it into either. Submits are 58% of messages, and each one now paid a call into its own inner loop.

Forcing the matcher inline in a scratch copy won back about half a nanosecond. Half was still missing.

The disassembly gave the rest. The book is built without link-time optimisation, so the boundary between the header and the .cpp file decides what can be inlined. modify used to live in the .cpp, next to the helpers it calls, and the compiler had folded them all in. As a header template it could see only their declarations.

Function calls made by a modify that does not cross

Before the fix: modify in the .cpp
1 call
After the fix: modify in the header
5 calls
After the repair: amend() in the .cpp
1 call
The five: find, shrink_in_place, take_off_book, match, rest

Cancel suffered too. Its body had been split into a helper that the compiler then kept out of line, so it saved three pairs of registers around a call where it used to save one.

The repair

Two changes, and each needed the other. The part of a modify that cannot trade went back into the .cpp as one function, amend(), which either finishes the job or reports that the new price crosses. Only then does the template go on to the trading path, which is marked never-inline so that its copy of the matcher stays out of the hot loop. And the matcher and the unlink helper are forced inline.

include/lob/OrderBook.h7c62d0d
// OrderBook.h: modify() is a thin template again
template <typename OnFill>
Result OrderBook::modify(const OrderId id, const Price newPrice, const Qty newQty,
                         OnFill&& on_fill) {
    const Amended amended = amend(id, newPrice, newQty);     // one call into the .cpp
    if (amended.crossing != NIL) {                           // rare
        trade_amended(amended.crossing, newPrice, newQty, on_fill);
    }
    return amended.result;
}

// Forced inline, since it's submit()'s inner loop. Once modify() called it too, the
// compiler stopped inlining it, and every submit paid a call
template <typename OnFill>
[[gnu::always_inline]] inline void match(Order& incoming, OrderId taker, OnFill& on_fill);

// The cancel and replace of a modify that crosses. Not inlined, since it's rare and holds
// a copy of match()
template <typename OnFill>
[[gnu::noinline]] void trade_amended(Slot slot, Price newPrice, Qty newQty, OnFill& on_fill);

// Forced inline: left to itself, clang kept it out of line, and cancel paid for extra
// register saves around the call
[[gnu::always_inline]] void take_off_book(const Slot slot) {
    unlink(slot);
    erase_if_empty(order_pool[slot].level, order_pool[slot].side);
    --resting_orders;
}
src/OrderBook.cpp7c62d0d
// OrderBook.cpp: everything a modify does that can't trade, with its helpers inlined
OrderBook::Amended OrderBook::amend(const OrderId id, const Price newPrice, const Qty newQty) {
    const Slot slot = find(id);
    if (slot == NIL) {
        return {.result = Result::UnknownId};
    }
    if (newQty <= 0) {
        erase_order(slot);
        return {.result = Result::Ok};
    }
    if (!band.contains(newPrice)) {
        return {.result = Result::OutOfBand};
    }
    if (shrink_in_place(slot, newPrice, newQty)) {
        return {.result = Result::Ok};
    }

    const Order order = replacement(slot, newPrice, newQty);
    if (crosses(order)) {
        return {.result = Result::Ok, .crossing = slot};   // hand back to the template
    }
    // Nothing to trade, so this is the whole cancel and replace. Taking the order off first
    // releases its level before rest() asks for one, so a full book can still move it
    take_off_book(slot);
    rest(slot, order);
    return {.result = Result::Ok};
}

Builds tried / gap to the build before the fix, same round

The fix as committed
+0.9 to +1.3 ns
match forced inline only
+0.4 to +0.9 ns
modify split, match not forced
+0.9 to +1.4 ns
modify split, match forced
−0.1 to +0.4 ns
and take_off_book forced
−0.3 to +0.1 ns
Vertical line: level with the build before the fixRanges over three to six rounds of alternating runs

Splitting modify alone was no better than the broken build: the matcher still had two callers and stayed out of line. Forcing the matcher alone left the five calls. Together they closed the gap, and forcing the last helper inline made cancel compile exactly as it had before the fix: 23 instructions and one call.

Mean per message, same round, on mains power

Sides by index
12.3 to 12.5 ns
With the modify fix
13.7 to 14.0 ns
Fix plus repair
12.3 to 12.4 ns
Every build ran about 0.3 ns slower than earlier that day, so compare within the round

What it taught

A change to code that runs for one message in twenty slowed the other nineteen, and it did so without touching a single line they execute. Inlining decisions depend on how many callers a function has and on which file a definition sits in. No unit test can catch that. All 107 tests passed throughout, in release, debug, GCC and under the address sanitiser. Only the benchmark saw it.

Why the compiler stopped inlining was not checked in the compiler itself. The likely reason is that it gives a large bonus to inlining a function that has exactly one call site, and a second caller takes that bonus away.

LET THE LINKER INLINE ACROSS FILES

The previous entry ended on a boundary: without link-time optimisation, a template in the header cannot inline anything defined in OrderBook.cpp. That was worked around by hand for three functions. This step removes the boundary for all of them, and it changes no C++ at all.

What link-time optimisation does

Normally each source file is compiled to machine code on its own, and the linker only joins the results. With LTO the compiler emits its intermediate representation instead, and optimisation runs again at link time across all the files at once. A call from the benchmark's loop into OrderBook.cpp can then be inlined like any other.

Thin LTO does this per function with a shared summary, in parallel. Full LTO merges everything into one module first. Both were built and raced.

CMakeLists.txt3c869d4
# Link-time optimisation, so code in OrderBook.cpp can be inlined into the header templates.
# Set here, after GoogleTest and Google Benchmark are made, so only this project's
# targets get it and the harness stays as it was. CMake's default for Clang is THIN
set(LOB_LTO OFF CACHE STRING "Link-time optimisation of this project's targets: OFF, THIN or FULL")
set_property(CACHE LOB_LTO PROPERTY STRINGS OFF THIN FULL)
if(NOT LOB_LTO STREQUAL "OFF")
  include(CheckIPOSupported)
  check_ipo_supported(RESULT lob_lto_supported OUTPUT lob_lto_error LANGUAGES CXX)
  if(NOT lob_lto_supported)
    message(FATAL_ERROR "LOB_LTO=${LOB_LTO}, but this toolchain can't do LTO: ${lob_lto_error}")
  endif()
  if(LOB_LTO STREQUAL "FULL" AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
    set(CMAKE_CXX_COMPILE_OPTIONS_IPO -flto=full)
  elseif(NOT LOB_LTO STREQUAL "THIN")
    message(FATAL_ERROR "LOB_LTO must be OFF, THIN or FULL (FULL needs Clang), not ${LOB_LTO}")
  endif()
  set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
endif()

# With LTO, Apple's linker compiles the bitcode into temporary objects and deletes them after
# the link, and their debug info goes with them, so dsymutil and atos can't see the book's
# code. Keep them, one path per executable
if(APPLE AND NOT LOB_LTO STREQUAL "OFF")
  foreach(exe lob_app lob_tests lob_no_alloc_test lob_bench)
    target_link_options(${exe} PRIVATE
      "LINKER:-object_path_lto,${CMAKE_CURRENT_BINARY_DIR}/lto-${LOB_LTO}-${exe}")
  endforeach()
endif()
CMakePresets.json3c869d4
{ "name": "release", "inherits": "base",
  "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LOB_LTO": "THIN" } }

The option is set after the test and benchmark libraries are fetched, so only the book's own ten source files are compiled this way and the harness is built exactly as before. The debug, GCC and sanitiser presets do not use it.

What it did to the hot loop

Calls out of the replay loop, without LTO

validate
open_slot
can_fill
should_rest
rest
close_slot4 sites
unlinkper fill
amend

With thin LTO

rest
amend
inlined
From the disassembly. Tail calls to cancel and to the crossing-modify path remain in both

A submit that rests makes one call where it made at least four. One that does not rest makes none. Filling a maker no longer calls out to unlink it and free its slot. Cancel's lookup is folded into the dispatch, which jumps straight to the erase.

What was measured

Mean per message, four sets of three alternating rounds

Previous commit, no LTO
12.4 to 12.8 ns
Thin LTO, forced inlines removed
12.3 ns
Full LTO
11.4 to 12.1 ns
Thin LTO
10.7 to 11.2 ns
Measured on battery, with a calibration run first. One round with 3.5% variation left out of the ranges

Thin was 1.5 to 1.8 ns faster than the previous commit in every clean pair. How that 1.7 ns divides between the calls it removed was not measured.

Two results were not obvious in advance. The forced inlines from the previous entry are still needed: with them removed, LTO leaves the matcher out of line again and the build runs 1.3 ns slower. And full LTO lost to thin in every round, by 0.5 to 1.2 ns. Its version of the loop is shorter, 262 instructions against 281, and makes the same calls, so the difference is in what was inlined where or in how the code was laid out. That was not investigated.

A detail that only matters for profiling

On macOS, the linker compiles the LTO bitcode into temporary object files and deletes them after the link. The debug information goes with them, and the profiler can then show only raw addresses for the book's code. The build passes the linker a path at which to keep those objects, so profiles of the LTO build still show inlined frames by name.

This has only been tried with Apple Clang. With GCC the same option would use GCC's own LTO, which is untested here.

Live book / WebAssembly

lob 3c869d4 / the same C++, unchanged
Side
Type
Time in force

The flow rests orders either side of 100.00, cancels some and crosses with small IOC takers. Your orders trade against it.

Bid qtyPriceAsk qty

Loading the book

Your resting orders

    Log

    Runs the repo's replay benchmark in your browser. Native on an Apple M2 Pro: 10.9 ns per message.

    What it costs, and what is still open

    From the repo's own notes
    Fixed capacity
    Everything is sized at construction. A full book returns BookFull.
    Fixed price band
    The default band is 65,536 ticks. Prices outside it return OutOfBand, and nothing re-centres it.
    The book owns the ids
    Replaying an exchange feed with its own order ids would need a hash index again.
    add() does not match
    It can rest an order that crosses. submit() is the safe way in.
    Generation wraps
    After 2^31 orders through one slot, an id that old would match again.
    Clock resolution
    The clock ticks every 41.67 ns, so percentiles move in whole ticks. The mean is the signal.
    Live book