ML Interview Notes
28 min read10 sections
Part 0 · Foundations · 00-04

Arithmetic intensity and the roofline

Status
SOURCE PINNED
Primary sources
  • benchmarks/kernels/
  • python/sglang/benchmark/one_batch.py
Edition pins
vllm a556f3f · sglang 7d89325

An H100 SXM is rated at 989 TFLOP/s of dense bf16. A batch-1 decode step of Llama-3-8B uses about 0.34% of that. Under the assumed compulsory traffic, improving arithmetic throughput alone does not raise that ideal bandwidth ceiling — because the step never had enough arithmetic in it to keep the tensor cores fed. This chapter builds the one-page model that tells you, before you write any code, whether an optimisation can possibly help.

§1

The problem

Here is the symptom that starts every serving investigation. You load Llama-3-8B in bf16 onto an 80 GB H100, send a single request, and watch it generate. You get a couple of hundred tokens per second. You then send thirty-two requests concurrently and get roughly an order of magnitude more in aggregate — many times the work for barely more wall-clock time per step. The GPU did not get faster. Something about the first measurement was structurally wasteful, and the waste is not in your code. §3 derives both of those figures as bandwidth floors, so you can check the claim before you believe it.

Profile first, then identify the limiting resource. Arithmetic intensity relates useful work to transferred bytes, but better kernels may reduce actual rereads, improve achieved bandwidth, fuse launches, or change the algorithm. The roofline rules out improvements from extra arithmetic peak alone on a bandwidth-bound kernel; it does not rule out software optimization.

§2

Mental model

Think of the GPU as a workshop with an enormously productive machine floor and a single loading dock. Work arrives as pallets of bytes. If a pallet keeps the floor busy for a long time, the dock idles and the floor is the limit; if it is consumed instantly, the floor idles and the dock is the limit. Arithmetic intensity is how much work comes on one pallet: FLOPs performed per byte fetched from HBM.

The roofline plot is the picture of that. Intensity runs along x and achievable throughput along y. On the left, throughput is a diagonal — you are bandwidth-limited, so doubling intensity doubles throughput. On the right it is a flat ceiling — you are compute-limited, and more intensity buys nothing. The corner between them is the ridge point, and where your kernel lands relative to it is the entire diagnosis.

Figure 1 — the H100 SXM bf16 roofline, with prefill, decode, and decode attention placed on it. All coordinates are derived arithmetic from the formulas in §3 plus the cited spec-sheet peaks; nothing here is measured.

Roofline plot for H100 SXM with bf16 tensor cores Log-log plot of achievable throughput against arithmetic intensity. A diagonal bandwidth roof of 3.35 terabytes per second meets a flat compute ceiling of 989 teraFLOP per second at a ridge point of 295 FLOP per byte. Batch-1 decode sits at intensity 1, decode attention at 4, batch-32 decode at 31, and a 2048-token prefill GEMM at 1024, past the ridge and on the ceiling. 0.1 1 10 100 1000 10000 0.1 1 10 100 1000 arithmetic intensity I — FLOP per byte moved from HBM (log scale) achievable TFLOP/s (log scale) unattainable — above the roof memory bound compute bound batching moves decode rightward: B = 1 to 32 to 345 batch-1 decode GEMV I = 1.0 — 3.4 TFLOP/s, 0.34% of peak decode attention vs KV cache, GQA 32:8 I = 4.0 — 13 TFLOP/s, 1.4% of peak decode weight GEMMs at batch 32 I = 31 — 106 TFLOP/s, 11% of peak prefill GEMM, T = 2048 tokens: I = 1024 ridge I* = 295 FLOP/byte 989 TFLOP/s over 3.35 TB/s

Read Figure 1 once and the rest of the book gets easier. Prefill sits on the ceiling. Decode sits in the far bottom-left, two and a half orders of magnitude from the corner. They are not two settings of the same workload; they are two different machines running on the same silicon, which is why §1.1 gives them a chapter of their own.

§3

First principles

Definitions

Let $\pi$ be achievable peak compute in FLOP/s for the dtype you are actually using, and $\beta$ achievable HBM bandwidth in bytes/s. For a kernel, let $F$ be the FLOPs it performs and $Q$ the bytes it must move across the HBM boundary. Then

$$I = \frac{F}{Q}, \qquad \text{achieved FLOP/s} \;\le\; \min\!\left(\pi,\; \beta \cdot I\right), \qquad I^{*} = \frac{\pi}{\beta}.$$

