ML Interview Notes
14 min read5 sections
Lab 10 · for chapter 10-05

Profile a decode step

Capture a torch.profiler trace of one decode iteration and account for every microsecond in it.

A decode step has a floor you can compute in two minutes, and a real duration you have to capture. This lab is about the difference. Not the sum of the kernel durations — any profiler prints that — but the microseconds that belong to no kernel at all, which are the only ones that tell you whether the step is bound by the machine or by Python.

Hardware

One GPU with an 8B model in bf16; a 24 GB card is enough. Two of the four modes need no GPU at all: --mode floor is arithmetic, and --mode analyze reads a trace file someone else captured, on a laptop. Only --mode launch-probe needs torch. Where no GPU is available, the substituted figure is the per-launch driver cost: 2.374 µs, cited from Vellaisamy et al., ISPASS 2025 (arXiv:2504.11750, Table V), on H100 / CUDA 12.6. --mode launch-probe is how you replace it with your own.

Not executed here

run.py was written against the profiler configuration, HTTP endpoints and trace annotations cited below, and its trace parser was exercised against a synthetic Chrome trace, but it has not been run against a live engine or a real capture — no GPU was available while writing. Every number it prints that is not read out of your own trace is labelled derived or cited. Treat the expected shapes below as predictions and report anything that disagrees.

§1

What you measure

Four quantities, and the order matters because each one is only interpretable given the one before it:

  1. The floor. Compulsory HBM traffic divided by bandwidth, for your exact batch and context. Pure arithmetic — the derivation is §10.5 and the roofline behind it is §0.4.
  2. Wall time of one step span, from the engine's own trace annotation.
  3. Kernel-busy time, as the union of kernel intervals on the device timeline — not their sum. Kernels on different streams overlap, and a naive sum double-counts them. vLLM's own nsys post-processor exists for exactly this reason (tools/profiler/nsys_profile_tools/gputrc2graph.py, which computes non-overlapped GPU cycles).
  4. The gap: wall minus busy. Time the GPU spent doing nothing.

The gap is the lab. vLLM prints a self_cuda_time_total table on every capture stop, by default (torch_profiler_dump_cuda_time_total defaults to True; vllm/profiler/wrapper.py:L308-L320), and it is genuinely useful — it answers "which kernel was slowest". It cannot answer "was the GPU idle", because a table of kernel durations has thrown away the timeline that would show you. Reading a summary table and concluding the step is bandwidth-bound is the specific mistake this lab prevents.

The four measurements and what each one falsifies — predictions, not results
QuantityExpectedIf it comes out otherwise
wall time≥ floor Below the floor means the byte model is wrong, not the machine: prefix caching made some traffic non-compulsory, or the KV dtype is not what you passed. Fix the floor first; nothing downstream means anything until you do.
kernel-busy≈ floor Much less, with a large gap, is launch-bound. Much more is a kernel doing extra work: an unfused residual, a dtype cast, a KV layout reading whole pages for partial rows.
gap≤ launch budget Above it, the host is not keeping up. That is chapter §8.1's problem and CUDA graphs are its answer.
launch count≈ 330 §8.1 counts 330 launches per decode step for Llama-3-8B at TP=1 and 395 at TP=8. A trace showing single digits means CUDA graphs are on and you are counting cudaGraphLaunch, which is the whole point of them.

Predict all four before you capture anything:

shell — labs/10-profile-a-decode-step shell
$ python3 run.py --mode floor --batch 32 --seq 2048
$ python3 run.py --mode floor --batch 1 --seq 512 --launches 330
$ python3 run.py --help          # every knob, all four modes

For Llama-3-8B at B=32, s=2048 on an H100 SXM this reproduces §10.5's numbers exactly, because it is the same arithmetic: 23.60 GB of compulsory traffic, a 7.04 ms floor, and a 0.78 ms launch budget that is 11% of it.

§2

Running it

Both engines drive torch.profiler from an HTTP endpoint, and they differ in one instructive place: where the capture window is configured.

