Part C · Substrate › CPU microarchitecture

Module 6·Part C — Substrate·18 min

CPU microarchitecture

False sharing, dependent chains, store buffers and memory ordering, the AVX-512 downclock, and the cache-line layout that makes an SPSC ring fast.

The core mental model

A modern core is an out-of-order machine that goes to enormous lengths to appear sequential. Instructions issue in order, execute whenever their operands are ready, and retire in order, with a reorder buffer of 350–512 entries holding the illusion together. Performance is therefore a question of whether that window stays full of useful, independent work, and there are exactly three ways to fail. You can be latency-bound, where a chain of dependent operations means the window is full but almost everything in it is waiting. You can be throughput-bound, where you have run out of some resource — a port, a load buffer, decode bandwidth. Or you can be flushing, where mispredicted branches and memory-ordering violations throw away work you already did.

core not retiringthe symptom, identical in all threefront-end boundstarved of instructionsback-end boundwaiting on memorybad speculationwork thrown away
The three reasons a core is not retiring instructions, and the fact that they look identical from outside. Only a top-down breakdown separates them, and each wants an opposite intervention — more independent work, better layout, or fewer unpredictable branches. Guessing costs you the effort and usually moves the wrong one.

The dependent-chain case has a Little’s Law of its own, and it is the single most useful number in this module. An FMA has latency 4 cycles and throughput 2 per cycle, so saturating the units requires 4×2=84 \times 2 = 8 independent accumulators in flight. A dot product written with one accumulator runs at one FMA per 4 cycles instead of 2 per cycle: one eighth of peak, from code that looks perfectly reasonable. This is the same shape as needing bytes in flight to saturate HBM, one level down.

Memory is where the surprises live. The 64-byte line is the unit of coherence, so two threads writing different variables in the same line ping-pong that line between caches and turn a 1 ns store into 100 ns — false sharing, invisible in the source, catastrophic in the profile. On ordering, x86 is Total Store Order: loads are not reordered with loads, stores are not reordered with stores, but a store can be reordered after a later load, because stores sit in a store buffer. So the only barrier x86 ever needs is StoreLoad, which is why memory_order_release and memory_order_acquire compile to plain mov instructions and cost nothing, while memory_order_seq_cst on a store compiles to a locked exchange and costs about 20 cycles. Getting this straight is what lets you write a single-producer/single-consumer ring with genuinely zero synchronisation instructions on the fast path.

Numbers worth memorizing

StructureSize / costConsequence
Cache line64 B (prefetch pairs → 128 B)false-sharing granularity
L1d32–48 KB, 4–5 cyc~1.3 ns
L21–2 MB, 14–16 cyc~5 ns
L31.5–3 MB/core, 40–60 cyc~15–25 ns, shared and therefore noisy
DRAM~80 ns local~240 cycles
Reorder buffer350–512 entriesthe out-of-order window
Line-fill buffers (MSHRs)10–16caps single-core memory parallelism
Store buffer56–72 entrieswhy x86 needs a StoreLoad fence
dTLB / STLB~64–96 / 1536–2048~6 MB reach on 4 KB pages
OperationCostNote
FMA latency / throughput4 cyc / 2 per cycneeds 8 accumulators
Branch mispredict15–20 cyc≈ pipeline depth
lock op, uncontended~20 cyccheap enough to ignore
lock op, contended100–300 cyca cache-line transfer
mfence20–30 cyconly StoreLoad needs one on x86
False sharing10–100× slowdownthe highest-leverage bug in this module
Store-to-load forward, aligned~5 cycfast path
Store-to-load forward, partial overlap+15–20 cyca real and easily-created stall
Single-core DRAM bandwidth8–12 GB/s10–16 MSHRs × 64 B / 80 ns

Ordering on x86-TSO, and what each maps to:

C++ memory orderx86 instructionCost
relaxed load / storemovfree
acquire loadmovfree
release storemovfree
seq_cst loadmovfree
seq_cst storexchg (implicit lock)~20 cyc
acq_rel RMWlock-prefixed op~20 cyc uncontended

Critical thinking

A dot product loop reaches one quarter of peak FLOPs. The assembly looks fine. What is wrong?

