ML Interview Notes
36 min read13 sections
Part 10 · Benchmarking and performance engineering · 10-05

Profiling, roofline of a decode step, capacity planning

Status
SOURCE PINNED
Primary sources
  • vllm/profiler/
  • python/sglang/profiler.py
Edition pins
vllm a556f3f · sglang 7d89325

A derived bound with no measurement beside it is a hypothesis. This chapter closes the loop opened in §0.4: derive the floor for one decode step, capture a trace, attribute the gap kernel by kernel, and then turn the surviving number into a replica count and a price per million tokens.

§1

The problem

Turn on vLLM's built-in performance metrics and the engine will start telling you how fast it is going. The log line is one printf in vllm/v1/metrics/loggers.py, fed by an analytic model in vllm/v1/metrics/perf.py whose module docstring says exactly what it is for:

vllm/v1/metrics/perf.py:L4-L7 vLLM
"""
Analytic flops/memory estimation module for transformer components,
to help derive MFU (Model Flops Utilization) stats for a running model.
"""

Analytic. Not counted — multiplied out from the config, per scheduler step, in vllm/v1/core/sched/scheduler.py:L1764-L1766. That is the right design; counting real FLOPs would cost more than the model does. But it makes the number only as good as its arithmetic, and on Multi-head Latent Attention the arithmetic is wrong:

vllm/v1/metrics/perf.py:L670-L681 vLLM
        # KV projection (always compressed, shared across heads)
        # kv_a: h -> (kv_lora_rank + qk_rope_head_dim)  [replicated]
        flops["kv_a_proj"] = 2 * T * D * (c + r) * L
        # kv_b: kv_lora_rank -> num_heads * (qk_nope + v_head_dim)
        flops["kv_b_proj"] = 2 * T * c * q * (self.qk_nope_head_dim + v_d) * L

        # Attention core
        flops["attn_qk"] = 2 * q * TC * qk_head_dim * L
        flops["attn_av"] = 2 * q * TC * v_d * L

        # Output projection: num_heads * v_head_dim -> h
        flops["out_proj"] = 2 * T * q * v_d * D * L

qk_head_dim is qk_nope_head_dim + qk_rope_head_dim = 192 for DeepSeek-V3, and v_d is 128. Those are the widths of the un-absorbed attention core. §7.2 walked one decode token through vLLM's real MLA path and found the kernel attending against a 576-wide latent row with a 512-wide value slice, because forward_impl folds $W_{UK}$ into the query before the kernel ever runs. The model charges 320 units of head width per context token; the hardware does 1,088. That finding was handed to this chapter to verify and price; it holds, with one correction that makes it cleaner. Section 6 has the arithmetic.

That is the failure mode this chapter exists to prevent, in miniature: a number produced by a model nobody checked against the machine. The cure is not to distrust models — the whole book is models — but to pair every derived bound with a measurement and an explicit account of the difference.

§2

Mental model

Performance work on an inference engine is three steps in a fixed order, and the order is what makes it a method rather than a hunt. Derive the floor for the specific model, batch and context from §0.4's roofline — this is pure arithmetic and takes two minutes. Measure one step under a trace. Attribute the gap: every microsecond between the floor and the measurement belongs to a named cause, and the trace tells you which.

Division of labour

This book does step 1. It has no GPU, so every number below is derived arithmetic or a cited vendor spec, and none of it is measured. Steps 2 and 3 are Lab 10, 10-profile-a-decode-step. A derived bound on its own tells you what is possible; only the trace tells you what is happening. Never ship a performance claim that has only one of the two.

Figure 1 — the loop. The left branch is arithmetic and costs minutes; the right branch is instrumentation and costs an afternoon. Doing them in the wrong order is how afternoons disappear. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The branch labels in Figure 1 are the whole diagnostic vocabulary. Four shapes, four causes. Section 4 draws them.

§3

One decode step on the roofline, worked

Fix an operating point and never move it mid-analysis: Llama-3-8B, bf16, TP=1, one H100 SXM, batch $B = 32$, context $s = 2048$ tokens per sequence. Symbols follow FORMULAS: $L=32$ layers, $d=4096$, $h=32$ query heads, $h_{kv}=8$, $d_h=128$, $b=2$ bytes, $\beta = 3.35$ TB/s and $\pi = 989.4$ TFLOP/s (cited NVIDIA peaks), $I^{*} = \pi/\beta = 295$ FLOP/byte.

Bytes

§0.4 established that a decode step streams $\text{bytes}_{\text{weights}} = 15.01$ GB — 7.50 B parameters at 2 bytes, the full 8.03 B count minus the input embedding table, which is gathered one row at a time rather than streamed. KV bytes come from the cell size $k = 2 L h_{kv} d_h b = 131{,}072$ bytes = 128 KiB per token, times the resident tokens $B \cdot s$.

Derived — compulsory HBM traffic for one decode step at $B=32$, $s=2048$. Nothing measured.
TermExpressionBytesShare
Weights streamed$2 \cdot 7.50\times10^{9}$15.01 GB63.6%
KV read$k \cdot B \cdot s = 131072 \times 65536$8.590 GB36.4%
KV write$k \cdot B$0.0042 GB0.02%
Total $Q$23.60 GB100%

Note what batching did to the mix. At batch 1 and short context, §0.4 found 98% of the traffic was weights; at $B=32$, $s=2048$ the KV cache is already a third of it — which is the difference between "quantise the weights" and "quantise the KV" being the right next move.

FLOPs

From FORMULAS, cached decode costs $F = 2P + 4 L h d_h s$ per token, with $P = 7.50\times10^{9}$ the streamed parameter count. Per token: $1.500\times10^{10}$ for the GEMVs and $4 \times 32 \times 32 \times 128 \times 2048 = 1.074\times10^{9}$ for attention — attention is 6.7% of decode FLOPs, matching §0.2's point that the cache saves weight GEMMs, not attention arithmetic. Times $B = 32$:

$$F = 32 \times (1.500\times10^{10} + 1.074\times10^{9}) = 5.144\times10^{11}\ \text{FLOP}.$$

Placing it, and the prediction

