← Back to roadmap
Phase 3Week 38~105 min read
0%·0/4

Hot Path / Cold Path

Keep the instruction cache for the code that matters.

Performance · Measure, Profile, Then Go Fast

Why this week

Profile your engine with week 26's tools and the hot function looks fine: a few hundred nanoseconds, no allocations, good IPC. Then look at its assembly. Alongside the twenty instructions that handle a normal limit order you'll find a hundred that format reject messages, build log lines, handle FOK feasibility, unwind panics and check things that fail once a day. None of it runs on the normal path, but it all sits in the same cache lines and pages as the code that does. It also adds branches that the predictor has to track.

In a microbenchmark that costs nothing: the loop runs a million times and everything stays in the L1 instruction cache and the branch predictor. In production, between two orders the engine thread publishes market data, writes the journal and services timers, and that other work evicts parts of the order path from the instruction cache and overwrites predictor state. The next order then pays for instruction-cache misses and mispredicted branches. That cost doesn't show in the average, but it shows at the p99, and the p99 is what week 39's budget will hold you to.

This week you make the hot path short and straight. Rare things move out of line, errors become cheap values, and branches are arranged so the common case falls through. Then you measure whether it worked, at the tail rather than the mean.

💡
Tip

"Cold" is a claim about frequency in production, not about importance. Rejects are cold until a misconfigured client sends 50,000 bad orders a second. Design cold paths to be cheap enough to survive a storm. Just don't let them live in the hot path's cache lines.

Day 1 — The front end

Day 01

Fetch, decode, predict

The half of the CPU your profiler doesn't show you

Before an instruction executes, the CPU's front end has to predict where control goes next, fetch the bytes, and decode them into micro-ops. The structures involved are small:

  • L1 instruction cache: 32 KiB on most Intel and AMD server cores of the last decade (a few newer designs have 64 KiB), in 64-byte lines. A miss that hits L2 costs on the order of 10–15 cycles; one that has to go to L3 costs several times more.
  • Decoded µop cache: a few thousand already-decoded micro-ops (the size varies a lot by generation). Code that runs from it skips the legacy decoders, which are slower and narrower.
  • Branch predictors and the branch target buffer: tables, indexed by branch address and history, that predict the direction and target of every branch before it's decoded. They have finite capacity, and cold code with many branches competes for it.
  • Instruction TLB: a big binary whose hot code is spread over many 4 KiB pages pays iTLB misses.

Cold code hurts the hot path even when it never runs. A 64-byte line holding six hot instructions and forty bytes of an error handler wastes most of a fetch. A hot function spread across three pages because of inlined formatting code needs three iTLB entries instead of one. And every branch around a cold block is one more entry in the predictor's tables.

Measure the front end before changing anything. perf stat -e L1-icache-load-misses,iTLB-load-misses,branch-misses on your engine benchmark gives you the raw counts. On CPUs that support it, perf stat --topdown splits stall cycles into front-end bound, back-end bound, bad speculation and retiring. That's Ahmad Yasin's top-down method, built into Intel's PMU. A hot loop in a microbenchmark will look 95% retiring. Run the same code interleaved with realistic other work and watch the front-end-bound share grow.

Paperby Ahmad Yasin (ISPASS 2014)
A Top-Down Method for Performance Analysis and Counters Architecture

The four top-level categories and how to drill from 'front-end bound' to fetch latency vs fetch bandwidth, and from 'bad speculation' to branch mispredicts. This is the vocabulary perf --topdown and VTune use.

Docsby Agner Fog
The microarchitecture of Intel, AMD and VIA CPUs

Manual 3. Read the branch prediction chapter and the front-end section for one microarchitecture close to what you deploy on (µop cache size, decode width, misprediction penalty). Note how much the numbers differ between generations, and never quote one without naming the CPU.

Recall#dead-code-costs

How can code that never executes make the code that does execute slower?

Design note#hot-path-inventory

List every function on your engine's submit path, from decoded order to emitted events. For each, estimate how often each branch is taken in a normal day (use your week 12 generator's order mix). Which blocks run on fewer than 1 in 1,000 orders? Those are your cold-path candidates.

0 chars

Day 2 — Moving code out of line

Day 02

#[cold], #[inline(never)] and cheap errors

Tell the compiler what's rare, then make rare things small