Three things about $Q$ matter and are routinely got wrong.

  • For the optimistic calculations below, $Q$ is compulsory traffic, not total traffic — each distinct byte that must cross the HBM boundary at least once, counted once. A value re-read from L2 or SRAM does not count again. So the $I$ computed below is an upper bound on what any kernel can achieve for that computation; a kernel that spills tiles back to HBM sits further left than the algebra says. FlashAttention (§3.2) is exactly the story of reducing materialized intermediates and HBM traffic; finite SRAM still requires tile rereads. An operational roofline can instead use measured total HBM traffic, counting every HBM reread.
  • $Q$ counts reads and writes. Inputs, weights, and outputs.
  • $\pi$ and $\beta$ are achievable, not advertised. Spec-sheet peak assumes perfect tiling, no throttling, and no tail effects. Every number in this chapter uses the spec sheet because that is all a machine without a GPU can honestly cite; lab 01 replaces both with numbers measured on your card, and every prediction in this book should be re-checked against them.

Composing kernels, not only totals

For sequential kernels, a tighter lower bound is $\sum_i\max(F_i/\pi_i,Q_i/\beta_i)$, not merely $\max(\sum_iF_i/\pi,\sum_iQ_i/\beta)$. A compute-heavy kernel taking at least 3 ms and a separate bandwidth-heavy kernel taking at least 4 ms need at least 7 ms if they cannot overlap, even when the aggregate roofline suggests 4 ms. Launch gaps and synchronization add further costs. Shared-prefix reuse, quantization metadata, tensor-parallel communication, and speculative multi-token verification each change the accounting assumptions.

The H100 ridge point

Cited — NVIDIA H100 datasheet (SXM form factor), dense rates without structured sparsity
QuantityValueNote
bf16 / fp16 tensor core peak, $\pi$989.4 TFLOP/sThe datasheet headline of 1,979 TFLOPS is the 2:4-sparsity number; halve it
fp8 tensor core peak1,979 TFLOP/sDense
HBM3 bandwidth, $\beta$3.35 TB/s80 GB HBM3
Ridge point $I^{*}$, bf16295 FLOP/byteDerived: 989.4e12 / 3.35e12
Ridge point $I^{*}$, fp8591 FLOP/byteDerived. Faster math moves the ridge right — it does not help a memory-bound kernel

Hold that number: 295 FLOP per byte. To saturate an H100's bf16 tensor cores you must do 295 floating-point operations for every single byte you pull out of HBM. That is a lot of arithmetic per byte, and almost nothing in decode comes close.

(a) A prefill GEMM

Take one linear projection: activations $X$ of shape $[T, d]$ times weights $W$ of shape $[d, d]$, producing $[T, d]$, all in a dtype of $b$ bytes. $T$ is the number of tokens in the batch, $d$ the hidden size.

$$F = 2Td^{2}, \qquad Q = b\left(Td + d^{2} + Td\right), \qquad I(T) = \frac{2Td^{2}}{b\,(2Td + d^{2})} = \frac{2Td}{b\,(2T + d)}.$$

The factor 2 in $F$ is one multiply plus one add per MAC. In $Q$: read the activations, read the weights, write the result. For bf16, $b = 2$ and this collapses to $I = Td / (2T + d)$.

Two limits carry the meaning. As $T \to \infty$, $I \to d/2$ — 2048 FLOP/byte for Llama-3-8B's $d = 4096$, seven times past the ridge, because the weight matrix is read once and reused by every token. At $T = 1$, $I \to d/(d+2) \approx 1$, and the GEMM is not a GEMM at all.

Setting $I(T) = I^{*}$ and solving gives the batch at which a square projection becomes compute-bound:

$$T^{*} = \frac{b I^{*} d}{2(d - b\,I^{*})} \;=\; \frac{295 \times 4096}{4096 - 2 \times 295} \;\approx\; 345 \text{ tokens.}$$

Derived. A Llama-3-8B projection needs about 345 tokens in flight before an H100's bf16 tensor cores are the limit. A 1,024-token prompt is comfortably past it; so is every value vLLM resolves max_num_batched_tokens to on an H100 — the 2,048 declared on SchedulerConfig is a fallback, and EngineArgs.get_batch_defaults (vllm/engine/arg_utils.py:L2580-L2671) picks 8,192 for vllm serve and 16,384 for the offline LLM class on any device with at least 70 GiB that is not an A100. Note the denominator: if $d \le b\,I^{*} = 590$, no batch size whatsoever makes a square bf16 GEMM compute-bound on an H100. Small models are structurally memory-bound.

Real shapes

Llama-3-8B's projections are not square. vLLM records the real ones as [K, N] pairs at benchmarks/kernels/weight_shapes.py:L52-L57: [4096, 6144] (fused QKV — 4096 query plus 1024 key plus 1024 value columns, the GQA ratio visible in the shape), [4096, 4096], [4096, 28672] and [14336, 4096]. The general form is $I = 2TKN / \big(b(TK + KN + TN)\big)$; the square case is the one to memorise.

