ML Interview Notes
12 min read5 sections
Lab 01 · for chapter 00-04

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.

Hardware

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.

Not executed here

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.

§1

What you measure

Three numbers, and then one prediction you check against them.

  1. Achieved π — peak TFLOP/s, from a GEMM sweep that walks M from 1 to 16,384 against both square shapes and Llama-3-8B's real projection shapes.
  2. Achieved β — peak GB/s, from two streaming kernels: a copy (one read, one write) and a triad (two reads, one write).
  3. 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.

What the two halves of the sweep are for
SweepShapesWhat it pins down
SquareM = 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 π.
SkinnyLlama-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.
Streamingcopy 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:

benchmarks/kernels/weight_shapes.py:L52-L57 vLLM
    "meta-llama/Llama-3.1-8B-Instruct": [
        ([4096, 6144], 1),
        ([4096, 4096], 0),
        ([4096, 28672], 1),
        ([14336, 4096], 0),
    ],
§2

Running it

shell — labs/01-measure-your-gpu-roofline shell
$ 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.

  1. What fraction of your card's published bf16 peak will the largest square GEMM reach?
  2. What fraction of published HBM bandwidth will a copy reach? Will the triad be higher or lower?
  3. 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:

benchmarks/kernels/benchmark_fp8_gemm.py:L89-L97, L123-L124 vLLM
@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:

benchmarks/kernels/benchmark_cp_gather.py:L193-L201 vLLM
    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}]"
fp8

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:

vllm/config/observability.py:L77-L78 vLLM
    enable_mfu_metrics: bool = False
    """Enable Model FLOPs Utilization (MFU) metrics."""
vllm/v1/metrics/perf.py:L1521-L1534 vLLM
        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:

python/sglang/srt/managers/scheduler_components/metrics_reporter.py:L892-L903 SGLang
        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.

shell — the same two numbers, under load shell
$ 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"
§3

What to expect

Neither measured number will match its datasheet, and the two miss by different amounts. That asymmetry is the result.

Where the achieved numbers go, and why — no figures here, because none were measured
ObservationCauseHow to confirm it
Peak TFLOP/s well under the datasheetSustained 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 copyTwo 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 datasheetYour buffer fits in L2. You measured cache, not HBM. Raise --buffer-mib until the number stops falling.
TFLOP/s still climbing at M = 16384A 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/sCorrect 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.

§4

Exercises

  1. 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?
  2. Re-run with --dtype float16 and 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.)
  3. Find the M at which the [4096, 6144] fused-QKV shape reaches half of your measured π. Compare it against the T* 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?
  4. Predict, then verify: set --buffer-mib to a value smaller than your card's L2 and re-run the bandwidth half. Predict the direction and rough size of the change first.
  5. Read the source. Open vllm/v1/metrics/perf.py around L1521 and answer: is the GB/s/GPU figure 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
  1. 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 derived T* = 345 for Llama-3-8B falls with it. If the reverse, everything moves the other way. Record both efficiencies, not just the ridge.
  2. 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_tf32 before concluding anything.
  3. Derived, from the book's H100 figures: the square-GEMM crossover is T* = I* d / (d - b I*) = 295 × 4096 / (4096 - 590) ≈ 345 tokens. A non-square shape with a wider N amortises the weight read over more output columns, so [4096, 6144] and especially [4096, 28672] reach half-peak at a smaller M than 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.
  4. 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.
  5. It is a model. The engine accumulates total_num_flops_per_gpu and total_read_bytes_per_gpu analytically 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.
§5

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.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px