Rust gives you three levers, and they compose:

  • #[cold] on a function tells LLVM that calls to it are unlikely. Branches that lead to a cold call get low weight, so the compiler lays out the other side as the straight-line fall-through and moves the cold block to the end of the function. The cold function itself is optimised with less inlining around it, and on ELF targets it may be placed in a separate .text.unlikely section, away from hot code.
  • #[inline(never)] keeps the cold function's body from being inlined back into the hot function. Without it, a small cold helper can still be inlined, and its instructions come back.
  • std::hint::cold_path() marks one branch arm as cold without creating a function. It's stable only in recent Rust releases. On an older toolchain, calling an empty #[cold] function in that arm does the same job. likely/unlikely intrinsics exist but are still unstable.
#[cold]
#[inline(never)]
fn reject(code: RejectCode) -> Result<Accepted, Reject> {
    Err(Reject { code })            // the slow, rare work goes here
}

#[inline]
pub fn validate(o: &Order, lim: &[Limits]) -> Result<Accepted, Reject> {
    let Some(l) = lim.get(o.instrument as usize) else { return reject(RejectCode::UnknownInstrument) };
    if o.price.rem_euclid(l.tick) != 0 { return reject(RejectCode::BadTick) }
    // ... each failing check jumps out of line; the success path falls straight through
    Ok(Accepted { /* ... */ })
}

Panics are already handled this way: the standard library's panic entry points are cold and out of line, which is why an unwrap() costs only a compare and a branch on the hot path. Watch for eager work in the arguments, though. .expect(&format!("bad order {id}")) formats a String on every call, success or not. Logging macros expand formatting setup at the call site, so keep them out of hot functions entirely (week 19's binary log records exist for this). And keep the error type small: a Result<T, E> with a large E makes every return larger, and an E that owns a String gives every caller drop glue on the error path and an allocation per reject.

Drill

Split a cold error path

Target: 6/6 casesNot yet run
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RejectCode {
    UnknownInstrument,
    BadTick,
    OutsideBand,
    BadLot,
    TooLarge,
}

#[derive(Clone, Copy, Debug)]
pub struct Order {
    pub instrument: u32,
    pub price: i64,
    pub qty: u32,
}

#[derive(Clone, Copy, Debug)]
pub struct Limits {
    pub tick: i64,
    pub lot: u32,
    pub band_lo: i64,
    pub band_hi: i64,
    pub max_qty: u32,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Accepted {
    pub instrument: u32,
    pub price: i64,
    pub qty: u32,
}

/// The error the hot path returns. As written it carries a formatted
/// message: every reject allocates, and every caller inherits drop glue.
/// Slim it down (keep `code()`), and build the text in `describe` instead.
#[derive(Debug)]
pub struct Reject {
    code: RejectCode,
    message: String,
}

impl Reject {
    pub fn code(&self) -> RejectCode {
        self.code
    }
}

/// Hot path. Checks in this order: unknown instrument (index out of
/// range), tick (price must be a multiple), band (inclusive), lot (qty
/// must be a positive multiple), max qty.
pub fn validate(o: &Order, limits: &[Limits]) -> Result<Accepted, Reject> {
    todo!()
}

/// Cold path: the human-readable reject text, built only when someone
/// actually logs it. Exact formats:
///   UnknownInstrument  "unknown instrument 7"
///   BadTick            "price 10005 is not a multiple of tick 10"
///   OutsideBand        "price 20000 outside band [9000, 11000]"
///   BadLot             "qty 150 is not a positive multiple of lot 100"
///   TooLarge           "qty 50000 exceeds max 10000"
pub fn describe(o: &Order, limits: &[Limits], r: &Reject) -> String {
    todo!()
}
Rust

Two of the harness checks can't be expressed as behaviour, so they inspect the type: size_of::<Reject>() must be at most 8 bytes, and std::mem::needs_drop::<Reject>() must be false. After it passes, put validate into Compiler Explorer with and without #[cold] on your reject helper and compare where the reject blocks end up.

Recall#expect-format

On a hot path, what's wrong with book.best_bid().expect(&format!(...)) where the message names the symbol, and how do you fix it?

Day 3 — Error paths that survive a storm

Day 03

Rare is not the same as unimportant

Rejects, logging and rare order types as first-class designs

Moving rejects out of line protects the hot path. That isn't permission to make rejects slow. The moments when rejects spike are exactly the moments the exchange is under the most stress: a fat-fingered algorithm hammering a price band during a fast market, a client reconnecting with a stale sequence, a misconfigured lot size after a corporate action. If each reject allocates a String, formats it and writes a log line synchronously, a reject storm becomes a latency incident for every client.

A design that holds up:

