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

Memory Layout, Alignment & Cache Lines

The CPU reads 64 bytes at a time. Design for it.

Foundation · Concurrency, Memory & the Core Engine

Why this week

Up to now you have reasoned about values: who owns them, who may mutate them, which thread sees which write. The hardware does not see values. It sees 64-byte cache lines moving between private caches, and every design decision you make about structs decides which of your fields travel together.

Two numbers frame the week. A load that hits L1 costs about 4–5 cycles, roughly a nanosecond. A load that has to fetch a line another core just wrote costs tens of nanoseconds on one socket and more than 100 ns across sockets. That 50–100× gap is the difference between a counter you can bump a few hundred million times a second and one that collapses to a few tens of millions the moment a second thread touches the line next to it. Matching-engine budgets are measured in hundreds of nanoseconds, so you cannot afford to be on the wrong side of it by accident.

The artifacts: a layout calculator that predicts what rustc does, a CachePadded<T> and per-thread counters that never share a line, and in the repo, a refit of your week 1 ring buffer so the producer's and the consumer's indices stop fighting.

ℹ️
Note

All the latency figures this week are ballparks for a recent x86-64 server or desktop part at 3–4 GHz (Skylake to Sapphire Rapids, Zen 2 to Zen 4). They move with microarchitecture, core count and clock speed. Measure your own machine before quoting numbers in an interview, and name the machine when you do.

Day 1 — Bytes, lines and the hierarchy

Day 01

What the CPU actually moves

Caches, lines and the cost of a miss

Paperby Ulrich Drepper
What Every Programmer Should Know About Memory

Read section 3 (CPU caches) closely and skim section 6 (what programmers can do). The hardware details are from 2007; the shape of the argument — lines, associativity, write-back, coherence traffic — has not changed.

Bookby Mara Bos · ch. 7
Rust Atomics and Locks

'Understanding the Processor': x86-64 and ARM64 instructions behind atomics, cache coherence, and a performance experiment that shows false sharing with align(64). The best short bridge from Rust code to what the core does.

The memory hierarchy, with numbers you should be able to recite (they vary by part; the ratios are what matter):

  • Registers: zero extra latency.
  • L1d: 32–48 KB per core, ~4–5 cycles (about 1 ns).
  • L2: 256 KB–2 MB per core, ~12–16 cycles (3–5 ns).
  • L3: shared, several MB to hundreds of MB, ~40–70 cycles (10–20 ns), more on big mesh parts.
  • Local DRAM: ~80–100 ns.
  • Remote NUMA DRAM: often 130–150 ns or more.

Everything between the core and DRAM moves in lines: 64 bytes on current Intel and AMD x86 cores and most ARM server cores, 128 bytes on Apple's M-series. Read one u8 and the whole line comes in; write one u8 and the core must own the whole line exclusively first.

Latency numbers · log scaleQuiz yourself →

Dot = typical modern x86 server; band = plausible range across hardware. Click a row for details.

Recall#why-lines

You read a single u64 from a struct that is not in any cache. How many bytes cross the memory bus, and what does that imply for where you put the fields you read next?

Design note#hot-path-bytes

Write down every field the matching loop reads or writes when an aggressive order fills against the front order at one price level. How many bytes is that? How many cache lines does it touch with the order struct you would naively write today?

0 chars

Day 2 — Layout rules: size, alignment, padding

Day 02

How rustc lays out a struct

repr(Rust), repr(C), align and packed

Docs
The Rust Reference — Type Layout

Read the size/alignment definitions, the default representation (no guarantees), and the C representation's algorithm. The repr(C) struct algorithm is exactly what you implement in today's drill.

Docsby Nicholas Nethercote
The Rust Performance Book — Type Sizes

Practical techniques for measuring and shrinking types: -Zprint-type-sizes, boxing large enum variants, smaller integer types, and static size assertions.

