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

Compile-Time Specialization

Generics, const generics and monomorphization as performance tools.

Performance · Measure, Profile, Then Go Fast

Why this week

Weeks 26 to 28 removed waste you could see in a profile: hot functions, allocations, cache misses. This week the waste is subtler. It's decisions the CPU makes at runtime that you already knew at compile time: which order-book implementation this engine uses, which risk policy, how big the ring is. Every Box<dyn OrderBook> call is an indirect jump the compiler can't see through. Every index % capacity with a runtime capacity is a hardware division. Every Vec-backed ring carries a pointer and a bounds check that a fixed-size array wouldn't.

Rust gives you three tools to move those decisions into the compiler: generics (monomorphisation makes a copy of the code per concrete type, so calls become direct and inlinable), const generics (sizes and other values become part of the type), and inlining control. Used well, they let the optimiser constant-fold, remove bounds checks and vectorise across what used to be an opaque call. Used carelessly, they inflate code size and compile times, and that has its own cost: next week is about instruction-cache pressure.

So the week's discipline is the same as week 26's: don't trust the abstraction, read the output. By Friday you should be able to look at a function's assembly and say whether the call was inlined, whether the bounds check survived, and whether there's a div in the hot loop.

ℹ️
Note

Specialisation pays only on the hot path. Configuration loading, admin commands and logging sinks are fine behind dyn Trait, and making them generic just multiplies code. The skill is putting the static/dynamic boundary in the right place.

Day 1 — What dispatch compiles to

Day 01

Fat pointers, vtables and monomorphisation

Three ways to call a trait method

Dynamic dispatch. A &dyn FeeModel is a fat pointer: a data pointer plus a pointer to a vtable, a static table generated per (type, trait) pair. In current rustc, the vtable starts with the drop function, size and alignment, followed by the trait's methods. That layout is an implementation detail, not a guarantee. A call loads the vtable pointer, loads the function pointer from it, and makes an indirect call. On x86-64 that typically looks like call qword ptr [rax + 24].

The indirect call itself isn't the main cost. When the target is predictable (one concrete type behind the pointer, called repeatedly), the CPU's branch target buffer predicts it and the call costs a few cycles more than a direct one. A misprediction costs a pipeline flush, roughly 15–20 cycles on recent x86 cores, the same as any mispredicted branch. The real cost is what the compiler can't do across an opaque call: inline the body, propagate constants into it, hoist loop-invariant work out of it, keep values in registers across it, or vectorise a loop that contains it.

Static dispatch. A generic fn total<M: FeeModel>(m: &M, …) is monomorphised: the compiler makes a separate copy for every concrete M it's used with. Inside each copy the call is direct, almost always inlined, and the loop body becomes plain arithmetic the optimiser can work on. You pay with code size (one copy per type) and compile time.

Enum dispatch. When the set of types is closed and known (three fee models, two book implementations), an enum with a match gives you one copy of the calling code with direct, inlinable calls in each arm. The match compiles to a compare chain or a jump table on the discriminant. For a small set this is often nearly as fast as generics, with no code multiplication. The catch is that adding a type means editing the enum.

dyn Traitgeneric <T: Trait>enum + match
Callindirect via vtabledirect, usually inlinedbranch on tag, then direct
Inlining across the callno (unless devirtualised)yesyes
Code sizeone copyone copy per typeone copy
Open to new typesyesyes (at compile time)no
Heterogeneous collectionsyesnoyes
Videoby Jon Gjengset · ~2h
Crust of Rust: Dispatch and Fat Pointers

Monomorphisation, trait objects, vtables, object safety and the where Self: Sized escape hatch, explained by poking at real compiler output. The object-safety part matters for your capstone, because you'll need a trait that works both ways.

Docs
Trait objects (The Rust Reference)

The precise rules: which traits can be made into objects (dyn compatibility), what auto traits and lifetimes mean in a trait-object type. Short, and worth knowing exactly.

Recall#dyn-real-cost

If a well-predicted indirect call costs only a few cycles more than a direct one, why can dyn dispatch in a hot loop cost 2× or more?

Day 2 — Measuring dispatch honestly

Day 02

Benchmarks that don't lie about dispatch

Devirtualisation, monomorphic call sites and black_box

Dispatch microbenchmarks mislead in two predictable ways.

The compiler devirtualises. If the concrete type behind a &dyn is visible at the call site (you built the Box three lines up), LLVM can turn the indirect call into a direct one and inline it, and your "dyn" benchmark measures static dispatch. Hide the type: pass the trait object through std::hint::black_box, construct it in a separate #[inline(never)] function, or select it from runtime input.

