2026-09-26 Parsing a Lot of Things Fast (How to Quantlarp)
Quantlarping in the big 2026
If you’re in year 3-4 of a CS degree, you will probably encounter a similar sight of people making the same trading bot either for normal equities, prediction markets, or commodities (via hyperliquid HIP-3). Not only are these projects kinda overdone, but they don’t really get to the gist of what actual SWE work inside major market makers looks like. A trading bot is all about what you would want to buy, but at an MM, the question is basically who’s on the other side of any trade you make and whether they’re about to pick you off.
First off, remember there are two options if an order is going out from IB: payment for order flow (IB may match internally or route to a dealer/MM) or exchange (and PFOF is really an IBKR Lite thing; Pro is SMART routed). So as the MM getting that IB order, I’m already adverse selected on one hurdle: IB didn’t match it internally, the rest of the street could’ve seen it (and may have passed on it), and now I’m holding it.
My modeling of markups/PFOF off 605/606 data used IB as the proxy/control versus dumb money retail. All things considered it’s basically the opposite of Robinhood flow, and partially because of their model. An IB order is the most likely to be informed, because it has broad retail access AND funds can route through it. As an MM, I’m going to have some buckets of predictable flow:
- Money manager type accounts who are one-way in a name. Maybe VWAP/TWAP orders, or I’m talking to them and I know.
- Option-driven and index-driven flow, which is the offset (here AP create-redeem is more important).
- Thematic flow, much of what the retail herd does is one directional.
- Pods, which all herd. I’m basically playing a momentum game against them and waiting for their blow ups to monetize, but I can predict them. If pod A is a buyer at 11am, pod B will be at 11:15, esp if they have any momentum-type pricing. It’s usually clusters of them coming in off each other, and it is very amusing in practice.
Bursty random flow, even if “unintelligent,” can be quite toxic. If one of my clients is doing a large trade (large in market impact) and he says he’s done, then goes and sells more on another venue, that’s toxic/unpredictable and hurts my ability to hedge/distribute. So if you were sending in your order, I may see this 50k lot from IB, but if it was my client, I may be able to have them TWAP/VWAP it, and that’s what makes it predictable. Basically: on an intraday 15 minute reset TWAP/VWAP, distance from the benchmark is “is the MM pissed off,” to simplify.
Usually you look into these issues and assume something went wrong: you were quoting poorly, there was some arb. That’s maybe less the case in cash equities. Brokers are actually worse for dealers in less liquid markets/derivs/swaps, because many of them pick you off since there’s no trust relationship.
Which is where the actual SWE work comes in: everyone’s anonymous and nobody tells you they’re done, and the only thing you’ve got is the market feeds. Every microsecond you’re behind in gathering and interpreting this data, someone is picking you off before you can pull or re-price.
Of course as a CS student you’re not going to be able to get access to these datasets, but we can simulate something similar using datasets that are more available. This is where NASDAQ’s ITCH dataset comes in, as the database carries all orders sent to the Nasdaq stock market of a certain day. The idea of this project isn’t necessarily to make something that tells you what to buy, but something that tells you what the market is doing fast enough to actually matter.
The code is at rwxmai/parseritch.
ITCH in sixty seconds
TotalView-ITCH 5.0 is NASDAQ’s market data feed that provides full order book depth for all U.S. equities securities traded on the Nasdaq execution system. This means you get the raw order events and build the book yourself.1
Nasdaq publishes whole days of it for free on emi.nasdaq.com. The file format is about as simple as binary gets: a 2-byte big-endian length, then the message, repeated until the market closes.
[u16 len][ 'A' | locate | tracking | timestamp(48b) | ref | side | shares | stock | price ]
[u16 len][ 'D' | locate | tracking | timestamp(48b) | ref ]
[u16 len][ 'U' | locate | tracking | timestamp(48b) | old ref | new ref | shares | price ]
...
The parser
At first when we look at the problem at hand, the job of the parser sounds fairly easy, which is read a length of data, read a message, call something, repeat 368 million times. But at ~7 ns per message, every branch, load and copy in that loop shows up in the total. So the whole design is about making the compiler see as much as possible at compile time, and doing as little as possible at run time.
Framing: length, bytes, next
The Nasdaq file format and the MoldUDP64 packet payload (what arrives over the network) share the same layout: [u16 big-endian length][message], back to back. So one loop serves both replay and live:
std::size_t parse_stream(const uint8_t* buf, std::size_t len) noexcept {
std::size_t off = 0;
while (off + 2 <= len) {
const std::size_t n = load_be16(buf + off);
if (ITCH_UNLIKELY(off + 2 + n > len)) break; // partial record: leave it for the next buffer
parse(buf + off + 2, n);
off += 2 + n;
}
return off; // bytes consumed
}
A trailing partial record is left unconsumed rather than parsed or skipped, so the caller can feed the file in 64 MiB slices (or a socket in datagrams) and glue the remainder onto the next chunk. A zero-length record is also counted and stepped over, where an off-by-one here shifts the framing by a byte and turns the rest of the day into garbage, which is the kind of bug that passes every synthetic test but messes up when eating real data.
Validate before you decode
Every ITCH 5.0 message type has a fixed length, the parser checks the length against the spec before a single field is read, so a truncated or corrupt frame can never make the decoder read past its end. The length table is built at compile time from the list of message types.
inline constexpr std::array<uint16_t, 256> kMessageLength = [] {
std::array<uint16_t, 256> t{};
[&]<class... Ms>(TypeList<Ms...>) {
((t[static_cast<uint8_t>(Ms::kType)] = static_cast<uint16_t>(Ms::kLength)), ...);
}(AllMessages{});
return t;
}();
That’s a 512-byte array that lives in L1, one load and one compare per message, and unknown types (expected == 0) fall out of the same check for free.
One table per message, and the wire format can’t drift
Each of the 23 message types lists its fields exactly once, as (member, wire offset) pairs:
struct MsgAddOrder : Header {
ITCH_MESSAGE('A', 36);
uint64_t ref = 0;
char side = 0; ///< 'B' or 'S'
uint32_t shares = 0;
Alpha<8> stock{};
uint32_t price = 0;
using M = MsgAddOrder;
static constexpr auto kFields = std::tuple{
Field<&M::ref, 11>{}, Field<&M::side, 19>{}, Field<&M::shares, 20>{},
Field<&M::stock, 24>{}, Field<&M::price, 32>{}};
};
Decoding is one function that folds over that tuple, picking the right big-endian load from each member’s type at compile time:
template <class M>
[[nodiscard]] ITCH_ALWAYS_INLINE M decode(const uint8_t* p) noexcept {
static_assert(detail::fields_tile_message<M>(), "field offsets must tile the message exactly");
M m;
m.locate = load_be16(p + 1);
m.tracking = load_be16(p + 3);
m.timestamp = load_be48_overread(p + 5);
std::apply([&](auto... f) { (detail::decode_value(p + decltype(f)::offset, m.*decltype(f)::member), ...); },
M::kFields);
return m;
}
fields_tile_message is a consteval function that walks the tuple and checks that the fields start right after the 11-byte header, touch end to end with no gaps or overlaps, and end at the spec length. encode() is generated from the same tuple, so the tests round-trip all 23 types byte for byte.
Big-endian without intrinsics
ITCH is big-endian, x86 is little-endian, so every field needs a byte swap.
ITCH_ALWAYS_INLINE uint32_t load_be32(const uint8_t* p) noexcept {
uint32_t v;
std::memcpy(&v, p, sizeof v); // unaligned load, no UB
return __builtin_bswap32(v); // -> MOV + BSWAP, or a single MOVBE with -march=x86-64-v3 (see note)
}
GCC and Clang both recognise the pattern, so the compiler already emits the optimal instruction.2 The one trick is the 48-bit timestamp.
/// Reads the 8 bytes ending at p+6 (i.e. starting 2 bytes before the field).
ITCH_ALWAYS_INLINE uint64_t load_be48_overread(const uint8_t* p) noexcept {
return load_be64(p - 2) & 0x0000'FFFF'FFFF'FFFFULL;
}
One load, one swap, one AND. The “over-read” is safe because the timestamp sits at offset 5 in every message type, so bytes 3..10 are always inside the message.
Compile-time dispatch
Parser<Handler> asks the compiler which on() overloads the handler actually has, and builds a 256-entry table of function pointers.
template <class Handler, class M>
concept HandlesMessage = requires(Handler& h, const M& m) { h.on(m); };
template <class M>
static void dispatch(Handler& h, const uint8_t* msg) noexcept { h.on(decode<M>(msg)); }
template <class M>
static constexpr DispatchFn entry() noexcept {
if constexpr (HandlesMessage<Handler, M>) return &dispatch<M>;
else return &ignore; // validated + counted, never decoded
}
static constexpr std::array<DispatchFn, 256> make_dispatch_table() noexcept {
std::array<DispatchFn, 256> t{};
t.fill(&ignore);
[&]<class... Ms>(TypeList<Ms...>) {
((t[static_cast<uint8_t>(Ms::kType)] = entry<Ms>()), ...);
}(AllMessages{});
return t;
}
The hot path is then just the length check, two counters and one indirect call:
ITCH_ALWAYS_INLINE void parse(const uint8_t* msg, std::size_t len) noexcept {
if (ITCH_UNLIKELY(len == 0)) { ++stats_.empty; return; }
const uint8_t type = msg[0];
const uint16_t expected = kMessageLength[type];
if (ITCH_UNLIKELY(expected != len)) {
if (expected == 0) ++stats_.unknown_type;
else ++stats_.bad_length;
return;
}
++stats_.messages;
++stats_.by_type[type];
static constexpr auto kTable = make_dispatch_table();
kTable[type](handler_, msg);
}
The is some elegance inside dispatch<M>: decode<M> and the handler’s on() are both inlined into it, so the compiler can see which fields the handler actually reads and drops the loads for the rest. A handler that only looks at ref and shares never pays for decoding the stock symbol. It’s basically lazy decoding without writing a lazy decoder.
Stacking handlers
BookBuilder<Sink> implements on() for the seven book-changing messages (A, F, E, C, X, D, U), applies each one to the engine, and then forwards a BookUpdate to whatever sink you plug in, but only if the sink asked for it:
template <class Sink, class M>
concept SinkOnBook = requires(Sink& s, const M& m, const BookUpdate& u) { s.on_book(m, u); };
ITCH_ALWAYS_INLINE void on(const MsgOrderDelete& m) { notify(m, engine_.remove(m.ref)); }
template <class M>
ITCH_ALWAYS_INLINE void notify(const M& m, const BookUpdate& u) {
if constexpr (SinkOnBook<Sink, M>) sink_.on_book(m, u);
}
Any other message is forwarded as sink.on(m) if the sink declares it, and otherwise never decoded. So the same template stack gives you a pure book builder (empty sink), a strategy hook (sink with on_book), or the full feed handler (sink that publishes top-of-book changes through a seqlock cache and a lock-free SPSC ring to a consumer thread), and each one compiles down to only the work it asked for. The Stock Directory message, which arrives before the open, is where each symbol’s level storage gets allocated, so the first order of the day doesn’t take an allocation.
Prefetch lookahead
The order map is hundreds of MB and the access pattern is basically random, so on a full day most lookups miss the cache. Since the parser already knows what’s coming (it’s all sitting in the buffer), it can hint record i + D while parsing record i:
template <std::size_t Distance>
std::size_t parse_stream_prefetch(const uint8_t* buf, std::size_t len) noexcept {
std::size_t ahead = 0; // next record to prefetch
const auto prefetch_next = [&]() noexcept {
if (ahead + 2 > len) return;
const std::size_t n = load_be16(buf + ahead);
if (ahead + 2 + n > len) return;
handler_.prefetch(buf + ahead + 2, n); // handler decides what to touch
ahead += 2 + n;
};
for (std::size_t i = 0; i < Distance; ++i) prefetch_next();
std::size_t off = 0;
while (off + 2 <= len) {
const std::size_t n = load_be16(buf + off);
if (ITCH_UNLIKELY(off + 2 + n > len)) break;
parse(buf + off + 2, n);
off += 2 + n;
prefetch_next();
}
return off;
}
The misses of the next 16 messages now overlap instead of arriving one after another. Results are identical to plain parsing, and the tests check it at distances 0, 1, 16 and 64. The hook only reads fields the (not yet validated) length proves are there, so a short garbage record can’t make it read past the end.
Playing with Intrinsics
128-bit by default, on purpose
On Intel server parts, wide vector instructions interact with the core’s frequency. Heavy 256-bit and all 512-bit work can drop the CPU clock, and even light 256-bit integer work makes the core power the upper lanes up and down; the first wide instruction after an idle gap pays a hefty performance penalty.3 Market data can sometimes be quiet, with sudden bursts of data, which makes this jitter really impact performance of HFT systems.
So the default build is -march=x86-64-v2 (SSE up to 4.2) with compiler auto-vectorization capped at 128 bits, and AVX2 is built as separate *_avx2 binaries, to be measured against the default rather than assumed faster. Separate binaries rather than runtime dispatch, because compiling the same inline function for two ISAs into one binary lets the linker keep either copy for both, and then the “narrow” path can quietly run wide code.4 AVX-512 isn’t used anywhere.
Swiss-table groups
The order map is a Swiss table, the design behind Abseil’s flat_hash_map and Rust’s hashbrown.5 Every slot has a 1-byte control tag stored in its own contiguous array:
0x00..0x7F FULL low 7 bits = "H2", a 7-bit fragment of the key's hash
0xFF EMPTY never used since the last rehash; a probe stops here
0x80 DELETED tombstone; probing continues past it
A lookup loads a whole group of tags at once and compares all of them against H2 in one instruction:
struct GroupSse2 {
static constexpr uint32_t kWidth = 16;
__m128i ctrl;
explicit GroupSse2(const uint8_t* p) noexcept
: ctrl(_mm_load_si128(reinterpret_cast<const __m128i*>(p))) {}
Mask match(uint8_t h2) const noexcept { // pcmpeqb + pmovmskb: 16 slots
const __m128i eq = _mm_cmpeq_epi8(ctrl, _mm_set1_epi8(static_cast<char>(h2)));
return Mask(static_cast<uint32_t>(_mm_movemask_epi8(eq)));
}
Mask match_empty_or_deleted() const noexcept { // both have bit 7 set: no compare at all
return Mask(static_cast<uint32_t>(_mm_movemask_epi8(ctrl)));
}
};
The tag encoding is chosen so match_empty_or_deleted(), the question an insert asks, is one pmovmskb of the raw tags. The result is a bitmask with one bit per candidate slot, walked with countr_zero / clear-lowest-bit, and only candidates get a full 8-byte key compare. A false tag match happens with probability ~1/128 per occupied slot, so the typical lookup is one aligned load, one compare, one bit scan and one key compare.
There’s also a SWAR fallback that runs on any CPU and is the reference the vector widths are checked against: 8 tags in a uint64_t, and the classic “does this word contain a zero byte” trick on ctrl ^ broadcast(h2):6
Mask match(uint8_t h2) const noexcept {
const uint64_t x = ctrl ^ (kLsbs * h2); // matching bytes become 0x00
return Mask((x - kLsbs) & ~x & kMsbs); // high bit set where a byte was zero
}
A borrow can light up a false positive in the byte just above a real match. That’s fine, as every candidate gets a full key compare anyway, so a false positive costs one compare and can never produce a wrong answer. Compare the naive approach, where gathering four scattered 8-byte keys into a vector costs four scalar loads from four cache lines just to set up one compare. Contiguous 1-byte tags mean one aligned load covers the whole group.
The order map on top
[[nodiscard]] ITCH_ALWAYS_INLINE Order* find(uint64_t ref) noexcept {
const uint64_t h = hash(ref); // Fibonacci hashing: one multiply by 2^64/phi
const uint8_t h2 = tag(h);
std::size_t g = group_index(h); // group from the top bits, H2 from just below
for (std::size_t step = 1;; ++step) {
const Group grp(ctrl_ + g * W);
for (uint32_t i : grp.match(h2)) {
Order* s = slots_ + g * W + i;
if (ITCH_LIKELY(s->ref == ref)) return s;
}
if (ITCH_LIKELY(grp.match_empty().any())) return nullptr;
g = (g + step) & group_mask_; // triangular probing visits every group
}
}
ITCH order references are unique per day across all symbols, so one global map covers the whole feed. The obvious alternative is a flat array indexed by ref, which is what the fastest open-source book does. But the spec stopped promising that refs increase back in 2009, so that’s a bet on Nasdaq’s implementation, not on the spec.7
One of the tricky parts are deletes, as ITCH is brutally delete-heavy, since most orders get cancelled, and a normal open-addressing table fills up with tombstones and needs periodic rebuilds. Here, we erase writes EMPTY instead of a tombstone whenever the slot’s group still has an EMPTY in it. That’s safe because a probe only ever continues past a group with no EMPTY, so no key can live beyond a group that has one.8 Probe chains stay short all day with zero rebuilds. The table stays at most 7/8 full, and the backing memory comes from mmap with MADV_HUGEPAGE set first and the pages pre-faulted second, so the hot path never takes a first-touch fault.9
Level search
Each side of a book is a sorted array with the best price at the back. A new top-of-book insert or erase shifts zero or a few elements; with the best at index 0, every new top would shift the whole side. Keys live in their own array (quantities and order counts in separate ones), so the search streams 4-byte keys only. Asks are stored as ~price, so “higher key = better” holds on both sides and one search routine serves both.
The search scans 8 keys at a time from the top and turns the insertion point into a popcount, with no per-lane branches and no scalar tail:
/// 8-bit mask of lanes in p[0..8) that are >= key. (max_epu32(v, k) == v) <=> v >= k
ITCH_ALWAYS_INLINE uint32_t ge_mask8(const uint32_t* p, __m128i k) noexcept {
const __m128i lo = _mm_loadu_si128(reinterpret_cast<const __m128i*>(p));
const __m128i hi = _mm_loadu_si128(reinterpret_cast<const __m128i*>(p + 4));
const auto m_lo = static_cast<uint32_t>(_mm_movemask_ps(_mm_castsi128_ps(_mm_cmpeq_epi32(_mm_max_epu32(lo, k), lo))));
const auto m_hi = static_cast<uint32_t>(_mm_movemask_ps(_mm_castsi128_ps(_mm_cmpeq_epi32(_mm_max_epu32(hi, k), hi))));
return m_lo | (m_hi << 4);
}
template <uint32_t Chunks = kLinearChunks>
inline uint32_t lower_bound_from_back(const uint32_t* keys, uint32_t n, uint32_t key) noexcept {
const __m128i k = _mm_set1_epi32(static_cast<int>(key));
uint32_t i = n;
for (uint32_t c = 0; c < Chunks && i >= kLevelSearchPad; ++c) {
const uint32_t ge = ge_mask8(keys + i - 8, k);
if (ge != 0xFFu) return i - detail::popcount8(ge); // insertion point = end - popcount
i -= 8;
}
if (i >= kLevelSearchPad) return detail::lower_bound_branchless(keys, i, key);
const uint32_t ge = ge_mask8(keys, k) & ((1u << i) - 1u);
return i - detail::popcount8(ge);
}
x86 has no unsigned 32-bit vector compare before AVX-512, so the SSE4.1 version fakes one with pmaxud: max(v, k) == v exactly when v >= k.10 SSE2 doesn’t even have pmaxud, so the SSE2 variant flips the sign bit of both operands and uses the signed compare instead:
const __m128i bias = _mm_set1_epi32(static_cast<int>(0x8000'0000u));
// (a ^ 0x80000000) < (b ^ 0x80000000) as signed <=> a < b as unsigned
const __m128i lo = _mm_xor_si128(_mm_loadu_si128(reinterpret_cast<const __m128i*>(p)), bias);
const auto m_lo = static_cast<uint32_t>(_mm_movemask_ps(_mm_castsi128_ps(_mm_cmpgt_epi32(key_biased, lo))));
It’s needed because ITCH prices fit in 31 bits,11 but ~price sets the top bit for every ask, so a signed compare would sort asks backwards. How many 8-key chunks to scan before falling back to a branchless binary search (kLinearChunks, 4 by default) is a build knob, and feed_handler --depth-profile records how deep every level search on a real day actually lands, which is the data to tune it from.
Add Order as one byte permutation
Add Order is the most common message, so it got the fanciest kernel. Every byte of the decoded struct is either one byte of the wire message (in reverse order, because big-endian) or zero:
/// kAddOrderSrc[k] = wire byte feeding output byte k, or -1 for zero.
inline constexpr std::array<int, 32> kAddOrderSrc = {
18, 17, 16, 15, 14, 13, 12, 11, // ref <- wire 11..18 reversed
10, 9, 8, 7, 6, 5, -1, -1, // timestamp <- wire 5..10 reversed, zero-extended
23, 22, 21, 20, // shares <- wire 20..23 reversed
35, 34, 33, 32, // price <- wire 32..35 reversed
2, 1, // locate <- wire 1..2 reversed
4, 3, // tracking <- wire 3..4 reversed
19, // side
-1, -1, -1};
That’s a pure byte permutation, but the catch is that PSHUFB only indexes within a 16-byte lane (even 256-bit VPSHUFB is two independent 128-bit shuffles), and these fields straddle 16-byte boundaries (ref is bytes 11..18, price is 32..35). So the SSSE3 version splits the message into three 16-byte chunks, shuffles each one with a mask that zeroes every byte belonging to another chunk (mask bit 7 set → PSHUFB writes 0), and ORs the pieces together:12
const __m128i c0 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(msg));
const __m128i c1 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(msg + 16));
const __m128i c2 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(msg + 32));
// Bytes 0..15 (ref, timestamp) come only from chunks 0 and 1.
const __m128i lo = _mm_or_si128(_mm_shuffle_epi8(c0, ld(kMask<0, 0>)),
_mm_shuffle_epi8(c1, ld(kMask<1, 0>)));
// Bytes 16..31 (shares, price, locate, tracking, side) need all three.
const __m128i hi = _mm_or_si128(_mm_or_si128(_mm_shuffle_epi8(c0, ld(kMask<0, 16>)),
_mm_shuffle_epi8(c1, ld(kMask<1, 16>))),
_mm_shuffle_epi8(c2, ld(kMask<2, 16>)));
Five PSHUFBs and three PORs decode the whole message, and the masks are generated at compile time from the table above by a consteval function. The AVX2 version broadcasts each 16-byte chunk into both 128-bit lanes (VBROADCASTI128 is a plain load), so lane 0 builds the low half of the output and lane 1 the high half in parallel: three VPSHUFB, two VPOR, one store.13
Block moves that libc can’t hijack
Inserting or erasing a price level shifts the levels above it by one slot, and while memmove would do it, glibc picks its copy routine at run time based on the CPU,14 so on AVX2 or AVX-512 hardware you get 256/512-bit copies no matter what -march you compiled with, sneaking wide instructions (and their frequency penalty) right back into the hot path. So the level books use 16-byte SSE2 overlapping moves. The funny part is keeping the compiler from “helpfully” recognising the loop and turning it back into a memmove call:
// An empty asm with the value as an in/out operand: the compiler can no longer prove
// this is a plain copy, so it can't rewrite the loop as a call to memmove.
ITCH_ALWAYS_INLINE void opaque(__m128i& v) noexcept { __asm__("" : "+x"(v)); }
ITCH_ALWAYS_INLINE void move_up(uint8_t* dst, const uint8_t* src, std::size_t n) noexcept {
std::size_t i = n;
while (i >= 16) { // copy from the top so overlap is safe
i -= 16;
__m128i v = _mm_loadu_si128(reinterpret_cast<const __m128i*>(src + i));
opaque(v);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), v);
}
// ... then 8- and 4-byte tails
}
Unlike a "memory" clobber, the empty asm only hides that one value, so the compiler can still schedule the loads and stores freely.
Every kernel has a scalar twin
The rule for every intrinsic in the repo is that it ships next to a plain scalar reference, and the tests check them against each other. The level search is checked against std::lower_bound for every array size from 0 to 130, with duplicate keys and keys on both sides of the sign bit. The group kernels are checked against the SWAR version on 20k random groups, the three Add Order decoders against each other on 100k random messages, and the block moves against std::memmove at every size and offset. A build flag (ITCH_FORCE_SCALAR) swaps every kernel for its scalar twin, so any SIMD path can be A/B’d against plain C++ on the same data. SIMD that isn’t checked against a boring reference is just a faster way to be wrong.
Benchmarking against everyone else
I rounded up every open-source ITCH parser and book builder I could get to compile, and ran them all through the same harness:
- itchcpp: modern C++20, eager parser, a lazy “overlay” API and a full L3
BookManager. - CppTrader: the classic, ITCH handler plus a full
MarketManager. - charles-cooper/itch-order-book: the famous “61 ns/tick” book, which keeps total size per price only, with no per-order state.
- itchfeed and MeatPy: the Python ones, for scale.
Same rules for everyone: the whole file is mmaped and every page touched before the clock starts, one pass is timed, median of 3 runs, all on the full 2019-01-30 day. Every library is built with the same compiler and flags and run on the same machine, so the comparison is apples to apples. Treat the absolute numbers as provisional until a bare-metal run with pinned cores; the method and raw results are in the repo’s bench-results.