  • The reject is a value: a one-byte code plus the order id the caller already has. It's Copy, there's nothing to drop, and constructing it costs nothing.
  • Counting stays on the engine thread, formatting doesn't. Increment a per-reason counter (plain u64s owned by the engine thread, published like week 19's metrics). Send a compact binary reject record to the logging thread. The text is built there, or never.
  • The client still gets a precise reason: the reject code maps to the protocol's reject reason field. The gateway (or the encoder) formats any human-readable text off the engine thread.
  • Rare order types are cold functions too. FOK feasibility scans, post-only cross handling and anything else most orders never touch go behind #[inline(never)] functions called from one branch, so the plain-limit-order path stays short.
Recall#reject-storm

A client's algorithm goes wrong and sends 50,000 orders per second, all outside the price band. Why does the design of the reject path suddenly matter for every other client, and what should the engine do per reject?

Day 4 — Branch prediction

Day 04

Predictable is free, random is not

How predictors learn, and what defeats them

A modern core predicts every conditional branch before decoding it and speculates down the predicted path. When it's right, the branch costs almost nothing. When it's wrong, all the work started on the wrong path is thrown away and the pipeline refills. That costs roughly 15–20 cycles on recent x86 cores, about 5 ns at 4 GHz. It's the same order as an L2 miss, and it can happen on every iteration of a loop.

How predictors learn, from simplest to what you actually run on:

  • Bimodal: a table of 2-bit saturating counters indexed by the branch's address. A branch that's almost always taken (or almost never) is predicted perfectly after one miss. A branch that alternates defeats it completely.
  • Global history (gshare): index the counters by the branch address XORed with the outcomes of the last n branches (Scott McFarling, 1993). Now patterns are learnable: alternating, every third time, a loop that exits every 8th iteration, if the history is long enough to cover the pattern.
  • Modern predictors (TAGE-style designs using several history lengths at once, perceptron-based predictors) learn long and correlated patterns remarkably well. What no predictor can learn is randomness: a branch on data that's genuinely unpredictable, such as "is this order a buy?" on mixed flow or "is this price at or above the threshold?" on shuffled data, is mispredicted about half the time.

The famous demonstration is summing the elements of an array that pass a threshold. It runs several times faster when the array is sorted, because the branch becomes "false, false, … false, true, true, … true", which the predictor gets wrong only once or twice. You'll build the predictors and count the mispredictions exactly, which is deterministic and needs no noisy timer, and then see the timing effect on real hardware.

Docsby Dan Luu
Branch prediction

A clear walk from static prediction through 2-bit counters, two-level and gshare predictors to perceptrons and TAGE, with the misprediction rates each achieves. Read it before the drill: it's the predictor you're about to build.

Docsby Stack Overflow (answer by Mysticial)
Why is processing a sorted array faster than processing an unsorted array?

The canonical example, with the branchless rewrite and the twist that compilers can make the difference disappear. Compare its explanation with what your predictor drill measures.

Drill

Predictable vs unpredictable branches

Target: 6/6 casesNot yet run
/// A table of 2-bit saturating counters. 0 and 1 predict not-taken, 2 and 3
/// predict taken. Every counter starts at 1 (weakly not-taken).
pub struct Predictor {
    table: Vec<u8>,
    history: u32,      // last `history_bits` outcomes, newest in bit 0 (taken = 1)
    history_bits: u32, // 0 = bimodal (no history)
}

impl Predictor {
    /// `entries` is a power of two. Index = pc & (entries - 1).
    pub fn bimodal(entries: usize) -> Self {
        todo!()
    }

    /// gshare: index = (pc ^ history) & (entries - 1). history_bits <= 16.
    pub fn gshare(entries: usize, history_bits: u32) -> Self {
        todo!()
    }

    /// Predict this branch, then train the counter on the real outcome
    /// (saturating at 0 and 3) and shift the outcome into the history.
    /// Returns true if the prediction was right.
    pub fn predict_and_update(&mut self, pc: u32, taken: bool) -> bool {
        todo!()
    }
}