7.04 ms
Predicted step floor, $B$=32, $s$=2048 (derived)
4,542 tok/s
Implied decode ceiling (derived)
21.8
FLOP/byte — $13.5\times$ left of the 295 ridge
7.4%
MFU at the floor: 73.0 of 989.4 TFLOP/s

$I = F/Q = 5.144\times10^{11} / 2.360\times10^{10} = 21.8$ FLOP/byte, so $\min(\pi, \beta I) = \beta I = 73.0$ TFLOP/s and $t = Q/\beta = 2.360\times10^{10}/3.35\times10^{12} = 7.04$ ms. Even a perfect implementation of this operating point reaches 7.4% of the H100's bf16 peak. If someone demands 50% MFU on decode, the answer is not "optimise harder", it is "that number is unreachable by a factor of seven at this batch and context, and here is why".

Figure 2 — the worked decode step on the H100 SXM bf16 roofline, with the attribution budget drawn. The filled dot is the derived prediction, which sits on the bandwidth roof by construction. Any real measurement lands on or below it; the vertical distance is exactly the time you have to account for. All coordinates derived; nothing measured.

Roofline with the worked Llama-3-8B decode step placed Log-log roofline for H100 SXM. The bandwidth roof of 3.35 terabytes per second meets the 989 teraFLOP per second compute ceiling at intensity 295. The worked decode step at batch 32 and context 2048 sits at intensity 21.8 and 73 teraFLOP per second, on the bandwidth roof. A shaded band below the point marks where any measured step must land, labelled the attribution budget. Batch-1 decode at intensity 1 is marked for comparison. 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) ridge I* = 295 FLOP/byte batch-1 decode, I = 1.0 (from §0.4) Llama-3-8B decode, B = 32, s = 2048 I = 21.8, 73.0 TFLOP/s, 7.04 ms/step — PREDICTED attribution budget every measured step lands in here; the trace says which cause owns each gap memory bound compute bound unattainable — above the roof

What a measurement would have to show

Three quantities are checkable independently, and each plausible gap has a distinct signature.

The confirm/refute table for the operating point above. Left column derived; right column is what Lab 10 must produce.
PredictionValueRefuted if the trace shows…
Step wall time≥ 7.04 msanything below 7.04 ms — then $\beta$ or the byte model is wrong, most likely because prefix caching or an L2-resident weight slice made some traffic non-compulsory
Sum of kernel durations≈ 7.04 msmuch less — the missing time is host-side, i.e. launch-bound
DRAM throughput on the GEMV kernelsnear 3.35 TB/sfar below with kernels back-to-back — occupancy or tail effect, not bandwidth
Bytes actually moved23.60 GBmaterially more — extra traffic: an unfused residual, a dtype cast, a KV layout that reads whole pages for partial rows
Launch overhead share≤ 0.78 ms (11%)more — framework cost above the 2.374 µs driver floor; see §8.1

The launch-overhead row is where this operating point diverges from §8.1's. §8.1 derived 330 kernel launches for a TP=1 Llama-3-8B step against a cited driver-only 2.374 µs per cudaLaunchKernel (Vellaisamy et al., ISPASS 2025, arXiv:2504.11750, Table V) — 0.78 ms of host time. That was 17% of §8.1's 4.48 ms batch-1 step and "marginal"; it is 11% of this one and comfortably hidden. Batching buys launch headroom as well as throughput. The other direction is the dangerous one: at TP=8 device work falls to roughly an eighth while the launch count rises to 395, giving 0.94 ms of launches against 0.56 ms of work — launch-bound before Python is considered. Derive first and you know which world you are in.

§4

Reading a trace: four shapes, four diagnoses

A decode-step trace has two lanes that matter: a host lane of cudaLaunchKernel calls and a device lane of kernels. Everything diagnosable is a relationship between them.

Figure 3 — the four diagnosable decode-step shapes. Schematic, not a capture: these are the geometries the two lanes can take, drawn from the cost model, not from a profile. Bar widths are illustrative within each lane.

Four decode-step trace shapes Four stacked pairs of lanes, each a CPU launch lane above a GPU kernel lane. Launch-bound shows a packed CPU lane and a GPU lane full of gaps. Bandwidth-bound shows a sparse CPU lane and a GPU lane of long back-to-back kernels at peak memory throughput. Compute-bound shows one very long kernel dominating the GPU lane. Occupancy-limited shows back-to-back kernels well below peak throughput with idle streaming multiprocessors. 1 · LAUNCH-BOUND gaps between kernels; CPU lane saturated 2 · BANDWIDTH-BOUND — the healthy decode step kernels back to back, DRAM throughput near 3.35 TB/s 3 · COMPUTE-BOUND one kernel dominates; DRAM idle inside it 4 · OCCUPANCY-LIMITED back to back but far below any roof; grid too small for 132 SMs CPUGPU CPUGPU CPUGPU CPUGPU 330 x 2.374 us of driver time, saturated GPU idle in every gap — fix with CUDA graphs, §8.1 host runs ahead; launches queued long before they execute one kernel — take it to Nsight Compute looks like shape 2 in the timeline — only per-kernel counters tell them apart

The decision procedure is mechanical. Is the GPU lane gappy? If yes, and the host lane is dense, you are launch-bound; nothing about the kernels matters until you fix that, and §8.1 owns the fix. If the GPU lane is dense, is one kernel most of it? If yes, that kernel is your entire problem — take its name to Nsight Compute. If neither, are the kernels near a roof? Only per-kernel counters answer that, and this is exactly the question where using the wrong tool costs an afternoon: shapes 2 and 4 are visually identical in a timeline and differ only in achieved DRAM throughput. Shape 2 means you are done and the only lever left is fewer bytes. Shape 4 means the kernel is leaving the machine idle; for decode the usual cause is the FlashAttention-2 grid, which at query length 1 is $\text{batch} \times h$ blocks — 1,024 at $B=32$, $h=32$, but only 128 at $B=4$, under-filling 132 SMs before the kernel starts.

torch.profiler

Which op, and how long

Op-level attribution with CPU and CUDA times, a Chrome/Perfetto trace, and (with record_shapes) tensor shapes. Answers where does the time go by operator. Both engines drive it. It does not tell you why a kernel is slow.

Nsight Systems

Timeline, gaps, NCCL