The rules you need:

  • Every type has a size and an alignment (a power of two). A value's address is always a multiple of its alignment, and its size is always a multiple of its alignment — that is what lets [T; N] place element i at i * size_of::<T>().
  • repr(C) lays fields out in declaration order. Each field goes at the next offset that is a multiple of its alignment, the struct's alignment is the largest field alignment, and the size is rounded up to it. The gaps are padding: real bytes that occupy cache lines and carry no information.
  • repr(Rust), the default, makes no promises. Today rustc reorders fields (roughly largest alignment first) to remove interior padding and exploits niches: Option<NonZeroU64>, Option<Box<T>> and Option<&T> are 8 bytes, while Option<u64> is 16 because every u64 bit pattern is a valid value.
  • #[repr(align(N))] raises the alignment to at least N (and so the size to a multiple of N). Use it to start a value on a line boundary.
  • #[repr(packed)] removes padding by allowing misaligned fields. Rust will not let you take a reference to a misaligned packed field; you copy it out or use read_unaligned. It is for matching wire formats (week 11), not for speed.

A typical first-draft order:

#[repr(C)]
struct NaiveOrder {
    side: u8,     // offset 0, then 7 bytes of padding
    id: u64,      // 8
    is_ioc: bool, // 16, then 7 bytes of padding
    price: i64,   // 24
    qty: u32,     // 32
    owner: u16,   // 36
    flags: u8,    // 38, then 1 byte of tail padding
}                 // size 40, align 8 — 15 of 40 bytes are padding

Sorted by alignment the same fields fit in 32 bytes: two orders per line instead of one and three-fifths. That is the whole trick, and repr(Rust) usually does it for you; with repr(C) (or any layout you must control) it is your job. You can pin the result in a test with const _: () = assert!(std::mem::size_of::<Order>() == 32); so a later field addition that blows the budget fails the build instead of the benchmark.

Recall#repr-c-vs-rust

Why would you ever choose repr(C) over the default repr(Rust) in a matching engine, given that repr(Rust) usually produces a smaller struct?

Drill

Predict and shrink struct layouts

Target: 6/6 cases · matches rustcNot yet run
/// A struct layout as the compiler computes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
    pub size: usize,
    pub align: usize,
    /// Byte offset of each field, in declaration order.
    pub offsets: Vec<usize>,
}

/// Round `n` up to the next multiple of `align` (a power of two).
pub fn align_up(n: usize, align: usize) -> usize {
    // Hint: one add and one mask. No division, no branches.
    todo!()
}

/// Lay out `fields` exactly like `#[repr(C)]`. Each field is `(size, align)`.
///   - fields stay in declaration order
///   - each field starts at the next offset that is a multiple of its align
///   - the struct's align is the largest field align (1 if there are no fields)
///   - the size is rounded up to the struct's align (so arrays stay aligned)
pub fn repr_c_layout(fields: &[(usize, usize)]) -> Layout {
    todo!()
}

/// The smallest size any ordering of `fields` can achieve — what you get by
/// reordering by hand, and what rustc's default `repr(Rust)` usually finds.
pub fn min_reordered_size(fields: &[(usize, usize)]) -> usize {
    todo!()
}

/// How many 64-byte cache lines does an object of `len` bytes that starts at
/// byte address `addr` touch? A zero-length object touches none.
pub fn cache_lines_touched(addr: usize, len: usize) -> usize {
    todo!()
}
Rust

The harness checks your repr_c_layout against size_of, align_of and offset_of! on real #[repr(C)] structs, so you are predicting the compiler, not a made-up model. For min_reordered_size, convince yourself why sorting by alignment, largest first, can never leave interior padding when every alignment is a power of two.

Day 3 — Coherence and false sharing

Day 03

When two cores want the same line

MESI, RFOs and ping-pong

Each core has private L1 and L2 caches, so the hardware runs a coherence protocol to keep copies consistent: MESI and its variants (Intel uses MESIF, AMD MOESI). A line is Modified, Exclusive, Shared or Invalid in each cache. The rule that matters: to write, a core must hold the line exclusively. If another core has a copy, the writer sends a read-for-ownership (RFO), the other copy is invalidated, and if it was Modified the data travels core to core.

