The roofline model and arithmetic intensity
Before this lesson: lesson 1 (SMs, HBM, tensor cores) and the matrix product (linear algebra 1). For the attention rows of section 3 you need attention with a KV cache (deep learning 9; the KV cache is also treated in the LLMs track, in preparation); skip those rows if you have not seen attention yet. Time: about 60 minutes, plus the notebook.
Why this matters #
Before you optimise anything, you need to know what limits it. A two-line calculation tells you, and so what helps:
- memory-bound → move fewer bytes: fuse kernels (lesson 3), keep data on chip (lesson 4), use smaller number formats (lesson 11), batch more tokens per weight read (lesson 12);
- compute-bound → use tensor cores and lower precision, or do fewer FLOPs.
The same calculation explains why an H100 decodes an LLM only about 1.6× faster than an A100 (not 3×), and why a small MLP never gets close to the advertised FLOP/s. Every later lesson of this track uses it.
1. Predict first #
2. The picture #
Two resources. For one kernel, think of the GPU as a calculator and a pipe. The calculator does at most FLOP/s. The pipe moves at most bytes per second between HBM and the chip (lesson 1 explains why nearly all data crosses it). We use the H100 SXM (2022): TFLOP/s dense bf16 on the tensor cores, 67 TFLOP/s fp32 on the CUDA cores, and TB/s.
First case: an elementwise op. ReLU on bf16 values reads bytes and writes bytes: bytes in total. It does about one FLOP per value.
- Data: µs.
- Arithmetic, even on the slower CUDA cores: µs.
The calculator waits almost all the time. The op is memory-bound.
Second case: a square matrix multiply. For matrices in bf16, needs FLOPs (one multiply and one add for each of the terms of each of the outputs). It moves at least bytes (read and once, write once). For :
- Arithmetic: µs.
- Data: µs.
Now the pipe waits. The matmul is compute-bound.
The ratio decides. ReLU does FLOP per byte. The matmul does FLOP per byte, because each number it loads is used times. This ratio is the subject of the whole lesson.
The roofline of one GPU. The horizontal axis is the intensity (FLOP per byte), the vertical axis the FLOP/s a kernel can reach; both are logarithmic. The sloped orange line is the memory roof , the flat blue line is the compute roof . Click an op to read its numbers, and drag it left or right to change its intensity. Then switch the GPU: the memory-bound points on the left move up only by the bandwidth ratio.
On an H100 in bf16, a matmul multiplies tokens of width 4096 by a weight. Is it compute-bound or memory-bound?
FLOPs M. Bytes the weight, MB (the 8 tokens add only 0.13 MB). So . Each weight value is loaded once and used for only 8 tokens. A large weight matrix does not make a matmul compute-bound; a large number of tokens per weight read does.
3. Definitions and main results #
Intensity, ridge point and the roofline bound #
For one kernel on one GPU:
- FLOPs: the floating-point operations the kernel performs. One multiply-add counts as 2.
- bytes: the bytes it moves between HBM and the chip (reads plus writes).
- : the peak FLOP/s of the GPU for the format and units used (dense, from the datasheet).
- : the HBM bandwidth in bytes/s.
- arithmetic intensity , in FLOP/byte. It is a property of the kernel (and its data sizes), not of the GPU.
- ridge point , in FLOP/byte. It is a property of the GPU.
The roofline bound.
Why the two forms agree. Divide FLOPs by both sides of the first inequality. is at most and at most .
On log–log axes (achieved FLOP/s against ), this bound is a sloped line (the memory roof, slope 1) that meets a flat line (the compute roof) at :
- : the kernel is memory-bound; it can reach at most , a fraction of the peak.
- : the kernel is compute-bound; it can reach .
It is a bound, not a prediction. It assumes peak rates and a perfect overlap of arithmetic and data movement. Real kernels are slower. With no overlap at all, the time would be the sum of the two terms, and a sum of two numbers is at most twice their maximum. So the bound is a good first estimate: within a factor of 2 for a kernel that runs at peak rates.
Ridge points #
| GPU and units | (dense) | BW | |
|---|---|---|---|
| H100 SXM, bf16 tensor cores | 989 TFLOP/s | 3.35 TB/s | ≈ 295 FLOP/byte |
| H100 SXM, fp8 tensor cores | 1,979 TFLOP/s | 3.35 TB/s | ≈ 591 |
| H100 SXM, fp32 CUDA cores | 67 TFLOP/s | 3.35 TB/s | 20 |
| A100 SXM 80 GB, bf16 tensor cores | 312 TFLOP/s | 2.039 TB/s | ≈ 153 |
| A100 SXM 80 GB, fp32 CUDA cores | 19.5 TFLOP/s | 2.039 TB/s | ≈ 9.6 |
Elementwise ops and norms do a few FLOPs per element and move 4–6 bytes per element (in bf16): their intensity is below 5 FLOP/byte. That is below even the CUDA-core ridge of an A100. So they are memory-bound on every GPU in the table. This is why most non-matmul ops are limited by HBM bandwidth, and why a memory-bound op speeds up by the bandwidth ratio when you change GPUs, not by the FLOP/s ratio.
An RMSNorm kernel takes 100 µs on an A100. About how long does it take on an H100? (H100/A100: 3.2× the bf16 FLOP/s, 1.64× the bandwidth.)
RMSNorm has an intensity of about 1 FLOP/byte, far below both ridge points, so its time is bytes/BW. The bytes stay the same, and BW grows 1.64×: µs. The extra FLOP/s of the H100 do not help a memory-bound op.
The matmul intensity theorem #
Theorem (matmul intensity). Let be (activations: tokens of width ) and be (a weight). In bf16, with each matrix moved once, has intensity
So for , and as . On a GPU with ridge point , the matmul is compute-bound exactly when
Why this is true, in one sentence. Each weight value is loaded once and used once per token, so when the weights dominate the traffic, the work per loaded byte grows with the number of tokens.
Proof.
- FLOPs. Each of the outputs is a dot product of length : multiplies and adds. Total: .
- Bytes. Read ( values), read ( values), write ( values), 2 bytes each: . No algorithm moves less, because each value must cross at least once.
- Divide. The factor 2 cancels:
Divide the numerator and the denominator by : . 4. Limits. If , the denominator is close to 1, so : the weight read dominates. As , : now the activation traffic dominates. 5. Compute-bound condition. Multiply both sides by and divide by bytes:
- Solve for . Multiply by the positive denominator :
If , that is , divide to get . Otherwise the left side is never larger than , and no is large enough.
Rule of thumb. For large and , : a bf16 matmul on an H100 needs about 300 tokens per weight read to be compute-bound (an A100 about 150).
In fp8 (all three matrices in fp8), the bytes halve, so ; but the ridge point also doubles (591). The threshold stays at about 300 tokens: for it is , next to 344.9 in bf16.
Predict first. Set your guess for the batch size at which the matmul becomes compute-bound, then press Reveal. The curve is the intensity , the blue line the ridge point and the dashed line the ceiling . Move and read the table: from to the work grows 64×, but the time hardly changes. Then make : the ceiling drops below the ridge, and no batch is large enough.
For on an H100, the bf16 threshold is . You switch the whole matmul to fp8 (1 byte per value, ridge point 591). What happens to ?
With 1 byte per value, , twice the bf16 intensity. The fp8 peak is about twice the bf16 peak, so also doubles. The two factors cancel: . fp8 makes a compute-bound matmul faster, but it does not lower the batch you need.
What "" means in a transformer #
The of the theorem counts the tokens that share one weight read. It is not the number of sequences.
- Training and prefill: all tokens of the micro-batch go through each weight matrix together. ( sequences of length ), usually thousands of tokens. The weight matmuls are compute-bound.
- Decode (generating one token per sequence per step): each sequence brings one new token, so , the number of sequences in the batch. With a few sequences, : memory-bound.
Classifying the ops of a transformer #
For attention, take one head with head dimension and tokens in bf16, and count only the two matmuls and (full attention, no causal mask).
| Op | FLOPs | Bytes | Intensity | H100 regime () |
|---|---|---|---|---|
| elementwise ops, norms | a few per element | 4–6 per element | below 5 | memory |
| weight matmul, training or prefill ( = thousands of tokens) | up to | compute (if are large) | ||
| weight matmul, decode ( = sequences) | memory while | |||
| attention, prefill, fused | ( once) | compute for | ||
| attention, decode, MHA | per sequence and head | (its own and ) | memory, at any batch | |
| attention, decode, GQA ( query heads per KV head) | per sequence and KV head | memory |
Batching does not help decode attention. Each sequence has its own KV cache. A larger batch adds bytes in the same proportion as FLOPs, so the intensity stays at about 1. GQA (grouped-query attention) helps because one read of a K/V head serves query heads; the cache is also times smaller.
One transformer layer, op by op. Each bar is the roofline time of one kernel: blue if compute-bound, orange if memory-bound. Start in decode: almost every bar is orange, and the weight matmuls dominate. Raise the batch: the matmul bars stay almost the same until about 300 sequences, but attention grows with every sequence. Switch to GQA, then to prefill, where the matmuls turn blue.
In decode, why does grouped-query attention (GQA) with query heads per KV head raise the intensity of attention from about 1 to about ?
With multi-head attention, each query head has its own K and V: every byte of cache is used by one query (about 1 FLOP per byte). With GQA, query heads share one K/V head, so the same bytes feed FLOPs instead of . The batch size does not change this, because each sequence has its own cache.
Answers to section 1. (1) About 1.6×, the bandwidth ratio . ReLU is memory-bound, so the extra FLOP/s do not help. (2) About 300. For the exact threshold is , so (section 4).
The back-of-the-envelope method #
Every performance question in this track starts with the same four steps:
- Count the FLOPs (a matmul: ; an elementwise op: a few per element).
- Count the bytes that must cross between HBM and the chip (every input read once, every output written once, times the bytes per element).
- Divide: and .
- Take the larger one. It is the minimum time and tells you the regime. Only a change that lowers the larger term makes the kernel faster.
A calculator for the four steps. Pick an op (or type your own FLOPs and bytes) and a GPU. The two bars are compute time and memory time; the longer one is the bound. The panel shows what three changes would give: a faster GPU, half the bytes, or twice the FLOP/s. For a memory-bound op only fewer bytes or more bandwidth help.
A profiler shows that a kernel reaches only 20% of the H100's peak FLOP/s. Your colleague says: "It is badly written." What should you check first?
A memory-bound kernel can reach at most of the peak. Compare the achieved bandwidth with 3.35 TB/s, not the achieved FLOP/s with 989 TFLOP/s. If it moves bytes at close to 3.35 TB/s, it is well written; only fewer bytes (fusion, smaller formats, more reuse) can make it faster.
Limits of the model
- Caches. The bound counts the bytes that cross HBM. If data is read twice and fits in the 50 MB L2 cache, the second read may not reach HBM. The real HBM traffic can be lower than a naive count (or higher, if a kernel re-reads data that does not fit).
- Achievable peaks. Datasheet peaks are not reached in practice. A large, well-tuned bf16 matmul usually reaches about 70–80% of the datasheet FLOP/s, and a simple copy kernel about 80–90% of the datasheet bandwidth. Use measured peaks when you need precision.
- Several phases. A kernel with a memory-bound phase and a compute-bound phase takes about the sum of the two phase bounds, not one max.
- Launch overhead. Each kernel launch costs a few microseconds. For tiny tensors this overhead, not FLOPs or bytes, sets the time (lesson 3).
- Hierarchical roofline. You can draw one roof per memory level (HBM, L2, shared memory), each with its own bandwidth. A tiled kernel can be HBM-light but shared-memory-bound. Nsight Compute draws these charts.
4. Worked example #
A matmul at growing batch. () times () in bf16 on an H100: TFLOP/s, TB/s, FLOP/byte. For each :
- FLOPs ;
- bytes bytes;
- = FLOPs/bytes; the two times; the regime; the last column is the achieved fraction of peak at the roofline time, .
| FLOPs | Bytes | Regime | % of peak | ||||
|---|---|---|---|---|---|---|---|
| 1 | 33.6 M | 33.6 MB | 1.0 | 0.03 µs | 10.0 µs | memory | 0.3% |
| 16 | 537 M | 33.8 MB | 15.9 | 0.54 µs | 10.1 µs | memory | 5.4% |
| 64 | 2.15 G | 34.6 MB | 62.1 | 2.2 µs | 10.3 µs | memory | 21% |
| 256 | 8.59 G | 37.7 MB | 227.6 | 8.7 µs | 11.3 µs | memory | 77% |
| 345 | 11.6 G | 39.2 MB | 295.3 | 11.70 µs | 11.70 µs | compute (just) | 100% |
| 1024 | 34.4 G | 50.3 MB | 682.7 | 34.7 µs | 15.0 µs | compute | 100% |
| 4096 | 137 G | 100.7 MB | 1365 | 139 µs | 30.0 µs | compute | 100% |
What to read from it:
- Batching is almost free below the ridge. From to the work grows 64×, but the time grows only 3% (10.0 → 10.3 µs): the 33.6 MB weight read is paid once.
- The exact threshold. , so , not the rule-of-thumb 295. The activation traffic (5.7 MB at , next to 33.6 MB of weights) is not negligible.
- On an A100 (): , so .
- A whole decode step. A 7B-parameter model in bf16 has 14 GB of weights. At batch 1, a decode step reads all of them once: ms. So one sequence gets at most about 240 tokens/s, whatever the FLOP/s (A100: 6.9 ms, about 146 tokens/s).
5. In code #
The first block recomputes the worked example and checks the theorem against a brute-force search. NumPy only.
import numpy as np
P, BW = 989e12, 3.35e12 # H100 SXM: dense bf16 FLOP/s, HBM bytes/s
R = P / BW # ridge point, FLOP/byte
def matmul_cost(B, D, F, e=2):
"""FLOPs and minimal HBM bytes of (B x D) @ (D x F) with e bytes per element."""
return 2 * B * D * F, e * (B * D + D * F + B * F)
def roofline_time(flops, nbytes, P=P, BW=BW):
return max(flops / P, nbytes / BW)
for B in [1, 64, 345, 4096]:
fl, by = matmul_cost(B, 4096, 4096)
print(B, round(fl / by, 1), f"{roofline_time(fl, by) * 1e6:.2f} us")
# 1 1.0 10.02 us
# 64 62.1 10.33 us
# 345 295.3 11.71 us
# 4096 1365.3 138.97 us
def b_star(D, F, R=R):
return R * D * F / (D * F - R * (D + F)) if D * F / (D + F) > R else np.inf
brute = next(B for B in range(1, 10**5) if np.divide(*matmul_cost(B, 4096, 4096)) >= R)
print(round(b_star(4096, 4096), 1), brute) # 344.9 345
print(round(b_star(4096, 4096, 312 / 2.039), 1)) # 165.4 (A100)
print(b_star(512, 512)) # inf (D = F = 512 never compute-bound)
Your laptop CPU has a roofline too. This block measures one point far below the ridge and one far above it:
import time
import numpy as np
def best_time(fn, repeats=5):
fn() # warm-up
times = []
for _ in range(repeats):
t0 = time.perf_counter(); fn(); times.append(time.perf_counter() - t0)
return min(times)
# An elementwise op: 1 FLOP and 8 bytes per fp32 element (read x, write y).
n = 2**25
x = np.random.default_rng(0).standard_normal(n, dtype=np.float32)
y = np.empty_like(x)
t = best_time(lambda: np.multiply(x, 2.0, out=y))
print(f"elementwise: {n / t / 1e9:7.1f} GFLOP/s, {8 * n / t / 1e9:6.1f} GB/s, intensity 0.125")
# A square matmul: 2m^3 FLOPs and 12m^2 bytes (fp32), intensity m/6.
m = 2048
a, b = np.ones((m, m), np.float32), np.ones((m, m), np.float32)
c = np.empty((m, m), np.float32)
t = best_time(lambda: np.matmul(a, b, out=c))
print(f"matmul: {2 * m**3 / t / 1e9:7.1f} GFLOP/s, {12 * m**2 / t / 1e9:6.1f} GB/s, intensity {m / 6:.0f}")
On the Apple M1 laptop where this lesson was checked, it printed 5–7 GFLOP/s at 40–60 GB/s for the elementwise op (the memory bandwidth) and about 900 GFLOP/s for the matmul. Your numbers will differ. The gap of more than 100× will not.
6. Where it shows up #
- LLM decoding is memory-bound, so servers batch many sequences (lesson 12), shrink weights and caches (lesson 11) and use GQA. Speculative decoding verifies several draft tokens in one weight read, which raises .
- Training and prefill: the weight matmuls are compute-bound. Much of the remaining time goes to memory-bound norms, activations and the attention softmax. These are the targets of fusion (lesson 3) and FlashAttention (lesson 4).
- Diffusion and flow-matching samplers (for example a latent diffusion model of seismic ground motion) are not like LLM decoding: each sampling step runs the network over all latent tokens at once. A DiT-XL (width 1152) on a 1024-token latent has tokens per weight read even at batch 1, above for its projections: compute-bound. The cost is the number of sampling steps.
- Small MLP surrogates and PINNs of width 512 or less can never be compute-bound in bf16 on an H100: at any batch size. They are limited by memory traffic and by kernel launch overhead (lesson 3).
- Classic scientific kernels (stencils, sparse matrix–vector products) do less than 1 FLOP per byte. The roofline model was first proposed for such kernels (Williams, Waterman and Patterson, 2009).
- In practice, Nsight Compute shows a roofline chart and a "Speed Of Light" section (compute and memory throughput as a percentage of peak) for each kernel (lesson 13).
7. Common mistakes #
- Using the sparse peak. Product pages list bf16 with 2:4 sparsity (1,979 TFLOP/s on an H100). Dense work gets at most half.
- Using the FLOP/s ratio for a memory-bound op. Elementwise ops and decode steps speed up by the bandwidth ratio, not by the FLOP/s ratio.
- "Big matrices are compute-bound." The intensity of a weight matmul is about , the number of tokens per weight read, not the size of the weight. A matmul with 8 tokens is memory-bound.
- Taking as the number of sequences in training. The of the theorem counts the tokens that share one weight read: micro-batch × sequence length.
- Expecting batching to help decode attention. Each sequence reads its own KV cache, so the intensity stays about 1 (about with GQA).
- Reading the roofline as a prediction. It is a lower bound on time: peak rates, perfect overlap, no launch overhead.
- Judging a kernel by its FLOP/s alone. A memory-bound kernel should be judged by its achieved bandwidth.
8. Check yourself #
- Compute the ridge point of an A100 SXM 80 GB (312 TFLOP/s dense bf16, 2.039 TB/s) and of an H100
in fp8 (1,979 TFLOP/s dense, 3.35 TB/s).
Answer
FLOP/byte; FLOP/byte. - RMSNorm over 16,384 tokens × 4096 features in bf16 on an H100: it reads once, writes once
and does about 4 FLOPs per element. Give its intensity, regime and minimum time.
Answer
M elements. Bytes MB; FLOPs ; FLOP/byte, memory-bound; µs. - Can a bf16 matmul with become compute-bound on an H100 at any batch size? On an
A100?
Answer
H100: no, because . A100 (): yes, from , so . - Llama-3-8B has 32 query heads and 8 KV heads (). What is the intensity of its decode
attention, and does a batch of 64 sequences change it?
Answer
, so FLOP/byte: memory-bound. A batch of 64 does not change it, because each sequence brings its own cache: bytes grow with FLOPs. - Fused attention at prefill, one head, , tokens, on an H100. Compute- or
memory-bound?
Answer
: memory-bound. From on (with ) it becomes compute-bound. - A 70B-parameter model in bf16 decodes at batch 1 on a (hypothetical) GPU with the bandwidth of
an H100 and enough memory for all weights. What is the upper bound on tokens per second?
Answer
Weights: GB. One step reads them once: ms, so at most about 24 tokens/s. (In practice the model is split over several GPUs, whose bandwidths add up.)
Define arithmetic intensity.
= FLOPs / bytes moved between HBM and the chip, in FLOP/byte.
State the roofline bound on the time of a kernel.
; equivalently, achieved FLOP/s .
What is the ridge point of an H100 SXM in bf16?
FLOP/byte.
How many FLOPs does a matmul do?
: outputs, each a dot product of length (one multiply and one add per term).
Why is the intensity of a weight matmul about when is small?
The weight read ( bytes) dominates the traffic, and each weight value is used once per token: .
From about which batch size is a bf16 matmul compute-bound on an H100?
tokens per weight read; exactly for .
What is the intensity of decode attention with MHA, and with GQA?
About 1 FLOP/byte with MHA; about with GQA ( query heads per KV head). Batching does not change it.
A large ReLU moves from an A100 to an H100. What speed-up do you expect, and why?
About 1.6×, the HBM bandwidth ratio (): ReLU is memory-bound, so the 3.2× FLOP/s do not matter.
Compute the minimum time of one decode step of a 7B model in bf16 at batch 1 on an H100.
14 GB of weights / 3.35 TB/s ≈ 4.2 ms, so at most about 240 tokens/s.
Which ops of a transformer are memory-bound in training?
Elementwise ops, norms, softmax and other non-matmul ops (intensity below 5); the weight matmuls are compute-bound.
9. Interview angle #
- "Why is LLM decoding memory-bound, and what makes it faster?" — Each step reads all weights and the KV cache for few tokens: batch for the weight matmuls, for attention. Batch more sequences, quantize weights and cache, use GQA or speculative decoding.
- "From which batch size is a matmul compute-bound on an H100?" — About peak/bandwidth ≈ 300 tokens in bf16; a little more for finite (345 at ).
- "An H100 has 3× the FLOP/s of an A100. Is my inference 3× faster?" — Only the compute-bound parts. Decode gains about the bandwidth ratio, 1.6×.
- "Explain the roofline model." — Achieved FLOP/s . Draw the two roofs on log–log axes; the ridge point separates memory-bound from compute-bound.
- "Your kernel runs at 20% of peak FLOP/s. Is that bad?" — Compute its intensity first. If it is memory-bound, compare its bandwidth with the HBM peak instead.
10. Practice #
The notebook for this lesson runs in Colab, with NumPy only. It has five exercises, each with a check cell and a folded solution:
roofline_time(flops, nbytes, P, BW)and the regime of a kernel.matmul_intensity(B, D, F)andmin_compute_bound_batch(D, F, R), checked against the closed form and a brute-force search.- Classify every op of one transformer layer for given shapes and phases (prefill, decode).
- Measure your own computer: count the FLOPs and bytes of
x * 2andA @ B, time them, and plot your CPU's roofline (runs in under 20 seconds). - The A100 → H100 speed-up of a list of ops, from their FLOPs and bytes alone.
11. Watch and read #
- Horace He, Making Deep Learning Go Brrrr From First Principles: compute, bandwidth and overhead, with pictures.
- Austin et al., How to Scale Your Model, All About Rooflines: the same analysis for TPUs and GPUs, with many exercises.
- Williams, Waterman and Patterson (2009), "Roofline: an insightful visual performance model for multicore architectures", CACM; free technical report version.
- NVIDIA, Matrix multiplication background: arithmetic intensity of GEMMs, tile and wave quantization.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.