Serving · CUDA Graphs

How CUDA Graphs Speed Up Decode And by how much

They replay a recorded launch schedule. They do not make the math cheaper.

A GPU kernel — one program the GPU runs — can finish faster than the CPU can ask for the next one. One language-model decode step is not one kernel. It is a long chain of matrix multiplies, normalizations, attention, state updates, sampling, and copies — often hundreds of launches per token.

In ordinary eager execution, the CPU submits those launches one at a time, every token. A CUDA Graph records that sequence once and replays it with a single cudaGraphLaunch. The model's floating-point work does not change. The tax of starting each kernel does.

On one NVIDIA B200 serving 16-bit Brain Float (bfloat16) Qwen/Qwen3.8-27B, full decode graphs cut time per output token from 52.33 ms to 10.06 ms. One request rose from 18.69 to 90.03 tokens/s4.82×. With 16 requests in flight the same switch was 4.11×: 279.64 to 1,150.60 tokens/s. Those are end-to-end serving numbers, not a kernel microbenchmark.

CUDA GraphA recorded sequence of GPU work. Capture once, then replay the same operations with one launch.
Prefill / decodePrefill is the first forward over the prompt. Decode emits one new token per request and repeats.
TPOTTime per output token — the interval between successive generated tokens after the first.
Batch bucketA captured batch or token count. A live size of 3 replays the size-4 graph and pads the extra row.
SGLangThe serving engine used for the measurements. It can graph decode and prefill independently.
MTPMulti-token prediction — a draft head proposes several tokens; the target model verifies them together.
Where this mechanism is used

Every modern serving engine graphs decode: SGLang, vLLM, TensorRT-LLM. They do it because autoregressive decode repeats the same one-token shape hundreds of times. Flash Attention is a different optimization — it changes the math's memory traffic. CUDA Graphs only cheapen the launch schedule around whatever kernels you already have.

1.The launch tax

Consider a simplified transformer layer:

x = rms_norm(x)
q, k, v = qkv_projection(x)
x = attention(q, k, v, kv_cache)
x = x + residual
x = mlp(x)
x = x + residual

Those six Python lines are not six GPU operations. Each line can invoke several CUDA kernels, and a full model repeats the pattern across dozens of layers. In eager execution the CPU walks that chain on every token:

Eager · one launch per kernel

CPU: launch K1, K2, … Kn
↓ every token
GPU: kernels with gaps

CUDA Graph · one replay

CPU: cudaGraphLaunch
↓ every token
GPU: recorded sequence, no CPU gaps

Same kernels, same math. The graph removes CPU launch and dispatch overhead between them.

A CUDA Graph does not fuse kernels, reduce weight traffic, pick a better attention algorithm, or remove communication across GPUs that split one model. Those are jobs for compilers, kernel libraries, quantization, and model architecture. If a step spends most of its time inside one large compute-bound matrix multiply, launch optimization cannot produce a 5× win. If it spends most of its time dispatching thousands of short kernels, it can.

2.Fixed addresses, changing values

Traditional CUDA Graph replay assumes the captured execution stays structurally identical: same operations, same tensor shapes, same device addresses, graph-safe control flow, and no forbidden host synchronization during capture. The values stored in those tensors may change. Their addresses and shapes generally may not.

The serving-engine pattern is therefore: allocate persistent buffers once, capture a forward that reads those fixed addresses, then copy_ live request data into the buffers on every step and replay.

static_ids = torch.empty(max_tokens, device="cuda", dtype=torch.int32)
static_pos = torch.empty(max_tokens, device="cuda", dtype=torch.int64)

with torch.cuda.graph(graph):
    static_out = model(static_ids, static_pos)

static_ids[:n].copy_(live_ids)
static_pos[:n].copy_(live_pos)
graph.replay()

SGLang's decode runner follows exactly this design. Live token IDs, positions, request-pool indices, and sequence lengths are copied into persistent buffers. The graph is replayed at a captured bucket size. Padded output rows are sliced away afterward.

Sequence length is usually a value, not a shape

During ordinary decode, each request contributes one new query token. The query-side shape is approximately [batch_size, 1, hidden_size]. The past length grows from 128 to 129 to 130, but it lives as a value in a length tensor and as data in a preallocated cache of past keys and values. A graph can keep the input shape fixed while an attention kernel reads a different runtime cache length on every replay.

That is why “sequence length changes, so CUDA Graphs cannot be used for decode” is usually wrong. The decisive question is whether changing length changes the captured launch topology — not whether an integer stored in a tensor changes.

3.Decode versus prefill

Continuous batching means the live batch can change every iteration: 3 requests, then 5, then 4. Capturing every possible batch size minimizes padded work but costs startup time and resident graph memory. Capturing only powers of two is cheaper to store and may pad a lot.

This experiment made the policy explicit: decode batches 1, 2, 4, 8, 16. A live batch of 3 replays the batch-4 graph. A live batch of 9 replays batch 16.

