Multi-Instrument Engine
Shard by symbol, sequence per partition.
Performance · Measure, Profile, Then Go Fast
Why this week
Engine v1 (week 12) is one thread that owns every order book. That's the right first design: one thread, one input sequence, bit-for-bit replay. But one core has a ceiling. When your profiled, allocation-free, cache-friendly engine tops out at a few million orders a second, the only way up is more cores, and the question is how to use them without giving up the determinism everything else rests on.
The answer is hiding in the domain. Order books don't interact. Matching an order in ES never reads or writes the NQ book. The engine is a set of independent state machines that happen to share a thread. Split them across threads by instrument, give each partition its own sequence numbers and journal, and every guarantee you built still holds per partition. Real venues work this way: CME Globex spreads products across market segments served by separate matching engines and publishes market data on many channels, each with its own sequence numbers. Nasdaq's TotalView-ITCH, by contrast, publishes the whole equities market as one sequenced stream. Both designs work. They put the cost of ordering in different places, and this week you learn to choose where.
What doesn't partition cleanly is everything that spans instruments: an account's risk limits, a client's session, a kill switch, a spread instrument whose legs trade in two books. Most of this week's design work is deciding what to do about those.
The invariant to protect is per-partition determinism: given a shard's input journal, its outputs and final state are bit-for-bit reproducible. Give up the idea of a single global order of everything. You never had one in real time anyway, only the order one thread happened to see.
Day 1 — What partitions, and what doesn't
Find the partition key
And list everything that crosses it
The partition key is the instrument. Every operation that touches exactly one book (new order, cancel, replace, the match itself, that book's market data) goes to the book's shard, and it's processed in the order that shard receives it. Most of the message volume falls in that category.
Then list the operations that cross partitions, because each needs a deliberate design:
- Per-account risk. An account trades ES on shard 0 and NQ on shard 2 against one gross-exposure limit. Who knows the total?
- Sessions. One client connection sends orders for many instruments. Its acks and fills come back from several shards and interleave on one TCP stream.
- Mass cancel and kill switches. "Cancel everything for account X" has to reach every shard and be acknowledged by all of them.
- Spread and implied instruments. A calendar spread's legs are two outright books. Implied matching (as CME does for many futures) needs both legs' books in one place, so co-locate related instruments in the same shard. This is the one cross-instrument interaction you keep inside a partition.
- Market-wide state. Trading halts, circuit breakers and session transitions (open, close, auction) must apply to every shard at a well-defined point in each shard's sequence.
- Global ordering. Consolidated audit trails and some market-data consumers want a single stream. Producing one costs a merge, and the merge has to be deterministic.
Partitioning: key-range vs hash partitioning, skew and hot spots, rebalancing and request routing. Read it as an exchange designer. Your 'hot spot' is the front-month future that trades 40% of the volume, and your 'rebalancing' must never happen mid-session.
A single-threaded business-logic processor fed by an input journal: your engine v1. Note how they scale (more processors, each owning its own state) and what they say about keeping everything in memory and replaying the log.
Name four operations in an exchange that cannot be handled entirely inside one instrument shard, and one cross-instrument interaction you'd deliberately keep inside a shard instead.
Draw your exchange's partition map: which state lives in a shard, which lives in the gateway, which in a global service. For each cross-shard operation above, write one sentence on where it's handled and what it costs in latency.
Day 2 — Reference data and routing
Resolve once, route by integer
Strings at the edge, dense ids inside, load-aware assignment
Instrument reference data is the table every component consults: symbol, a dense numeric id, tick size, lot size, price bands, trading status, and the shard that owns it. Three rules keep it from becoming a source of bugs:
- Strings stop at the edge. The gateway resolves
"ESZ6"to instrument id17once per order (or once per session if clients use ids). From then on everything is an integer, andshard_of[17]is one array load. - It's versioned and sequenced. Reference data loads at start of day. Any intraday change (a new instrument, a band adjustment, a halt) arrives as an admin message through the shard's input sequence, so replaying the journal reproduces the same state at the same point.
- It's deterministic. Never let anything that affects output depend on
std::collections::HashMapiteration order or its hash values. The default hasher is SipHash with random keys per process, so two runs, or the primary and its standby (week 20), would disagree. For anything that must be reproducible, use a fixed hash such as FNV-1a, or better, an explicit assignment table.
Assignment is where most first designs go wrong. Hashing symbols to shards is uniform over symbols, but trading volume is wildly non-uniform. A handful of instruments (the front-month equity index future, the most active stocks) can carry a large fraction of all messages, roughly following a power law. Hash four busy symbols onto four shards and three of them can land together, as you'll see in the drill. The practical approach is an explicit table built from measured volume: greedy longest-processing-time (place the heaviest instrument on the least-loaded shard, repeat) gets within a factor of 4/3 of optimal, and the very heaviest instruments get a shard to themselves. Rebuild the table at start of day from yesterday's statistics, and never move a book intraday. That would need a quiesce, a state transfer and a journal handover, all while the market is open.
Route orders to symbol shards
use std::collections::HashMap;
/// FNV-1a, 64-bit (offset basis 0xcbf29ce484222325, prime 0x100000001b3).
/// Deterministic across processes and runs, unlike std's RandomState.
pub fn fnv1a(s: &str) -> u64 {
todo!()
}
/// fnv1a(symbol) % shards
pub fn hash_shard(symbol: &str, shards: usize) -> usize {
todo!()
}
/// Greedy longest-processing-time assignment: visit instruments by weight
/// descending (ties: lower index first) and put each on the least-loaded
/// shard so far (ties: lower shard index). Returns the shard per instrument.
pub fn assign_lpt(weights: &[u64], shards: usize) -> Vec<usize> {
todo!()
}
/// Load of the busiest shard.
pub fn max_load(weights: &[u64], assignment: &[usize], shards: usize) -> u64 {
todo!()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Route {
pub instrument: u32,
pub shard: usize,
}
/// Symbols are resolved once, at the edge; the hot path carries dense ids.
pub struct Router {
by_symbol: HashMap<String, u32>,
shard_of: Vec<usize>, // indexed by instrument id
}
impl Router {
/// Instrument id = position in `symbols`; shard from `assignment`.
pub fn new(symbols: &[&str], assignment: &[usize]) -> Self {
todo!()
}
/// Edge lookup: symbol -> (instrument id, shard). None if unknown.
pub fn route(&self, symbol: &str) -> Option<Route> {
todo!()
}
/// Hot-path lookup: one array index, no hashing.
pub fn shard_of(&self, instrument: u32) -> usize {
todo!()
}
}
The drill's Router uses a HashMap for the edge lookup, and that's fine: lookup results don't depend on the hasher's keys, only iteration order and hash values do. Which of your structures iterate a HashMap in a way that affects output?
Why is std's default HashMap hasher a determinism hazard for an exchange, even though lookups always return the right value?
Day 3 — Sequence per partition
Many sequences, each one gap-free
Journals, replay and market data per partition
Each shard gets its own sequencer: an input sequence number stamped on every message the shard accepts, and an output sequence on every event it emits. Everything you built for one engine now holds per shard:
- Journals. Each shard appends to its own journal (week 9). Journals don't contend with each other, and recovery replays them in parallel, one per shard.
- Determinism. A shard's state is a pure function of its own journal. Cross-shard effects (an admin halt, a mass cancel) enter as messages in each affected shard's journal, so they're part of its sequence.
- Market data. Each shard publishes on its own channel with its own sequence numbers, and consumers detect gaps per channel. This is the CME model: a gap on one channel triggers recovery for that channel only. A single consolidated feed (the ITCH model) needs a publisher that merges shard outputs and assigns one global sequence. It's simpler for consumers, but it's a serialisation point that every message passes through.
Sometimes you do need one ordered stream: a consolidated audit trail, a drop copy, a single-feed product. Then define the order deterministically from the logs, not from arrival times at some merging thread. Ordering by (timestamp, partition, sequence) is reproducible given the journals. It's only as meaningful as the clocks are synchronised, so stamp from one well-synchronised clock (the TSC on one host, PTP across hosts, week 21) and document the tie-break.
Logs as the source of truth, partitioned logs for scale, and the explicit statement that ordering is guaranteed within a partition and not across them. That's this week's design, coming from data infrastructure rather than trading.
Per-partition sequence numbers
use std::cmp::Reverse;
use std::collections::BinaryHeap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Event {
pub partition: usize,
pub seq: u64,
pub ts: u64,
}
/// One counter per partition. No shared state between partitions, so each
/// shard can stamp its own events without coordination.
pub struct Sequencer {
next: Vec<u64>,
}
impl Sequencer {
pub fn new(partitions: usize) -> Self {
todo!()
}
/// Next sequence number for this partition; each partition starts at 1.
pub fn stamp(&mut self, partition: usize) -> u64 {
todo!()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SeqCheck {
InOrder,
/// Already seen, or inside a gap you already reported (recovery's job).
Stale,
/// Missing range [from, to]; the message itself is accepted.
Gap { from: u64, to: u64 },
}
/// Consumer-side gap detection, independently per partition.
pub struct GapDetector {
expected: Vec<u64>,
}
impl GapDetector {
pub fn new(partitions: usize) -> Self {
todo!()
}
pub fn check(&mut self, partition: usize, seq: u64) -> SeqCheck {
todo!()
}
}
/// Merge per-partition logs (each in seq order, timestamps non-decreasing)
/// into one stream ordered by (ts, partition, seq). Deterministic: the same
/// logs always produce the same stream. A k-way merge with a heap is the
/// natural fit.
pub fn merge(logs: &[Vec<Event>]) -> Vec<Event> {
todo!()
}
Your market data is published on four channels, one per shard. A consumer sees a gap on channel 2. What does it do, and what is unaffected?
Day 4 — Threads, queues and the router's hot path
Wiring N gateways to M shards
No shared queues, no head-of-line blocking
With N gateway threads and M shard threads, the simplest wiring is one MPSC queue per shard. Every gateway pushes into it, and producers contend on its tail with a CAS (week 5). The better default is one SPSC ring per (gateway, shard) pair: N × M rings with no contention and no CAS, each with exactly one producer and one consumer. With 2 gateways and 4 shards that's 8 small rings, which costs nothing. Each shard polls its N inbound rings round-robin with a per-ring batch cap so no gateway starves the others. Replies flow back the same way, one ring per (shard, gateway).
There's a subtle bonus. A single session lives on one gateway thread, so its messages to a given shard travel through one FIFO ring. Per-session order is preserved within each shard, which is exactly the guarantee clients need: a cancel can never overtake the order it cancels.
Three more wiring decisions:
- Backpressure. If a shard's inbound ring is full, the gateway must not spin waiting. That would stall every session on that gateway, including ones trading on idle shards (head-of-line blocking). Reject the order with a "busy" reason, or park it in a small per-shard overflow queue. Either way, count it, because a full ring means that shard is overloaded.
- Placement. Each shard thread gets an isolated core on the NIC's NUMA node (week 30), with its journal buffer and book memory allocated from that node.
- False sharing. Per-shard counters and ring indices live in cache-padded structs (week 4). Four shards incrementing adjacent
u64statistics will share a line and bounce it.
Why prefer N×M single-producer/single-consumer rings between gateways and shards over one multi-producer queue per shard, and what ordering guarantee do they give you for free?
Day 5 — The cross-shard problems
Risk, mass cancel and halts
Where sharding costs you something
Per-account risk is the hardest. There are three common shapes, each with a different failure mode:
- Central risk before routing. Every order passes one risk thread that knows every account's total exposure. It's exact and simple, but it's a serialisation point: you've rebuilt the single-threaded bottleneck one hop earlier. That's fine if risk checks are much cheaper than matching (often true), and fatal if not.
- Partitioned credit. Split each account's limit across shards (say, in proportion to where it traded yesterday). Each shard checks locally with no coordination, and a slow background process moves unused credit between shards. It's fast and deterministic per shard, but an order can be rejected for lack of local credit while the account has plenty in total.
- Local checks with asynchronous aggregation. Shards check against a conservative local view and publish fills to a global aggregator that can tighten limits or trigger a kill. It's fast, but the global limit can be briefly exceeded, by at most the in-flight volume. Size the headroom for that.
Mass cancel fans out: the gateway sends "cancel all for account X" to every shard holding X's orders. Each shard executes it at a definite point in its own sequence and acknowledges, and the gateway reports completion when all acks are in. Because of the per-ring FIFO, every order the session sent before the mass cancel reaches each shard before it, so nothing slips through. An order sent after the cancel is a new order. Decide whether a kill-switched account's new orders are rejected at the gateway (you want that) or left to each shard.
Market-wide events (halts, circuit breakers, the transition into a closing auction) enter every shard as an admin message. Each shard applies it at its own sequence point, and the journals record exactly which orders were before and after. They won't be simultaneous across shards in wall-clock time, and you don't need them to be. You need each shard's journal to say what happened.
A client sends three orders on shards 0 and 2, then a kill switch (cancel all + block). Why can't one of those orders survive the cancel, and what's still your responsibility?
Pick a risk shape (central, partitioned credit, or async aggregation) for your exchange's per-account gross exposure limit. Write the worst case: how far over the limit an account can go, or how many valid orders get wrongly rejected, and under what traffic.
Day 6 — Plan the sharded engine
Registry, router, shards, merge
Engine v1, times N, plus the glue
Your engine v1 becomes the shard. Mostly unchanged, it owns a subset of books, its own sequencer, journal and market-data output. Around it you add:
InstrumentRegistry: reference data, loaded once, immutable for the session, with symbol→id at the edge and an id→shard table built from a weight file (LPT) or an explicit map. The repo's registry is keyed byInstrumentId, so symbols are resolved before it.register(spec)puts an instrument on the shard with the fewest instruments (round robin for a fresh registry), andregister_on(spec, shard)places it explicitly. A volume-balanced table is a list ofregister_oncalls you compute yourself.ShardedEngine::new(registry, batch_size): spawns one pinned thread per shard and wires the rings. In the repo the threads aren't pinned yet, and the queues arestd::sync::mpscchannels.- A router that resolves ids and pushes to the right queue. In the repo it lives inside
ShardedEngine, on the caller's thread:send(&cmd). - Market-data aggregation: either per-shard channels or a deterministic merge into one stream. Pick one and justify it.
Write the signatures: InstrumentRegistry::new / register, ShardedEngine::new / send / flush / shutdown, and the per-shard journal and market-data handles. Where does the shard id appear in the types so a message can never be applied to the wrong shard's book?
Capstone
In crates/engine, build the sharded engine around your engine v1, and keep everything week 12 proved true per shard.
The repo's version (src/shard.rs) is the core of this. It has the registry, a router on the caller's thread, one thread per shard running one Engine per instrument on it, and per-shard sequence numbers:
send(&cmd)returnsResult<(ShardId, Seq), RouteError>. A new order goes to its instrument's shard. A cancel or reduce goes through an id → shard map built from the new orders the router has sent. The command gets that shard's next sequence number (1-based, gap-free) and joins the shard's batch, and full batches go out at once. The router refuses three things with aRouteError: an unknown instrument, an unknown id, and a new order that reuses an id live on another instrument. A refused command gets no sequence number and no event; the gateway answers it.flush(&mut out)sends the partial batches, waits for every shard, and appendsShardEvent { shard, seq, instrument, event }s. The router forgets ended orders only at flush points, so routing depends on the commands and where you flush, never on thread timing. Flush regularly, or replies pile up.shutdown(&mut out)flushes, stops the threads and returns the engines. Dropping the engine without it still joins the threads.- The queues. Shards are linked by
std::sync::mpscchannels carrying pooled batches, not Day 4's SPSC rings. The module docs say where thesynccrate's ring would replace them.
Per-shard journals, market data, mass cancel and halts aren't in the repo's week 36; they're the extensions below. The w36_ tests check:
- the registry's assignment and errors;
- routing by instrument and through the id map, and the router's refusals;
- every instrument's events equal those of a standalone
Enginefed the same commands, with gap-free per-shard sequence numbers; - identical shard streams with batch sizes 1 and 512;
- ended orders forgotten at flush, with their ids free for reuse;
shutdownreturning every engine, and a drop that doesn't hang.
Tests to write beyond the obvious routing checks:
- Per-shard determinism. Run a seeded multi-instrument order stream, then replay each shard's journal alone and compare state hashes. They must be identical, and must not change when you alter the other shards' traffic.
- Equivalence. For a single-instrument stream, a 4-shard engine must produce exactly the events engine v1 produces. Sharding must be invisible to one book. (The repo's test does this for every instrument of a multi-instrument stream.)
- Cross-shard operations. Mass cancel removes every resting order of the account on every shard and completes only after all shards acknowledge. A halt message stops matching on every shard at a recorded sequence point.
- Deterministic merge. If you produce a consolidated stream, the same journals always produce the same merged bytes.
Reading the benchmark. The target is aggregate throughput across 4 shards with uniformly distributed symbols, so each shard needs a quarter of it. w36.sharded_throughput registers 8 instruments round robin on 4 shards and interleaves eight week 7 mixed flows, one per instrument, choosing the instrument of each command uniformly at random. One router thread sends everything in batches of 256 and flushes every 65,536 commands. The rate counts every command, and the timed run includes starting the shard threads and shutting them down. Two traps make sharded benchmarks lie. First, a single producer thread routing every order caps you at the producer's speed (at the target rate it has about 133 ns per order for routing plus a queue push), so use one producer per gateway thread or batched pushes, as in week 5. The repo batches. Second, uniform symbols hide skew. The repo's benchmark doesn't measure that, so run it a second time yourself with Zipf-distributed volumes and report the busiest shard's utilisation next to the headline number. That second figure is the one a real market will hit you with.
crates/engineN engines, one exchange
An instrument registry, a router to N pinned engine threads, per-shard journals and sequence numbers, and aggregated market data, with each shard as deterministic as engine v1.
cargo xtask test --week 36 # tests named w36_*
cargo xtask bench --week 36 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 36 tests pass w36_*
- 4 shards, uniform symbols w36.sharded_throughputtarget ≥ 7.5M ops/s
With Zipf-distributed volume, how far was your busiest shard from the average? Would moving the top instrument to its own shard fix it, and what would you lose by doing so (for example, a spread that needed both legs together)?
Feeds into
No later week lists this one as a prerequisite. In the repo, the performance weeks that follow keep tuning the single-instrument engine (week 38's FastEngine, week 40's FastestEngine), and the sharded engine is how that engine scales across instruments. Quote the sharded throughput next to the single-engine numbers in week 40's report. If you add per-shard journals, a "which shard" column for week 39's attribution and a kill-one-shard scenario for week 48's chaos suite are natural extensions.