(b) A decode GEMV

Now set $T = 1$ — one token per sequence, batch 1, which is what a decode step is. Keep the general $[1, K] \times [K, N]$ shape:

$$I = \frac{2KN}{b\,(K + KN + N)} \;\xrightarrow[\;KN \gg K+N\;]{}\; \frac{2}{b}.$$

For bf16, $I$ approaches 1.0 FLOP per byte — the fused QKV projection at $K = 4096$, $N = 6144$ gives $50{,}331{,}648$ FLOPs over $50{,}352{,}128$ bytes, an intensity of 0.9996. Notice what dropped out: $K$, $N$, the layer, the model, the head count. Only $b$ survives. Every dense weight matrix, read once and applied to one token, does exactly $2/b$ FLOPs per byte. You load two bytes of bf16 weight and do one multiply-add with it, forever.

On the roofline that is $\beta \cdot I = 3.35$ TFLOP/s against a ceiling of 989.4: a batch-1 bf16 decode step can use at most 0.34% of the H100's advertised tensor-core throughput. Quantizing weights to fp8 doubles $I$ to 2.0 and doubles achievable throughput — still 0.68% of peak, but the time halved, because in the memory-bound regime the ideal memory-time term is bytes over bandwidth. Conversion, scale metadata, launch gaps, and achieved bandwidth determine the realized speedup. That is the whole argument for weight-only quantization, made before opening §4.1.

(c) Decode attention against the KV cache

The other half of a decode step reads the KV cache. For one sequence at context length $s$, one layer, with $h$ query heads, $h_{kv}$ key/value heads and head dimension $d_h$: the kernel must read $K$ and $V$ for all $s$ positions, then compute $q K^{\top}$ and $\text{softmax} \cdot V$ for each of the $h$ query heads.

$$F = 4\,h\,s\,d_h, \qquad Q = 2\,s\,h_{kv}\,d_h\,b, \qquad I = \frac{4 h s d_h}{2 s h_{kv} d_h b} = \frac{2h}{b\,h_{kv}}.$$

The context length cancels. Decode attention has a fixed arithmetic intensity that depends only on the GQA group size $g = h/h_{kv}$ and the dtype: $I = 2g/b$, which for bf16 is simply $g$. Llama-3-8B has $g = 32/8 = 4$, so $I = 4$ FLOP/byte — 74 times left of the ridge. Multi-head attention ($g = 1$) gives $I = 1$; multi-query ($h_{kv} = 1$, $g = 32$) gives $I = 32$. GQA and MQA are visible on the roofline as pure rightward moves, and §3.5 is where they get built.

So if intensity does not degrade with context, what gets worse? First, the bytes grow linearly in $s$ even though the ratio does not, so the attention part of the step grows linearly with context while the weight part stays flat. Second — and this is the structural asymmetry the whole book turns on — batching amortises weight reads but does not amortise KV reads. Every sequence has its own cache, so doubling the batch doubles both the FLOPs and the bytes of attention and $I_{\text{attn}}$ is invariant in $B$ as well as in $s$. Taking $B \to \infty$ at fixed $s$, the blended intensity of a whole decode step tends to

$$I_{\text{step}}(B \to \infty) = \frac{2P_{\text{stream}} + 4 L h d_h s}{2 L h_{kv} d_h b \, s}.$$

where $P_{\text{stream}}$ is the parameters actually streamed per token. For Llama-3-8B at $s = 8192$ this is $(15.01 \times 10^{9} + 4.29 \times 10^{9}) / (1.074 \times 10^{9}) \approx 18$ FLOP/byte. Derived. At 8k context, no batch size on earth makes a Llama-3-8B decode step compute-bound on an H100 — a ceiling of 18 against a ridge of 295. Long context is not a capacity problem batching solves; it is a bandwidth problem only fewer KV bytes solve. That is the roofline predicting prefix caching (§2.3), KV quantization (§2.5) and MLA (§7.2) before any of them are described.

Batching, and why it is the biggest lever

Put $B$ sequences in a decode step, one token each. The flattened activation is $[B, d]$ and hits the same $[d, d]$ weight — algebraically identical to the prefill GEMM with $T \to B$. So the weight-GEMM intensity of a decode step is $I = Bd/(2B + d)$ for bf16, and batching walks you up the diagonal of Figure 1.

Derived — Llama-3-8B (d = 4096), bf16, weight GEMMs only, against H100 SXM spec-sheet peaks
Batch BI (FLOP/byte)Achievable% of 989 TFLOP/s
11.03.4 TFLOP/s0.34%
88.027 TFLOP/s2.7%
3231.5106 TFLOP/s10.7%
128120.5404 TFLOP/s40.8%
345295989 TFLOP/s100% — ridge
2048 (prefill-scale)1024989 TFLOP/s100% — capped

