Module 1·Part A — Foundations·16 min
Memory hierarchy, roofline, arithmetic intensity
What the roofline actually bounds, why the ridge point keeps running away from us, and the exact point at which the model stops being useful.
The core mental model
Roofline is one inequality: , where is peak compute, is peak bandwidth, and is arithmetic intensity in FLOPs per byte. Everything interesting is in , and specifically in the denominator, because arithmetic intensity is not a property of an algorithm. It is a property of a triple: algorithm, boundary, implementation. The FLOPs are fixed by the math. The bytes are whatever crosses one specific interface, and you get to choose which interface you are asking about. A tiled GEMM and a naive GEMM do identical arithmetic and have identical minimum traffic, yet differ by three orders of magnitude in achieved at the HBM boundary. Anyone who quotes “the arithmetic intensity of attention” without naming a boundary has said nothing.
Which is why the roofline is hierarchical. Each level of the memory system has its own and therefore its own roof, and a kernel sits somewhere different under each one. A kernel can be comfortably above the HBM ridge and still be starved by shared-memory bandwidth or L2. The ridge point is where the two roofs meet, and its long-run behavior is the single most important fact about the last decade of accelerators: peak FLOPs has grown roughly 3× per generation while HBM bandwidth has grown roughly 1.5×, so keeps climbing. On an H100 with dense fp16 tensor cores it is about 295 FLOPs per byte. Being compute-bound is now a rare and privileged condition, achieved by engineering, not stumbled into. Essentially all of kernel optimization is manufacturing reuse until you reach the ridge.
Map that onto inference and the two phases separate cleanly. Prefill is GEMM-shaped: for a square fp16 matmul of size , HBM-boundary intensity is , so it crosses the H100 ridge somewhere around and is solidly compute-bound at realistic sizes. Decode is GEMV-shaped: each weight element is read once and used for two FLOPs, giving at fp16, roughly 300× below the ridge. The consequence is the number that governs single-stream serving: batch-1 decode latency has a hard floor of model bytes divided by memory bandwidth, and no amount of compute changes it. The clean way to see the whole batching story is that decode intensity is approximately the batch size, because the weights are read once and amortized across tokens. You need before an H100 stops being memory-bound on the weight matmuls, which is exactly why continuous batching exists and exactly why it does nothing for the tail of a single request.
The last paragraph is the one to carry forward, because it is the seam this whole sequence is built on. Roofline is a steady-state throughput model. It silently assumes enough concurrent work to saturate the pipes, no dependent-stall bubbles, warm caches, and fixed overheads amortized to zero. At batch 1 against a hard deadline, every one of those assumptions fails. You are not under either roof, you are in a regime the model does not describe, and its central prescription (raise by batching and reusing) is precisely the move that is unavailable to you. Part B replaces the objective with critical-path depth. Keep the roofline for what it is genuinely excellent at, which is telling you what you are not limited by.
Numbers worth memorizing
The latency ladder, in the units that matter. Cycle counts assume roughly 1.7 GHz on the GPU and 3 GHz on the CPU. Treat these as order-of-magnitude anchors, not datasheet values.
| Level | Latency | Bandwidth | Capacity |
|---|---|---|---|
| GPU register file | ~1 cyc | effectively ∞ | 256 KB / SM |
| GPU shared memory / L1 | ~25–35 cyc, ~20 ns | ~128 B/clk/SM | 228 KB / SM |
| GPU L2 | ~200–350 cyc, ~150–250 ns | ~5–10 TB/s | 50 MB (H100) |
| GPU HBM3 | ~350–450 ns | 3.35 TB/s | 80 GB |
| NVLink 4 (peer GPU) | ~1.5–2.5 µs | 450 GB/s / dir | — |
| PCIe Gen5 ×16 round trip | ~1–2 µs | 64 GB/s / dir | — |
| CPU L1d | 4–5 cyc, ~1.3 ns | ~200 GB/s/core | 32–48 KB |
| CPU L2 | 14–16 cyc, ~5 ns | ~100 GB/s/core | 1–2 MB |
| CPU L3 (LLC) | 40–60 cyc, ~15–25 ns | ~400 GB/s | 30–100 MB |
| DRAM, local socket | ~80–100 ns | 200–400 GB/s | — |
| DRAM, one NUMA hop | ~130–160 ns | lower, contended | — |
| Cut-through switch hop | ~50–100 ns | — | — |
Ridge points. This is the table that explains why your kernel is memory-bound.
| Machine | Peak | Ridge | |
|---|---|---|---|
| H100 SXM, fp8 tensor, dense | 1979 TFLOP/s | 3.35 TB/s | ~590 |
| H100 SXM, fp16 tensor, dense | 989 TFLOP/s | 3.35 TB/s | ~295 |
| H100 SXM, fp32 CUDA cores | 67 TFLOP/s | 3.35 TB/s | ~20 |
| A100 80GB, fp16 tensor | 312 TFLOP/s | 2.0 TB/s | ~156 |
| Server CPU, AVX-512 fp32 | ~2–3 TFLOP/s | ~0.4 TB/s | ~6–8 |
Intensities of things you actually run, all at the HBM boundary, fp16 unless noted.
| Workload | (FLOP/byte) | Regime |
|---|---|---|
| Elementwise add, SAXPY | 0.08–0.17 | memory, hopelessly |
| LayerNorm, softmax | ~0.5 | memory |
| GEMV / batch-1 decode weight matmul | ~1 | memory, ~300× under ridge |
| Decode attention, KV-cache read | ~1 | memory |
| Decode at batch | memory until | |
| Square GEMM, dimension | compute above | |
| FlashAttention prefill | ~ | compute-ish |
| Training forward+backward | 100s–1000s | compute |
Batch-1 decode floors on one H100 at 3.35 TB/s, assuming perfect bandwidth utilization. Real achieved bandwidth is 70–85% of peak, so multiply the times by ~1.25 for an honest number.
| Model | Weight bytes | Floor / token | Ceiling |
|---|---|---|---|
| 8B fp16 | 16 GB | 4.8 ms | 210 tok/s |
| 8B fp8 | 8 GB | 2.4 ms | 420 tok/s |
| 70B fp16 | 140 GB | 42 ms | 24 tok/s |
| 70B fp8 | 70 GB | 21 ms | 48 tok/s |
| 70B int4 | 35 GB | 10.4 ms | 96 tok/s |
Little’s Law, , applied to memory:
- H100 HBM: in flight, which is roughly 10,500 outstanding 128 B sectors, or ~80 per SM. Occupancy is not a virtue in itself. It is the mechanism by which you meet this number.
- One CPU core: ~10–16 line-fill buffers × 64 B / 80 ns ≈ 8–12 GB/s, an order of magnitude below the socket’s 200–400 GB/s. A single core cannot saturate its own DRAM. This is a memory-level-parallelism limit, not a bandwidth limit, and it is why prefetching and multiple independent pointer chases matter so much on CPU.
Critical thinking
The profiler reports 45% of peak FLOPs and 35% of peak HBM bandwidth. Where is the bottleneck?
Neither roof, which means the binding constraint is not in this roofline at all. Roofline gives an upper bound. Sitting under both roofs tells you only that something outside the model is limiting you. Ranked suspects:
- You are bound at a level this roofline cannot see. Shared-memory or L2 bandwidth, or register-file bandwidth. Draw the hierarchical roofline before drawing conclusions.
- Latency-bound, not bandwidth-bound. Insufficient memory-level parallelism to satisfy Little’s Law: low occupancy, long dependent chains, address arithmetic on the critical path. The pipes are wide open and you are not putting enough in flight.
- Issue-bound. The limiter is instruction throughput on a pipe that is neither FMA nor memory: address math, predication, shared-memory instructions, or the epilogue.
- Wave quantization. A partly-empty final wave dilutes the whole-kernel average. 132 SMs and 140 blocks means a second wave running at 6% utilization.
- Synchronization.
__syncthreads()in an inner loop serializes what the roofline assumes is concurrent.
The discipline worth internalizing: roofline names what you are not limited by. It never names the limiter.
You fuse two memory-bound elementwise kernels. Bound the speedup before you measure.
Count bytes at the binding boundary, not FLOPs. Two passes over bytes, each reading and writing: total. Fused: . Ceiling is 2×, and that is the whole answer at the HBM level.
Reality lands below it for three reasons and above it for one. Below: if is small enough that the intermediate stayed resident in L2, the second pass never touched HBM, so fusion bought you nothing at that boundary. On H100, with 50 MB of L2, anything under ~25 MB of working set is already in this regime. Fusion also raises register pressure, which lowers occupancy, which lowers memory-level parallelism, which lowers achieved bandwidth, so the fused kernel can run at a worse point on the same roof. Above: you also eliminated a kernel launch, roughly 3–5 µs, which dominates everything else for small .
Generalized: the ceiling on any fusion is bytes-before over bytes-after, evaluated at whichever boundary is actually binding. The same arithmetic sizes recomputation-versus-storage tradeoffs, FlashAttention included.
Decode is memory-bound. So why does speculative decoding help, and where is its ceiling?
Because in decode, arithmetic intensity is tokens-verified-per-weight-load. Reading the weights is the entire cost. The FLOPs are free, since you are sitting 300× below the ridge with all that compute idle. Verifying drafted tokens in a single forward pass loads the weights exactly once and does the arithmetic: goes from ~1 to ~, and you move right along the memory roof at constant byte cost. You are spending headroom you already owned.
Four ceilings, in the order you hit them:
- Acceptance. Expected accepted tokens per round is set by draft-target agreement, typically 2–4 for a well-matched draft. Everything past that is discarded work.
- The draft model’s own serial decode, which is itself batch-1 memory-bound and sits on the critical path.
- KV cache traffic. The “bytes are free” premise holds only while weights dominate. At long context the KV cache becomes the larger read and it scales with the tokens you process, so the argument decays.
- The ridge itself. Once effective batch × approaches ~300 you become compute-bound and further buys nothing.
The same one-line test explains GQA, quantization, and continuous batching: they are all byte-count or intensity moves. None of them is a FLOP move. In a memory-bound regime, FLOP moves are not interesting.
Batch 1, hard 5 µs deadline, forever. What does roofline tell you?
Close to nothing, and the direction it points is wrong. Three distinct failures:
- It is asymptotic. presumes enough concurrent work to reach steady state and hide latency. At batch 1 the machine is overwhelmingly idle and you are under both roofs by a wide margin, in a regime the model does not describe.
- Its only lever is unavailable. “Raise ” means batch or reuse. Batch is pinned at 1 by the problem statement, and there is no reuse to find inside a single sample.
- It assumes fixed costs amortize to zero. At 5 µs nothing amortizes. Kernel launch alone is 3–5 µs. A PCIe round trip is 1–2 µs. Either one consumes the budget before a single FLOP retires.
The replacement model is , with the objective being depth of the critical path measured in cycles or gate delays, not bytes moved. That reframing is the content of Part B, and it is why this workload class ends up on an FPGA or ASIC with weights held in on-chip SRAM and registers. Flattening the memory hierarchy to a single level does not make “bytes over bandwidth” smaller. It removes the term from the equation.
Would faster memory fix this kernel? Answer without buying the faster memory.
Measure at the binding boundary and compare to . Below the ridge, performance scales roughly linearly in and not at all in . Above it, the reverse. That is the model’s answer, and it is worth exactly as much as your byte accounting.
Two empirical tests that need no model at all. Clock sweep: pin the memory clock and sweep the SM clock, then swap. The slope of each curve tells you which roof you are on. Byte ablation: halve the bytes without touching the math, by quantizing weights fp16 → fp8. If the kernel gets ~2× faster you were memory-bound. If it barely moves, you were not, and you just learned something more reliable than any profiler counter.
Self-check
State the definition of arithmetic intensity so that it survives the follow-up “at which level?”
FLOPs performed divided by bytes moved across a named boundary, for a specific implementation. The FLOPs come from the algorithm; the bytes come from the implementation and the boundary. Naive and tiled GEMM share FLOPs and share a minimum byte count, yet differ by ~1000× in achieved at HBM. An unqualified intensity number is not wrong so much as meaningless.
What is the ridge point for dense fp16 tensor cores on H100, and what square GEMM size crosses it?
FLOP/byte. A square fp16 GEMM of dimension has HBM-boundary intensity , so it crosses at , call it 900. Below that, a “compute” kernel is memory-bound, which is why small-batch matmuls disappoint and why the fp8 ridge at ~590 pushes that crossover out to again once you account for halved bytes.
Batch-1 decode floor for a 70B model in fp8 on one H100. Then explain what tensor parallelism does to that number.
70 GB / 3.35 TB/s ≈ 21 ms per token, so ~48 tok/s, before derating for ~80% achieved bandwidth. Tensor parallelism across GPUs divides the per-GPU weight bytes by and multiplies aggregate bandwidth by , so the floor drops roughly as . What it adds is an all-reduce per layer on the critical path: two collectives per transformer block over NVLink, at a few microseconds of latency each. For 80 layers that is a real fraction of the budget, and it is a latency cost, not a bandwidth cost, so it does not shrink as you add GPUs. It is the reason TP scaling on decode goes sublinear well before the roofline says it should.
How many bytes must be in flight to saturate H100 HBM? Why can one CPU core not saturate its socket's DRAM?
, roughly 10,500 outstanding 128 B sectors, ~80 per SM. That is the real reason occupancy matters: it is the mechanism for meeting Little’s Law, not a goal in itself.
One CPU core is capped by its line-fill buffers, roughly 10–16 outstanding misses. At 64 B each over ~80 ns that is 8–12 GB/s, against 200–400 GB/s for the socket. The limit is memory-level parallelism, not bandwidth, which is why the fix is more independent streams and prefetch distance, and why a single-threaded pointer chase is the worst case in both directions.
Two kernels have identical arithmetic intensity. One reaches the memory roof; the other sits at 40% of it. Give three reasons.
Insufficient memory-level parallelism to satisfy Little’s Law, from low occupancy or long
dependent chains. Poor access patterns, so the bytes requested are far fewer than the bytes
transferred — intensity computed from useful bytes flatters an uncoalesced kernel that is
actually pulling whole lines to use four of them. And structural stalls the model ignores:
__syncthreads(), wave quantization, or partition camping across memory channels.
The general form: identical means identical position on the horizontal axis, and says nothing about whether you can generate enough concurrency to reach the roof above you.
One sentence: why does roofline mispredict at batch 1 with a hard deadline?
It is a steady-state throughput asymptote that assumes saturating concurrency and amortized fixed costs, and at batch 1 there is no concurrency to saturate with and nothing over which to amortize, so the binding quantity is critical-path depth rather than bytes over bandwidth.