Now put two counters next to each other in one struct, and give each one its own thread:

struct Stats {
    orders_in: AtomicU64,  // written only by the gateway thread
    fills_out: AtomicU64,  // written only by the engine thread
}

No data is shared, yet every increment by one thread invalidates the line in the other core, and the next increment there has to pull it back. That is false sharing: the line ping-pongs between cores at tens of nanoseconds per transfer, and both threads run at the speed of the interconnect instead of the speed of L1. With true sharing (both threads updating the same counter) you get the same traffic and also need a locked read-modify-write; with false sharing you get the traffic for nothing.

False sharing
Core 0 · thread increments a
0
holds the shared line in M state
Core 1 · thread increments b
0
line invalidated — next write must fetch it (~40–100 ns)
struct { a: u64, b: u64 } — both on one 64-byte line
a
b
·
·
·
·
·
·
line 0: Modified in core 0's L1
Cache-line transfers between cores: 0 — one per alternation, although the threads never share data.

Things that make it worse or confusing in practice:

  • Atomic RMWs are not free even without contention. A lock xadd on an L1-resident line is roughly 20 cycles on recent Intel cores, versus about one for a plain store. If a counter has exactly one writer, a Relaxed load plus store avoids the locked instruction entirely.
  • The adjacent-line prefetcher. Intel's L2 spatial prefetcher pulls in 128-byte-aligned pairs of lines, so two hot variables 64 bytes apart can still interfere. That is why crossbeam pads to 128 bytes on x86-64.
  • SMT siblings share L1. Two hyperthreads of one physical core do not ping-pong a line, so a false-sharing benchmark pinned to sibling hyperthreads shows nothing. Pin to separate physical cores.
Recall#false-vs-true-sharing

Two threads each increment their own AtomicU64 with fetch_add, and the two counters are adjacent in memory. Is this a data race? Why is it slow?

Paperby Martin Thompson (Mechanical Sympathy)
False Sharing

The classic demonstration from the LMAX/Disruptor school: padding a hot field to its own line, and the throughput difference as thread count grows. The Java padding tricks are dated; the measurements are not.

Day 4 — CachePadded and per-thread counters

Day 04

Give every writer its own line

repr(align) as an architectural tool

Docs
crossbeam_utils::CachePadded

Read the source comment that picks the alignment per architecture: 128 on x86-64 (the spatial prefetcher pulls line pairs) and on aarch64 and powerpc64 (some cores have 128-byte lines), other values elsewhere. Your drill only needs at least 64; the repo's version makes the same per-target choice.

The pattern is one line of attribute and a Deref:

#[repr(align(128))]
pub struct CachePadded<T> { value: T }

Because size_of is always a multiple of align_of, every CachePadded<T> occupies at least 128 bytes, so neighbours in a Vec or a struct cannot share a line. Vec<T> allocates with T's alignment, so the first element starts on a boundary too. The price is memory: 8 bytes of counter in 128 bytes of space. Spend it on the handful of variables that different threads write; never on data that is read-mostly, where sharing a line is free.

The other design move is one writer per line: rather than one global counter every thread bumps, give each thread its own padded slot and sum them when someone asks. Writes become core-local; the reader pays a few cache misses once per scrape instead of every writer paying on every increment. The per-thread histograms and counters of week 19 use exactly this shape.

Drill

Measure false sharing with padded counters

Target: 5/5 cases · exact counts under 4 threadsNot yet run
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicU64, Ordering};

/// Wraps a `T` so that no two `CachePadded` values ever share a cache line.
// TODO: one attribute on this struct does all the work. Which, and why 64
// (or 128 — read crossbeam's comment before you choose)?
pub struct CachePadded<T> {
    value: T,
}

impl<T> CachePadded<T> {
    pub fn new(value: T) -> Self {
        todo!()
    }

    pub fn into_inner(self) -> T {
        todo!()
    }
}

