vLLM Explained

PyTorch for serving: custom ops and torch.compile

IntermediateLearn the basicsabout 8 min read + 7 min deep dives

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:

  1. 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.
  2. 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#

Figure: the same forward pass in three forms. Eager runs op by op (panel 1). torch.compile captures and fuses the ops, split at attention (panel 2). CUDA graphs replay the recorded launches (panel 3).
Open full size ↗

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.

  1. The decorator. @support_torch_compile on a model class marks which forward() arguments have a dynamic first dimension, for example input_ids (vllm/model_executor/models/llama.py:337-347). It adds TorchCompileWithNoGuardsWrapper as a base class (vllm/compilation/decorators.py:360-367).
  2. 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 is VllmBackend (vllm/config/compilation.py:1123-1134).
  3. The custom op helper. direct_register_custom_op() registers an op in the vllm library (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 output
LlamaAttention.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 dive

nn.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
  1. Lines 95-101. gate_up_proj is one linear layer for two matrices, the gate and the up projection. output_sizes=[intermediate_size] * 2 stacks them. With tensor parallelism, each rank holds a slice of the columns (Parallelism basics).
  2. Lines 103-110. down_proj is a row-parallel linear layer. With reduce_results=True, it ends with an all-reduce over the TP ranks.
  3. Line 116. SiluAndMul is a CustomOp: a vLLM layer that selects a CUDA kernel or a PyTorch version (vllm/model_executor/layers/activation.py:116).
  4. Lines 118-122. forward() calls the three layers. The activation goes through maybe_fused_act_quant(): for a quantized down_proj, it can use one kernel for the activation and the input quantization; else it calls SiluAndMul (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:

  1. x: [256, 4096].
  2. gate_up_proj: weight [28672, 4096] (2 × 14,336 rows), output [256, 28672].
  3. SiluAndMul: silu(first half) × second half, output [256, 14336].
  4. 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 dive

Custom 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)
  1. Line 1056. One Library("vllm", "FRAGMENT") object holds all vLLM ops. "FRAGMENT" means that other code can add ops to the same namespace.
  2. Lines 1082-1083. mutates_args lists the arguments that the op writes into. The compiler must know them, so it does not reorder or remove the op.
  3. 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).
  4. Line 1090. infer_schema reads the type hints of the Python function and writes the op schema, for example (Tensor query, Tensor key, ...) -> ().
  5. Lines 1093-1094. define adds the schema; impl binds the Python function for the dispatch key.
  6. 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 dive

torch.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 dive

Compilation modes and CUDA graph modes in vLLM

CompilationModeMeaning (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#

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#

  1. 1
    What are the two halves of torch.compile, and what does each do?
    easy
    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).

  2. 2
    Which flag turns off both torch.compile and CUDA graphs?
    easy
    Show answer

    --enforce-eager. It sets the compile mode and the CUDA graph mode to NONE (vllm/config/vllm.py:1745-1747).

  3. 3
    Why is attention registered as a torch.library op on CUDA, and not left as plain PyTorch code?
    medium
    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 for opaque_attention_op() (vllm/platforms/cuda.py:595-596).

  4. 4
    Your model plugin has if positions.max() > 4096: in the forward() of a class with @support_torch_compile. What happens at start-up with default flags, and why?
    hard
    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), and VLLM_COMPILE is the default above O0 (vllm/config/vllm.py:1806-1810). Move the check out of forward(), or use a tensor operation such as torch.where.

  5. 5
    In the LlamaMLP worked example, how many parameters does one rank hold with TP = 4, and what is the shape after gate_up_proj for 256 tokens?
    hard
    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_proj the shape is [256, 7168]: 2 × 14,336 / 4 columns. The column split comes from MergedColumnParallelLinear (vllm/model_executor/models/llama.py:95-101), and down_proj then reduces the results over the ranks (vllm/model_executor/models/llama.py:103-110).