The predictor is perfect. A loop that calls the same dyn method on the same object a million times is a monomorphic call site. The indirect branch is predicted every time, so all you measure is the lost inlining. Real engines often have polymorphic call sites: different order types, different instruments' policies. Modern indirect-branch predictors use history and can learn short repeating patterns, but data-dependent sequences defeat them. Measure both cases.

In the drill you'll implement three fee models (the kind of per-fill calculation an exchange or a broker runs millions of times a day) and total them through static dispatch, a monomorphic dyn call, a polymorphic dyn call and an enum. The harness checks correctness and reports nanoseconds per fill. On one x86-64 machine in release mode, the static loop came in around 3 ns per fill, monomorphic dyn around 5, polymorphic dyn around 12, and the polymorphic enum around 3.4. Your numbers will differ. In debug mode, which is the Playground's default, everything collapses to the same slow speed, because nothing is inlined anywhere. Run it both ways if you can, and explain the gaps before you move on.

Drill

Measure dyn vs generic dispatch

Target: 5/5 cases · timings explainedNot yet run
pub trait FeeModel {
    fn fee(&self, qty: u64, px: u64) -> u64;
}

/// Fixed fee per fill.
pub struct Flat(pub u64);

/// Basis points of notional (qty * px), rounded down. Notional can exceed
/// u64::MAX: compute it in u128.
pub struct Bps(pub u64);

/// `low` bps when notional <= cutoff, `high` bps above it.
pub struct Tiered {
    pub cutoff: u64,
    pub low: u64,
    pub high: u64,
}

impl FeeModel for Flat {
    fn fee(&self, _qty: u64, _px: u64) -> u64 {
        todo!()
    }
}

impl FeeModel for Bps {
    fn fee(&self, qty: u64, px: u64) -> u64 {
        todo!()
    }
}

impl FeeModel for Tiered {
    fn fee(&self, qty: u64, px: u64) -> u64 {
        todo!()
    }
}

/// A closed set of models: dispatch by `match`, which the compiler can inline.
pub enum AnyFee {
    Flat(Flat),
    Bps(Bps),
    Tiered(Tiered),
}

impl FeeModel for AnyFee {
    fn fee(&self, qty: u64, px: u64) -> u64 {
        todo!()
    }
}

/// Static dispatch: monomorphised per model, `fee` inlinable into the loop.
pub fn total_static<M: FeeModel>(m: &M, fills: &[(u64, u64)]) -> u64 {
    todo!()
}

/// Dynamic dispatch: one indirect call per fill through the vtable.
pub fn total_dyn(m: &dyn FeeModel, fills: &[(u64, u64)]) -> u64 {
    todo!()
}

/// Fill i is charged by models[i % models.len()]: a polymorphic call site.
pub fn total_dyn_mixed(models: &[Box<dyn FeeModel>], fills: &[(u64, u64)]) -> u64 {
    todo!()
}

/// Same, through the closed enum.
pub fn total_enum_mixed(models: &[AnyFee], fills: &[(u64, u64)]) -> u64 {
    todo!()
}
Rust
Recall#devirtualize

Your benchmark shows dyn and generic dispatch running at exactly the same speed in release mode. Give two explanations other than 'dispatch is free'.

Design note#dispatch-asm

Open total_static::<Bps> and total_dyn in Compiler Explorer (rustc, -C opt-level=3). Paste the inner loop of each. Where is the indirect call? Did the static loop vectorise or unroll? What happened to the 128-bit multiply and the division by 10,000 in each?

0 chars

Day 3 — Const generics

Day 03

Put the number in the type

Inline storage, constant masks and bounds checks that vanish

struct Ring<T, const N: usize> { buf: [T; N], … } makes the capacity part of the type. Three things improve at once:

  1. Storage is inline. [T; N] lives inside the struct: no heap allocation, no pointer to chase, and the ring can sit in a cache-aligned struct next to its indices (or on the stack).
  2. Arithmetic becomes immediate. i & (N - 1) compiles to and with a constant. A runtime i % cap compiles to a hardware div. Agner Fog's instruction tables list 64-bit division latency at tens of cycles on many x86 cores (roughly 35–90 cycles on Skylake-era Intel, much less on recent Intel and AMD cores). Even a constant that isn't a power of two is better than a runtime value: the compiler replaces % 1000 with a multiply by a magic reciprocal, a handful of cycles.
  3. Bounds checks disappear. With N constant, the compiler can prove i & (N - 1) < N, so self.buf[i & Self::MASK] needs no bounds check. With a Vec and a runtime mask, it can't, and the compare-and-branch to the panic path stays in your hot loop.

