← Back to roadmap
Phase 1Week 11~110 min read
0%·0/5

Binary Encoding & Zero-Copy Parsing

Fixed layouts, little-endian, and reading without allocating.

Foundation · Concurrency, Memory & the Core Engine

Why this week

Until now every message in your exchange has been a Rust value moving between functions in one process. From this week on, orders arrive as bytes from a socket and market data leaves as bytes on a wire. The encoding you choose sets a floor on latency that no later optimization can remove.

Exchanges settled this decades ago. Nasdaq's ITCH feed, its OUCH order-entry protocol, CME's MDP 3.0 (built on SBE) and most proprietary venue protocols are fixed-layout binary: every field of a given message type sits at a known byte offset, has a known width, and holds an integer. Decoding a field is then a single load from base + offset. No tokenizing, no string-to-number conversion, no allocation. A well-written decoder handles a 40-byte message in a few nanoseconds. A general-purpose JSON parser spends hundreds of nanoseconds or more on the same order, and it allocates while doing it.

You'll design the exchange's own wire format, encode it byte-exactly, and parse it through zero-copy views: structs that borrow the receive buffer and decode a field only when it is asked for. The capstone turns this into crates/wire, which the gateway builds on in week 13 and week 35 extends with schema-generated, versioned codecs.

ℹ️
Note

"Zero-copy" here means the decoder does not copy the message into an intermediate owned structure before you can read it. The NIC and kernel have already copied the bytes into your buffer, and kernel bypass (week 33) is about removing those copies. This week's concern is the copies and allocations your own code adds after the bytes arrive.

Day 1 — Reading ITCH like an engineer

Day 01

TotalView-ITCH 5.0 and MoldUDP64

How a real feed lays out bytes, and why

Docsby Nasdaq
Nasdaq TotalView-ITCH 5.0 Specification

Read the data-types section and the Add Order (type 'A') message. Note that every integer is big-endian, timestamps are 6-byte nanoseconds since midnight, prices are 4-byte integers with 4 implied decimal places, and stock symbols are 8-byte space-padded ASCII. Then skim the full message list: count how many types there are and how many share a common leading layout.

Docsby Nasdaq
MoldUDP64 Protocol Specification

The transport under ITCH. Look at the downstream packet header: a 10-byte session, an 8-byte sequence number of the first message in the packet, and a 2-byte message count, followed by length-prefixed message blocks. Week 10's gap detection lives in that header.

ITCH's Add Order message is 36 bytes: message type (1), stock locate (2), tracking number (2), timestamp (6), order reference number (8), buy/sell indicator (1), shares (4), stock (8), price (4). A few of those choices are worth copying, and a few are not:

  • Stock locate is a 2-byte integer that indexes the day's instrument directory. A subscriber keeps an array of books indexed by locate code, so it never hashes a symbol on the hot path. The 8-byte symbol is sent anyway, mostly for human debugging and for consumers that never built the directory.
  • 6-byte timestamps save two bytes per message. 2^48 nanoseconds is about 78 hours, which is plenty for a trading day. The cost is that you cannot load a 6-byte field with one native load. You have to assemble it from smaller pieces.
  • 4-byte prices with implied decimals are fixed-point integers. Every price level comparison in the book is then an exact integer compare. Floats would be wrong here, because 0.1 has no exact binary representation and two "equal" prices could land on different levels.
  • Big-endian everywhere is a convention inherited from network protocols. On x86-64 and ARM, which are both little-endian, every multi-byte field needs a byte swap. That swap is cheap, but it is never free to reason about.
Recall#why-fixed-width

Why do exchange feeds use fixed-layout binary messages instead of JSON or protobuf?

Design note#itch-critique

ITCH's Add Order is 36 bytes with a 6-byte timestamp and fields at odd offsets (the 8-byte order reference starts at offset 11). If you were redesigning it for a 2026 feed handler on x86-64, what would you change and what would each change cost in bandwidth? Is 4 extra bytes per message worth aligned fields?

0 chars

Day 2 — Endianness, offsets and your AddOrder

Day 02

Byte-exact encoding

Drill 1: write the bytes a spec describes, exactly

The exchange's own feed is little-endian, the same choice SBE makes by default and CME MDP 3.0 inherits. Your servers and nearly every consumer's servers are little-endian, so a field load is a plain mov. Fields are placed at naturally aligned offsets: a u64 starts at a multiple of 8, a u32 at a multiple of 4. Message lengths are multiples of 8. If a receive buffer starts 8-aligned, every field of every message in it is aligned too.