Sixteen times the batch for sixteen times the throughput at almost the same step time. That is the single largest multiplier available in LLM serving, and it is exactly what continuous batching exists to harvest (§1.3) — which is also why that chapter is about admission and eviction rather than matmuls. The hard part is keeping $B$ high, not making the GEMM fast.

The decode step lower bound

Because decode is memory-bound, its step time has a hard floor set by bandwidth:

$$t_{\text{decode}} \;\gtrsim\; \frac{\text{bytes}_{\text{weights}} + \text{bytes}_{\text{KV read}}}{\beta}.$$

Work it for Llama-3-8B in bf16, $L = 32$, $d = 4096$, $h = 32$, $h_{kv} = 8$, $d_h = 128$, vocabulary 128,256. Per layer: fused QKV $4096 \times 6144$, output $4096 \times 4096$, gate+up $2 \times 4096 \times 14336$, down $14336 \times 4096$ — 218.1 M parameters, times 32 layers is 6.98 B. Add the 525 M-parameter output head and you stream 7.50 B parameters per token. The input embedding table is gathered, not streamed — one row of 8 KB, not 1.05 GB — so $\text{bytes}_{\text{weights}} = 15.01$ GB, not the 16.06 GB the full 8.03 B parameter count suggests. KV per token is $2 \cdot L \cdot h_{kv} \cdot d_h \cdot b = 2 \times 32 \times 8 \times 128 \times 2 = 131{,}072$ bytes, exactly 128 KiB.

4.48 ms
Floor per decode step, batch 1, short context
223 tok/s
Implied ceiling at batch 1
14.7 ms
Floor at B = 32, s = 8192 (49.4 GB moved)
3,120 tok/s
KV-bandwidth ceiling at s = 8192, any B

All four derived from the formula above with the cited 3.35 TB/s. The first pair is the number to carry around: a single H100 cannot generate Llama-3-8B faster than about 223 tokens per second at batch 1 in bf16 under one full target-model forward per accepted token and the stated streamed-weight assumptions. Speculative verification can emit multiple accepted tokens per weight stream; it is outside this particular ceiling. Not because the kernels are bad — because streaming 15 GB of weights at 3.35 TB/s takes 4.48 ms. Measure 6 ms and you have 1.5 ms of slack to hunt for in launch overhead and CUDA graphs (§8.1); measure 4.6 ms and you are done, go quantize.

The last KPI is the cruel one. At 8k context, KV traffic per token is 1.07 GB, so aggregate throughput cannot exceed $3.35\times10^{12} / 1.07\times10^{9} \approx 3{,}120$ tokens/s regardless of batch size — and you cannot even reach that. Capacity stops you long before bandwidth does: §0.1 budgets the same card at $0.92 \times 79.65 - 14.96 - 6 \approx 52.32$ GiB of KV pool — note that the weight term there is the full 14.96 GiB of resident parameters, not the 15.01 GB streamed per step, because the embedding table occupies memory whether or not it is read — which is about 429k tokens at 128 KiB each, or roughly 52 sequences at 8k context. Well short of the 345 the ridge wants. Part 2 is that fight.

Figure 2 — the decision procedure. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The negative results are as useful as the positive ones. At $I = 1$ a kernel with greater arithmetic throughput alone cannot raise the bandwidth ceiling — you would be making the idle half of the machine idler. A compute-bound kernel may see little benefit from reduced bytes, but a full step mixes kernels with different bottlenecks. Most disappointing optimisations in this field are optimisations applied on the wrong side of the ridge.

§4

How production systems do it

Neither engine contains a file called roofline.py. What both contain is machinery whose sole purpose is to raise $T$ — the number of tokens handed to one forward pass — because it improves weight reuse; reducing transferred bytes is another way to move rightward.

vLLM: one flat token budget, then one flat GEMM

The V1 scheduler runs a token budget over running and waiting requests, and hands the model runner a per-request token count rather than a batch of sequences.

vllm/v1/core/sched/scheduler.py:L502-L510 vLLM
        req_to_new_blocks: dict[str, KVCacheBlocks] = {}
        num_scheduled_tokens: dict[str, int] = {}
        token_budget = self.max_num_scheduled_tokens
        spec = self.vllm_config.speculative_config
        draft_slots = spec.max_num_new_slots_for_drafting if spec is not None else 0
        input_budget = self.scheduler_config.max_num_batched_tokens