| Operation | Time per message | Throughput | vs peers |
|---|---|---|---|
| Order books, every symbol (prefetch 16) | 37.4 ns | 26.8M msg/s | 4.3× itchcpp, 5.8× CppTrader |
| Order books, every symbol (no prefetch) | 78.5 ns | 12.7M msg/s | 2.0× itchcpp, 2.8× CppTrader |
| Parse every message | 7.5 ns | 132.5M msg/s | 1.4× itchcpp, 1.6× CppTrader |
Header-only walk (for_each_frame) | 3.2 ns | 317M msg/s | ≈ itchcpp’s lazy overlay |
| Python (itchfeed / MeatPy), parse | 763 / 992 ns | 1.3 / 1.0M msg/s | for scale |
Speed is worthless if the book is wrong, so there’s a correctness check too. I cut the file at 12:00:00 (151M messages) and had the four C++ book builders print best bid and ask, price and size, for AAPL, MSFT, AMZN, SPY, QQQ, TSLA, NVDA and INTC. All four agree exactly, and my engine finishes the whole day with unknown_ref=0 missing_level=0.
Running everyone else’s code on a real day also shook out some bugs:
- CppTrader’s
ReadTimestamponly copies 3 of the 6 timestamp bytes on little-endian machines, so every timestamp it decodes is wrong. Its books are fine, since they don’t use the timestamp.15 - CppTrader’s “optimized” market manager never stores the order price on an Add. Its levels are garbage, so it’s excluded from the table, since it’s fast at being wrong.16
- charles-cooper’s book doesn’t know the
h(Operational Halt) message, and on an unknown type it prints a warning without advancing the buffer. With asserts off, that’s an infinite loop. The harness skips unknown types by length.17 - MeatPy’s book raises at 09:30:00.59, because it assumes executions always hit the front of the queue, which real Nasdaq data doesn’t guarantee.18
Where the nanoseconds actually go
The parse-only number bugged me: 7.3 ns per message, when itchcpp’s lazy overlay does 2.8. So I took the loop apart:
| Loop | ns/msg |
|---|---|
full Parser | 7.2–7.5 |
| same, without per-type stats counters | 6.8–7.2 |
| same, dispatching to each type but decoding nothing | 6.7–7.3 |
| just framing + length check + read locate & timestamp | 3.1 |
Decoding is basically free (the inlined handler lets the compiler drop unread fields), and stats cost ~0.3 ns. The whole ~4 ns gap is the indirect jump on the message type. On a real feed the type sequence (A, D, U, E, X…) is close to random, so the branch predictor keeps guessing the target wrong. The lazy benchmark only reads fields that sit at the same offset in every message type, so it never branches on type at all.
You can’t skip that branch if you need type-specific fields, but you can skip it when you don’t. So two new APIs:
// 1) walk validated records without any per-type dispatch: 3.2 ns/msg on the full day
itch::for_each_frame(buf, len, [&](const itch::Frame& f) {
sum += f.locate() + f.timestamp(); // header fields sit at fixed offsets in every type
});
// 2) let a handler reject messages *before* the jump
struct MySymbols {
bool wants(uint8_t type, uint16_t locate) const { return traded[locate]; }
};
BookBuilder forwards wants() from its sink, so you can build books only for the names you trade. Every ITCH message carries its stock locate, including a Replace, which keeps its original’s, so a locate filter keeps each wanted book exact. Books for 8 symbols over the whole day run at 5.7 ns/msg, with 354M of the 368M messages never dispatched.
For full books the story is memory, not branches. The prefetch lookahead already hinted the order-map group of record i + 16 while parsing record i, but every add, cancel and delete still missed on the level arrays it was about to touch. So the hint now also prefetches the top-of-book lines:
switch (msg[0]) {
case 'A': case 'F':
engine_.prefetch_order(load_be64(msg + 11));
if (len >= 20) engine_.prefetch_levels(load_be16(msg + 1), msg[19]); // side is in the message
break;
case 'E': case 'C': case 'X': case 'D':
engine_.prefetch_order(load_be64(msg + 11));
engine_.prefetch_levels(load_be16(msg + 1)); // side unknown until the lookup: both
break;
// ...
}
That took full books from 43.8 to 38.4 ns/msg in the A/B run, and 37.4 ns in the final 3-run median. Just as useful is what didn’t work, each tried and measured on the full day:
- Tuning the prefetch distance: 8, 16, 32 and 64 records ahead all land at ~43 ns. Distance was never the bottleneck.
- A smaller order map: 78 to 61 ns without prefetch, but 43 to 52 with it. Dropped.
- A locality-preserving hash (consecutive refs to nearby groups, like the flat-array trick): 78 to 60 ns without prefetch, zero gain with it, and less robust to weird ref patterns. Dropped.
- Merging each level’s quantity and order-count arrays to save a cache line: no measurable change. Dropped.
Here’s the engine across the whole session, in 2M-message slices:

It holds ~27M msg/s through the trading day. The two dips are real market events, not noise: the 09:30 open, and 14:00, the FOMC statement, when everyone re-priced everything at once.19 That’s exactly the moment the intro was about: the burst when being microseconds behind means getting picked off.
So, am I faster than the pros?
Here’s parseritch next to what the vendors publish:

| System | Latency (ns) | What it measures |
|---|---|---|
| parseritch, parse | 7.5 | compute per message, replayed from memory |
| Exegy + AMD (FPGA) | 13.9 | STAC-T0 tick-to-trade, network I/O only, no book20 |
| Fractal ITCH parser (FPGA) | 24.8 | parse, last byte in → decoded |
| parseritch, full-depth books | 37.4 | compute per message, replayed from memory |
| NovaSparks NovaTick (FPGA) | 750–1,250 | Nasdaq ITCH + books, wire → FPGA core / server memory |
| Tickerplant (open source) | 2,455 p50 | Nasdaq ITCH, wire → book |
| Redline InRush 3 | 5,200 mean | STAC-T1 tick-to-trade |
The vendor numbers include the network: packet in on the NIC, book or order out. Mine are the compute share alone, replayed from memory. So 37 ns is the book-building work a software handler has to fit inside a wire-to-book budget like Tickerplant’s 2.5 µs or NovaSparks’ 1.25 µs. It fits with room to spare, and it’s the fastest full-depth open-source book I tested. It doesn’t beat an FPGA that parses in 25 ns and trades in 14.21
Nasdaq TotalView-ITCH 5.0 specification (latest revision April 28, 2023). Timestamps are “nanoseconds since midnight” in a 6-byte field at offset 5 of every message (p. 4); Add Order is §1.3 (p. 12), Order Replace §1.4.5 (p. 15), Operational Halt §1.2.8 (p. 12). ↩︎
MOVBE(load/store with a byte swap) is part of the x86-64-v3 level in the x86-64 psABI, together with AVX2, BMI1/2, FMA and LZCNT, so the AVX2 build gets it for free. ↩︎Intel cores run heavy 256-bit and all 512-bit work at reduced “license” frequencies, and switching licenses isn’t free. Travis Downs measured the transitions in detail in Gathering Intel on Intel AVX-512 Transitions (2020). The repo’s
BM_Burstbenchmark andtools/license_check.shexist to measure exactly this on the target CPU. ↩︎Inline functions are emitted as COMDAT (weak) symbols in every translation unit that uses them, and the linker keeps one arbitrary copy. If one TU was built with
-mavx2and another without, the surviving copy can be the AVX2 one, and the non-AVX2 code path now calls it. One ISA per binary makes that impossible. ↩︎Matt Kulukundis, “Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step”, CppCon 2017. The control-byte encoding here (
0xFFEMPTY,0x80DELETED,0x00–0x7FFULL) is the same one hashbrown uses. ↩︎Sean Eron Anderson, Bit Twiddling Hacks, “Determine if a word has a zero byte”.
(x - 0x01..01) & ~x & 0x80..80is non-zero exactly when some byte ofxis zero, but a borrow out of a real zero byte can also set the high bit of the byte above it. That’s harmless here, because every candidate is confirmed with a full key compare. ↩︎Appendix A of the spec (p. 26), February 3, 2009, TotalView-ITCH 4.00: Nasdaq “revised Order Reference Number field definition for the Add Order messages to remove the following statement: ‘The order reference number is increasing, but not necessarily sequential.’” The 5.0 spec only calls it “day-unique” (§1.3, p. 12). ↩︎
Lookup stops at the first group that contains an
EMPTY, and insert places a key in the first group with a free slot. So when a key sits in group g, every group before it on the probe sequence had noEMPTYat insert time. Ifgitself still has anEMPTYafter the erase, no probe can have passed throughgto reach a key further along, so the erased slot can becomeEMPTYwithout breaking any lookup. Only when the group is completely full does erase need aDELETEDmarker. ↩︎madvise(MADV_HUGEPAGE)first, thenmadvise(MADV_POPULATE_WRITE)(Linux 5.14+) to fault everything in.MAP_POPULATEatmmaptime would fault the region in as 4 KiB pages before the huge-page advice could apply. ↩︎PMAXUD(packed maximum of unsigned dwords) arrived with SSE4.1. The first real unsigned dword compare,VPCMPUD, needs AVX-512F. Intel® 64 and IA-32 Architectures Software Developer’s Manual, Vol. 2 (instruction set reference). ↩︎The spec’s maximum Price(4) is 200,000.0000,
0x77359400(p. 4), which is below 2³¹. So a bid key never has the top bit set, and an ask key (~price) always does. ↩︎PSHUFB xmm, xmm/m128writes byte i assrc[mask[i] & 0x0F], or zero when bit 7 ofmask[i]is set. The AVX2 formVPSHUFB ymmdoes that separately in each 128-bit lane: an index can never reach into the other lane. SDM Vol. 2, PSHUFB. ↩︎The scalar version is only seven
MOVBE-class loads, which the compiler already schedules well, so whether the shuffle wins is an empirical question that depends on the CPU. That’s why all three variants exist side by side andbm_kernelsmeasures them against each other. ↩︎glibc resolves
memmovethrough an IFUNC at load time, choosing between variants such as__memmove_avx_unaligned_erms,__memmove_evex_unaligned_ermsand__memmove_avx512_unaligned_ermsby CPUID (sysdeps/x86_64/multiarch). None of that sees your-march. ↩︎include/trader/providers/nasdaq/itch_handler.inl,ReadTimestamp: the little-endian branch writesbuffer[2],buffer[1],buffer[0]into the low three bytes of the result and zeroes the other five, then returns 6. So only the top 24 of the 48 timestamp bits survive, and they land in the wrong position. ↩︎performance/market_manager_optimized.cpp,MarketManagerOptimized::AddOrder: it setsId,Symbol,SideandQuantitybut neverPrice, so the level lookup uses whatever price the recycled slot last held. OnlyReplaceOrderwrites a real price. ↩︎The
itch_tenum initch.hhas noh(Operational Halt, §1.2.8), and thedefault:case inmain.cppprints “Uh oh bad code” andassert(false)s without advancing the buffer. Built with-DNDEBUG, the loop re-reads the same message forever. ↩︎meatpy.lob.ExecutionPriorityExceptionList: “Order ID 4732725 not first in line, 9501373 is”, at 09:30:00.590622. MeatPy checks that each execution hits the order at the front of its level’s queue. ↩︎The FOMC statement was released at 2:00 p.m. ET on January 30, 2019, and said the Committee “will be patient” about future rate changes. It’s usually read as the Fed’s pivot away from hiking. Federal Reserve press release. ↩︎
STAC-T0 measures tick-to-trade network I/O latency: from a market-data packet arriving to an order leaving, with minimal logic in between. STAC-T1 (Redline’s figure) is an older tick-to-trade benchmark that includes the feed handler and a simple strategy. Exegy, June 2024; A-Team Insight, June 2013. ↩︎
Aquis publishes port-to-port latencies of “17 microseconds or less for 99.99% of all messages” and 170,000 messages per second sustained for its exchange matching engine (Markets Media, December 2019). That’s order matching, a different job from building books off a market-data feed, so it isn’t in the table. ↩︎