/// Feed every outcome through the predictor; return how many were mispredicted.
pub fn count_mispredicts(p: &mut Predictor, pc: u32, outcomes: &[bool]) -> usize {
    todo!()
}

/// Sum of xs[i] where xs[i] >= t, with no data-dependent branch:
/// turn the comparison into an all-ones / all-zeros mask.
pub fn sum_at_least_branchless(xs: &[u32], t: u32) -> u64 {
    todo!()
}
Rust

About the timings in the last case: in a debug build (the Playground's default) you should see the branchy loop run noticeably slower on shuffled data than on sorted data, while the branchless loop doesn't care. In a release build, LLVM often turns the branchy version into a conditional move or vectorises it, and the gap disappears. That's the compiler doing what you just did by hand. Check the assembly before claiming either result.

Recall#loop-history

Your gshare predictor with 4 bits of history mispredicts a loop that exits every 8th iteration about as often as a plain bimodal predictor, but 8 bits fixes it. Why?

Day 5 — Branchless code, layout tools and keeping the path warm

Day 05

Remove the branch, or let a profile place it

cmov, structure, PGO, BOLT, and warming

When a branch is genuinely unpredictable, remove it:

  • Arithmetic and masks, as in the drill: compute both outcomes and select with a mask.
  • Conditional moves: the compiler emits cmov for simple selects. A cmov never mispredicts, but it makes the result depend on both inputs, which can lengthen a dependency chain. When the branch was predictable, the branchy version is often faster.
  • Structure instead of branching. The exchange's most common data-dependent branch is the order's side. Instead of if side == Buy { … } else { … } throughout the matching code, index by side: two ladders in an array books[side], with comparison direction encoded once. The branch disappears into an address calculation.

When a branch is predictable but rare, keep the branch and fix the layout. Profile-guided optimisation does that from evidence instead of annotations. Build an instrumented binary (-Cprofile-generate), run a representative workload, rebuild with -Cprofile-use, and the compiler places hot blocks together, orders branches by observed frequency and inlines based on real call counts. BOLT goes a step further and rearranges the linked binary from a perf profile. rustc itself is built with PGO and, on Linux, BOLT. Treat your hand-placed #[cold] annotations as a baseline and compare them against a PGO build. If PGO beats you, keep PGO in the release pipeline (week 22) and keep the annotations as documentation.

Finally, the trading-specific trick. The deepest cause of tail latency on the order path is that it hasn't run recently: between orders, other code evicted it from the caches and the predictor. Firms keep the path warm by running it continuously: the engine or strategy periodically pushes a dummy order through the whole path, flagged so it's discarded at the very end (before the journal, or before the NIC). Carl Cook's CppCon talk describes this technique, along with much of this week.

Videoby Carl Cook (CppCon 2017) · ~1h
When a Microsecond Is an Eternity: High Performance Trading Systems in C++

A trading-firm engineer on the hot path: keep error handling and logging out of it, avoid branches with templates, keep the cache warm, and measure end to end rather than in microbenchmarks. Nearly every point maps onto Rust directly.

Docs
Profile-guided Optimization (The rustc book)

The two-step build, merging .profraw files with llvm-profdata, and the warning that the training workload must be representative. A PGO build trained on a microbenchmark optimises the microbenchmark.

Paperby Panchenko, Auler, Nell, Ottoni (CGO 2019)
BOLT: A Practical Binary Optimizer for Data Centers and Beyond

Why post-link layout from sampled profiles helps large binaries with front-end stalls, and how much it adds on top of PGO. Read the introduction and evaluation. The mechanism is the same idea as #[cold], automated at scale.

Recall#branchless-not-always

When is a branchless rewrite slower than the original branch, and what should decide between them?

Design note#warming-plan

Design a cache-warming scheme for your engine: what the dummy order looks like, how often it runs, where exactly it's discarded so it can never trade, be journalled or reach a client, and how you'd prove in a test that it never leaks into market data.

0 chars

Day 6 — Plan the restructuring

Day 06

Before and after, measured at the tail

Decide what moves, then build the benchmark that can see it

Write down the plan before touching code:

  1. The baseline. Your current engine's p50/p99/p999 submit-to-events latency, the size of the hot function (cargo asm line count, or nm --size-sort on the binary), and perf stat front-end counters.
  2. What moves. From Day 1's inventory: reject construction and text, logging, rare order types, debug assertions you don't need in release, anything that formats.
  3. A benchmark that can see the change. A tight loop of identical orders keeps everything warm, and hot/cold splitting will look useless in it. Interleave realistic other work between orders, as the real engine does: publish market data, append to the journal, maybe touch a few hundred KiB of unrelated data. Use a realistic order mix that includes a few percent of rejects.
Design note#restructure-plan

List the five changes you'll make to the order path, in order, with the metric each one should move (hot-function size, iTLB/icache misses, branch misses, p99). Which one do you expect to matter most, and what result would prove you wrong?

0 chars

Capstone

In crates/engine, build FastEngine<B, R, TAPE> (src/fast.rs): the week 7/8 engine with a week 37 RiskPolicy built in, restructured so the common case (a valid limit, IOC or market order that rests or matches normally, or a valid cancel or reduce) is a short, straight sequence. It's a new type rather than a rewrite of Engine. The pre-split Engine stays as the oracle the tests compare against, and its w07.match_latency stays your "before" number. Measure code size and tail latency before and after.

What the repo's version keeps on the hot path: one combined spec check that only answers "valid or not" (tick, lot, band, non-zero), one look at the opposite best, and for a passive GTC limit a single book.add that doubles as the duplicate check. What it moves into #[cold], #[inline(never)] functions: every reject (the precise reason is worked out only once you know there is one), the per-reason reject counters (reject_count(reason), this engine's "logging"), fill-or-kill's depth walk and post-only orders that would take.