vllm/v1/core/sched/scheduler.py:L566-L576 vLLM
            num_new_tokens = (
                request.num_tokens_with_spec
                + request.num_output_placeholders
                - request.num_computed_tokens
            )
            if 0 < self.scheduler_config.long_prefill_token_threshold < num_new_tokens:
                num_new_tokens = self.scheduler_config.long_prefill_token_threshold
            num_new_tokens = min(
                num_new_tokens, token_budget, input_budget - draft_slots
            )

Each admitted request draws down the budget at vllm/v1/core/sched/scheduler.py:L704-L710. The default budget is small and explicitly labelled as a testing convenience — real deployments set it much higher, which is the point:

vllm/config/scheduler.py:L42-L55 vLLM
    DEFAULT_MAX_NUM_BATCHED_TOKENS: ClassVar[int] = 2048
    DEFAULT_MAX_NUM_BATCHED_TOKENS_FOR_BATCHED_DP: ClassVar[int] = 256
    DEFAULT_MAX_NUM_SEQS: ClassVar[int] = 128
# ...
    max_num_batched_tokens: int = Field(default=DEFAULT_MAX_NUM_BATCHED_TOKENS, ge=1)
    """Maximum number of tokens that can be processed in a single iteration.

    The default value here is mainly for convenience when testing.
    In real usage, this should be set in `EngineArgs.create_engine_config`.
    """

The model runner then flattens every request's scheduled tokens into one contiguous token axis. This is where the roofline point is physically created — the $M$ dimension of every weight GEMM in the forward pass is total_num_scheduled_tokens. Two GPU runners ship at this SHA and a dense model such as Llama-3-8B actually runs on the newer one (vllm/v1/worker/gpu/model_runner.py, selected by VllmConfig.use_v2_model_runner); the older file quoted here does the same flattening in NumPy rather than Triton, which is why it is the readable one. §11.4 owns the selection policy and reads both.

vllm/v1/worker/gpu_model_runner.py:L2032-L2050 vLLM
        total_num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
        assert total_num_scheduled_tokens > 0
        num_reqs = self.input_batch.num_reqs
        assert num_reqs > 0

        # OPTIMIZATION: Start copying the block table first.
        # This way, we can overlap the copy with the following CPU operations.
        self.input_batch.block_table.commit_block_table(num_reqs)

        # Get request indices.
        # E.g., [2, 5, 3] -> [0, 0, 1, 1, 1, 1, 1, 2, 2, 2]
        req_indices = np.repeat(self.arange_np[:num_reqs], num_scheduled_tokens)

        # cu_num_tokens: [2, 5, 3] -> [2, 7, 10]
        # self.query_pos.np[:10]: [0, 1, 0, 1, 2, 3, 4, 0, 1, 2]
        cu_num_tokens = self._get_cumsum_and_arange(
            num_scheduled_tokens, self.query_pos.np
        )

The comment's example — [2, 5, 3] tokens for three requests — is the roofline in miniature: three sequences become one $M = 10$ GEMM. Chunked prefill (§1.5) exists so a long prompt can be split across steps to top up this $M$ while decode tokens ride along, keeping every step near the ridge instead of alternating between a compute-bound prefill and a memory-bound decode.

SGLang: the same budget, a different shape

SGLang carries the equivalent knob as max_prefill_tokens, resolved from the model worker at python/sglang/srt/managers/scheduler.py:L1032-L1042 and declared as:

python/sglang/srt/server_args.py:L826-L836 SGLang
    max_prefill_tokens: A[
        int,
        Arg(
            help=(
                "The maximum number of tokens in a prefill batch. The real bound "
                "will be the maximum of this value and the model's maximum "
                "context length." + f"\n\n{human_readable_int.__doc__}"
            ),
            type_parser=human_readable_int,
        ),
        NS("schedule"),

The admission loop lives in PrefillAdder (python/sglang/srt/managers/schedule_policy.py:L511) — SGLang's per-step admission controller, taken apart properly in §1.4. It tracks three separate budgets and reports why it stopped:

python/sglang/srt/managers/schedule_policy.py:L841-L860 SGLang
    def budget_state(self):
        no_token = self.rem_total_tokens <= 0 or self.cur_rem_tokens <= 0
        if not no_token and self.is_hybrid_swa:
            no_token = self.rem_swa_tokens <= 0
        # Gate new mamba slots separately: rem_total_tokens' full_evictable can't
        # cover a mamba slot, which needs mamba-recoverable bytes (see __init__).
        if not no_token and self.rem_mamba_slots is not None:
            no_token = self.rem_mamba_slots <= 0
        if no_token:
            return AddReqResult.NO_TOKEN

        if self.rem_input_tokens <= 0:
            return AddReqResult.OTHER

        if self.dllm_config is not None:
            if self.rem_dllm_tokens <= 0:
                return AddReqResult.OTHER
        else:
            if self.rem_chunk_tokens is not None and self.rem_chunk_tokens <= 0:
                return AddReqResult.OTHER

Where they differ. vLLM keeps one scalar token_budget plus an input_budget and mixes prefill and decode tokens in a single undifferentiated flat batch. SGLang separates rem_total_tokens (KV capacity — what stops you reaching the ridge), rem_input_tokens (the GEMM-shape budget — what gets you there) and rem_chunk_tokens, and returns a typed reason for refusal. Same two roofline pressures; SGLang makes the distinction between out of batch and out of KV memory explicit in the return value, which is why its scheduler logs tell you which one bit.

The microbenchmark that draws the curve

vLLM's kernel benchmarks contain a runnable version of §3(a). benchmark_fp8_gemm.py sweeps $M$ from 1 to 16,384 against fixed model weight shapes and reports TFLOP/s:

benchmarks/kernels/benchmark_fp8_gemm.py:L89-L123, 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)",
        plot_name="BF16 vs FP8 GEMMs",
        args={},
    )
)
def benchmark(batch_size, provider, N, K):
# ...
    to_tflops = lambda t_ms: (2 * M * N * K) * 1e-12 / (t_ms * 1e-3)

