Profiling, roofline of a decode step, capacity planning
vllm/profiler/python/sglang/profiler.py
a556f3f · sglang 7d89325A 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.
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:
"""
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:
# 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.
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.
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
The branch labels in Figure 1 are the whole diagnostic vocabulary. Four shapes, four causes. Section 4 draws them.
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$.
| Term | Expression | Bytes | Share |
|---|---|---|---|
| Weights streamed | $2 \cdot 7.50\times10^{9}$ | 15.01 GB | 63.6% |
| KV read | $k \cdot B \cdot s = 131072 \times 65536$ | 8.590 GB | 36.4% |
| KV write | $k \cdot B$ | 0.0042 GB | 0.02% |
| Total $Q$ | 23.60 GB | 100% |
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$:
Placing it, and the prediction
$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.
What a measurement would have to show
Three quantities are checkable independently, and each plausible gap has a distinct signature.
| Prediction | Value | Refuted if the trace shows… |
|---|---|---|
| Step wall time | ≥ 7.04 ms | anything 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 ms | much less — the missing time is host-side, i.e. launch-bound |
| DRAM throughput on the GEMV kernels | near 3.35 TB/s | far below with kernels back-to-back — occupancy or tail effect, not bandwidth |
| Bytes actually moved | 23.60 GB | materially 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.
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.
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.
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.
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.
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.
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:
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:
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:
@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:
# 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:
# 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.
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:
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:
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:
start_profilehandles the POST and callsengine_client(raw_request).start_profile()(vllm/entrypoints/serve/profile/api_router.py:L22-L24).AsyncLLM.start_profilefans out: anengine_core.profile_async(True, profile_prefix)RPC to every worker, plusself.profiler.starton a thread for the front-end CPU trace unlessignore_frontendis set (vllm/v1/engine/async_llm.py:L945-L949).- In each worker,
Worker.profile(is_start=True)raises if no profiler is configured, builds the rank-qualified trace name fromget_worker_rank_suffix, constructs aTorchProfilerWrapperon first call, and calls.start()(vllm/v1/worker/gpu_worker.py:L1146-L1199). WorkerProfiler.startsets_activeand, ifdelay_iterations == 0, calls_call_start→TorchProfilerWrapper._start→torch.profiler.profile.start()(vllm/profiler/wrapper.py:L85-L95,L68-L74,L302-L306).- Every subsequent step,
Worker.annotate_profile(scheduler_output)callsself.profiler.step()for the delay/limit bookkeeping and returns the annotation context manager that wraps the forward — arecord_functionfor the torch profiler, an NVTX range for the CUDA one (vllm/v1/worker/gpu_worker.py:L930-L942). stop_profileunwinds the same path toTorchProfilerWrapper._stop, which stops Kineto, optionally builds theself_cuda_time_totaltable, and letstensorboard_trace_handlerwrite 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:
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.
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.
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".
get_num_flops_breakdown;
"actual" from §7.2's traced shapes. §7.2 independently derives the same 69.6 GFLOP core.| Term | vLLM model | Actual | Note |
|---|---|---|---|
q_a_proj + q_b_proj | 5.95 GFLOP | 5.95 GFLOP | correct |
kv_a_proj | 0.50 GFLOP | 0.50 GFLOP | correct |
kv_b_proj | 2.05 GFLOP | 2.05 GFLOP | mislabelled — it is the q-fold + v-fold bmms, same product |
attn_qk + attn_av | 20.47 GFLOP | 69.59 GFLOP | under-counted 3.4× |
out_proj | 14.33 GFLOP | 14.33 GFLOP | correct |
| MLA block total | 43.30 GFLOP | 92.42 GFLOP | 2.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:
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$ | Reported | Actual | True / reported |
|---|---|---|---|
| 1,024 | 79.1 GFLOP | 91.4 GFLOP | 1.16× |
| 4,096 | 94.5 GFLOP | 143.6 GFLOP | 1.52× |
| 16,384 | 155.9 GFLOP | 352.4 GFLOP | 2.26× |
| 65,536 | 401.5 GFLOP | 1,187 GFLOP | 2.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.
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.
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.
| Input | Value | Owner |
|---|---|---|
| Model & parallelism | Llama-3-8B bf16, TP=1, one H100 SXM | — |
| Workload | mean prompt 1,800 tok, mean output 220 tok | §10.1 |
| SLO | p99 TTFT ≤ 900 ms, p99 ITL ≤ 60 ms | §1.2 |
| Peak demand | 240 req/s | product |
| Prefill efficiency | assumed 40% MFU — a planning placeholder, not a measurement | Lab 11 replaces it |
| Price | USD 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.
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
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:
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.
| p99 TTFT SLO | $\rho$ | req/s | replicas at 240 req/s | $ / 1M output tokens |
|---|---|---|---|---|
| 400 ms | 0.1881 | 2.062 | 117 | 1.837 |
| 900 ms | 0.5579 | 6.117 | 40 | 0.619 |
| 2 s | 0.7933 | 8.698 | 28 | 0.435 |
| 5 s | 0.9164 | 10.048 | 24 | 0.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.
Pitfalls and war stories
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.
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.
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.
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.
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$.
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.
Hands-on
Capture five decode steps on a live vLLM server and reconcile them against the 7.04 ms floor:
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.
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.
Exercises
- Read the file. Open
vllm/v1/metrics/perf.pyand compareAttentionMetrics.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 withdecode_context_len? Does its total match 23.60 GB for our operating point? Name any term it omits. - 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.
- 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_breakdownis not divided bytp_sizeand why that barely matters here. - 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?
- 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.
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-L678charges the un-absorbed 192/128 head widths where the absorbed decode kernel runs 576/512. Thekv_b_projline 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.
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
cudaLaunchKernelfigure 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
--goodputhelp 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 annsysreport, 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 thensys profile -c cudaProfilerApiwrapper, including the--capture-range-end stopflag that makes a single capture terminate cleanly. - Formula sheet, FORMULAS — the cost expression derived here, plus every bound this chapter composed.