AddOrder  (type 'A', 40 bytes, little-endian)
offset size field
 0      1   msg_type      b'A'
 1      1   side          b'B' | b'S'
 2      2   instrument    u16   index into the instrument directory
 4      4   qty           u32
 8      8   seq           u64   per-instrument book sequence (week 10)
16      8   order_id      u64
24      8   price         i64   ticks; negative prices are legal
32      8   timestamp_ns  u64   ns since midnight

This is the layout the drills use. The repo's own protocol, described in the capstone, follows the same rules (little-endian, aligned fields, lengths a multiple of 8) with its own set of messages.

In Rust, the standard library does all of this with to_le_bytes / from_le_bytes on every integer type. On a little-endian target, u64::from_le_bytes(arr) compiles to a single load. from_be_bytes adds a bswap, or uses one movbe when the target CPU supports it. Either way the byte-order work costs about a cycle, so "which endianness" is really a question of consistency, not speed.

// The whole encoding toolkit: fixed offsets + to_le_bytes.
out[8..16].copy_from_slice(&msg.seq.to_le_bytes());
let seq = u64::from_le_bytes(buf[8..16].try_into().unwrap());

Why i64 for price? Instruments such as calendar spreads trade at negative prices, and in April 2020 the front-month WTI crude future did too. A u64 price is a latent outage.

Recall#endianness-cost

What does decoding a big-endian u64 cost on x86-64 compared with a little-endian one, and why does SBE default to little-endian anyway?

Drill

Encode and decode an AddOrder

Target: 6/6 cases · byte-exact against the specNot yet run
// AddOrder, 40 bytes, little-endian (see the layout table above).
//  0 u8 msg_type 'A' | 1 u8 side 'B'/'S' | 2 u16 instrument | 4 u32 qty
//  8 u64 seq | 16 u64 order_id | 24 i64 price | 32 u64 timestamp_ns

