PyTorch for serving: custom ops and torch.compile
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
vLLM writes models as PyTorch modules, hides attention in one custom op, and compiles the rest with torch.compile before it replays CUDA graphs.
The idea#
Modules and forward#
A PyTorch model is a tree of nn.Module objects. Each module holds its parameters (the weights, nn.Parameter tensors) and its child modules. Its forward() method computes the output from the input. When you call module(x), PyTorch calls forward(x). The parameters and some extra tensors (buffers) form the state of the module; a checkpoint is a saved copy of that state.
vLLM models are such trees. For example, LlamaMLP has two linear layers and an activation, and its forward() calls them in order (vllm/model_executor/models/llama.py:82-122). The basics of the transformer layers are on Inside a transformer.
Ops, kernels and streams#
Each tensor operation (an op, for example a matrix multiply) runs as one or more kernels on the GPU. The CPU only starts (launches) the kernels; the GPU runs them in order on a stream. A program can use several streams and synchronize them with events (GPUs, kernels, and speed). vLLM does this, for example, to copy the sampled tokens to the CPU on a separate copy stream. It records an event after the copy and waits for that event only when it needs the tokens (vllm/v1/worker/gpu/async_utils.py:134-140, vllm/v1/worker/gpu/async_utils.py:171-174).
Custom ops: one opaque box#
Code can register its own operator with torch.library. Then torch.ops.<namespace>.<name>(...) calls it, and PyTorch treats it like a built-in op. vLLM registers its ops in the namespace vllm (vllm/utils/torch_utils.py:1056). The most important one is attention: torch.ops.vllm.unified_attention_with_output (vllm/model_executor/layers/attention/attention.py:573-580). For the compiler, this torch.library op is one opaque node. The compiler does not look inside it. The page The forward context: how layers find per-step data explains how the op finds its KV cache and metadata without arguments for them.
Compile: capture, then generate code#
torch.compile has two halves:
- Dynamo watches the Python code of
forward()when it runs, and records the tensor ops as an FX graph: a list of nodes, one per op. - A backend turns the graph into code. The default backend, Inductor, fuses many small ops into fewer, larger kernels, for example Triton kernels on NVIDIA GPUs.
Some Python cannot be captured, for example a branch on a tensor value. That place is a graph break. Plain torch.compile then splits the function into several graphs and runs the rest in Python. vLLM does not allow this: it compiles with fullgraph=True (vllm/compilation/wrapper.py:168-175).
CUDA graphs on top#
A CUDA graph records a series of kernel launches once and replays them with one call. This removes most of the launch cost of small decode steps (CUDA graphs). vLLM captures CUDA graphs for the compiled pieces and, for pure decode batches, for the whole forward pass. The page torch.compile and CUDA graphs in vLLM explains the full design.
An everyday comparison#
Think of a cook who follows a recipe. Eager mode reads the recipe line by line, every time. Compilation rewrites the recipe once: it joins steps that use the same pan. A CUDA graph is a video of the cook; next time, the kitchen plays the video. Attention is one step that the rewrite must not touch, so it stays a sealed box in the recipe.
The comparison stops here: a video plays the same moves with the same food. A CUDA graph also reads the same memory addresses every time, so vLLM must copy each new input into the same buffers before a replay.
The picture#
Read the panels from top to bottom: each one adds a layer on top of the one before. The violet box in panel 2 is the vLLM part: it cuts the graph at the attention op. That cut is why panel 3 has PIECEWISE graphs, with attention between the pieces.
In vLLM#
This is a basics page, so this part gives only pointers. The page torch.compile and CUDA graphs in vLLM explains the compile and capture code.
- The decorator.
@support_torch_compileon a model class marks whichforward()arguments have a dynamic first dimension, for exampleinput_ids(vllm/model_executor/models/llama.py:337-347). It addsTorchCompileWithNoGuardsWrapperas a base class (vllm/compilation/decorators.py:360-367). - The compile call. The wrapper calls
torch.compile(forward, fullgraph=True, dynamic=False, backend=...)(vllm/compilation/wrapper.py:168-175). In the default mode the backend isVllmBackend(vllm/config/compilation.py:1123-1134). - The custom op helper.
direct_register_custom_op()registers an op in thevllmlibrary (vllm/utils/torch_utils.py:1059-1096).
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
) -> torch.Tensor:
qkv, _ = self.qkv_proj(hidden_states)
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
q, k = self.rotary_emb(positions, q, k)
attn_output = self.attn(q, k, v)
output, _ = self.o_proj(attn_output)
return outputLlamaAttention.forward(): plain PyTorch calls. self.attn(q, k, v) ends in the opaque custom op.Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep divenn.Module in vLLM: LlamaMLP, line by line
class LlamaMLP(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
hidden_act: str,
quant_config: QuantizationConfig | None = None,
bias: bool = False,
prefix: str = "",
reduce_results: bool = True,
disable_tp: bool = False,
) -> None:
super().__init__()
self.gate_up_proj = MergedColumnParallelLinear(
input_size=hidden_size,
output_sizes=[intermediate_size] * 2,
bias=bias,
quant_config=quant_config,
disable_tp=disable_tp,
prefix=f"{prefix}.gate_up_proj",
)
self.down_proj = RowParallelLinear(
input_size=intermediate_size,
output_size=hidden_size,
bias=bias,
quant_config=quant_config,
reduce_results=reduce_results,
disable_tp=disable_tp,
prefix=f"{prefix}.down_proj",
)
if hidden_act != "silu":
raise ValueError(
f"Unsupported activation: {hidden_act}. Only silu is supported for now."
)
self.act_fn = SiluAndMul()
def forward(self, x):
x, _ = self.gate_up_proj(x)
x = maybe_fused_act_quant(self.act_fn, x, self.down_proj)
x, _ = self.down_proj(x)
return x- Lines 95-101.
gate_up_projis one linear layer for two matrices, the gate and the up projection.output_sizes=[intermediate_size] * 2stacks them. With tensor parallelism, each rank holds a slice of the columns (Parallelism basics). - Lines 103-110.
down_projis a row-parallel linear layer. Withreduce_results=True, it ends with an all-reduce over the TP ranks. - Line 116.
SiluAndMulis a CustomOp: a vLLM layer that selects a CUDA kernel or a PyTorch version (vllm/model_executor/layers/activation.py:116). - Lines 118-122.
forward()calls the three layers. The activation goes throughmaybe_fused_act_quant(): for a quantizeddown_proj, it can use one kernel for the activation and the input quantization; else it callsSiluAndMul(vllm/model_executor/layers/fusion/fused_act_quant.py:196-205). The linear layers return a pair; the second value (an optional bias) is not used here.
Shapes. vLLM flattens the batch: the first dimension is the number of tokens in the step, not the number of requests (Batching).
Worked example (sizes from the config.json of Llama-3.1-8B: hidden size 4,096, intermediate size 14,336; inferred, not in the repo). A step has 256 tokens and TP = 1, in BF16:
x:[256, 4096].gate_up_proj: weight[28672, 4096](2 × 14,336 rows), output[256, 28672].SiluAndMul:silu(first half) × second half, output[256, 14336].down_proj: weight[4096, 14336], output[256, 4096].
The two weights hold 28,672 × 4,096 + 4,096 × 14,336 = 176,160,768 parameters, which is 352 MB in BF16. With TP = 2, each rank holds half of each weight and computes [256, 14336] after gate_up_proj.
The weights are not loaded in __init__. vLLM makes the modules first and fills the parameters later from the checkpoint; see "Weight loading: from checkpoint files to GPU parameters".
Deep diveCustom ops with torch.library: direct_register_custom_op()
PyTorch has the public decorator torch.library.custom_op. vLLM uses its own helper, because the docstring says that custom_op "can have significant overhead" (vllm/utils/torch_utils.py:1068-1071):
# create a library to hold the custom op
vllm_lib = Library("vllm", "FRAGMENT") # noqa
def direct_register_custom_op(
op_name: str,
op_func: Callable,
mutates_args: list[str] | None = None,
fake_impl: Callable | None = None,
target_lib: Library | None = None,
dispatch_key: str | None = None,
tags: tuple[torch.Tag, ...] = (),
):
"""`torch.library.custom_op` can have significant overhead because it
needs to consider complicated dispatching logic. This function
directly registers a custom op and dispatches it to the CUDA backend.
See https://gist.github.com/youkaichao/ecbea9ec9fc79a45d2adce1784d7a9a5
for more details.
By default, the custom op is registered to the vLLM library. If you
want to register it to a different library, you can pass the library
object to the `target_lib` argument.
IMPORTANT: the lifetime of the operator is tied to the lifetime of the
library object. If you want to bind the operator to a different library,
make sure the library object is alive when the operator is used.
"""
if mutates_args is None:
mutates_args = []
if dispatch_key is None:
from vllm.platforms import current_platform
dispatch_key = current_platform.dispatch_key
schema_str = infer_schema(op_func, mutates_args=mutates_args)
my_lib = target_lib or vllm_lib
my_lib.define(op_name + schema_str, tags=tags)
my_lib.impl(op_name, op_func, dispatch_key=dispatch_key)
if fake_impl is not None:
my_lib._register_fake(op_name, fake_impl)- Line 1056. One
Library("vllm", "FRAGMENT")object holds all vLLM ops. "FRAGMENT" means that other code can add ops to the same namespace. - Lines 1082-1083.
mutates_argslists the arguments that the op writes into. The compiler must know them, so it does not reorder or remove the op. - Lines 1085-1088. The dispatch key comes from the platform:
"CUDA"on NVIDIA (vllm/platforms/cuda.py:230),"CPU"by default (vllm/platforms/interface.py:145). - Line 1090.
infer_schemareads the type hints of the Python function and writes the op schema, for example(Tensor query, Tensor key, ...) -> (). - Lines 1093-1094.
defineadds the schema;implbinds the Python function for the dispatch key. - Lines 1095-1096. A fake implementation only makes empty output tensors with the right shapes. Dynamo calls it during capture instead of the real kernel (general PyTorch behaviour). The helper registers it only when the caller gives one.
Attention as an opaque op. vLLM registers unified_attention_with_output with mutates_args=["output", "output_block_scale"] (vllm/model_executor/layers/attention/attention.py:799-803). It returns nothing; it writes the result into output. Inside, it gets the attention metadata, the layer and the KV cache from the forward context (vllm/model_executor/layers/attention/attention.py:705-723). A second op, unified_kv_cache_update, writes the new keys and values into the cache first. It runs only when the attention backend does not write the KV cache in its own forward (vllm/model_executor/layers/attention/attention.py:564-572). It returns an empty tensor only to make a data dependency: the attention op takes it as an argument, so the compiler keeps the order (vllm/model_executor/layers/attention/attention.py:726-732, vllm/model_executor/layers/attention/attention.py:777-782).
Which platforms use the opaque op. The layer calls torch.ops.vllm.* only when current_platform.opaque_attention_op() is true. Otherwise it calls the Python functions directly (vllm/model_executor/layers/attention/attention.py:436-440, vllm/model_executor/layers/attention/attention.py:542-580). The base class returns false (vllm/platforms/interface.py:1286-1290); CUDA, ROCm, CPU and XPU return true (vllm/platforms/cuda.py:595-596, vllm/platforms/rocm.py:1231-1232, vllm/platforms/cpu.py:640-641, vllm/platforms/xpu.py:609-610).
Deep divetorch.compile, FX graphs and graph breaks
What Dynamo records. When the compiled function runs for the first time, Dynamo follows its Python bytecode. It records each tensor op as a node of a torch.fx.Graph. The graph has a placeholder node per input, a call_function node per op, and an output node. vLLM's split code walks these nodes (vllm/compilation/backends.py:576-600). Python that does not touch tensors (for example a loop over the layers) runs during capture and leaves only its tensor ops in the graph.
Guards and dynamic shapes. Plain torch.compile adds guards: checks that run before each call and compile again when, for example, a shape changes. vLLM drops the guards in its own modes (vllm/compilation/wrapper.py:126-146). It marks the token dimension as dynamic instead, from dynamic_arg_dims (vllm/model_executor/models/llama.py:337-346). So one graph serves every number of tokens.
A graph break, by example (plain PyTorch, not vLLM code):
import torch
def f(x):
y = x.sin()
if y.sum() > 0: # Python branch on a tensor value
return y.cos()
return y
torch.compile(f)(torch.randn(8)) # works: two graphs and a Python branch
torch.compile(f, fullgraph=True)(torch.randn(8)) # fails: fullgraph does not allow the break
Dynamo cannot know the value of y.sum() during capture. Without fullgraph, it ends the graph before the branch, runs the branch in Python, and starts a new graph. With fullgraph=True, it raises an error instead. vLLM uses fullgraph=True (vllm/compilation/wrapper.py:168-175). So model code that vLLM compiles must not branch on tensor values in forward().
Where the work goes. In the default mode, the backend is VllmBackend (vllm/config/compilation.py:1123-1134). It splits the FX graph at the splitting ops. By default these are the attention ops in _attention_ops (vllm/config/compilation.py:766-788, vllm/config/compilation.py:1184). When Inductor graph partition is off (the O2 default), they also include the two KV cache update ops (vllm/config/compilation.py:1191, vllm/config/compilation.py:1212-1213, vllm/config/vllm.py:311). Then Inductor compiles each piece. The design document says that all compilation finishes before the server takes requests, and that vLLM caches the results (docs/design/torch_compile.md:18-28).
Deep diveCompilation modes and CUDA graph modes in vLLM
| CompilationMode | Meaning (vllm/config/compilation.py:37-50) |
|---|---|
NONE (0) | No torch.compile; eager PyTorch. |
STOCK_TORCH_COMPILE (1) | The standard torch.compile pipeline. |
DYNAMO_TRACE_ONCE (2) | One Dynamo trace, no compile again. |
VLLM_COMPILE (3) | vLLM's Inductor-based backend with caching, piecewise compilation and custom passes. |
Defaults. The default optimization level is O2 (vllm/config/vllm.py:434). Above O0, an unset mode becomes VLLM_COMPILE (vllm/config/vllm.py:1806-1810). O2 sets the CUDA graph mode FULL_AND_PIECEWISE (vllm/config/vllm.py:310). --enforce-eager sets both the compile mode and the CUDA graph mode to NONE (vllm/config/vllm.py:1745-1747).
CUDA graph modes. NONE, PIECEWISE and FULL are run-time modes. FULL_DECODE_ONLY and FULL_AND_PIECEWISE are pairs: one mode for pure decode batches, one for mixed batches (vllm/config/compilation.py:53-69). Each step, the model runner looks for a captured graph for the padded token count, and falls back to NONE when none fits (vllm/v1/worker/gpu/cudagraph_utils.py:523-553). In the common case (no speculative decoding), a FULL decode graph is captured for batches where each request has 1 new token (vllm/v1/worker/gpu/model_runner.py:442-445, vllm/v1/worker/gpu/cudagraph_utils.py:333-339). A batch matches it only with that uniform length and at most the captured token count (vllm/v1/worker/gpu/cudagraph_utils.py:125-138). Other batches use PIECEWISE graphs.
Worked example (illustrative numbers). A decode step of a 32-layer model starts about 15 kernels per layer in eager mode: 480 launches. At about 5 µs of CPU time per launch, that is 2.4 ms per step. A step of a small model on a fast GPU can take less than that, so the GPU would wait for the CPU. With a FULL CUDA graph, the CPU starts one replay instead.
What can go wrong#
- Slow first start. Compilation runs at start-up, before the server takes requests (
docs/design/torch_compile.md:28). CUDA graph capture also runs at start-up. Later starts reuse the cache.VLLM_DISABLE_COMPILE_CACHE=1turns the cache off (vllm/envs.py:375), so every start compiles again. - A graph break in your own model. This applies to a model class with
@support_torch_compile. Withfullgraph=True, a branch on a tensor value or an unsupported Python call stops compilation with an error (vllm/compilation/wrapper.py:168-175). Move such logic out offorward(), or run with--enforce-eagerto find the problem. - A custom op without a fake implementation. In PyTorch, an op that returns new tensors needs a fake implementation, so that Dynamo knows the output shapes during capture (general PyTorch rule). With the vLLM helper, you give it as
fake_impl(vllm/utils/torch_utils.py:1095-1096), as forunified_kv_cache_update(vllm/model_executor/layers/attention/attention.py:759-764). The attention op is registered without a fake implementation; it returns nothing and writes intooutput(vllm/model_executor/layers/attention/attention.py:799-803). - A missing
mutates_args. An op that writes into an argument without saying so can be reordered or removed by the compiler. vLLM listsoutputfor attention (vllm/model_executor/layers/attention/attention.py:802). --enforce-eagerin production. It turns off both compilation and CUDA graphs (vllm/config/vllm.py:1745-1747). Use it to debug, not to serve: small decode steps become much slower.
Why it matters. Every model file in vLLM is written for this pipeline. Know what the compiler sees. Then you can read the model code, write a model plugin that compiles, and explain the start-up time of vllm serve.
Check yourself#
- 1What are the two halves ofeasy
torch.compile, and what does each do?Show answer
Dynamo captures the Python code as an FX graph. A backend, by default Inductor, turns the graph into fused kernels. In vLLM's default mode, the backend is
VllmBackend, which splits the graph and then compiles the pieces (vllm/config/compilation.py:1123-1134). - 2Which flag turns off botheasy
torch.compileand CUDA graphs?Show answer
--enforce-eager. It sets the compile mode and the CUDA graph mode toNONE(vllm/config/vllm.py:1745-1747). - 3Why is attention registered as amedium
torch.libraryop on CUDA, and not left as plain PyTorch code?Show answer
The compiler then sees attention as one opaque node. vLLM splits the graph at that node, so the pieces around it can be compiled and captured as PIECEWISE CUDA graphs (
vllm/config/compilation.py:766-769). The op reads its KV cache and metadata from the forward context (vllm/model_executor/layers/attention/attention.py:705-722). CUDA returns true foropaque_attention_op()(vllm/platforms/cuda.py:595-596). - 4Your model plugin hashard
if positions.max() > 4096:in theforward()of a class with@support_torch_compile. What happens at start-up with default flags, and why?Show answer
Compilation fails with an error. The branch depends on a tensor value, so Dynamo cannot capture it: it is a graph break. vLLM compiles with
fullgraph=True(vllm/compilation/wrapper.py:168-175), andVLLM_COMPILEis the default above O0 (vllm/config/vllm.py:1806-1810). Move the check out offorward(), or use a tensor operation such astorch.where. - 5In thehard
LlamaMLPworked example, how many parameters does one rank hold with TP = 4, and what is the shape aftergate_up_projfor 256 tokens?Show answer
Each rank holds a quarter of each weight: 176,160,768 / 4 = 44,040,192 parameters (88 MB in BF16). After
gate_up_projthe shape is[256, 7168]: 2 × 14,336 / 4 columns. The column split comes fromMergedColumnParallelLinear(vllm/model_executor/models/llama.py:95-101), anddown_projthen reduces the results over the ranks (vllm/model_executor/models/llama.py:103-110).