Schema-Driven Binary Protocols
SBE-style codecs and safe schema evolution.
Performance · Measure, Profile, Then Go Fast
Why this week
In week 11 you designed fixed binary layouts by hand: offsets as constants, from_le_bytes at each one, a zero-copy view over &[u8]. That's exactly right for one message at one version. It falls apart at real-venue scale, which means dozens of message types, a new version every few months, thousands of clients upgrading whenever they choose, and a bug in any hand-computed offset silently corrupting prices.
Real venues solve it the same way. The schema is the source of truth, encoders and decoders are generated from it, and the rules for changing it are mechanical, so a machine can check that version 7 still decodes everything version 6 clients send. CME's MDP 3.0 market data and iLink 3 order entry both use Simple Binary Encoding (SBE), a FIX Trading Community standard designed for exactly this: fixed offsets, no per-field tags, decode cost in nanoseconds. This week you learn how SBE lays out bytes, how it evolves without breaking old clients, and how to build the same machinery for your exchange in Rust.
The goal isn't "use SBE". It's to make the compatibility contract explicit and testable. Every protocol eventually needs a v2. The question is whether you find out it broke a client from a conformance test or from that client's trading desk.
Day 1 — Anatomy of an SBE message
Fixed where it can be, counted where it must be
Header, root block, groups, variable data
An SBE message is four regions, always in this order:
- Message header, 8 bytes in the standard schema:
blockLength: u16,templateId: u16,schemaId: u16,version: u16.templateIdsays which message this is,schemaIdwhich schema it belongs to, andversionwhich version of the schema the sender used.blockLengthsays how many bytes the sender's root block occupies. - Root block: the message's fixed-size fields at fixed offsets. No tags, no lengths, no delimiters. A field is wherever the schema says it is.
- Repeating groups, each with a small dimension header (typically
blockLength: u16andnumInGroup: u16; CME's MDP 3.0 uses a one-byte count) followed bynumInGroupentries ofblockLengthbytes each. Groups can nest. - Variable-length data: length-prefixed byte strings, last.
Fields are little-endian by default (the schema can say otherwise), and SBE doesn't align anything unless the schema asks for it with explicit offsets. Optional fields don't have a presence bit. A designated null value of the field's type means "absent": the maximum for unsigned types, the minimum for signed ones.
The schema itself is XML. Here's a NewOrder in the shape you'll decode on Day 3 (the field ids are the FIX tag numbers, which is SBE convention):
<sbe:message name="NewOrder" id="1">
<field name="clOrdId" id="11" type="uint64"/>
<field name="price" id="44" type="int64"/>
<field name="qty" id="38" type="uint32"/>
<field name="side" id="54" type="Side"/>
<field name="minQty" id="110" type="uint32" presence="optional" sinceVersion="2"/>
<group name="parties" id="453" dimensionType="groupSizeEncoding">
<field name="partyId" id="448" type="uint32"/>
<field name="role" id="452" type="uint8" presence="optional" sinceVersion="2"/>
</group>
</sbe:message>The design consequence that matters most: access is streaming and single-pass. Root fields are at constant offsets, so reading price is one load. A group's position, though, depends on the root block's length, and the second group's position depends on the first group's length. Decoders therefore walk the message front to back, and generated APIs make you read groups in order. In exchange for that discipline you get a decoder that's a few loads and no branches per field.
Read the sections on message structure, the message header, repeating groups, null values and schema extension. The extension rules are short, normative, and exactly what Days 2 and 3 implement.
The reference implementation's design notes. Read the pages on the flyweight/codec design and on safe extension of messages. Notice how much of the API exists to enforce in-order access.
Every SBE message header carries the sender's root blockLength even though the schema already defines the root layout. Why?
Day 2 — From schema to offsets
Layout is a pure function of the schema
And evolution is a constraint on that function
A code generator's first job is the layout: walk the fields in declaration order, place each one where the previous ended (or at an explicit offset, leaving padding), and record the root block's length. That's the same arithmetic you did by hand in week 11, but now it's done by a function that can be tested.
Alignment is a deliberate choice. SBE doesn't require it. On x86 an unaligned 8-byte load costs nothing extra unless it crosses a 64-byte cache-line boundary, and even then it costs only a few cycles. Crossing a 4 KiB page boundary is worse. Explicit offsets let you put 8-byte fields at multiples of 8 so a message never splits a hot field across lines, at the price of a few padding bytes. CME's schemas are packed, while many in-house protocols align. Make the choice once, in the schema, and let the layout function enforce it.
Evolution constrains the function. The extension rules boil down to one principle: a field's offset, type and meaning never change once published. It follows that:
- New fields go at the end of the root block or of a group entry, tagged with
sinceVersion. The sequence ofsinceVersionvalues along a block never decreases. - Fields are never removed or reordered. You can deprecate one (stop populating it and send the null value), but its bytes stay.
- New groups go after existing groups, and new variable-length fields after existing ones.
Because the layout function sees every field's sinceVersion, it can compute the root block length for each version: the length a v0 encoder writes, the length a v1 encoder writes, and so on. That's exactly what a conformance test needs to construct "a message from an old client".
Compute field offsets from a schema
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prim {
U8,
U16,
U32,
U64,
I64,
Char(usize), // fixed-length character array
}
#[derive(Clone, Copy, Debug)]
pub struct FieldSpec {
pub name: &'static str,
pub prim: Prim,
pub since: u16, // sinceVersion
pub offset: Option<usize>, // explicit offset; padding before it is allowed
}
#[derive(Debug, PartialEq, Eq)]
pub enum SchemaError {
Overlap(&'static str), // explicit offset lands inside an earlier field
VersionOrder(&'static str), // a field older than one before it
}
#[derive(Debug, PartialEq, Eq)]
pub struct Layout {
pub offsets: Vec<(&'static str, usize)>,
pub block_length: usize,
}
pub fn prim_size(p: Prim) -> usize {
todo!()
}
/// Lay the fields out in declaration order. A field without an explicit
/// offset starts where the previous one ended; an explicit offset must not
/// overlap the previous field. sinceVersion must never decrease: new fields
/// are only ever appended. Report the first offending field.
pub fn layout(fields: &[FieldSpec]) -> Result<Layout, SchemaError> {
todo!()
}
/// The root block length an encoder of `version` writes: the end of the
/// last field that exists in that version (0 if none). Schema errors
/// propagate.
pub fn block_length_at(fields: &[FieldSpec], version: u16) -> Result<usize, SchemaError> {
todo!()
}Why can a new field only be appended at the end of a block, never inserted between existing fields?
Day 3 — Versioned decoding
Old clients, new clients, one exchange
Acting version, blockLength skipping and nulls
A decoder compiled against schema version S reads a message whose header says version V, the acting version. Two directions must both work:
- New decoder, old message (
V < S): fields withsinceVersion > Vdon't exist in this message. Their accessors return the null value (orNonein an idiomatic Rust API) without reading the bytes, because those bytes belong to whatever comes next. - Old decoder, new message (
V > S): the root block has fields the decoder has never heard of. It reads the fields it knows at their known offsets and then finds the groups atheader + blockLength, the sender's length and not its own idea of the block size. The same holds inside every group: step through entries by the group'sblockLength, not your own entry size.
Then be strict about malformed input. If the acting version says a field exists but blockLength is too short to contain it, the message is corrupt, and decoding garbage would be worse than rejecting it. A buffer that ends before the header, a root block or a group entry ends is Short. A decoder on an exchange's order-entry path faces hostile and buggy input all day, so it must reject cleanly and never panic.
Two smaller traps:
- Null sentinels in the wire format,
Optionin the API.u32::MAXmeaning absent is a wire convention. Convert at the boundary, somin_qty: Option<u32>can't be mistaken for a 4-billion-lot order. - Enums grow too. Adding an enum value (a new
TimeInForce) is a schema change that old decoders see as an unknown byte. Decode into an enum with an explicitUnknown(u8)variant, or reject, and nevertransmutea byte into a Rust enum: an out-of-range discriminant is undefined behaviour.
Decode v1 and v2 messages
pub const HEADER_LEN: usize = 8;
pub const TEMPLATE_NEW_ORDER: u16 = 1;
pub const SCHEMA_ID: u16 = 7;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Party {
pub id: u32,
pub role: Option<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewOrder {
pub cl_ord_id: u64,
pub price: i64,
pub qty: u32,
pub side: u8,
pub min_qty: Option<u32>,
pub parties: Vec<Party>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecodeError {
Short, // the buffer ends before something it must contain
WrongTemplate(u16),
WrongSchema(u16),
BlockTooShort, // a block too small for the fields its version promises
}
/// Schema id 7, NewOrder template 1. Version 2 is current.
///
/// header : blockLength u16 | templateId u16 | schemaId u16 | version u16
/// root : clOrdId u64 @0 | price i64 @8 | qty u32 @16 | side u8 @20 (since v1)
/// minQty u32 @21 (since v2, null = u32::MAX)
/// group : blockLength u16 | numInGroup u16, then numInGroup entries,
/// each blockLength bytes
/// entry : partyId u32 @0 (since v1) | role u8 @4 (since v2, null = 255)
///
/// All integers little-endian. Rules:
/// - read the whole header first (Short if < 8 bytes), then check
/// templateId, then schemaId
/// - root blockLength < 21, or < 25 when version >= 2 -> BlockTooShort;
/// entry blockLength < 4, or < 5 when version >= 2 -> BlockTooShort
/// - a field exists iff version >= its sinceVersion
/// - the group starts at HEADER_LEN + blockLength (the sender's!)
/// - entries are blockLength apart; a partial entry -> Short
/// - null sentinels decode to None
pub fn decode(buf: &[u8]) -> Result<NewOrder, DecodeError> {
todo!()
}
The drill decodes into an owned NewOrder with a Vec so the tests are easy to read. Your capstone decoder must not do that, because the allocation alone would blow the latency target. Tomorrow is about the zero-copy shape.
A v2 decoder receives a v3 message whose root block is 29 bytes and whose party entries are 7 bytes. What exactly does it do, and what bug do you get if it uses its own sizes (25 and 5) instead?
Write your exchange's version-support policy: how many schema versions the gateway accepts at once, how a client learns which versions are supported, what happens to a client on a retired version, and how you'd roll out a breaking change if you ever truly needed one.
Day 4 — Generating Rust from a schema
build.rs, macro_rules!, or a derive
The generated code should look like what you'd write by hand on a good day
What should a generated decoder look like? The SBE reference implementation answers with a flyweight: a thin view over the bytes that knows the acting version and the block length and decodes fields on access. In Rust:
pub struct NewOrderDecoder<'a> {
buf: &'a [u8], // validated once: header + root block fit
version: u16,
block_len: u16,
}
impl<'a> NewOrderDecoder<'a> {
pub fn wrap(buf: &'a [u8]) -> Result<Self, DecodeError> { /* one length check, header checks */ }
#[inline]
pub fn price(&self) -> i64 {
i64::from_le_bytes(self.buf[HDR + 8..HDR + 16].try_into().unwrap())
}
#[inline]
pub fn min_qty(&self) -> Option<u32> {
if self.version < 2 { return None; }
match u32::from_le_bytes(self.buf[HDR + 21..HDR + 25].try_into().unwrap()) {
u32::MAX => None,
v => Some(v),
}
}
pub fn parties(&self) -> PartiesIter<'a> { /* starts at HDR + block_len */ }
}Each accessor is a load from a constant offset. Validate the lengths once in wrap (header, root block, and if you can, the whole message including groups), and per-field slicing becomes a bounds check the optimiser can often remove. If it can't, generated get_unchecked behind a validated constructor is the one place unsafe earns its keep. Check the assembly (week 37 is all about this).
Three ways to produce that code:
build.rs+ a schema file. The build script parses the schema (XML, TOML, or your own format), writes Rust intoOUT_DIR, and the crate pulls it in withinclude!(concat!(env!("OUT_DIR"), "/messages.rs")). It's the most flexible: the same parse can emit docs, test vectors or a C header for clients. Remembercargo:rerun-if-changed=schema/…, or edits won't trigger rebuilds.macro_rules!. The schema becomes Rust tokens:message! { NewOrder = 1 { cl_ord_id: u64, price: i64, … } }. Nothing to parse, and offsets can come fromconst fnarithmetic over the field list. Error messages and versioning metadata get awkward, though, and nobody else can read the schema.- A derive / procedural macro. You annotate a Rust struct and generate the codec. It reads naturally, but compiles slowest, and the struct definition quietly becomes the wire contract.
For an exchange whose clients are written in other languages, a schema file that isn't Rust wins: you hand it to clients. macro_rules! is a fine way to start, and the spine allows either.
OUT_DIR, rerun-if-changed, and the code-generation example. Generated code should be formatted and readable: you'll be stepping through it in a debugger one day.
If you go the macro_rules! route: repetitions, the incremental TT muncher pattern for walking a field list, and how to accumulate offsets. Use cargo expand to see what you generated.
Why should a generated decoder validate buffer lengths once in its constructor instead of in every accessor, and what does that buy at the assembly level?
Day 5 — Conformance, and the alternatives
Pin the bytes, then try to break them
Golden vectors, version matrices, fuzzing
A codec is correct when its bytes are right, not when encode-then-decode round-trips. A buggy encoder paired with the matching buggy decoder round-trips perfectly. A conformance suite has four layers:
- Golden vectors. Checked-in hex files, one per message and version, produced once and reviewed by a human, ideally cross-checked against an independent implementation such as the reference SBE tool. Your encoder must produce them byte for byte, and your decoder must decode them to known values.
- The version matrix. For each pair of (encoder version, decoder version) you support, encode with one and decode with the other. Include a synthetic future version with extra root and group bytes, as in the drill, so old-decoder-new-message is tested before a future version exists.
- Property tests. For random valid messages (week 12's generators),
decode(encode(m)) == m, and the encoded length equals what the layout predicts. - Fuzzing. Feed the decoder arbitrary bytes (
cargo fuzz) and require that it never panics, never reads out of bounds and never loops. Your gateway parses bytes from the internet, so this isn't optional.
It's also worth knowing what you didn't choose, because interviewers ask:
| SBE | Protocol Buffers | FlatBuffers / Cap'n Proto | |
|---|---|---|---|
| Field location | fixed offset | tag + varint, any order | offset table (vtable / pointers) |
| Read one field | one load | parse the message up to it | a couple of indirections |
| Evolution | append + sinceVersion + blockLength | field numbers, unknown fields skipped | vtable slots / pointer sections |
| Size | fixed, sometimes padded | compact (varints) | larger (offsets, alignment) |
| Fits | fixed-shape, latency-critical messages | general RPC and storage | large documents read partially |
Protobuf's varints and tag-per-field parsing cost tens to hundreds of nanoseconds per message. That's fine for configuration and admin RPCs, and wrong for a gateway's hot path. SBE wins there by refusing flexibility: no optional reordering, no sparse fields, fixed shapes.
Read it to be able to say precisely why protobuf decoding is sequential and branchy: varints, wire types, unknown-field handling. Then compare with your drill's decoder, which has no loop over fields at all.
The zero-copy alternative with pointers and 8-byte words. Worth knowing as the 'random access into large messages' end of the design space, and for its clear discussion of evolution.
Why is an encode→decode round-trip property test insufficient as a conformance test for a wire protocol, and what catches the bugs it misses?
List the messages your exchange's schema needs (order entry: new, cancel, replace, acks, fills, rejects; market data: add, modify, delete, trade, snapshot). For each, mark the fields you expect to change within a year and where you'd leave room. Which message is most likely to need a repeating group?
Day 6 — Plan the generator
Decide the moving parts
Schema format, generator, generated API, tests
Before you write the generator, decide:
- Schema format. XML that mirrors SBE (familiar to anyone who has seen CME's schemas), a TOML file, or a
macro_rules!DSL. If clients will ever see it, avoid the DSL. - What gets generated. At least: constants (template ids, offsets, block lengths per version), a decoder flyweight per message with
Optionaccessors for versioned fields, group iterators, an encoder that writes into&mut [u8]and returns the length, and aMessageenum to dispatch on template id. - Migration of week 11. Your existing messages should be expressible in the schema and produce identical bytes as version 1. Make that the first golden test. (The repo chose not to. Its schema messages have their own 4-byte header and packed fields, and week 11's codec stays as it is beside them. See the capstone.)
Write, by hand, the code you want the generator to emit for one message with one group: the decoder struct, two accessors (one versioned), the group iterator, and the encoder signature. This file becomes the generator's expected output in a snapshot test.
Capstone
In crates/wire, replace hand-written layouts with a schema and a generator, move the week 11 messages into it as version 1, add at least one message with a version-2 field and one with a repeating group, and build the conformance suite.
The repo's version (src/schema.rs) is smaller than that, and differs in a few places:
- Generator:
macro_rules!. The module docs give the reasons. The schema, the generator and the generated items sit in one file in plain Rust syntax.cargo expandshows what was generated, errors point at the schema line, and every offset is a compile-time constant. The template ships a stubwire_schema!withtodo!()bodies; the real macro you write after it shadows it. - Format. Every message starts with a 4-byte header:
templateu8,versionu8 andblock_lenu16, little-endian. There's no schema id. The root block follows, packed in schema order with no padding. - Messages.
NewOrder(template 1),Cancel(2),Reduce(3) andTrade(4). Each has v1 fields plusOption<T>v2 fields, whose null values areu64::MAX,i64::MINand so on.NewOrderis 39 bytes as v1 and 55 as v2. These are the schema's twins of the week 11 messages, not byte-identical to them: week 11'sNewOrderis 40 bytes with a one-byte tag. Week 11's hand-written codec stays alongside. - No repeating groups. Adding one group type to
wire_schema!is the natural extension. - What's generated. Each message gets an owned struct,
TEMPLATE,V1_BLOCK,V2_BLOCK, theFIELDSlayout table,encoded_len(version),encode_into(&self, version, buf)anddecode(buf). It also gets a zero-copy view,NameView<'a>, withnew(buf)(thewrapof Day 4),version(),message_len(), one accessor per field andto_msg().decode_message(buf)dispatches on the template and returns the message with its length. - Versioning. Decoders accept v1, v2 and any later version, skipping trailing fields they don't know by using
block_len. Version 0 is invalid. A block shorter than its version requires isBlockTooShort, and an enum byte outside its domain isInvalidField.
The w35_ tests cover:
- golden vectors for v1 and v2, as byte arrays in the test file;
- decoding both versions;
- newer versions skipped, not rejected;
- bad headers and fields rejected;
- random round trips through frames;
- the generated layout table matching the schema;
- the null value meaning absent.
Pitfalls worth designing out:
- Silent offset drift. Commit a generated layout table (message, field, offset, size, sinceVersion) as a snapshot. Any change to it fails review loudly, and the only acceptable diff is appended rows.
- Allocating decoders. A decoder that builds a
Vecfor groups or aStringfor symbols blows the latency budget and moves allocation into the gateway's hot path (week 27). Generated decoders are views, and anything owned is an explicit, separate call. - Validation in the wrong place. Validate lengths and template once in
wrap, and keep accessors branch-free except for version checks. Assert in tests that accessors on a truncated buffer are unreachable, becausewraprejects it. - Build-script hygiene. Use
rerun-if-changedfor the schema and the generator, deterministic output (sorted iteration, no timestamps), and formatted generated code checked into a snapshot test so reviewers see what changed.
Reading the benchmark. The target is the p50 cost of decoding one message with the generated decoder. At that scale you're measuring a header check and a few loads. Anything that allocates, copies the whole message or loops over fields won't make it. Benchmark the realistic access pattern: wrap the message and read the fields the gateway actually uses, not just wrap. Make sure the optimiser can't delete the work (std::hint::black_box on the input and the results), otherwise you're measuring nothing at 0.3 ns. The repo's w35.decode calls NewOrder::decode on a 55-byte v2 NewOrder. That's NewOrderView::new, which checks the header, template, block length and enumerated fields once, followed by to_msg, which reads every field into the owned Copy struct with no allocation.
crates/wireSchema in, codecs out
A schema for your exchange's messages, generated zero-copy encoders and decoders in crates/wire, versioned messages, and a conformance suite that pins the bytes.
cargo xtask test --week 35 # tests named w35_*
cargo xtask bench --week 35 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 35 tests pass w35_*
- Generated decoder, one message w35.decodetarget p50 ≤ 10 ns
Pick one message and plan its v3: what you'd add, the sinceVersion, the layout change, and the exact conformance tests that prove a v2 client and a v3 client can both still trade. What would you have to do if a client demanded a field be removed?
Feeds into
No later week lists this one as a prerequisite. In the repo, nothing outside crates/wire uses the schema codecs yet. Week 46's feed packets (marketdata::feed) have their own hand-laid layout, built on the same ideas: a versioned header, fixed little-endian fields and strict decoding. Making the schema the gateway's and the publisher's wire layer is yours to do, and the version matrix is part of what you'll show in week 51's portfolio.