System-wide timeline including the CUDA driver, NVTX ranges and NCCL collectives across ranks. Answers is the GPU idle, and who is waiting for whom. The only tool that shows a TP=8 rank skew or an all-reduce that arrived late. Attach it with nsys profile -c cudaProfilerApi.

Nsight Compute

Why this kernel is slow

Per-kernel achieved occupancy, DRAM throughput, and its own roofline. Answers is this kernel at a roof, and which one. Replays each kernel many times, so it is unusable on a live serving loop — isolate the kernel first.

Pick by question, not by habit. "The step is 40% slower than the floor" is a Systems question. "This attention kernel is 40% of the step" is a Compute question. "Which op is 40% of the step" is a torch.profiler question.

§5

Turning profiling on, in each engine

Both engines expose torch.profiler behind an HTTP endpoint, and differ in one instructive way.

vLLM: configure at launch, arm at runtime

As of a556f3f, vLLM's profiling is a config object, not an environment variable — the old VLLM_TORCH_PROFILER_DIR is gone from vllm/envs.py and a ProfilerConfig has taken its place:

vllm/config/profiler.py:L42-L52 vLLM
    profiler: ProfilerKind | None = None
    """Which profiler to use. Defaults to None. Options are:

    - 'torch': Use PyTorch profiler.
    - 'cuda': Use CUDA profiler.
    - 'proton': Use Triton Proton profiler."""

    torch_profiler_dir: str = ""
    """Directory to save torch profiler traces. Both AsyncLLM's CPU traces and
    worker's traces (CPU & GPU) will be saved under this directory. Note that
    it must be an absolute path."""

Forget to set it and the engine tells you the exact flags, which is the friendliest error string in this part of the codebase:

vllm/v1/worker/gpu_worker.py:L1146-L1153 vLLM
    def profile(self, is_start: bool = True, profile_prefix: str | None = None):
        # Check if profiling is enabled
        if self.profiler_config is None or self.profiler_config.profiler is None:
            raise RuntimeError(
                "Profiling is not enabled. Please set --profiler-config to enable "
                "profiling. Example: "
                "'--profiler-config.profiler=torch --profiler-config.torch_profiler_dir"
                "=YOUR_DIR_PATH_TO_DUMP_TRACE'"

The endpoints are two bare POSTs, attached to the app only when a profiler is configured:

vllm/entrypoints/serve/profile/api_router.py:L21-L34 vLLM
@router.post("/start_profile")
async def start_profile(raw_request: Request):
    logger.info("Starting profiler...")
    await engine_client(raw_request).start_profile()
    logger.info("Profiler started.")
    return Response(status_code=200)


@router.post("/stop_profile")
async def stop_profile(raw_request: Request):
    logger.info("Stopping profiler...")
    await engine_client(raw_request).stop_profile()
    logger.info("Profiler stopped.")
    return Response(status_code=200)

Both take no body. When to capture is decided at launch instead, through delay_iterations, max_iterations, and a torch.profiler.schedule built from wait_iterations / warmup_iterations / active_iterations (default 5) in vllm/profiler/wrapper.py:L215-L232. The per-step bookkeeping lives in WorkerProfiler.step (vllm/profiler/wrapper.py:L97-L129), which counts iterations, starts after the delay and auto-stops once max_iterations of recorded steps have elapsed. Without it, a capture on a busy server is gigabytes of trace with no way to find the step you wanted.

SGLang: describe the capture in the request

SGLang puts the same knobs in the POST body. python/sglang/profiler.py is a thin client that builds the request and blocks until the trace is flushed:

python/sglang/profiler.py:L51-L65 SGLang
    # Start profiler. The API replies when all steps are processed
    # and files are generated.
    json_data = {
        "output_dir": str(output_dir),
        "num_steps": str(num_steps),
        "activities": activities,
        "profile_by_stage": profile_by_stage,
        "merge_profiles": merge_profiles,
        "profile_prefix": profile_prefix,
    }
    if start_step is not None:
        json_data["start_step"] = str(start_step)

    response = requests.post(url=url + "/start_profile", json=json_data)
    response.raise_for_status()

activities is a list from ["CPU", "GPU", "MEM", "RPD"] (python/sglang/srt/managers/io_struct.py:L2073-L2074), plus "CUDA_PROFILER" handled separately. The scheduler arms and disarms by forward-pass counter, checked once per batch:

python/sglang/srt/managers/scheduler_components/profiler_manager.py:L436-L446 SGLang
            # Check profiler
            if (
                self.profiler_target_forward_ct
                and self.profiler_target_forward_ct <= self.get_forward_ct()
            ):
                self._stop_profile()
            if (
                self.profiler_start_forward_ct
                and self.profiler_start_forward_ct == self.get_forward_ct()
            ):
                self._start_profile()

The call site is python/sglang/srt/managers/scheduler.py:L3723-L3724, inside the batch-run path, so start_step counts forward passes, not requests. SGLang also has profile_by_stage, which no vLLM flag matches: it starts a fresh profile the first time it sees a prefill batch and another the first time it sees a decode batch, force-flushing the prefill trace in between (same file, L413-L430). On a continuously-batched server that is the difference between one trace containing both phases mixed and two traces you can read.

Where they differ, and why

vLLM binds the capture window at launch and keeps the endpoint stateless; SGLang binds it per request. vLLM's shape suits a fixed benchmark harness (vllm/benchmarks/latency.py:L125-L129 wraps one run in start_profile()/stop_profile()); SGLang's suits poking a running server from a laptop, which is exactly what python3 -m sglang.profiler is. Both warn you off production: vLLM logs "Profiler with mode '%s' is enabled in the API server. This should ONLY be used for local development!" (vllm/entrypoints/serve/profile/api_router.py:L41-L45).

The annotations are the reason the trace is readable

A raw kernel timeline cannot tell you what the batch was. Both engines solve this by naming the per-step trace span after the batch composition. SGLang wraps every ModelRunner.forward in a span built here:

python/sglang/srt/utils/profile_utils.py:L476-L487 SGLang
    mode = forward_batch.forward_mode
    bs = forward_batch.batch_size
    if mode == ForwardMode.EXTEND:
        ext_toks = forward_batch.extend_num_tokens or 0
        base = f"step[EXTEND bs={bs} toks={ext_toks}"
    else:
        base = f"step[{mode.name} bs={bs}"

    if detailed_annotations:
        suffix = build_detailed_annotation_suffix(forward_batch)
        if suffix:
            base = f"{base} {suffix}"

With detailed_annotations on, a decode span also carries four aggregates whose names are worth memorising, because they are the roofline inputs:

python/sglang/srt/model_executor/step_span_utils.py:L92-L102 SGLang
    if mode == ForwardMode.DECODE or mode == ForwardMode.TARGET_VERIFY:
        if seq_lens_cpu is None:
            return ""
        nq = _decode_query_width(forward_batch)
        nkvs = [int(x) for x in seq_lens_cpu.tolist()]
        nqs = [nq] * len(nkvs)
        sq, sk, sqsq, sqsk = _agg(nqs, nkvs)
        # ``sq`` is always emitted (self-contained suffix): it equals ``bs``
        # (vanilla decode) or ``bs * num_tokens_per_req`` (spec draft-decode /
        # target-verify).
        return f"g_sq={sq} g_sqsq={sqsq} g_sqsk={sqsk} g_sk={sk}"

$\Sigma N_Q$, $\Sigma N_{KV}$, $\Sigma N_Q^2$, $\Sigma N_Q N_{KV}$. The last one is the token-context product — the exact quantity attention FLOPs are linear in. vLLM emits the same four under different spelling: sq, sk, sqsq, sqsk, split into context and generation groups, assembled in vllm/v1/worker/gpu_worker.py:L1000-L1024 behind --profiler-config.detailed_trace_annotation. Two projects independently landing on the same four aggregates is decent evidence they are the right four. Turn detailed annotations on and every step span carries its own roofline inputs, so you re-derive the floor for the batch that step actually ran rather than the batch you assumed.

Worked trace: one POST to one trace file

The whole vLLM path, in order, so you know where to put a breakpoint when nothing appears in your trace directory:

  1. start_profile handles the POST and calls engine_client(raw_request).start_profile() (vllm/entrypoints/serve/profile/api_router.py:L22-L24).
  2. AsyncLLM.start_profile fans out: an engine_core.profile_async(True, profile_prefix) RPC to every worker, plus self.profiler.start on a thread for the front-end CPU trace unless ignore_frontend is set (vllm/v1/engine/async_llm.py:L945-L949).
  3. In each worker, Worker.profile(is_start=True) raises if no profiler is configured, builds the rank-qualified trace name from get_worker_rank_suffix, constructs a TorchProfilerWrapper on first call, and calls .start() (vllm/v1/worker/gpu_worker.py:L1146-L1199).
  4. WorkerProfiler.start sets _active and, if delay_iterations == 0, calls _call_startTorchProfilerWrapper._starttorch.profiler.profile.start() (vllm/profiler/wrapper.py:L85-L95, L68-L74, L302-L306).
  5. Every subsequent step, Worker.annotate_profile(scheduler_output) calls self.profiler.step() for the delay/limit bookkeeping and returns the annotation context manager that wraps the forward — a record_function for the torch profiler, an NVTX range for the CUDA one (vllm/v1/worker/gpu_worker.py:L930-L942).
  6. stop_profile unwinds the same path to TorchProfilerWrapper._stop, which stops Kineto, optionally builds the self_cuda_time_total table, and lets tensorboard_trace_handler write the gzipped trace (vllm/profiler/wrapper.py:L309-L332).

Two failure points live in that chain. If step 3 raises, you get the flag-naming RuntimeError quoted above and nothing else happens. If max_iterations fires in step 5, _call_stop runs without your ever calling /stop_profile, and a later stop logs "Profiler was not started, nothing to stop." — which reads like a bug and is not one.

Nsight Systems on a live server

The "cuda" profiler kind in vLLM is a two-line wrapper around torch.cuda.profiler.start()/.stop() whose annotation context manager is torch.cuda.nvtx.range(name) (vllm/profiler/wrapper.py:L472-L489). SGLang's equivalent is the "CUDA_PROFILER" activity, and its log message says the quiet part out loud:

python/sglang/benchmark/one_batch.py:L121-L126 SGLang
    if "CUDA_PROFILER" in profile_activities:
        try:
            torch.cuda.cudart().cudaProfilerStart()
            rank_print("CUDA Profiler started (nsys will begin capturing)")
        except Exception as e:
            rank_print(f"Failed to start CUDA profiler: {e}")

Neither engine launches nsys; you wrap the server in it and let the endpoint trip the capture range. SGLang's own test spells out the invocation (test/registered/profiling/test_start_profile.py:L233-L241): nsys profile -c cudaProfilerApi --capture-range-end stop -o OUT python3 -m sglang.launch_server .... vLLM ships a post-processing tool for the result under tools/profiler/nsys_profile_tools/gputrc2graph.py, which shells out to nsys stats -r cuda_gpu_kern_trace and computes non-overlapped GPU cycles per kernel — the number you actually want, because overlapping kernels double-count in a naive sum.

Unverified

I found no Nsight Compute (ncu) integration in either tree at these SHAs: grep -rni "ncu\|nsight" over both repositories returns only Nsight Systems references (tools/profiler/nsys_profile_tools/ in vLLM, test/registered/profiling/test_start_profile.py in SGLang). If a per-kernel counter hook exists it is likely under vllm/profiler/ or python/sglang/srt/utils/profile_utils.py; I read both and did not see one. Treat Nsight Compute as an external tool you point at an isolated kernel, not something the server drives.

§6

The MLA FLOP model, verified and corrected

Now back to the opening. §7.2 flagged two defects in MLAAttentionMetrics. Reading the code against §7.2's own trace of the absorbed decode path, one holds exactly and one needs correcting — and the correction makes the remaining error cleaner, not smaller.

The attention core is under-counted by exactly 3.4×. Confirmed. attn_qk charges qk_head_dim = $d_{\text{nope}} + d_{\text{rope}}$ = 192 and attn_av charges v_head_dim = 128, for $2(192+128) = 640$ FLOP per head per context token. §7.2's trace of forward_impl shows the decode kernel receiving a 576-wide query (concat of the 512-wide folded $\tilde q$ and the 64-wide $q^{\text{pe}}$) against the 576-wide latent cache row, with V the leading 512-wide slice: $2(576+512) = 2{,}176$ FLOP per head per context token. $2176/640 = 3.4$.

The spurious kv_b_proj charge is, arithmetically, not spurious. §7.2 is right that the absorbed path never calls kv_b_proj. But it runs two bmms instead — the q-fold $2h\,d_{\text{nope}}r_{kv}$ and the v-fold $2h\,r_{kv}d_v$ — whose sum is $2h\,r_{kv}(d_{\text{nope}}+d_v)$, identically the product 2 * T * c * q * (qk_nope + v_d) the model bills. For DeepSeek-V3 both come to 33.55 MFLOP per layer per token. That line is mislabelled but numerically correct, so the entire error is the attention core — one term, one constant factor. That is a better bug report than "two things are wrong".

Derived. DeepSeek-V3 ($L$=61, $h$=128, $r_{kv}$=512, $d_{\text{rope}}$=64, $d_{\text{nope}}$=$d_v$=128, $D$=7168, $q_{\text{lora}}$=1536), one decode token, $s$ = 4,096, before TP division. Model FLOPs from get_num_flops_breakdown; "actual" from §7.2's traced shapes. §7.2 independently derives the same 69.6 GFLOP core.
TermvLLM modelActualNote
q_a_proj + q_b_proj5.95 GFLOP5.95 GFLOPcorrect
kv_a_proj0.50 GFLOP0.50 GFLOPcorrect
kv_b_proj2.05 GFLOP2.05 GFLOPmislabelled — it is the q-fold + v-fold bmms, same product
attn_qk + attn_av20.47 GFLOP69.59 GFLOPunder-counted 3.4×
out_proj14.33 GFLOP14.33 GFLOPcorrect
MLA block total43.30 GFLOP92.42 GFLOP2.13×

What that does to a reported MFU depends on context length, because the error is on the only term that scales with $s$. DeepSeek-V3 activates about 37 B parameters per token, so the non-attention work is roughly $2 \times 37\times10^{9} = 74$ GFLOP per decode token and is unaffected. Reported total versus true total:

Derived. Factor by which the true achieved FLOP/s exceeds what vllm:estimated_flops_per_gpu_total reports for DeepSeek-V3 decode. Approximately invariant under tensor parallelism, since both core terms divide by TP together.
Context $s$ReportedActualTrue / reported
1,02479.1 GFLOP91.4 GFLOP1.16×
4,09694.5 GFLOP143.6 GFLOP1.52×
16,384155.9 GFLOP352.4 GFLOP2.26×
65,536401.5 GFLOP1,187 GFLOP2.96×

If you are tuning a DeepSeek deployment against vLLM's reported TFLOP/s, at 16k context you are reading a number 2.3× too low, and the error grows with your context window. The consequence is a wrong decision: you conclude there is tensor-core headroom that does not exist and spend a week chasing it. The byte model is right — get_read_bytes_breakdown reads kv_compressed_dim = $r_{kv}+d_{\text{rope}}$ from cache on the decode path (vllm/v1/metrics/perf.py:L747-L751) — which is why the GB/s half of the log line can be trusted while the TF/s half cannot.

The unit test that covers MLA decode (tests/v1/metrics/test_perf_metrics.py:L1056-L1087) asserts only on get_write_bytes_breakdown and get_read_bytes_breakdown; the FLOP breakdown is asserted only in the prefill test at L1118-L1122, where the un-absorbed 192/128 geometry is correct. The decode FLOP path is untested. That is the shape of the upstream issue: one term, one constant, one missing test.

The correct arithmetic

For an absorbed MLA decode step, replace qk_head_dim with $r_{kv} + d_{\text{rope}}$ and v_head_dim with $r_{kv}$, i.e. $F_{\text{core}} = 2\,q\,\text{TC}\,L\,\big[(r_{kv}+d_{\text{rope}}) + r_{kv}\big]$, and relabel kv_b_proj as the absorbed folds. Prefill keeps the existing 192/128 form, because §7.2 shows the MHA branch decompresses. A correct model therefore has to branch on ctx.decode_num_tokens > 0 exactly as get_read_bytes_breakdown already does — the FLOP function simply never learned the trick its own byte function knows.

§7

Capacity planning, end to end

Everything above prices one step. Capacity planning is the arithmetic that turns a step time into a replica count and a bill. Here is the template, worked. Change the six inputs and it re-runs.

The six inputs. Everything downstream is derived from these plus the book's H100 constants.
InputValueOwner
Model & parallelismLlama-3-8B bf16, TP=1, one H100 SXM
Workloadmean prompt 1,800 tok, mean output 220 tok§10.1
SLOp99 TTFT ≤ 900 ms, p99 ITL ≤ 60 ms§1.2
Peak demand240 req/sproduct
Prefill efficiencyassumed 40% MFU — a planning placeholder, not a measurementLab 11 replaces it
PriceUSD 3.00 per GPU-hour (stated input; substitute yours)finance

Step 1 — concurrency from the KV budget. §2.1 derives a 52.32 GiB KV pool for this deployment, holding 428,569 tokens at 128 KiB each. A request's mean resident context over its life is $1800 + 220/2 = 1{,}910$ tokens, so $B_{\max} = \lfloor 428{,}569 / 1{,}910 \rfloor = 224$ concurrent sequences.

Step 2 — price a step at that batch. Step bytes at $B=224$, $\bar s = 1910$: $15.01 + 224 \times 1910 \times 131072 = 71.09$ GB, so $t = 71.09/3.35 = 21.2$ ms, and decode throughput is $224 / 21.2\ \text{ms} = 10{,}556$ tok/s. Note that 21.2 ms is the step time, not the ITL a user sees: the reader's ITL is the step time divided by the fraction of wall clock the replica spends in decode rather than in someone else's prefill. Step 5 comes back to that.

Step 3 — price the prefill. $F_{\text{prefill}}(1800) = 2PS + 2Lhd_hS^2 = 2.700\times10^{13} + 8.49\times10^{11} = 2.785\times10^{13}$ FLOP. At the assumed 40% of 989.4 TFLOP/s that is 70.4 ms per request. Prefill is above the ridge, so it is compute-bound and the MFU assumption is doing real work here — it is the softest number on the page. Replacing it is Lab 11, 11-end-to-end-benchmark's job, not Lab 10's: prefill MFU is a saturated-throughput measurement over a realistic workload on 80 GB-class hardware, whereas Lab 10 is deliberately one decode iteration on a 24 GB card and has no prefill mode.

Step 4 — the GPU-time budget. Prefill and decode contend for the same card. Per request the replica spends 70.4 ms of prefill plus $220/10{,}556 = 20.8$ ms of decode share: 91.2 ms. Saturation is $1000/91.2 = 10.96$ req/s. Prefill is 77% of the GPU time at this input/output ratio — a result that surprises people who assume decode dominates because decode dominates latency.

Step 5 — one consistent queueing proxy. Use the same full GPU service share $S=91.2$ ms that defined saturation, not just the 70.4 ms prefill component. As a deliberately simplified M/M/1 waiting-time model, let $W_{q,99}=S\ln(100\rho)/(1-\rho)$ and approximate TTFT by $W_{q,99}+70.4$ ms. A 900 ms target then gives $\rho\approx0.5579$ and $\lambda=\rho/S\approx6.117$ requests/s. This is a fixed-$B=224$ screening calculation, not a measured capacity or a guarantee about a continuous-batching scheduler.

Step 6 — close the occupancy and ITL model. The memory ceiling $B_{\max}=224$ does not establish the actual live decode batch. Define $t(B)=(15.01\times10^9+B\cdot1910\cdot131072)/(3.35\times10^{12})$ seconds and $S(B)=0.0704+220t(B)/B$. In a stationary fluid model with output flow $220\lambda$, decode duty cycle is $f_d=220\lambda t(B)/B$ and effective ITL is $t(B)/f_d=B/(220\lambda)$. These equations explicitly distinguish kernel time, GPU service share, and per-request residence.

At a binding 60 ms ITL target, set $B=13.2\lambda$ and solve $S(B)\ln(100\lambda S(B))/(1-\lambda S(B))+0.0704=0.9$, requiring $0<\lambda S(B)<1$ and $B\le224$. The higher-load root is $\lambda\approx5.0964$, $B\approx67.27$, $S(B)\approx0.10149$ s, and $\rho\approx0.51725$. A nearest-integer batch and the actual scheduler must be rechecked. The model gives 48 replicas for 240 requests/s and about USD 0.743 per million output tokens at the illustrative USD 3/GPU-hour price. Neither this root nor the earlier fixed-batch estimate is hardware certification: batching changes service distributions, TTFT depends on chunking and queue discipline, and live occupancy is length-biased.

Model limits and startup

The fixed-$B$ queueing proxy and the fluid closure are screening models whose assumptions must be calibrated against a trace. Retain prompt/output joint distributions, cache state, successful/failed/cancelled requests, and confidence intervals. Standing headroom, warm pools, and forecasting address startup delays; weight loading and graph warmup cannot be assumed to react within a 900 ms SLO. See §9.4 for readiness versus partial-cache usefulness, and §1.5 for prefill interruption.

Figure 4 — the capacity funnel. The fixed-batch arithmetic is a screening bound; the final nodes explicitly solve a separate fluid occupancy/ITL closure. Neither path is a measured scheduler simulation. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§8

Cost per million tokens, and how much it moves

FORMULAS names this chapter the owner of the cost expression, so here is the derivation. A replica of $N_{\text{GPU}}$ cards costs $\text{price}_{\text{GPU-hour}} \cdot N_{\text{GPU}}$ per hour and delivers $\text{goodput}_{\text{tok/s}}$ sellable tokens per second, i.e. $3600 \cdot \text{goodput}$ per hour. Dividing and scaling to $10^{6}$ tokens:

$$\text{cost}_{\text{1M}} = \frac{10^{6} \cdot \text{price}_{\text{GPU-hour}} \cdot N_{\text{GPU}}}{3600 \cdot \text{goodput}_{\text{tok/s}}}$$

For the fluid-closure example, $10^6\times3/(3600\times5.0964\times220) \approx\$0.743$ per million output tokens. The price is illustrative, not a current cloud quote. Cost is linear in price and inversely proportional to measured goodput. Include CPU, RAM, network, idle reserve, and failure capacity in a real TCO.

Define the goodput policy. A p99 SLO violation does not make every token unbillable. Request-level goodput can count only successful requests meeting latency and quality gates, while a contractual service-period SLO may trigger credits at a different granularity. Report both the successful fraction and the aggregate percentile. At $\rho=0.9$, the fixed-batch proxy predicts $91.2\ln(90)/0.1+70.4\approx4174$ ms TTFT, but this does not determine the fraction billed without that policy.

Illustrative fixed-$B=224$ screen only: $S=91.2$ ms, prefill offset $p=70.4$ ms, $S\ln(100\rho)/(1-\rho)+p\le\mathrm{SLO}$, rate $\rho/S$. These values do not include the fluid ITL/occupancy closure above.
p99 TTFT SLO$\rho$req/sreplicas at 240 req/s$ / 1M output tokens
400 ms0.18812.0621171.837
900 ms0.55796.117400.619
2 s0.79338.698280.435
5 s0.916410.048240.377

The same screening model therefore moves cost by about 4.9 times between these SLOs with unchanged hardware. The workload changes the model again. For 500 input and 2000 output tokens, the mean-context estimate is 1500 tokens, $B_{\max}=285$, and the assumed step is about 21.2 ms. Prefill is 19.1 ms and the decode service share is 148.8 ms, so use total $S=167.9$ ms in the queue formula. With a 900 ms TTFT target and 19.1 ms prefill offset, $\rho\approx0.3323$, rate $\approx1.979$ requests/s, and illustrative cost $\approx\$0.211$ per million output tokens. This is about 2.9 times lower than the 1800/220 fixed-batch screen, not a certified engine speedup.

Decode is about 89% of the latter workload's GPU service share, versus about 23% for 1800/220. That changes which optimizations deserve experiments: prefill throughput and reuse for long inputs, decode batching and KV traffic for long outputs. It does not make any optimization universally worthless. Recompute actual occupancy and effective ITL before choosing a fleet size. See §10.1 for joint length distributions and §10.3 for measuring success-conditioned goodput and uncertainty.

§9

Pitfalls and war stories

trap 1

Profiling the warmup

A capture that starts at POST /start_profile and stops two seconds later on a freshly started server records torch.compile, autotuning and first-touch allocations, not steady state. Both engines give you the fix: vLLM's warmup_iterations / wait_iterations feed a torch.profiler.schedule whose warmup steps are recorded and discarded (vllm/profiler/wrapper.py:L215-L232); SGLang's start_step arms on a forward-pass counter. Use them.

trap 2

Nsight Compute on a live server

ncu serialises and replays each kernel to collect counters. A serving loop under it is not a serving loop — the batch composition changes because the scheduler sees different timings. Capture the kernel name and shapes with torch.profiler first, reproduce it standalone, then point ncu at that.

trap 3

Trusting a reported MFU

Section 6 is the general lesson, not a DeepSeek footnote. Any analytic FLOP model encodes an assumed execution path; when a backend takes a different one, the model silently diverges. Before quoting MFU, read the model and check its widths against the shapes your backend actually launches. vLLM's counters are named estimated_* for exactly this reason.

trap 4

NVTX ranges and CUDA graphs

vLLM's --enable-layerwise-nvtx-tracing logs, from vllm/v1/worker/gpu_model_runner.py:L4178-L4183, that markers may be "part or all" missing when CUDA graphs are involved — hooks fire at capture, not at replay. A layer-annotated Nsight timeline and a graph-replayed step are mutually exclusive; profile eager for attribution, then confirm the win with graphs on.

trap 5

Planning on throughput

The worked screening and fluid models give different fleet sizes; a raw-throughput division omits the declared latency constraints, and the shortfall arrives as a latency incident rather than a capacity alert. Anchor the plan to goodput at a stated $\rho$.

trap 6

One trace, one batch

Under continuous batching consecutive steps have different composition, so one step is not representative. This is what the detailed annotations exist for: the g_sq/g_sk/g_sqsk suffix lets you select the steps matching the operating point you derived instead of averaging over a moving target.

§10

Hands-on

Capture five decode steps on a live vLLM server and reconcile them against the 7.04 ms floor:

shell — vLLM, capture with warmup and a hard stopshell
vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
  --max-num-seqs 32 \
  --profiler-config.profiler=torch \
  --profiler-config.torch_profiler_dir=/abs/path/traces \
  --profiler-config.detailed_trace_annotation=true \
  --profiler-config.warmup_iterations=3 \
  --profiler-config.active_iterations=5 \
  --profiler-config.ignore_frontend=true

# drive load in another shell, then:
curl -X POST http://localhost:8000/start_profile
sleep 2
curl -X POST http://localhost:8000/stop_profile

Open the trace in Perfetto. Find a span named execute_32_context_0(...)_generation_32(...) — zero context requests, 32 generation requests, i.e. a pure decode step at your batch. Read sk from the generation group: that is $\Sigma N_{KV}$, which times 128 KiB is the KV bytes that step actually read. Add 15.01 GB, divide by 3.35 TB/s, and compare to the span's wall duration. Measure the union of device-active intervals and the critical path; a naive sum double-counts overlapping kernels. Uncovered wall time can include launch gaps, synchronization, communication, and CPU work, not only driver launch overhead. Compare those components against §8.1's illustrative 0.78 ms budget.

shell — SGLang, prefill and decode as separate tracesshell
SGLANG_TORCH_PROFILER_DIR=/abs/path/traces \
  python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct

python3 -m sglang.profiler --url http://localhost:30000 \
  --num-steps 5 --profile-by-stage --output-dir /abs/path/traces

# Nsight Systems instead, using the engine's own capture range:
nsys profile -c cudaProfilerApi --capture-range-end stop -o /abs/path/step \
  python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct
curl -X POST http://localhost:30000/start_profile \
  -H 'Content-Type: application/json' \
  -d '{"num_steps": 5, "activities": ["CUDA_PROFILER"]}'

SGLang's step spans are named step[DECODE bs=32 g_sq=32 g_sqsq=32 g_sqsk=... g_sk=...] when detailed_annotations is set, so the same reconciliation works verbatim. Full procedure in Lab 10.

§11

Exercises

  1. Read the file. Open vllm/v1/metrics/perf.py and compare AttentionMetrics.get_read_bytes_breakdown (L470-L513) with the byte model this chapter derived in section 3. Which term does vLLM charge once per step regardless of batch size, and which scales with decode_context_len? Does its total match 23.60 GB for our operating point? Name any term it omits.
  2. Predict, then verify. Take the worked operating point to $B=64$ at the same $s=2048$. Predict the step time, the arithmetic intensity, the decode throughput, and whether launch overhead becomes a larger or smaller share. Then check whether $B=64$ even fits — use §2.1's 428,569-token pool.
  3. Re-derive the MFU error. Redo section 6's table for DeepSeek-V3 at TP=8 and $s = 8{,}192$, per GPU. Show that the true/reported ratio is unchanged by TP, and explain in one sentence which term in get_num_flops_breakdown is not divided by tp_size and why that barely matters here.
  4. Rerun the funnel. Same model, same H100, same USD 3/GPU-hour, but Llama-3-70B at TP=8 with a 4,000-in / 500-out workload and a p99 TTFT SLO of 3 s. Use §2.1's 1,332,954-token cluster pool and 320 KiB/token cell. Produce replica count and cost per million output tokens. Which constraint binds — KV capacity, ITL, or queueing?
  5. Break the model. Name a decode configuration in which the derived floor is too optimistic by more than 2× for a specified execution structure — and explain how changing that structure could change the bound — and say which trace shape from Figure 3 you would expect to see.
Answers

1. read_bytes["qkv_weight"] and read_bytes["out_weight"] have no T factor — weights are streamed once per step regardless of batch, exactly the 15.01 GB term. attn_input carries 2 * ctx.decode_context_len * kv * d * cache_byte_size * L, the KV read. It will not total 23.60 GB, because AttentionMetrics covers only the attention block: the MLP weights are a separate ComponentMetrics subclass, and the full step total is the sum over self.metrics in get_read_bytes_breakdown (L1313-L1322). It also adds activation traffic our compulsory-traffic model deliberately ignores, so the two models answer slightly different questions — vLLM's counts what moves, ours counts what must move.

2. $B=64$, $s=2048$: KV read $= 131072 \times 131072 = 17.18$ GB, total 32.19 GB, $t = 9.61$ ms, throughput $64/9.61\,\text{ms} = 6{,}660$ tok/s. $F = 1.029\times10^{12}$ so $I = 32.0$ FLOP/byte, still $9\times$ left of the ridge. Launch overhead is unchanged in absolute terms (0.78 ms, the kernel count does not depend on batch) so it falls to 8.1% — a smaller share. Fit: $64 \times 2048 = 131{,}072$ resident tokens against a 428,569-token pool, so it fits three times over; you could run $B = 209$ at this context.

3. Reported and corrected cores both carry the factor q = max(1, q // tp_size), so dividing by 8 scales both by the same amount and the 3.4× on that term is untouched. kv_a_proj is the exception — the comment at L671 marks it [replicated], so it is not divided — but at 0.50 GFLOP against a >100 GFLOP total it shifts the ratio by well under a percent.

4. Mean resident context $4000 + 250 = 4{,}250$; $B_{\max} = 1{,}332{,}954/4250 = 313$. Step bytes per GPU: weights $141.1/8 = 17.64$ GB plus KV $313 \times 4250 \times 40\ \text{KiB} = 54.5$ GB $= 72.1$ GB, $t = 21.5$ ms — inside a 60 ms ITL. Prefill $F = 2 \times 70.55\times10^{9} \times 4000 + 2 \times 80 \times 64 \times 128 \times 4000^{2} = 5.644\times10^{14} + 2.097\times10^{13} = 5.85\times10^{14}$ FLOP, over 8 GPUs at 40% MFU $= 185$ ms. Per request $185 + 500 \times 21.5/313 = 185 + 34.3 = 219$ ms, saturation 4.56 req/s. $\rho^{*}$ must use the same full 219 ms service share that defined saturation: $219\ln(100\rho)/(1-\rho)+185\le3000$ ms gives $\rho\approx0.6726$, about 3.071 requests/s, and 1535.6 output tokens/s. At the illustrative USD 3/GPU-hour rate, cost is about USD 4.34 per million output tokens across eight GPUs. This fixed-batch queueing proxy still needs occupancy and effective-ITL closure before sizing a deployment.

5. Any configuration where the compulsory-traffic model is not the constraint. The clean example is small-batch decode at high TP: at TP=8, batch 1, the per-rank byte floor is 0.56 ms but §8.1 derives 0.94 ms of pure driver launch time, so this fixed launch structure cannot reach the byte floor without changing the execution path. Fusion, graph replay, or removing launches changes the bound; it is not an impossibility claim about every implementation. Expected shape: 1, launch-bound — a saturated host lane and a gappy device lane. A second answer: batch 1 with $h_{kv}=8$ and split-K disabled, where the FA2 grid is $1 \times 32 = 32$ blocks on 132 SMs — shape 4, occupancy-limited.

§12

Key takeaways

  • A bound tells you what is possible; a trace tells you what is happening. Shipping one without the other is how a performance claim becomes unfalsifiable. This book owns the first, Lab 10 the second.
  • Four teaching trace shapes cover useful diagnoses for a decode step, and two of them — bandwidth-bound and occupancy-limited — are indistinguishable in a timeline. That fact picks the tool: Systems for gaps and rank skew, Compute for per-kernel counters, torch.profiler for op attribution.
  • vLLM's analytic FLOP model under-counts MLA decode attention by exactly 3.4×, because vllm/v1/metrics/perf.py:L677-L678 charges the un-absorbed 192/128 head widths where the absorbed decode kernel runs 576/512. The kv_b_proj line is mislabelled but numerically right, so it is one term with one constant. At 16k context the reported TFLOP/s is 2.3× low and the error grows with the context window.
  • Batching buys launch headroom, not just throughput: the same 0.78 ms of driver time is 17% of a batch-1 step and 11% of a batch-32 step. Tensor parallelism moves it the other way. Derive the launch budget before you go hunting for it in a trace.
  • Queueing is the binding constraint in capacity planning far more often than memory. The KV pool allows up to 224 under the mean-context assumption, while the explicitly coupled fluid model gives about 67 live decode requests and utilisation 0.517. These are different constraints, not interchangeable batch estimates. Plan against goodput at a stated $\rho$, never against throughput.
  • The illustrative fixed-batch cost proxy changes substantially with the SLO and input/output ratio, so a price quoted without both attached carries no information. The same ratio tells you which optimisation to fund: prefill throughput at 1800/220, decode batching at 500/2000.
§13

Further reading

  • Vellaisamy, Kumar, Hoe et al., Characterizing GPU Kernel Launch Overheads, ISPASS 2025, arXiv:2504.11750. Table V is the source of the 2.374 µs driver-only cudaLaunchKernel figure this book uses on H100 / CUDA 12.6. §8.1 owns its application.
  • Williams, Waterman, Patterson, Roofline: An Insightful Visual Performance Model, CACM 2009. The original; §0.4 derives the inference-specific form.
  • DistServe, Zhong et al., arXiv:2401.09670 — the goodput framing behind the cost formula, and the paper vLLM's --goodput help text points at.
  • PyTorch profiler recipe, pytorch.org, and the memory-timeline post for activities=["MEM"], which SGLang exposes directly (python/sglang/profiler.py:L117-L123).
  • vLLM's nsys post-processor, tools/profiler/nsys_profile_tools/gputrc2graph.py — computes non-overlapped GPU cycles per kernel from an nsys report, which is the correct denominator when kernels overlap.
  • SGLang's profiling test, test/registered/profiling/test_start_profile.py:L144-L252 — the only in-tree documentation of the nsys profile -c cudaProfilerApi wrapper, including the --capture-range-end stop flag that makes a single capture terminate cleanly.
  • Formula sheet, FORMULAS — the cost expression derived here, plus every bound this chapter composed.

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