Decode is naturally regular One new token per request. The same one-token query shape repeats for hundreds of steps. Weights and cache pools stay resident. Changing length is mostly metadata.
Prefill is dynamic in two dimensions Total tokens, request count, per-request lengths, and prefix-cache hits all change. One request of 2,048 tokens is not the same graph as 16 requests of 128 tokens, even though both contain 2,048 tokens.

Engines use three broad strategies. A full graph captures the entire forward for token-count buckets and a fixed number of request slots — lowest launch count, strictest compatibility, real padding compute. A piecewise graph lets torch.compile split the forward into graph-safe regions. A breakable graph marks incompatible regions explicitly, ends capture before them, runs them eagerly, and resumes afterward. SGLang's breakable backend is the compiler-free version of that last idea: the boundary tensor keeps a stable address, and the eager region's fresh output is copied into it on replay.

At small batch, launch overhead is large relative to useful work, so graphing usually wins. As batch or sequence grows, kernels get larger and the relative benefit falls. Poor buckets erase the win by doing too much padded work.

4.Qwen3.8-27B on one B200

There is no Qwen3.8-30B checkpoint. The near-30B dense model is Qwen/Qwen3.8-27B. It is a useful CUDA Graph case because the text model is deep and heterogeneous: 64 layers, of which 48 are Gated DeltaNet linear-attention layers and 16 are full attention, hidden size 5,120, vocabulary 248,320, plus one in-checkpoint multi-token-prediction head. Batch-1 decode is a long chain of relatively small operations, not one large matrix multiply per token. For the linear-attention side of that hybrid, see Linear Attention, From Scratch to Kimi 3.

Method

One B200, 183,359 MiB. SGLang 0.5.6.post3.dev9219+g779e593bd, PyTorch 2.13.0+cu130, CUDA 13.0. One GPU, no model split. Context cap 32,768. At most 16 requests running. Prefill is cut into 4,096-token chunks. Decode used 128 input tokens, 64 output tokens, exact lengths, greedy sampling, two warmups, and 1, 2, 4, 8, or 16 requests in flight. Prefill used one output token, one request at a time, and 32 trials per length. Only the phase-specific CUDA Graph backend changed.

Eager batch 118.69tokens/s · 52.33 ms TPOT
Decode graph batch 190.03tokens/s · 10.06 ms TPOT · 4.82×
Both graphs, c=161,201tokens/s · 4.30× vs eager

Decode throughput

Output tokens/s · 128 input / 64 output · exact lengths · one B200 · bfloat16 Qwen3.8-27B

Eager Full decode graph Decode + breakable prefill
ConcurrencyEager tok/sDecode graphSpeedupEager TPOTGraph TPOT
118.6990.034.82×52.33 ms10.06 ms
235.91170.024.73×53.77 ms10.32 ms
465.85320.944.87×55.20 ms10.49 ms
8130.95618.044.72×54.68 ms10.72 ms
16279.641,150.604.11×54.03 ms11.22 ms

End-to-end serving with a fixed number of requests in flight, not a synthetic kernel timer. Graph column is decode=full, prefill=disabled.

At a mixed point — 1,024 input tokens, 128 output tokens, concurrency 16 — decode graphing delivered 711.64 versus 184.46 output tokens/s (3.86×). Adding breakable prefill was neutral there (710.00 tokens/s) because decode dominates 128 autoregressive steps while prefill happens once.

The price is capture time and resident memory. Five decode buckets took 4.85 s and 0.098 GB in the decode-only arm. Four prefill token buckets (128, 512, 2048, 4096) took 4.44 s and 1.086 GB. Combined, SGLang reported 4.77 s / 0.068 GB for decode plus 4.52 s / 1.102 GB for prefill. Model weights were 51.051 GB and the configured cache pool was 40.4 GB in every non-MTP arm.

5.Prefill, and a scheduler trap

Prefill should be measured as its own phase. The first combined-backend numbers looked too good at short prompts — about 2.9× — because SGLang's default overlap scheduler puts a throwaway decode on the time-to-first-token path.

With overlap enabled, the first token is sampled by the prefill forward, but it is not streamed immediately. On the next scheduler iteration the request still looks unfinished, so the engine launches one decode before it processes and streams the queued prefill result. For a one-token benchmark, that decode computes a second token that is discarded. decode=full turns the throwaway into a fast graph replay; decode=disabled runs it eagerly. The combined numbers are real end-to-end behavior. They are not the isolated speedup of breakable prefill.

Prefill input throughput

Input tokens/s · one request · one output token · 32 trials per length · overlap scheduler off

Eager Breakable prefill graph

This is why a serving engine should choose graph policy per phase. “Graphs on” is not one universal switch.

6.1,920 launches become 20