Almost certainly a single accumulator, making the loop a dependent chain of FMAs. Each FMA must wait for the previous one’s result, so you run at one per 4-cycle latency instead of two per cycle — one eighth of peak, and a quarter is what you see once the compiler has partially unrolled.

The fix is latency×throughput=4×2=8\text{latency} \times \text{throughput} = 4 \times 2 = 8 independent accumulators, summed once at the end. This is Little’s Law applied to the execution engine, and the general form is worth carrying: the number of independent operations you need in flight is the product of an operation’s latency and its throughput. It is the same reasoning that produced “80 sectors per SM” in Module 1 and “8 to 16 warps is not enough” in Module 3.

Two caveats that matter in practice. Compilers will not do this for you in floating point without -ffast-math or an explicit #pragma omp simd reduction, because reassociating a float sum changes the result (Module 8) and the compiler is not permitted to change your answer. And if the data does not fit in cache, none of this helps — you are then bound by the 10–16 line-fill buffers, which cap a single core at 8–12 GB/s no matter how many accumulators you have.

Write the minimum synchronization for an SPSC ring on x86, and name the three things that make it slow anyway.

Synchronisation first, and there is essentially none. The producer writes the payload, then publishes with a release store to the write index. The consumer reads the write index with an acquire load, then reads the payload. On x86-TSO both compile to plain mov: no fences, no locked instructions. The release ordering guarantees the payload writes are visible before the index update, which is the only invariant the algorithm needs.

The three things that make it slow anyway are all about layout and traffic:

  1. Head and tail on the same cache line. The producer writes one, the consumer writes the other, and they ping-pong a shared line on every single operation. This alone can cost 100×. Separate them by 128 B (adjacent-line prefetch, above).
  2. Reading the other side’s index every iteration. Even correctly padded, each read of the consumer’s index pulls a line the consumer keeps dirtying. Cache a local copy of the far index and only refresh it when the local view says the ring is full or empty — typically cutting coherence traffic by the batch size.
  3. A non-power-of-two capacity. Wraparound then needs a modulo (20+ cycles) rather than a mask (1 cycle). Always size to a power of two.

Worth stating explicitly: none of this generalises. The absence of atomics is a property of single producer and single consumer. Add a second producer and you need a real RMW, and the whole cost structure changes.

Why does AVX-512 sometimes make a program slower?

Four mechanisms, and the first is the notorious one.

  • Frequency licensing. On Skylake-SP-era Intel server parts, sustained heavy 512-bit operations (FP and integer multiply) drop the core into a lower frequency licence — up to roughly 40% below base — and the transition costs tens of microseconds. Worse, the licence applies to the whole core, so a brief burst of AVX-512 slows down all the scalar code around it. Ice Lake and later reduced this considerably, and AMD’s Zen 4/5 implementation avoids most of it, but on the wrong part it is decisive.
  • You are memory-bound. Wider vectors do not move more bytes if you are already limited by bandwidth or by the 10–16 line-fill buffers. You keep the downclock and gain nothing.
  • Register pressure and spills. Thirty-two 512-bit registers sound generous until the compiler starts spilling 64-byte values.
  • Alignment and line splits. A 64-byte load spans an entire line, so any misalignment splits across two lines every time rather than occasionally.

Practical rule: use 256-bit for mixed or latency-sensitive code, and 512-bit only for long, compute-dense loops where you have measured both the throughput gain and the actual achieved frequency. And measure frequency, not just time — a change that looks neutral may be a real speedup masked by a downclock, or vice versa.

You add a mutex around a 5 ns operation and throughput collapses. Quantify it.

The lock has become the operation, by more than an order of magnitude.

Uncontended, lock-prefixed instructions cost about 20 cycles each, and a lock/unlock pair is two of them plus the surrounding code: call it 40–60 cycles, roughly 15–20 ns against a 5 ns payload. You have quadrupled the cost before any contention at all.

Contended is where it collapses. The lock line must transfer between caches — 100–300 cycles — and if the lock is held when you arrive, pthread_mutex will eventually make a futex syscall and park the thread. That is a context switch: 1–5 µs direct plus tens of microseconds of cache and TLB damage afterwards. Against a 5 ns operation you are now three to four orders of magnitude out, and the operation is serialised on top.