You can also enforce invariants at compile time. An associated constant that asserts is evaluated when the type is instantiated, so Ring<u64, 6> stops compiling instead of misbehaving at runtime:

impl<T: Copy + Default, const N: usize> Ring<T, N> {
    const MASK: usize = {
        assert!(N.is_power_of_two(), "Ring capacity must be a power of two");
        N - 1
    };
}
// Since Rust 1.79 an inline `const { assert!(...) }` block in a function works too.

This is a post-monomorphisation error: it fires only when a function that uses MASK is instantiated with a bad N. That's why the drill's new touches Self::MASK explicitly.

The limits are real. Const generics can't (on stable) be computed from other const parameters in type positions (no [T; N * 2] in a struct field), every distinct N is another copy of the code, and a capacity chosen from configuration at start-up can't be a const generic at all. For those, enforce a power of two at runtime and keep a runtime mask. You get most of the arithmetic win, and you keep the bounds check.

Drill

Const-generic ring capacity

Target: 6/6 casesNot yet run
/// Fixed-capacity FIFO whose capacity is part of the type.
pub struct Ring<T: Copy + Default, const N: usize> {
    buf: [T; N],
    head: usize, // free-running read index
    tail: usize, // free-running write index
}

impl<T: Copy + Default, const N: usize> Ring<T, N> {
    /// Evaluated once per instantiation, at compile time: Ring<u64, 6>
    /// fails to build as soon as a method that uses MASK is instantiated.
    const MASK: usize = {
        assert!(N.is_power_of_two(), "Ring capacity must be a power of two");
        N - 1
    };

    /// Touch Self::MASK here so a bad N fails even if push/pop are unused.
    pub fn new() -> Self {
        todo!()
    }

    pub const fn capacity() -> usize {
        N
    }

    pub fn len(&self) -> usize {
        todo!()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Err(v) hands the value back when full.
    pub fn push(&mut self, v: T) -> Result<(), T> {
        todo!()
    }

    pub fn pop(&mut self) -> Option<T> {
        todo!()
    }
}

/// The same queue with its capacity chosen at runtime (any value >= 1).
/// Change the fields if you prefer another representation.
pub struct DynRing<T: Copy + Default> {
    buf: Vec<T>,
    head: usize,
    len: usize,
}

impl<T: Copy + Default> DynRing<T> {
    pub fn new(cap: usize) -> Self {
        todo!()
    }

    pub fn capacity(&self) -> usize {
        self.buf.len()
    }

    pub fn len(&self) -> usize {
        todo!()
    }

    pub fn push(&mut self, v: T) -> Result<(), T> {
        todo!()
    }

    pub fn pop(&mut self) -> Option<T> {
        todo!()
    }
}
Rust
Recall#const-mask-bounds

Name three things the compiler can do for Ring<T, 1024> that it can't do for a Vec-backed ring whose capacity is read from a config file.

Day 4 — Inlining and code size

Day 04

Who decides what gets inlined

#[inline], LTO, codegen units, and the price of copies

LLVM's inliner decides most inlining with a cost model. Your levers:

  • #[inline] is a hint, but for non-generic functions it does something more important: it makes the function's body available to other crates so they can inline it. Without it (and without LTO), a small accessor in your book crate can't be inlined into engine. Generic functions don't need it, because they're instantiated in the calling crate anyway.
  • #[inline(always)] is a strong request. It's usually honoured, but it isn't a guarantee (recursion, for example). Use it sparingly, on tiny functions you've measured.
  • #[inline(never)] keeps cold code out of hot functions (next week's topic), gives benchmarks a stable boundary, and makes a function show up by name in the assembly and in profiles.
  • LTO (lto = "fat" or "thin") and codegen-units = 1 in the release profile let LLVM see across crate and codegen-unit boundaries. They often give a measurable win for an engine split across crates, at the cost of build time.

Every monomorphised copy is real machine code. An engine generic over three parameters, instantiated with two types each, compiles its hot path up to eight times if all combinations are used. Measure it: cargo llvm-lines counts LLVM IR lines per generic function (a good proxy for compile-time cost), and cargo bloat shows bytes per function in the binary. A common fix is the "generic shell, concrete core" pattern: a thin generic function converts its argument and calls a non-generic inner function holding the bulk of the logic. The standard library does this in many places, for example functions that take impl AsRef<Path>.

Docsby Nicholas Nethercote et al.
The Rust Performance Book — Inlining, and Build Configuration

When #[inline] matters (cross-crate, non-generic), when inline(always) helps or hurts, and the outlining trick for cold paths. Then read the Build Configuration chapter for LTO, codegen-units and PGO, and set your release profile accordingly.

Code
dtolnay/cargo-llvm-lines

Run it on your engine crate before and after the capstone. The top of the list is where monomorphisation costs you compile time, and usually where a generic-shell/concrete-core split pays off.

Recall#inline-cross-crate

Your book crate exposes pub fn best_bid(&self) -> Option<Price>, a two-instruction function. The engine crate calls it in its hot loop, and the profile shows a call instruction every time. Why, and what are your two fixes?

Day 5 — Reading assembly

Day 05

A checklist for hot-function assembly

Five things to look for before you believe a benchmark

Use Compiler Explorer for small experiments (pick rustc, add -C opt-level=3, and -C target-cpu=native if you deploy that way) and cargo asm (cargo-show-asm, from week 1) for real crates. Mark the function you're studying #[inline(never)] so it exists as a symbol. Then go through the checklist:

  1. Calls. Are the trait methods and accessors you expected to inline gone? Any call left in the loop is a boundary the optimiser couldn't cross.
  2. Indirect calls. call qword ptr [reg + offset] or call reg means dynamic dispatch survived, perhaps through a dyn you forgot about or a function pointer in a struct.
  3. Bounds checks. A cmp followed by a jae or jb to a block that calls core::panicking::panic_bounds_check. One per loop (hoisted) is fine. One per element means the compiler couldn't prove your indices.
  4. Divisions. div or idiv in a loop is tens of cycles each. Check whether a constant or power-of-two capacity would let it become a shift, an and or a multiply.
  5. Vectorisation and spills. Packed instructions on xmm/ymm registers mean the loop vectorised. Lots of moves to and from [rsp + …] mean register pressure, often caused by a call in the loop.
Videoby Matt Godbolt (CppCon 2017) · ~1h
What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid

The C++ examples translate directly: strength reduction of division, devirtualisation, loop optimisations. Watch it with Compiler Explorer open and reproduce two of his examples in Rust.

Docsby Agner Fog
Instruction tables and optimisation manuals

Manual 4 (instruction tables) for latency and throughput per instruction and microarchitecture: look up DIV r64, IMUL r64 and an indirect CALL on your CPU. Manual 3 covers branch and branch-target prediction, which you'll need next week.

Design note#asm-checklist

Run the five-point checklist on your engine's submit/match function as it is today. Record what you find (calls, indirect calls, bounds checks, divisions, vector code, spills) as a baseline table. You'll fill in the same table after the capstone.

0 chars

Day 6 — Plan the specialised engine

Day 06

Where the static/dynamic line goes

Generic hot path, dyn edges

Sketch the engine's type before you write it. A shape that works:

pub trait OrderBook { fn add(&mut self, o: &Order) -> AddResult; fn cancel(&mut self, id: OrderId) -> bool; /* ... */ }
pub trait RiskPolicy { fn check(&mut self, o: &Order) -> Result<(), Reject>; }

pub struct Engine<B: OrderBook, R: RiskPolicy, const MAX_EVENTS: usize> {
    book: B,
    risk: R,
    events: EventBuf<MAX_EVENTS>,
}

pub type ProdEngine = Engine<LadderBook, PreTradeRisk, 64>;   // what you ship
pub type DynEngine  = Engine<Box<dyn OrderBook>, Box<dyn RiskPolicy>, 64>; // the comparison

For the dyn twin to exist, both traits must be object-safe (dyn compatible): no generic methods, no Self by value in arguments or returns. Methods that need generics get where Self: Sized and stay off the object. Implement the traits for Box<dyn …> (forwarding impls) so one Engine definition serves both instantiations. That way the two versions differ only in dispatch.

The repo draws the line a little differently:

  • Engine<B: OrderBook> has been generic over the book since week 7. The book crate implements OrderBook for Box<B>, so Engine<Box<dyn OrderBook>> already works.
  • Guarded<B, R>. The risk policy goes in a wrapper instead of a third parameter on Engine. Guarded holds an Engine<B> and an R: RiskPolicy, so Engine's struct and constructors stay as earlier weeks left them. DynGuarded is Guarded<Box<dyn OrderBook>, Box<dyn RiskPolicy>>.
  • RiskPolicy. check(&self, order, reference: Price) -> Result<(), RejectReason> is read-only. The hooks on_accept and on_event, which do nothing by default, keep a stateful policy in sync. A risk reject is the single event Rejected, and the order never reaches the engine, so it gets no engine sequence number.
  • Policies. NoRisk, which compiles to nothing; MaxOrderQty<const MAX: u64>, one compare against an immediate; and week 15's risk::RiskEngine.
Design note#engine-generics

Write the trait definitions for your OrderBook and RiskPolicy as they'll be in the capstone. Mark each method hot or cold. Which cold methods could stay dyn even in the generic engine (for example via a separate admin trait object), and what would that save in code size?

0 chars

Capstone

In crates/engine, make the engine generic over its book implementation (your week 6 book and the week 28 ladder) and its risk policy, give the fixed-size internals (event buffers, pools, ladder width) const-generic capacities, and keep a dyn instantiation of the same code for comparison.

In the repo (src/policy.rs), that's Guarded<B, R> and DynGuarded from Day 6. The const generics are MaxOrderQty<MAX> and TapeRing<N>. TapeRing is a trade tape over an inline array that wraps with a mask; its capacity must be a power of two, checked at compile time, so TapeRing::<48>::new() fails to compile. The ladder width stays a runtime argument (LadderBook::new(window, orders)), and events still go into the caller's Vec. The w37_ tests check that:

  • static, dyn, NoRisk and ladder configurations produce identical events, and risk rejects take no engine sequence number;
  • MaxOrderQty rejects in front of the engine;
  • the RiskEngine policy tracks exposure through the engine;
  • a boxed policy keeps a shared kill switch;
  • TapeRing behaves like TradeTape, has a compile-time capacity and doesn't allocate.

Pitfalls that make this week's comparison meaningless:

  • Accidental devirtualisation. If the dyn engine is built where the concrete types are visible, LLVM may see through it. Construct the boxed book and policy in an #[inline(never)] factory driven by runtime configuration, and confirm with cargo asm that the indirect calls are there.
  • Different work. Both engines must run the same order stream and produce identical event streams. Assert it in the benchmark harness, not just in tests.
  • Code-size blowup. Record cargo llvm-lines and binary size before and after. If the hot path now exists in four instantiations you never use, cut them.
  • Readability. FastEngine<LadderBook, risk::RiskEngine, 64> in every signature is a tax. Use type aliases for the shipped configurations (the repo has DynGuarded and, in week 40, FastestEngine) and keep generics out of public APIs that don't need them.

Reading the benchmark. The target is a ratio, generic over dyn, on the full submit-to-events path, and it's deliberately modest. Matching, memory traffic and event generation dominate an order's cost, and dispatch is a slice of it. You'll clear the bar only if the dispatch boundaries sit inside the hot loops: per price level in the book, per check in the risk policy. w37.static_vs_dyn runs the week 7 mixed flow through Guarded<Book, MaxOrderQty<1_000_000>> and through DynGuarded (a boxed Book and a boxed MaxOrderQty). The cap is far above any order in the flow, so it never rejects. The reported ratio is generic throughput ÷ dyn throughput, the median of 5 alternating measurements. Both sides use the week 6 Book, so the difference is dispatch alone. Event equality is checked by the w37_ test, not inside the benchmark. If your ratio comes out near 1.0, that's a finding too. Read the assembly, explain where the time goes, and say so in the report rather than tuning the benchmark until it agrees with you.

Capstone · in your exchange · crates/engine

The engine, specialised

An engine generic over its book and risk policy with const-generic capacities, a dyn twin doing identical work, and assembly that shows why one is faster.

cargo xtask test --week 37      # tests named w37_*
cargo xtask bench --week 37     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 37 tests pass w37_*
  • Generic vs dyn engine w37.static_vs_dyntarget ≥ 1.05 ×
Design note#week-reflection

Fill in Day 5's assembly table for the generic engine. Which single change (inlining, a removed bounds check, a removed division, vectorisation) accounts for most of the ratio you measured? Would you ship the generic version if the ratio were 1.03?

0 chars

Feeds into

  • Week 40's performance sprint uses the specialised engine as its starting point and the assembly checklist as one of its tools. The report you write there should include the generic-vs-dyn comparison and its explanation.