pub const ADD_ORDER: u8 = b'A';
pub const ADD_ORDER_LEN: usize = 40;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AddOrder {
    pub side: Side,
    pub instrument: u16,
    pub qty: u32,
    pub seq: u64,
    pub order_id: u64,
    pub price: i64,
    pub timestamp_ns: u64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodecError {
    /// Destination (encode) or source (decode) shorter than 40 bytes.
    BufferTooShort { need: usize, have: usize },
    UnknownType(u8),
    BadSide(u8),
}

/// Write `msg` into the first 40 bytes of `buf` and return 40.
/// Must not allocate, and must not touch bytes past offset 40.
pub fn encode_add_order(msg: &AddOrder, buf: &mut [u8]) -> Result<usize, CodecError> {
    todo!()
}

/// Decode from the first 40 bytes of `buf` (trailing bytes are ignored).
/// Check order: length, then type byte, then side byte.
pub fn decode_add_order(buf: &[u8]) -> Result<AddOrder, CodecError> {
    todo!()
}
Rust

Day 3 — Why not just transmute?

Day 03

Layout, alignment and undefined behaviour

The tempting shortcut and the three ways it is wrong

The C instinct is to declare a #[repr(C)] struct that matches the wire layout and cast the buffer pointer to it. That would make decoding free. In Rust it is undefined behaviour for three separate reasons:

  1. Alignment. Creating a &AddOrderWire from a pointer that is not aligned to the struct's alignment (8 here) is UB, even if you never read through it. A buffer slice starting at offset 36 of a packet is not 8-aligned. On x86 the hardware would perform an unaligned load with little or no penalty, unless it splits a cache line. The compiler is still allowed to assume alignment and optimize on that assumption.
  2. Validity. A Side enum has two valid bit patterns. A byte of b'Z' in that position makes the reference point at an invalid value, and that is UB the moment it is produced. The same applies to bool, char and references.
  3. Endianness and padding. A native struct uses host byte order and may contain padding the compiler is free to fill with anything. Neither is part of a wire contract.

The sound shortcuts are crates that encode those rules in traits. zerocopy (FromBytes, KnownLayout, Immutable, Unaligned, plus byte-order-aware integer types like zerocopy::byteorder::U64<LittleEndian>) and bytemuck (Pod, try_from_bytes) let you overlay a struct on bytes when every field accepts every bit pattern and the alignment is 1 or checked. Enums stay as raw u8 in the overlay and are validated when you read them.

Docs
The Rust Reference — Type layout

Read the repr(C) and repr(packed) sections. Note what Rust promises about field order, padding and alignment for each repr, and what it promises for the default repr (almost nothing).

Docs
zerocopy

Skim the trait list and the byteorder module. The point is not to adopt it this week. It is to see the exact preconditions an overlay needs, written as traits the compiler checks.

Recall#transmute-ub

Your buffer is exactly 40 bytes and matches the layout. Why is casting it to `&AddOrderWire` (a repr(C) struct containing a `Side` enum and u64 fields) still unsound?

Bounds checks: pay once. Indexing a slice with buf[24..32] checks bounds every time. Inside a function that reads six fields that's six checks and six panic paths. They are cheap when predicted, but they bloat the code and block vectorization. Convert once to a fixed-size array, and every later constant index is checked at compile time:

// One check up front. After this, b[24..32] on a &[u8; 40] needs no runtime check.
let b: &[u8; 40] = buf.first_chunk().ok_or(CodecError::BufferTooShort { need: 40, have: buf.len() })?;
let price = i64::from_le_bytes(b[24..32].try_into().unwrap()); // unwrap is provably infallible, compiled away
Docsby Nicholas Nethercote
The Rust Performance Book — Bounds Checks

Short. The technique of asserting or slicing once so the optimizer can drop later checks is exactly what your decoder needs.

Day 4 — Views that borrow

Day 04

Zero-copy message views

Drill 2: decode a field only when someone asks for it

A decoder that returns an owned AddOrder copies all 40 bytes into a struct. For a feed handler that only needs the order id and quantity of a cancel, that work is wasted. A view is a struct holding &'a [u8] that has been validated once (type byte and length). Its accessor methods decode one field each. The lifetime 'a ties the view to the buffer, so the borrow checker stops you from keeping a view after the receive buffer is reused. In C++ the same bug compiles and turns into a use-after-free.

The second design trick is the common prefix. Put the fields every message type carries (type, instrument, qty, seq, order id) at the same offsets in every message. Generic code, such as sequence tracking, per-instrument routing or a capture indexer, can then read them without dispatching on type at all.

Common 24-byte prefix (all types)      AddOrder adds        Cancel ('X') is
 0 u8 type   1 u8 side/0  2 u16 instr   24 i64 price         just the prefix:
 4 u32 qty   8 u64 seq   16 u64 id      32 u64 timestamp_ns  24 bytes total
Drill

Zero-copy message view over bytes

Target: 6/6 cases · views point into the input bufferNot yet run
// Two types share the 24-byte prefix at identical offsets:
//  0 u8 msg_type 'A' (40 bytes) | 'X' (24 bytes)   1 u8 side ('B'/'S'; 0 for 'X')
//  2 u16 instrument   4 u32 qty   8 u64 seq   16 u64 order_id
//  AddOrder only: 24 i64 price, 32 u64 timestamp_ns.  All little-endian.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
    UnknownType(u8),
    /// Fewer bytes than the message needs (`need: 1` when the input is empty).
    Truncated { need: usize, have: usize },
}

pub fn msg_len(msg_type: u8) -> Option<usize> {
    match msg_type {
        b'A' => Some(40),
        b'X' => Some(24),
        _ => None,
    }
}

/// A borrowed view of exactly one message. Copies nothing.
#[derive(Debug, Clone, Copy)]
pub struct MsgView<'a> {
    bytes: &'a [u8],
}

impl<'a> MsgView<'a> {
    /// Validate type and length once. The view covers exactly msg_len bytes;
    /// anything after them in `buf` is not part of this message.
    pub fn parse(buf: &'a [u8]) -> Result<MsgView<'a>, ParseError> {
        todo!()
    }
    pub fn bytes(&self) -> &'a [u8] {
        self.bytes
    }
    pub fn msg_type(&self) -> u8 { todo!() }
    pub fn instrument(&self) -> u16 { todo!() }
    pub fn qty(&self) -> u32 { todo!() }
    pub fn seq(&self) -> u64 { todo!() }
    pub fn order_id(&self) -> u64 { todo!() }
    /// Some(price) for AddOrder, None for Cancel.
    pub fn price(&self) -> Option<i64> { todo!() }
}

/// Back-to-back messages in one buffer. After the first error, yield that
/// error once and then None forever: the stream position is lost.
pub struct Messages<'a> {
    buf: &'a [u8],
    pos: usize,
    failed: bool,
}

pub fn messages(buf: &[u8]) -> Messages<'_> {
    Messages { buf, pos: 0, failed: false }
}

impl<'a> Iterator for Messages<'a> {
    type Item = Result<MsgView<'a>, ParseError>;
    fn next(&mut self) -> Option<Self::Item> {
        todo!()
    }
}
Rust
Design note#common-header