vLLM — the window is bound at launch

As of a556f3f profiling is a config object, not an environment variable. Forget to set it and the worker tells you the exact flags, which makes this the friendliest error string in this part of the tree:

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 themselves take no body at all — when to capture was decided before the server started:

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)

Which is why the iteration knobs exist. A capture that starts on a freshly started server and stops two seconds later records torch.compile, autotuning and first-touch allocations, not steady state:

vllm/config/profiler.py:L118-L127, L136-L140 vLLM
    delay_iterations: int = Field(default=0, ge=0)
    """Number of engine iterations to skip before starting profiling.
    Defaults to 0, meaning profiling starts immediately after receiving /start_profile.
    """

    max_iterations: int = Field(default=0, ge=0)
    """Maximum number of engine iterations to profile after starting profiling.
    Defaults to 0, meaning no limit.
    """

# ... proton and torch_profiler_* fields elided ...
    active_iterations: int = Field(default=5, ge=1)
    """Number of active iterations for PyTorch profiler schedule.
    This is the number of iterations where profiling data is actually collected.
    Defaults to 5 active iterations.
    """

Note the gate: the torch.profiler.schedule is built only if warmup_iterations or wait_iterations is positive (vllm/profiler/wrapper.py:L215-L232). Set active_iterations alone and nothing changes; you get an unscheduled capture that records everything between your two POSTs.

shell — vLLM, five steady-state decode steps shell
$ 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, arm, disarm — all three from one command:
$ python3 run.py --mode capture --engine vllm --url http://localhost:8000 \
    --drive --concurrency 32 --warmup-s 8 --hold-s 2

SGLang — the window travels in the request

python/sglang/profiler.py:L51-L68 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()

    trace_link = str(output_dir)
    return trace_link

Two consequences. First, /start_profile blocks until the requested steps have run and the trace is flushed — so run_profile never calls /stop_profile, even though the route exists (python/sglang/srt/entrypoints/http_server.py:L1168-L1173), and a short HTTP timeout looks like a hang. Second, start_step is reachable from run_profile() and from a raw POST, but the python3 -m sglang.profiler CLI does not expose a flag for it (python/sglang/profiler.py:L73-L137): to arm on a specific forward pass you have to POST the body yourself.

shell — SGLang, prefill and decode as separate traces shell
$ 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

# or drive the load and the capture together:
$ python3 run.py --mode capture --engine sglang --url http://localhost:30000 \
    --out /abs/path/traces --steps 5 --by-stage --drive

Reading the trace

You cannot analyse a trace of "some steps". You need one named step, because under continuous batching consecutive steps have different composition. Both engines name the per-step span after the batch, which is what makes selection possible. vLLM assembles it from the scheduler output:

vllm/v1/worker/gpu_worker.py:L1000-L1015 vLLM
            annotation = "".join(
                [
                    "execute_",
                    str(total_scheduled_tokens),
                    "_context_",
                    str(iteration_details.num_ctx_requests),
                    "(sq",
                    str(iteration_details.num_ctx_tokens),
                    "sk",
                    str(ctx_seq_len_sum),
                    "sqsq",
                    str(ctx_qq_compute),
                    "sqsk",
                    str(ctx_qk_compute),
                    ")_generation_",
                    str(iteration_details.num_generation_requests),

So a pure decode step at batch 32 reads execute_32_context_0(sq0sk0...)_generation_32(sq32sk...), and sk in the generation group is $\Sigma N_{KV}$ — the KV bytes that step actually read, which is the input to your own floor for that step rather than the batch you assumed. SGLang's equivalent is step[DECODE bs=32 g_sq=... g_sk=...], built at python/sglang/srt/utils/profile_utils.py:L476-L487. Without --profiler-config.detailed_trace_annotation vLLM falls back to a shorter form carrying request and token counts only (vllm/v1/worker/gpu_worker.py:L1027-L1041).

shell — the analysis, no GPU required shell
$ python3 run.py --mode analyze --trace traces/rank0.pt.trace.json.gz --spans
$ python3 run.py --mode analyze --trace traces/rank0.pt.trace.json.gz \
    --span "execute_32_context_0" --floor 7.046 --top 15

# and the number the book had to cite from a paper:
$ python3 run.py --mode launch-probe --iters 4000
$ python3 run.py --mode analyze --trace ... --launchcost 3.9   # feed yours back in
Window

The analyzer prefers a gpu_user_annotation span over a user_annotation one, and says which it used. This is not fussiness: launches are asynchronous, so the host-side span for a step does not contain the kernels that step launched — the host has already moved on. Compute a gap from a CPU-side window and you will measure the host running ahead and call it GPU idle. Even a device-side kernel-free interval can contain memcpy or communication work; inspect all streams and event categories before calling it idle.

§3

What to expect

Four common shapes organize an initial diagnosis for a decode step, and the script prints which one it thinks you have. The decision procedure is mechanical — §10.5, Figure 3, draws them:

Trace shapes and the move each one implies — schematic, from the cost model
ShapeWhat the numbers look likeWhat to do
1 — launch-boundgap is a large share of wall; host launch time ≥ the gap Nothing about the kernels matters yet. Turn CUDA graphs back on and re-measure. Fix belongs to §8.1.
2 — bandwidth-boundkernels back to back, busy ≈ floor You are done. The only remaining lever is fewer bytes: KV quantisation, a smaller batch-resident context, prefix caching.
3 — compute-boundone kernel is most of device time Take that kernel's name and shapes to Nsight Compute. This tool cannot tell you why a kernel is slow, only that it is.
4 — occupancy-limitedkernels back to back, busy > floor Indistinguishable from shape 2 in a timeline. Only achieved DRAM throughput separates them. For decode the usual cause is the FlashAttention grid at query length 1.

Now the part that makes this a lab rather than a demo. Capture the same operating point twice, with CUDA graphs on and off, and diff the gap. That single experiment is the one §8.1 could not run:

shell — the graphs-on / graphs-off pair shell
# vLLM: graphs off
$ vllm serve meta-llama/Meta-Llama-3-8B-Instruct --enforce-eager \
    --profiler-config.profiler=torch --profiler-config.torch_profiler_dir=/abs/eager

# SGLang: graphs off for the decode phase only
$ python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
    --cuda-graph-backend-decode=disabled
Flag trap

SGLang's graph flags were reorganised per phase at this SHA. The disable_cuda_graph field is marked no_cli=True (python/sglang/srt/server_args.py:L1941), so it has no auto-generated flag; what still accepts --disable-cuda-graph is a hand-registered deprecated alias that sets it, prints "'--disable-cuda-graph' is deprecated and will be removed in a future release. Use '--cuda-graph-backend-{decode,prefill}=disabled' instead.", and turns off both phases (python/sglang/srt/server_args.py:L8877-L8882, resolved at :L4543-L4545). For this lab you want the decode phase only, so use --cuda-graph-backend-decode=disabled or its convenience alias --disable-decode-cuda-graph (python/sglang/srt/server_args.py:L1890-L1897, L1936-L1940). Disabling prefill too changes the prefill steps you are not measuring and lengthens startup.

--enforce-eager can change compilation and kernel selection as well as graph replay. Record the selected path and compare profiles; the difference is not necessarily pure launch overhead.

Predict the direction before you run it. Graphs off should raise the gap and leave kernel-busy roughly unchanged — the same kernels, issued one at a time. §8.1 derives 330 launches × 2.374 µs = 0.78 ms of pure driver time for a TP=1 step, so at batch 1 (4.48 ms of device work) that is 17% and at batch 32 (7.04 ms) it is 11%. Batching buys launch headroom, not just throughput. Tensor parallelism moves it the other way: at TP=8 the launch count rises to 395 while device work falls to roughly an eighth, giving 0.94 ms of launches against 0.56 ms of work — launch-bound on driver cost alone, before any Python is considered.

Your measured per-launch cost will be larger than 2.374 µs, and that is the point: the cited figure is driver-only, while --mode launch-probe measures a real PyTorch op through the dispatcher. The difference is exactly the framework cost §8.1 has to call unquantified.

§4

Exercises

  1. Run --mode floor at B=1, s=512 and again at B=32, s=2048. The launch budget is the same absolute number in both. Explain why, then say what that implies about which batch size to profile first when hunting launch overhead.
  2. Read the file. Open vllm/config/profiler.py and vllm/profiler/wrapper.py:L215-L232. You set --profiler-config.active_iterations=5 and nothing else. How many iterations are recorded, and why is the answer not five?
  3. Predict, then verify. Capture the same batch-32 decode step twice, once with --enforce-eager and once without. Predict, before looking: which of wall, kernel-busy and gap moves, in which direction, and by roughly how much. Then check whether the launch count in the eager trace is near 330.
  4. Run --mode launch-probe, then re-run --mode analyze with --launchcost set to your own number. Does the "framework cost above the floor" row go to zero? Explain what is left in it if not.
  5. Design. You have a trace whose kernels are back to back and whose busy time is 40% above the derived floor. Name two hypotheses that both fit, and the one measurement that distinguishes them. Then say why torch.profiler cannot make it.
Answers
  1. The kernel count per decode step does not depend on batch size — the same layers run, on wider tensors. So the launch budget is fixed in absolute terms and shrinks as a share as the batch grows. Profile at the smallest batch you care about: that is where launch overhead is the largest fraction and therefore where it is visible above the noise.
  2. Everything between your two POSTs. The schedule is constructed only when warmup_iterations > 0 or wait_iterations > 0 (wrapper.py:L217); with both at their default of 0 no torch.profiler.schedule is built and active_iterations is never read. Set warmup_iterations to a positive value to make the field take effect.
  3. Kernel-busy should be roughly unchanged — identical kernels on identical tensors. Wall and gap both rise, by up to the launch budget: 0.78 ms for a TP=1 Llama-3-8B step, derived from 330 launches at the cited 2.374 µs. The launch count in the eager trace should land near 330; with graphs on you should instead see a handful of cudaGraphLaunch calls, which is the mechanism, visible.
  4. No. The driver-floor row is launches × --launchcost, and --mode launch-probe measures an end-to-end PyTorch op — dispatcher, Python frame, and driver — so substituting it moves the floor up and the residual towards zero or negative. What remains is engine-level host work between launches that is not inside a cudaLaunchKernel event at all: metadata construction, block-table updates, Python attribute lookups.
  5. (a) Shape 4 — occupancy-limited: the grid is too small to fill the SMs, so the kernels are slow but back to back. (b) Extra traffic: the kernels are moving more bytes than the compulsory model counts, so they are correctly bandwidth-bound at a higher byte count. The distinguishing measurement is achieved DRAM throughput per kernel. torch.profiler reports durations, not memory counters, so it cannot make it; that is a Nsight Compute question, on an isolated kernel, because ncu replays each kernel and a serving loop under it is not a serving loop.
§5

Key takeaways

  • Kernel time is the easy half and the profiler's summary table hands it to you. The gap between kernels is the half that decides what to fix, and it only exists in the timeline.
  • Kernel-busy time is a union of intervals, never a sum. Overlapping streams make a sum larger than wall time, which reads as a negative gap and sends you looking for a bug that is in your arithmetic.
  • Compute a gap only from a device-side window. A host-side annotation span does not contain the kernels it launched, because launches are asynchronous and the host runs ahead.
  • Turn on detailed annotations and every step span carries its own roofline inputs, so you re-derive the floor for the batch that step actually ran instead of the batch you assumed. Under continuous batching those are rarely the same.
  • The graphs-on / graphs-off pair at a fixed operating point is the single most informative capture in this lab, and the one measurement chapter §8.1 had to derive rather than run.

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