2 * M * N * K is exactly the $F$ of §3(a) and x_vals straddles the $T^{*} \approx 345$ crossing, so the printed TFLOP/s column is the roofline's y-axis, sampled — the closest thing in either repo to a first-party roofline experiment.

§5

Worked trace: timing the two points

SGLang's single-batch harness times prefill and decode separately — precisely the two-point measurement Figure 1 needs. Both engines' historical entry points are now shims: python/sglang/bench_one_batch.py:L1-L22 re-exports from sglang.benchmark.one_batch, and benchmarks/benchmark_latency.py:L1-L17 points at vllm bench latency. The call path is cli_main()main()latency_test()latency_test_run_once(), and inside that last function the two phases are bracketed independently. Prefill:

python/sglang/benchmark/one_batch.py:L784-L804, L801-L805 SGLang
    model_runner.synchronize()
    tic = time.perf_counter()
    next_token_ids, _, batch = model_runner.extend(reqs)
    model_runner.synchronize()
    prefill_latency = time.perf_counter() - tic
# ...
    throughput = input_len * batch_size / prefill_latency
    rank_print(
        f"Prefill. latency: {prefill_latency:6.5f} s, throughput: {throughput:9.2f} token/s"
    )

Then decode, one timed iteration at a time, with the median reported rather than the mean — the first decode step after a prefill is not representative:

python/sglang/benchmark/one_batch.py:L817-L850, L831-L834, L848-L850 SGLang
    for i in range(output_len - 1):
        model_runner.synchronize()
# ...
        tic = time.perf_counter()
        next_token_ids, _ = model_runner.decode(next_token_ids, batch)
        model_runner.synchronize()
        latency = time.perf_counter() - tic
# ...
        tot_latency += latency
        throughput = batch_size / latency
        decode_latencies.append(latency)

Two details make this a valid roofline probe rather than a vibe. The model_runner.synchronize() calls on both sides of the timed region drain the asynchronous CUDA stream, so perf_counter measures completed wall-clock work including host dispatch and synchronization overhead, not just enqueue time. CUDA events delimit device-stream time when that is the desired quantity. And extend() (python/sglang/benchmark/one_batch.py:L487-L521) versus decode() (python/sglang/benchmark/one_batch.py:L526-L537) differ only in prepare_for_extend versus prepare_for_decode and the resulting token count — same weights, same kernels, different $T$.

Walk the bytes for one decode step at batch 1, context 1024: 15.01 GB of weights streamed once, plus $1024 \times 131072 = 0.134$ GB of KV, is 15.14 GB — floor 4.52 ms, about 221 token/s. At --batch-size 64 the weights are still read once and KV becomes 8.59 GB, so 23.6 GB — floor 7.05 ms, about 9,080 token/s. All derived; the harness prints the measured version and the residual is the lesson.

§6

Pitfalls and war stories

01

Using the sparsity number

NVIDIA's headline "1,979 TFLOPS BF16" for H100 SXM assumes 2:4 structured sparsity, which ordinary dense LLM inference does not automatically exploit. Halving it to 989 moves the ridge from 590 to 295 — a factor of two error in every prediction downstream. Check whether the footnote says "with sparsity" before you divide by anything.

02

Counting the embedding table as streamed

Llama-3-8B is 8.03 B parameters, but only 7.50 B of them are read during a decode step: the 128,256 x 4096 input embedding is a gather of one row, not a stream. Including it overstates the decode floor by 7%. The output head, by contrast, is fully read every step.

03

Optimising on the wrong side of the ridge