List the fields you would put in the common prefix of every message on your exchange's market-data feed, and the ones you'd deliberately leave out. For each field in the prefix, name one consumer that reads it without caring about the message type.

0 chars

Day 5 — Framing: streams versus datagrams

Day 05

Where one message ends

TCP length prefixes, UDP packet headers, and hostile input

A decoder for one message is half the problem. The other half is framing: finding message boundaries in whatever the transport hands you.

  • TCP is a byte stream. One read() can return half a message or three and a half messages. Every TCP protocol therefore carries an explicit length. SoupBinTCP (under OUCH and ITCH-over-TCP) prefixes each packet with a 2-byte length. FIX uses its BodyLength field (week 14). Your gateway will use a 2-byte little-endian length prefix, and you'll write the incremental decoder for it this week in crates/wire, ready for the gateway to use in week 13.
  • UDP preserves datagram boundaries but not delivery or order. MoldUDP64 puts a sequence number and a message count in each packet header and length-prefixes each message inside. A receiver detects gaps from the header alone, without parsing a single message.
Recall#moldudp64

A MoldUDP64 packet arrives with sequence number 1000 and message count 3. What sequence number should the next packet carry, and what do counts of 0 and 0xFFFF mean?

Treat every byte from the network as hostile. A decoder in the gateway sees input from clients you do not control, including buggy ones. A panic there is a remotely triggerable crash of your exchange. The rules:

  • Never index without a checked length. Return Err, never unwrap on data-derived values.
  • Validate a length field before you act on it. Reject lengths above the protocol maximum before you allocate or wait for the bytes.
  • Fuzz it. cargo fuzz with a target that feeds arbitrary bytes into messages() and asserts nothing panics will find the case you forgot in minutes.
Docs
Rust Fuzz Book

Set up cargo-fuzz for crates/wire this week. A fuzz target is about five lines: take &[u8], run the decoder, and let libFuzzer report any crash. Keep the corpus in the repo.

Day 6 — Measuring a 20-nanosecond decode

Day 06

Benchmarks that do not lie

When the stopwatch costs as much as the work

The week's benchmark targets are a single decode in 15 ns at p50 and a mixed stream at 40 million messages per second, 25 ns per message including framing. At 3–4 GHz, 15 ns is 45–60 cycles. That is plenty for a type dispatch and a handful of loads. It is not enough for an allocation: a malloc/free pair costs roughly 10–20 ns even on a good allocator's fast path, most of the budget, and a cache miss to DRAM costs ~80–100 ns. Two measurement traps matter at this scale:

  1. The timer costs as much as the work. Instant::now() on Linux x86-64 goes through the vDSO and costs roughly 15–25 ns on typical hardware. Timing each decode individually measures the clock. Time a batch of thousands of messages and divide, as the drill's last case does, as the repo's w11.decode does (1,000 decodes per timed sample), and as criterion does for you.
  2. The optimizer deletes unused work. If nothing reads the decoded value, LLVM can remove the decode entirely, and you'll "measure" 0.3 ns. Route inputs and outputs through std::hint::black_box, and accumulate something from every message.

A mixed stream is harder than a uniform one for a specific reason. The type dispatch becomes an unpredictable branch, and each mispredict costs roughly 15–20 cycles on modern x86 cores. If your mixed-stream throughput is far below single-type throughput, that branch is usually why. Ordering the match arms by frequency won't fix it, but a branch-light design that reads the common prefix unconditionally will.

Design note#v2-field

Version 2 of AddOrder needs a 4-byte participant id. Where do you put it, how does a v1 decoder reading a v2 stream behave, and how does a v2 decoder handle a v1 message? Sketch the rule you'd write into the spec. Week 35 builds this properly; write down your instinct now and compare later.

0 chars
Design note#week-reflection

What did you have to unlearn about 'parsing' this week? Name one place in code you have written before where a zero-copy view would have removed an allocation per message.

0 chars

Capstone

This week's repo work creates crates/wire: the single definition of every byte that crosses a process boundary in your exchange. Order entry (logon, new order, cancel, reduce, and the engine's events going back: accepted, rejected, trade, rested, cancelled, expired, reduced) and market data (add order, order executed, order delete, L2 update) both live here, so the gateway and every client agree on one spec.