impl<T> Deref for CachePadded<T> {
    type Target = T;
    fn deref(&self) -> &T {
        todo!()
    }
}

impl<T> DerefMut for CachePadded<T> {
    fn deref_mut(&mut self) -> &mut T {
        todo!()
    }
}

/// One counter per thread. Slot `i` is written only by thread `i`; any
/// thread may read the total.
pub struct PaddedCounters {
    slots: Vec<CachePadded<AtomicU64>>,
}

impl PaddedCounters {
    pub fn new(n: usize) -> Self {
        todo!()
    }

    /// Add one to slot `i`. Only the owning thread calls this.
    pub fn incr(&self, i: usize) {
        todo!()
    }

    pub fn get(&self, i: usize) -> u64 {
        todo!()
    }

    /// Sum of every slot (a metrics scraper calls this).
    pub fn total(&self) -> u64 {
        todo!()
    }

    /// Address of slot `i`'s counter — the harness checks the spacing.
    pub fn slot_addr(&self, i: usize) -> usize {
        todo!()
    }
}
Rust
⚠️
Warning

The harness also times your padded slots against adjacent unpadded AtomicU64s and prints the ratio. Treat it as a demo, not a verdict: the Playground container may expose only one or two cores, and threads that never run at the same time never contend. Run the same code on your own machine, with threads on separate physical cores, to see the real gap — often 5–20× for this pattern.

Recall#single-writer-incr

In PaddedCounters each slot has exactly one writer. Why can incr use a Relaxed load followed by a Relaxed store instead of fetch_add, and what does a reader give up?

Day 5 — Struct-of-arrays and hot/cold splits

Day 05

Lay data out for the loop that reads it

AoS, SoA and what the book scans

The order of fields inside one struct is half the story. The other half is how you arrange many of them.

  • Array of structs (AoS): Vec<Level> where each Level holds price, total quantity, order count and queue pointers. A loop that touches every field of each element uses every byte it pulls in.
  • Struct of arrays (SoA): prices: Vec<i64>, qtys: Vec<u64>, counts: Vec<u32>, …. A loop that reads only quantities — "how much is available within 10 ticks of the touch?" — streams through 8 bytes per level instead of 32 or more, gets 8 levels per line instead of 2, and is trivially vectorizable (week 29).

Neither wins in general; the access pattern decides. Matching touches one level and one order at a time and uses all of their hot fields, so AoS with a carefully packed hot struct is right there. Depth aggregation and market-data snapshots scan one field across many levels, so a SoA view (or a dense ladder of quantities, week 28) is right there.

The intermediate move is a hot/cold split. The fields the matching loop needs — price, remaining quantity, id, queue links — go in a compact hot struct; the client order id string, account, entry timestamp and other reporting data go in a parallel cold table indexed the same way. The hot struct then fits two to four orders per line, and the cold data is only touched when you emit an execution report.

#[repr(C)]
struct OrderHot {          // 32 bytes: two per line
    price: i64,
    qty: u64,
    id: u64,
    next: u32,
    prev: u32,
}

struct OrderCold {         // touched on reports and audits, not on matching
    client_order_id: [u8; 20],
    account: u32,
    entered_ns: u64,
}
Design note#aos-vs-soa

Your book must answer two queries: 'fill against the best level' (hot, every aggressive order) and 'total quantity in the top 10 levels each side' (once per market-data publish). Sketch a layout that serves both. Which query does each structure favour, and what does the other one cost?

0 chars
Recall#soa-when

When does struct-of-arrays beat array-of-structs, and when does it lose?

Day 6 — Seeing it on real hardware

Day 06

Measure before you pad

perf c2c and an honest benchmark

Docs
perf-c2c(1) — Linux manual page

perf c2c record / report finds cache lines with heavy cross-core traffic (HITM: a load that hit a line Modified in another core) and shows which code and which offsets inside the line are involved. It is the tool for confirming false sharing instead of guessing.

Padding everything is not a strategy: it bloats memory, spreads hot data across more lines and can make a cache-friendly design worse. Pad the variables different threads write frequently, verify with perf c2c or a before/after benchmark, and leave the rest alone.