To isolate the mechanism from SGLang, a 32-layer PyTorch toy — one matrix multiply, one SiLU activation, one residual-add per layer — was captured with NVIDIA Nsight Systems. Twenty eager iterations issued 1,280 cudaLaunchKernel + 640 cuLaunchKernelEx = 1,920 kernel-launch API calls. Twenty graph iterations issued 20 cudaGraphLaunch.

Eager · 20 iterations

1,920

kernel-launch API calls. Host range in Nsight: 21.27 ms.

Graph · 20 replays

20

cudaGraphLaunch calls. Host range in Nsight: 0.327 ms.

Nsight Systems API counts. Those timed ranges measure CPU submission, not synchronized GPU completion. Use them for structure, not speed.

Toy-model wall time

Unprofiled, GPU-synchronized. 32 layers × (matrix multiply + SiLU + add). Hidden 512.

Eager wall ms Graph wall ms

7.MTP and other techniques

CUDA Graphs are not an isolated knob. They constrain, and are enabled by, the rest of the serving stack.

Speculative decoding is the measured interaction

Qwen3.8's in-checkpoint multi-token prediction head uses SGLang's speculative path (EAGLE): three steps, top-k 1, four draft tokens. The number of tokens actually accepted stayed at 2.81–2.83 in both eager and graphed arms. Speculation has a mostly fixed verification width — one bonus token plus N draft tokens — so an engine can capture separate target-verify, draft-decode, and draft-extend graphs. That also multiplies graph paths.

Native MTP × CUDA Graphs

Output tokens/s · same 128/64 decode matrix · accept length 2.81–2.83

Plain eager Plain graph MTP eager MTP graph

How the rest of the stack fits

Continuous batchingSupplies repeated decode shapes. Membership changes every step, so IDs, positions, and block tables must be staged into static buffers. Padding is real compute.
Paged attentionA preallocated cache gives stable addresses. Block tables can change as values. Some backends still need metadata or kernel choice outside the graph.
Prefix cacheA hit shortens prefill. The uncached token count becomes irregular and may pick a different bucket, increase padding, or miss the ladder.
torch.compileCompile can fuse kernels; graphs replay the result. Dynamic guards, custom ops, and every compiled shape multiply autotune and capture work. Breakable graphs exist to avoid that compiler tax.
Tensor / expert parallelGPU collectives can be captured after the communicator is created. Expert-parallel mixture-of-experts is harder: routed token counts are data-dependent. Engines often graph static regions and leave routing eager.
Quantization, LoRA, samplingWarm JIT and autotuners before capture. Frequent adapter or weight updates invalidate captured addresses. Grammar masks often stay outside the model graph.

Prefill/decode disaggregation makes the phase split explicit: decode workers want a compact batch ladder of full graphs; prefill workers want eager, breakable, or piecewise execution over token buckets.

8.Training, limits, mental model

CUDA Graphs are usable in training, but serving decode is the cleaner fit. Decode has no backward pass, no optimizer, a repeating one-token query, persistent model and cache buffers, and hundreds of replays per request.

With a fixed microbatch and sequence length, a trainer can capture forward → loss → backward → optimizer. Distributed training can also capture collectives with compatible initialization. Training then adds everything graphs dislike: packed sequences, dropout, loss scaling, gradient accumulation, activation checkpointing, host-side scalar reads, and data-dependent mixture-of-experts routing. The usual remedies are fixed-shape buckets, capturable optimizers, and partial capture. Large training kernels are often compute-bound enough that launch overhead is a smaller fraction than in batch-1 decode.

Mental model

CUDA Graphs are most valuable when execution repeats, shapes can be bucketed, and CPU launch overhead is a meaningful fraction of step time. That describes autoregressive decode unusually well. It describes long, dynamic prefill much less well. It describes a typical training step only after you have already frozen the shapes.

What this measurement is not

One B200, one model, one precision, one GPU with no model split. Synthetic exact lengths, not a ShareGPT or production trace. One repetition per cell, no clock locking, relatively short runs. Prefill numbers include tokenizer, server, and client overhead plus one-token sampling — not a GPU-only forward timer. The phase-isolated prefill rerun disabled overlap; the combined numbers did not. MTP was measured on decode only. SGLang here is a development build, commit 779e593bd, not a release tag. Do not read “CUDA Graphs always make inference 5× faster” out of a launch-bound 27B hybrid decode.

References

  1. NVIDIA — CUDA Graph best practices for PyTorch.
  2. NVIDIA — Handling dynamic patterns with CUDA Graphs.
  3. PyTorch CUDA semantics — CUDA Graphs.
  4. LMSYS — Advanced CUDA Graph techniques in SGLang.
  5. SGLang — Qwen3.8-27B serving cookbook.
  6. vLLM — CUDA Graph design.
  7. Earlier on this site: Flash Attention, linear attention, Hopper-to-Blackwell MMA.

How to cite this post

Dong, S. (2026). How CUDA Graphs Speed Up Decode — and By How Much.
https://simondong1.github.io/cuda-graphs.html