The classic wasted week: a decode kernel at $I = 1$ gets hand-tuned tiling, wins 3% and the engineer concludes tuning does not work. Arithmetic-only optimization cannot beat the bound. Better kernels can nevertheless reduce HBM rereads, improve achieved bandwidth, fuse launches, and reduce overhead.

04

Assuming batching fixes long context

It fixes the weight term and does nothing for the KV term. At 8k context the derived intensity ceiling at infinite batch is 18 FLOP/byte. If your throughput plateaus as you raise concurrency at long context, you have hit KV bandwidth, not a scheduler bug — and the KV pool will run out first anyway.

05

Timing without synchronising

CUDA launches are asynchronous. A timed region without a device sync on both sides measures how fast Python enqueues work, which on a decode step is often faster than the work itself. SGLang's harness syncs at python/sglang/benchmark/one_batch.py:L784-L804.

06

Treating the derived floor as a target

$t \gtrsim \text{bytes}/\beta$ uses spec-sheet $\beta$. It is a bound you cannot beat, never a number you should expect to hit.

§7

Hands-on

Three commands, in increasing order of what they tell you.

measure your own ridge pointshell
# vLLM's GEMM sweep: M from 1 to 16384 against real Llama-3.1-8B weight shapes.
# The TFLOP/s column is the roofline y-axis, sampled.
python benchmarks/kernels/benchmark_fp8_gemm.py \
    --models meta-llama/Llama-3.1-8B-Instruct --tp-sizes 1
the two roofline points, separately timedshell
# SGLang. Prints "Prefill. latency ... throughput" and "Decode. median latency ...".
# Defaults are batch_size=(1,), input_len=(1024,), output_len=(16,)
#   — python/sglang/benchmark/one_batch.py:L196-L200
python -m sglang.benchmark.one_batch --model-path meta-llama/Meta-Llama-3-8B-Instruct \
    --batch-size 1 64 --input-len 1024 --output-len 16
the batch-1 latency harnessshell
# vLLM. Defaults: --input-len 32 --output-len 128 --batch-size 8
#   --num-iters-warmup 10 --num-iters 30  (vllm/benchmarks/latency.py:L34-L54)
vllm bench latency --model meta-llama/Meta-Llama-3-8B-Instruct \
    --batch-size 1 --input-len 32 --output-len 128

The third one gives an end-to-end latency for 128 output tokens (vllm/benchmarks/latency.py:L125-L135 wraps llm.generate in perf_counter). Divide by 128 and compare against the 4.48 ms derived floor. The gap is your budget for CUDA graphs, sampling, detokenization, and scheduler overhead — every one of which gets a chapter later.

Lab 01, 01-measure-your-gpu-roofline, does this properly: it sweeps matmul shapes to find achieved TFLOP/s, runs a streaming kernel to find achieved GB/s, computes your card's real $I^{*}$, and plots Figure 1 with your numbers. Every prediction in this book should be re-derived against them.

§8

Exercises

  1. Re-derive the ridge for a different card. An A100 SXM (80 GB) is rated at 312 TFLOP/s dense bf16 and 2.039 TB/s. Compute $I^{*}$. Is a batch-1 bf16 decode GEMV further from the ridge on an A100 or on an H100, and which card is better value for low-latency single-stream serving?
  2. Read the source. Open python/sglang/srt/managers/schedule_policy.py:L841-L860. Which of AddReqResult.NO_TOKEN and AddReqResult.OTHER means you ran out of the budget that moves you rightward on the roofline, and which means you ran out of the resource that caps how far right you can ever get?
  3. Predict, then verify. Llama-3-70B has $L = 80$, $d = 8192$, $h = 64$, $h_{kv} = 8$. Predict (a) the decode-attention intensity, (b) the KV bytes per token in bf16, (c) the batch size at which an $8192 \times 8192$ projection reaches the H100 bf16 ridge. Check (c) against $T^{*}$ and explain why it is smaller than for Llama-3-8B.
  4. Find the crossover. At what context length $s$ does the KV read of a single Llama-3-8B sequence equal the weight read of a whole decode step, and what does that imply about which optimisation to reach for first at 4k versus 128k context?
  5. Break the model. Name a decode-time optimisation that reduces bytes moved but does not improve throughput, and explain using Figure 1 why the roofline fails to predict it. (Hint: what does the roofline assume about the gap between kernels?)
Answers

1. $I^{*} = 312 / 2.039 = 153$ FLOP/byte, roughly half the H100's 295. A batch-1 GEMV sits at $I = 1$ on both, so it is relatively closer to the ridge on an A100 — but relative position is not what you buy. At $I = 1$ the achievable rate is $\beta \cdot 1$, so the H100 is 1.64x faster at batch-1 decode purely because of HBM3. Low-latency single-stream serving is a bandwidth purchase, not a FLOPs purchase.