The correct shapes, in order of preference: give each thread its own data and never share; hand off through an SPSC ring (previous probe) so there is no mutual exclusion at all; use a seqlock if the data is read-mostly and readers can retry; and only then reach for a lock, keeping the critical section large enough that 40 cycles of overhead is amortised — which for a 5 ns operation means batching thousands of them.

Branchless code is slower on your benchmark. Why might you ship it anyway?

Because you may be buying determinism rather than speed, and Part B established that those are different objectives.

A predictable branch costs about 1–2 cycles; a cmov or arithmetic mask costs a fixed 1–3 cycles and cannot be predicted away. So on predictable data, branchless loses — that is what your benchmark measured. On unpredictable data the branch costs 15–20 cycles roughly half the time, about 8–10 cycles on average, and branchless wins outright.

The decisive point for a latency path is the distribution rather than the mean. A branch gives you a bimodal 1-cycle/20-cycle result whose mixture depends on the input data; branchless gives a constant. If you are defending a p99, you will take a slower deterministic path over a faster bimodal one, because the tail is what the deadline is written against. And in a decision tree (Module 11) the branches are unpredictable by construction — a predictable split carries no information — so branchless is both faster and more deterministic there.

The general framing: benchmarks report means on whatever data you fed them. Ask what the branch predictor’s accuracy will be on production data, and whether you are optimising the mean or the tail, before believing the result.

Self-check

How many independent accumulators does an FMA-bound loop need, and why exactly that many?

Eight. The requirement is latency×throughput=4 cyc×2 per cyc\text{latency} \times \text{throughput} = 4 \text{ cyc} \times 2 \text{ per cyc} — Little’s Law applied to a functional unit. With one accumulator the loop is a dependent chain running at one FMA per 4 cycles, an eighth of peak. Note the compiler will not introduce them for floating point without explicit permission, because reassociation changes the result.

State the x86-TSO reordering rule and say which C++ memory orders are therefore free.

Loads are not reordered with loads, stores are not reordered with stores, but a store may be reordered after a later load, because stores retire through a store buffer. Only StoreLoad needs a barrier. So relaxed, acquire, release and even seq_cst loads compile to plain mov and are free; a seq_cst store compiles to a locked exchange at about 20 cycles, and read-modify-write operations cost a lock prefix.

Why can a single CPU core not saturate its socket's memory bandwidth?

Because it has only 10–16 line-fill buffers, capping outstanding misses. At 64 B each over an 80 ns latency that is 8–12 GB/s against a socket’s 200–400 GB/s. The limit is memory-level parallelism, not bandwidth, which is why the fixes are more independent streams, prefetching, and multiple cores — not a faster memory part.

Name the three things that make a correctly-synchronized SPSC ring slow, in order of cost.

Head and tail sharing a cache line, so every operation ping-pongs it between cores (up to 100×); reading the far index every iteration, which pulls a line the other side keeps dirtying (fixed by caching it locally and refreshing only on apparent full/empty); and a non-power-of-two capacity, forcing a 20-cycle modulo where a 1-cycle mask would do. Pad to 128 B rather than 64, because the adjacent-line prefetcher works in pairs.

Give two reasons AVX-512 can be a net loss, one about frequency and one about memory.

Frequency licensing: sustained heavy 512-bit operations drop the core’s clock — historically up to ~40% on Skylake-SP — with a transition costing tens of microseconds, and the penalty applies to all code on that core, including the scalar code around the vectorised loop. Memory: if the loop is already bandwidth- or MSHR-limited, wider vectors move no additional bytes per unit time, so you keep the downclock and gain nothing.

When is a branchless implementation the right choice despite being slower on your benchmark?

When the branch will be unpredictable on production data, and when you are defending a tail rather than a mean. A branch is bimodal — 1–2 cycles predicted, 15–20 mispredicted — while branchless is a constant 1–3. Benchmarks on predictable data flatter the branch; decision trees, where a predictable split would carry no information, are the canonical case where branchless wins on both counts.