Measure your GPU's roofline
Sweep matmul shapes to find achieved TFLOP/s and HBM GB/s, then plot the ridge point for your actual card.
Every derived number in this book is a function of two constants: how many FLOP/s your card can sustain and how many bytes per second it can pull from HBM. The book uses NVIDIA's published peaks because a machine without a GPU has nothing better. This lab replaces both with numbers you measured, and then makes you re-check a prediction against them.
One CUDA GPU, any size, plus PyTorch. Nothing else — no model weights, no engine, no
network. A 24 GB card runs the default sweep comfortably; on 16 GB or less drop
16384 from --square and lower --buffer-mib. There is no CPU
fallback and there should not be: the whole point is the silicon. If you have no GPU, read this
page and carry the book's cited H100 SXM peaks — 989.4 TFLOP/s dense bf16 and
3.35 TB/s, ridge I* = 295 — knowing they are optimistic by
whatever your real card loses to clocks, tiling and tail effects.
run.py was written against the timing and accounting idioms cited below, and its
argument handling and arithmetic were exercised, but it has not been run on a GPU —
none was available while writing. No number on this page is a measurement. Everything is either
cited (a published peak, with the card it belongs to) or derived (arithmetic,
labelled as such). Report anything that disagrees with what your card actually does.
What you measure
Three numbers, and then one prediction you check against them.
- Achieved
π— peak TFLOP/s, from a GEMM sweep that walksMfrom 1 to 16,384 against both square shapes and Llama-3-8B's real projection shapes. - Achieved
β— peak GB/s, from two streaming kernels: a copy (one read, one write) and a triad (two reads, one write). - Your ridge point
I* = π/β, the arithmetic intensity at which the bottleneck flips from HBM to tensor cores. Derivation in §0.4.
Then the check: the script places the book's three reference workloads on your roofline
and prints T*, the token count at which a square d×d projection
becomes compute-bound. On H100 spec-sheet numbers with d = 4096 that is
345 tokens. Your
measured π will be lower than the datasheet's and your measured β
will be lower too — but not by the same factor, so T* moves, and it moves in a
direction the datasheet cannot tell you.
| Sweep | Shapes | What it pins down |
|---|---|---|
| Square | M = N = K, 512 → 16384 |
The ceiling. Square GEMMs at large M are the friendliest shape a tensor
core will ever see; whatever they hit is your practical π. |
| Skinny | Llama-3-8B [K, N] pairs, M swept |
The climb. Real projection shapes at real token counts — this is the curve a prefill chunk or a decode batch actually rides. |
| Streaming | copy and triad over one large buffer | β. Buffers must be far larger than L2 or you are timing cache. |
The skinny shapes are not invented. They are vLLM's own record of Llama-3.1-8B's projections,
as [K, N] pairs with the tensor-parallel split dimension:
"meta-llama/Llama-3.1-8B-Instruct": [
([4096, 6144], 1),
([4096, 4096], 0),
([4096, 28672], 1),
([14336, 4096], 0),
],
Running it
$ python3 run.py # both halves, autodetect the card
$ python3 run.py --mode gemm --dtype float16 # fp16 tensor cores instead of bf16
$ python3 run.py --mode bandwidth --buffer-mib 4096 # bigger buffer, further from L2
$ python3 run.py --peak-tflops 989.4 --peak-gbps 3350 --csv roofline.csv
$ python3 run.py --help # every knob
Predict first, in this order, and write the answers down before you run anything.
- What fraction of your card's published bf16 peak will the largest square GEMM reach?
- What fraction of published HBM bandwidth will a copy reach? Will the triad be higher or lower?
- Will your measured ridge point be higher or lower than the datasheet's?
The third is the interesting one, and most people get it wrong. It asks which of the two peaks is harder to reach in practice, and the answer is not obvious from either datasheet.
Timing, and the one mistake that invalidates everything
CUDA launches are asynchronous. A perf_counter wrapped around a kernel launch
measures how fast Python enqueues work, which for a small GEMM is routinely faster than the work
itself. run.py records CUDA events on the stream and reads them after a synchronize.
Both engines' own harnesses do the equivalent — SGLang's single-batch harness calls
model_runner.synchronize() on both sides of every timed region
(§0.4 quotes
it), and vLLM's kernel benchmarks delegate to Triton's do_bench, which does the same
thing and returns a median rather than a mean.
The FLOP and byte accounting
FLOPs are 2·M·N·K: one multiply and one add per MAC. That is
exactly vLLM's own accounting in the sweep this lab's GEMM half is modelled on — note that
its x_vals straddle the T* ≈ 345 crossing, which is why the printed
TFLOP/s column is the roofline's y-axis, sampled:
@triton.testing.perf_report(
triton.testing.Benchmark(
x_names=["batch_size"],
x_vals=[1, 16, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384],
x_log=False,
line_arg="provider",
line_vals=_enabled,
line_names=_enabled,
ylabel="TFLOP/s (larger is better)",
# ...
to_tflops = lambda t_ms: (2 * M * N * K) * 1e-12 / (t_ms * 1e-3)
return to_tflops(ms), to_tflops(max_ms), to_tflops(min_ms)
Bytes are compulsory traffic — read A, read B, write C, each counted once. The same bytes-over-latency idiom appears in vLLM's cache-gather benchmark, which is the closest thing in either tree to a bandwidth probe:
latency_ms = triton.testing.do_bench(
run, warmup=warmup_ms, rep=rep_ms, return_mode="median"
)
bandwidth_gbps = bytes_moved / latency_ms / 1e6
lengths = ",".join(str(seq_len) for seq_len in seq_lens)
print(
f"{variant:15s} {name:10s} batch={len(seq_lens):2d} "
f"total={sum(seq_lens):7d} latency={latency_ms * 1e3:9.2f} us "
f"bandwidth={bandwidth_gbps:8.1f} GB/s lengths=[{lengths}]"
run.py deliberately refuses to sweep fp8. A plain
torch.nn.functional.linear cannot run it, and relabelling a bf16 GEMM as fp8 would
produce a wrong ridge point in the direction that flatters quantization. For an fp8 roofline run
vLLM's own sweep, which goes through the engine's scaled-mm path:
python benchmarks/kernels/benchmark_fp8_gemm.py --models meta-llama/Llama-3.1-8B-Instruct
--tp-sizes 1.
The engines will tell you the same two numbers at runtime
Once you have π and β from bare kernels, both engines can
report the versions they achieve while actually serving. vLLM's is a single log line, gated behind
--enable-mfu-metrics:
enable_mfu_metrics: bool = False
"""Enable Model FLOPs Utilization (MFU) metrics."""
else:
avg_tflops_per_gpu = self.total_num_flops_per_gpu / delta_time / 1e12
avg_gbps_per_gpu = (
(self.total_read_bytes_per_gpu + self.total_write_bytes_per_gpu)
/ delta_time
/ 1e9
)
log_fn(
"%sMFU: %.1f TF/s/GPU %.1f GB/s/GPU",
log_prefix,
avg_tflops_per_gpu,
avg_gbps_per_gpu,
)
SGLang appends its equivalent to the decode-batch line, behind a flag of the same name, and splits read from write bandwidth — which is the more useful split, because decode is almost entirely reads:
if self.enable_mfu_metrics and gap_latency > 0:
flops_per_s = self._mfu_log_flops / gap_latency
read_bytes_per_s = self._mfu_log_read_bytes / gap_latency
write_bytes_per_s = self._mfu_log_write_bytes / gap_latency
tflops_per_s = flops_per_s / 1e12
read_gb_per_s = read_bytes_per_s / 1e9
write_gb_per_s = write_bytes_per_s / 1e9
msg += (
f", est. decode TFLOPS/s (per GPU): {tflops_per_s:.2f}, "
f"est. read BW (GB/s per GPU): {read_gb_per_s:.2f}, "
f"est. write BW (GB/s per GPU): {write_gb_per_s:.2f}"
)
Both are estimated — the engines count FLOPs and bytes analytically from the batch shape and divide by elapsed time; neither reads a hardware counter. So they are the same class of number as this lab's, computed on a real workload instead of a synthetic one. Comparing the two is the most direct way to find out how much of your card a serving step actually uses.
$ vllm serve meta-llama/Meta-Llama-3-8B-Instruct --enable-mfu-metrics 2>&1 | grep MFU
$ python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
--enable-mfu-metrics 2>&1 | grep "est. decode TFLOPS"
What to expect
Neither measured number will match its datasheet, and the two miss by different amounts. That asymmetry is the result.
| Observation | Cause | How to confirm it |
|---|---|---|
| Peak TFLOP/s well under the datasheet | Sustained tensor-core work throttles clocks; the datasheet peak assumes boost clocks and perfect tiling. | Watch nvidia-smi -l 1 during the sweep and read the clock and power
columns. |
| The two efficiencies differ | They almost certainly will, and the sign is the interesting part: whichever peak you
reach a larger fraction of pulls your measured ridge toward it. Higher bandwidth
efficiency than compute efficiency moves I* left; the reverse moves
it right. Nobody can tell you which without your card. |
Compare the two efficiency percentages the script prints, and record both. |
| Triad slower per byte than copy | Two read streams in flight, more outstanding requests needed to cover latency. | Both rows are printed; the script takes the larger as β. |
| Absurd bandwidth — multiples of the datasheet | Your buffer fits in L2. You measured cache, not HBM. | Raise --buffer-mib until the number stops falling. |
TFLOP/s still climbing at M = 16384 | A large card with a small square shape; the GEMM has not saturated. | Extend --square upward until it plateaus. |
M = 1 rows report a fraction of a TFLOP/s | Correct and expected.
At I = 2/b that row is bandwidth-bound by construction — it is the
batch-1 decode point of Figure 1 in
§0.4. |
Check the I column reads about 1.0 in bf16. |
Then substitute. Every KPI in Part 0 and Part 1 was computed against 989.4 and 3.35 TB/s.
The Llama-3-8B decode floor of 4.48 ms per step, the 223 tok/s batch-1 ceiling, the
T* = 345 crossing, the 295 FLOP/byte ridge — all of them scale with the two
numbers you just measured, and all of them get worse. That is not a flaw in the derivations; it is
what "spec-sheet peak" means.
Exercises
- Run the full sweep and record achieved
π, achievedβ, and the ratio of each to its datasheet peak. Which of the two peaks is easier to reach on your card, and does your measured ridge sit left or right of the published one? - Re-run with
--dtype float16and then--dtype float32. Predict the TFLOP/s ratio between fp16 and fp32 before running, from your card's spec sheet, then explain the residual. (Hint: check whether your fp32 GEMM ran on tensor cores at all.) - Find the
Mat which the[4096, 6144]fused-QKV shape reaches half of your measuredπ. Compare it against theT*the script prints and against vLLM's default batched-token budget for your card (--max-num-batched-tokens). Is the default above or below your crossover? - Predict, then verify: set
--buffer-mibto a value smaller than your card's L2 and re-run the bandwidth half. Predict the direction and rough size of the change first. - Read the source. Open
vllm/v1/metrics/perf.pyaround L1521 and answer: is theGB/s/GPUfigure vLLM logs a measurement of HBM traffic, or a model of it? What would make it disagree with the number this lab measures, in each direction?
Answers
- No answer can be supplied here — this one is the whole lab, and nobody ran it. What
can be said is what the answer means, and it is a pure identity:
I*_measured / I*_published = (π_eff / β_eff), the ratio of the two efficiencies. If you reach a larger fraction of published bandwidth than of published compute, your ridge lands below the datasheet's, workloads become compute-bound sooner than the book's derivations suggest, and the derivedT* = 345for Llama-3-8B falls with it. If the reverse, everything moves the other way. Record both efficiencies, not just the ridge. - On a card whose fp16 and bf16 tensor-core rates are equal (H100, A100), fp16 should land
within noise of bf16. fp32 is the trap: unless TF32 is enabled, an fp32 GEMM runs on CUDA cores
at a small fraction of tensor-core throughput, and the ratio you see will be far larger than
the naive 2× from halving the bytes. Check
torch.backends.cuda.matmul.allow_tf32before concluding anything. - Derived, from the book's H100 figures: the square-GEMM crossover is
T* = I* d / (d - b I*) = 295 × 4096 / (4096 - 590) ≈ 345tokens. A non-square shape with a widerNamortises the weight read over more output columns, so[4096, 6144]and especially[4096, 28672]reach half-peak at a smallerMthan the square case. vLLM's default budget on an H100 API server is 8192 — well past the crossover on any of these shapes, which is why prefill chunks sit on the compute roof and decode steps do not. - Bandwidth rises, potentially several-fold, because the buffer now lives in L2 and the kernel never touches HBM. This is the single most common way a bandwidth microbenchmark lies. The fix is a buffer many times L2, which is why the default is 1 GiB and the flag exists.
- It is a model. The engine accumulates
total_num_flops_per_gpuandtotal_read_bytes_per_gpuanalytically from batch shapes and divides by elapsed wall time; no hardware counter is read. It will read lower than this lab's number whenever the engine is idle or stalled inside the interval, because idle time is in the denominator and no bytes are in the numerator. It will read higher than true HBM traffic whenever a value the model counts as fetched was actually served from L2 — prefix-cache hits and repeated small weights being the usual causes. Neither is a bug; both are reasons to treat it as a utilisation estimate rather than a bandwidth measurement.
Key takeaways
- Two numbers,
πandβ, generate every capacity and latency prediction in this book. Measure them once and every downstream estimate improves at once. - Achieved compute and achieved bandwidth do not miss their datasheets by the same factor, so your ridge point is not the published ridge point. Which way it moves is the ratio of the two efficiencies, and only your card can tell you.
- Time with CUDA events and a synchronize, or you are benchmarking Python's ability to enqueue work. On a small GEMM that is genuinely faster than the GEMM.
- A bandwidth microbenchmark whose buffer fits in L2 reports a beautiful number that means nothing. Size the buffer against L2, not against convenience.
- Both engines will report their own achieved TFLOP/s and GB/s under
--enable-mfu-metrics, computed analytically rather than from counters. The gap between those and this lab's synthetic peaks is the overhead the rest of the book is about.