The layout tables are already written, in the crate docs (src/lib.rs): they are the contract your clients would implement against. A one-byte type tag at offset 0 fixes each message's length (16, 24, 32 or 40 bytes, all multiples of 8); integers are little-endian, with every u64 at a multiple of 8. Instead of one shared 24-byte prefix, the repo's common layout is narrower: every message that names an order carries its id at offset 8, and the market-data messages carry the instrument at offset 4, so generic code can read the tag and the id without dispatching on type. The benchmarks time NewOrder (N, 40 bytes).

What to build (in src/codec.rs, src/view.rs and src/frame.rs):

  • The little-endian helpers and the codec. read_u64, write_u64 and friends; encode_into(&Message, &mut [u8]) -> usize, which writes into a caller-provided buffer and returns the length (no Vec in the signature: the caller owns the buffer, and in week 13 it will be the socket's write buffer); and decode(&[u8]) -> Result<Message, DecodeError>, an owned decode into the Message enum (Logon, LogonAck, Command, Event, MarketData) that rejects a short buffer, an unknown tag or any out-of-domain enumerated byte, and ignores bytes past the fixed length.
  • Zero-copy views of the inbound messages: NewOrderView, CancelView, ReduceView, LogonView, and InboundView::parse(frame), which dispatches on the tag once. Building a view checks the tag, the length and every enumerated field, so the accessors cannot fail and cost one load each; to_new_order() and to_command() turn a view into the shared types value.
  • Stream framing: a 2-byte little-endian length prefix (encode_frame, write_frame) and FrameDecoder::next_frame(&mut input), an incremental decoder you feed with whatever each read returned. A frame that lies entirely inside one read comes back as a slice of that read, with no copy; only a frame split across reads is copied, into a buffer allocated once. A zero or oversized length is a FrameError, after which the stream is out of sync. The MoldUDP64-style packet header for the market-data side comes later, in week 46's marketdata::feed.

The tests in tests/w11_wire.rs cover hostile input as well as the happy path: golden byte vectors for orders, events and market data; a round trip of 20,000 random messages; the views; malformed input, including every truncation of a NewOrder; frame decoding with chunk sizes from 1 byte up to several frames; a pointer check that frames inside a read are not copied; and bad frame lengths. The repo has no fuzz target, so adding one with cargo fuzz is still worth the five lines.

Decisions to make and write down:

  • Owned decode vs view. Measure both. Owned decode of a 40-byte message is cheap too. The difference shows up when consumers touch two fields out of eight, or when messages are larger.
  • Validation depth. Should parse validate enum-like bytes (side, order type) up front, or should accessors return Option/Result? Up-front validation makes accessors infallible and moves all rejects to one place. Lazy validation is faster when most fields are never read. For order entry, validate up front: rejects are part of the protocol.
  • Where the type dispatch lives. One match on the type byte at parse time, then type-specific views, keeps the unpredictable branch to one per message.

How to think about the targets: w11.decode times wire::decode of one 40-byte NewOrder into an owned Message, tag dispatch and validation included, and asks for 15 ns at p50. That is not hard if the decoder avoids allocation and redundant bounds checks. What usually blows it is measurement, as Day 6 describes, or copying the decoded order through a temporary on the stack: the reference's NewOrderView::to_new_order is #[inline(always)] for that reason, and its doc comment explains the store-forwarding stall it avoids. w11.decode_throughput frames 100,000 messages of a mixed order-entry and market-data stream, feeds them to FrameDecoder in 64 KiB reads so frames straddle every read boundary, and decodes each one. At 40M msgs/s the average message gets 25 ns including framing and the dispatch, which is where branch prediction and code layout start to matter. If you miss, look at the generated assembly for the hot loop before changing the design.

Capstone · in your exchange · crates/wire

crates/wire — the exchange's binary protocol

Fixed-layout codecs, zero-copy views and stream framing for orders and market data. No allocation on decode, no panic on hostile input.

cargo xtask test --week 11      # tests named w11_*
cargo xtask bench --week 11     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 11 tests pass w11_*
  • Decode one 40-byte order message w11.decodetarget p50 ≤ 15 ns
  • Decode stream of mixed messages w11.decode_throughputtarget ≥ 40.0M msgs/s

Feeds into

  • Week 12 generates random command streams for the engine v1 property tests; the same types::Command values are what this week's codec carries.
  • Week 13 puts the order-entry messages on a TCP socket behind this week's FrameDecoder.
  • Week 35 adds schema-generated codecs next to the hand-written ones, with a header that carries a version so v2 can append fields.
  • Week 46 defines the L2 feed's packet encoding and parses captured packets of it back into books, which only works if a spec is exact.