The w38_ tests hold it to exact equivalence: event for event against Engine on 1,000 randomized week 12 streams (shrunk to a minimal case on failure), on a reject-heavy stream (tick 5, lot 10, a price band) where every reject path fires, and against week 37's Guarded with MaxOrderQty and RiskEngine policies. They also check that the reject counters are exact, that the tape, sequence number and state hash match Engine, and that neither the hot nor the cold paths allocate.

Pitfalls:

  • Marking common paths cold. If 30% of your flow is IOC, IOC isn't cold, and marking it so moves 30% of orders onto the slow layout. Use the order mix from your generator (week 12) and your profile, not intuition.
  • Outlining tiny things. A cold function costs a call and a return when it does run. Outline blocks that are both rare and large (formatting, scans, logging). A one-line error constructor doesn't need it. Its branch being unlikely is enough.
  • Benchmarks that can't see it. As Day 6 says: if the benchmark keeps the whole path in L1i, the change can't show. Report results from the interleaved benchmark, and say which one you used.
  • Forgetting PGO. Build a PGO variant of the same code and report it next to your hand-annotated version. If PGO alone gets most of the win, that's a result worth writing down.

Reading the benchmark. The target is a p99 on submit-to-events after the split, tighter than the week 7 matching target. w38.match_latency uses week 7's method and flow (70% passive, one command at a time) on FastEngine<Book, NoRisk>: the week 26 book with no risk policy, so set against w07.match_latency it measures the split itself. Swapping in week 28's ladder is week 40's job. The commands run back to back with nothing in between, so this is the warm case the pitfall above describes. Your interleaved benchmark is the one that can show the front-end effect. The mean may barely move this week. Look for the p99 and p999 to come down, and for the front-end counters to explain why. If the tail doesn't improve, check whether your benchmark was ever front-end bound, then decide whether that's a property of the benchmark or of the engine.

Capstone · in your exchange · crates/engine

A straight-line order path

Rejects, logging and rare order types moved out of the engine's hot path, with code size, front-end counters and tail latency measured before and after.

cargo xtask test --week 38      # tests named w38_*
cargo xtask bench --week 38     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 38 tests pass w38_*
  • Submit→events after hot/cold split w38.match_latencytarget p99 ≤ 350 ns
Design note#week-reflection

Report your before/after table: hot-function bytes, icache and iTLB misses per order, branch misses per order, p50/p99/p999, for the hand-annotated build and for PGO. Which change moved the p99, and which ones did nothing you could measure?

0 chars

Feeds into

  • Week 39 builds the tick-to-trade latency budget stage by stage. The engine stage's p99 after this week's work is one of its key rows, and the warm-versus-cold difference you measured is one of the jitter sources it attributes.
  • Week 40's performance sprint uses this week's interleaved benchmark and before/after methodology as its template.