2. NO_TOKEN is driven by rem_total_tokens, cur_rem_tokens, rem_swa_tokens and rem_mamba_slots — all KV-pool capacity, the ceiling on how far right you can ever get. OTHER is driven by rem_input_tokens and rem_chunk_tokens — this step's GEMM shape. Capacity refusals mean you need less KV per token; input-budget refusals mean you have simply filled this step.

3. (a) $I = 2h/(b\,h_{kv}) = 8$ FLOP/byte, twice Llama-3-8B because the GQA group is 8. (b) $2 \times 80 \times 8 \times 128 \times 2 = 327{,}680$ bytes = 320 KiB per token, 2.5x the 8B model. (c) $T^{*} = 295 \times 8192 / (8192 - 590) = 318$ tokens, smaller than 345: a wider $d$ makes the weight matrix ($d^2$) grow faster than the activations ($Td$), so each token amortises more weight bytes. Width is friendly to the roofline; depth is not.

4. $s = 15.01 \times 10^{9} / 131072 \approx 114{,}500$ tokens. At 4k context KV is only 3.5% of the step's traffic, so weight quantization is the lever and KV quantization buys concurrency rather than speed. Past ~114k the priority inverts entirely. Note the crossover falls much earlier in batch: at $B = 32$ the KV term is 32x larger, so parity arrives at about 3,600 tokens of context per sequence.

5. Any byte reduction that leaves the step dominated by kernel-launch gaps rather than memory traffic — for example shrinking weights on a tiny model where each of the several hundred kernels per step takes less time than its launch overhead. The roofline assumes the machine executes your kernel continuously and says nothing about the microseconds between kernels. That gap is what CUDA graphs close (§8.1), and it is why the derived floor is a bound rather than a prediction.

§9

Key takeaways

  • A dense weight matrix applied to one token does exactly $2/b$ FLOPs per byte — 1.0 in bf16 — independent of model, layer, and shape. That is why decode is memory-bound on modern high-compute GPUs under this low-intensity workload, and why extra peak arithmetic alone is not the fix; a better implementation can still improve bandwidth utilization or reduce traffic.
  • Decode attention's intensity is $2h/(b\,h_{kv})$: independent of context length and of batch size. Weight reads amortise across a batch; independent-request KV reads do not. Shared-prefix-aware attention can exploit additional KV reuse; ordinary prefix caching alone need not reduce repeated decode reads. That asymmetry is why long-context serving is a different engineering problem from high-concurrency serving.
  • The ridge for H100 SXM bf16 is 295 FLOP/byte, reached at about 345 tokens per step for a Llama-3-8B projection. At hidden size $d \le 2I^{*} = 590$, no batch size makes a square bf16 GEMM compute-bound at all.
  • The decode floor $t \gtrsim (\text{weight bytes} + \text{KV bytes})/\beta$ gives 4.48 ms and a 223 tok/s ceiling for Llama-3-8B at batch 1 on an H100. Measure your step, subtract the floor; the remainder — not the total — is the part software can fix.
  • Faster math (fp8, fp4) shifts the ridge right. On a memory-bound kernel the benefit of low precision comes entirely from moving fewer bytes, not from the faster tensor cores — two mechanisms wearing the same name.
  • Both engines' scheduler token budgets exist for one reason: to raise the $M$ dimension of the forward pass's GEMMs. Read token_budget and rem_input_tokens as roofline controls and the rest of both schedulers becomes legible.
§10

Further reading

  • Williams, Waterman, Patterson, Roofline: An Insightful Visual Performance Model for Multicore Architectures, CACM 2009 — the original model. Everything here is that paper with $\pi$ and $\beta$ swapped for GPU numbers.
  • NVIDIA H100 Tensor Core GPU datasheet and the NVIDIA H100 Tensor Core GPU Architecture whitepaper — the source of the 989.4 TFLOP/s and 3.35 TB/s used throughout. Read the sparsity footnotes.
  • NVIDIA, Matrix Multiplication Background User's Guide — the arithmetic-intensity treatment of GEMM shapes, including why skinny $M$ is pathological.
  • Pope et al., Efficiently Scaling Transformer Inference (2022) — the operational-intensity analysis of PaLM serving that made the memory-bound-decode argument standard.
  • Dao et al., FlashAttention (2022) and FlashAttention-2 (2023) — the canonical example of moving actual traffic down to compulsory traffic. Built properly in §3.2.
  • vLLM PR #3130 — chunked prefill and issue #3861, plus Agrawal et al., Sarathi-Serve (OSDI 2024): an unusually clear record of engineers arguing about where on the roofline a mixed batch should sit. Picked up in §1.5.

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