Design note#padding-audit

List every atomic or shared-mutable field in your exchange so far (the w01 ring indices, the w03 cancel flags and task counters). For each: which threads write it, how often, and which other hot fields currently share its line? Which ones deserve padding and which do not?

0 chars

Capstone

This week's repo work lives in crates/sync (src/pad.rs, plus a refit of src/spsc.rs), next to the week 1 ring buffer and the week 3 cancellation primitives. Three pieces:

CachePadded<T>. A repr(align) wrapper: you write its field, new, into_inner and the Deref/DerefMut impls; Default, From<T> and Debug are provided. The per-target alignment ships with the template: 128 bytes on x86_64, aarch64 and powerpc64 (crossbeam's choice), 64 elsewhere, exported as CACHE_LINE. The tests pin it so a refactor cannot silently change it: align_of must equal CACHE_LINE, size_of must be a whole number of lines, and padded values in a Vec must each start on their own line.

Per-thread padded counters. PaddedCounters::new(slots) holds one CachePadded<AtomicU64> per slot, with add(slot, n), get(slot), sum() and len(). The repo keeps the slot an index and makes add a Relaxed fetch_add, so the count stays exact even if two threads share a slot (the 8-thread test adds to slot 0 from every thread), and an out-of-range slot panics. One thread per slot is the convention that makes it fast. The structural version, a slot handle that only its owner holds and that increments with a plain load and store, is the next step if the locked add ever shows up in a profile (see the check on Day 4). Either way, decide what a reader is promised: sum adds up Relaxed loads taken at slightly different instants, which is fine for metrics and wrong for anything that must balance exactly. UnpaddedCounters, the same API packed eight to a line, is provided as the baseline.

Refit the w01 SPSC ring. Put head (written by the producer) and tail (written by the consumer) in separate CachePadded fields, and keep the buffer pointer and mask off both lines. Also implement spsc::index_offsets::<T>(), which returns the byte offsets of the two indices inside the ring's shared state (offset_of!); the test requires them to be at least 64 bytes apart for every T. Re-run the week 1 throughput benchmark and record the before/after: on most machines this one change is worth a sizeable fraction of the ring's throughput when producer and consumer sit on different cores. Next week caches the remote index locally, which removes most of the remaining traffic.

How to think about the benchmark targets: in w04.padded_counters, 8 threads each add to their own slot 20 million times, and the aggregate should scale roughly linearly. Each thread should run at L1 speed, and the target is 1.5 billion increments per second in aggregate. w04.unpadded_counters runs the same loop over UnpaddedCounters to show the cliff, so its bar is deliberately low. The benchmark is written for you and already measures honestly: all threads start behind a barrier, the final sum is checked, and threads are pinned to distinct physical cores that share an L3 (override with HFT_BENCH_CPUS=2,3,…, or HFT_BENCH_CPUS=none). If you write your own measurement, do the same, run long enough to swamp thread start-up (hundreds of milliseconds), and make sure the optimizer has not collapsed your loop (use fetch_add, or black_box the counter). A laptop with 4 performance cores cannot run 8 threads in parallel; record the core count next to the numbers.

Capstone · in your exchange · crates/sync

CachePadded and a cache-aware SPSC ring

Padded counters scale with threads; the w01 ring keeps head and tail on separate lines.

cargo xtask test --week 4      # tests named w04_*
cargo xtask bench --week 4     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 4 tests pass w04_*
  • 8 threads incrementing padded counters w04.padded_counterstarget ≥ 1.50G ops/s
  • 8 threads incrementing adjacent counters (baseline) w04.unpadded_counterstarget ≥ 10.0M ops/s

Feeds into

  • Week 5 builds on the padded ring: cached remote indices and batching, then a multi-producer queue whose contended tail is exactly the kind of line this week taught you to isolate.
  • Week 6 designs the order book's node and level structs; the layout and hot/cold rules from Days 2 and 5 decide how many orders fit per line.