ML Interview Notes
31 min read11 sections
Part 8 · Compilation and runtime · 08-02

torch.compile, Inductor, piecewise capture

Status
SOURCE PINNED
Primary sources
  • vllm/compilation/
  • python/sglang/srt/compilation/
Edition pins
vllm a556f3f · sglang 7d89325

Compilation reduces Python overhead and can fuse tensor operations. The actual benefit depends on the eager baseline, selected custom kernels and graph boundaries. Both whole-forward and partitioned paths exist; an opaque attention operator can remain in an FX graph even when its internals are not lowered or fused by Inductor.

§1

The problem

Start a vLLM server and watch the log. Somewhere between weight loading and the first token you get a line that costs you real money:

vllm/compilation/monitor.py:L17-L22 vLLM
@contextlib.contextmanager
def monitor_torch_compile(
    vllm_config: VllmConfig,
    message: str = "torch.compile took %.2f s in total",
    is_encoder: bool = False,
) -> Generator[None, None, None]:

On a cold cache that number is minutes, not seconds, and it is paid before the server binds its port. Autoscaling a fleet on this is a real operational problem — see §9.4 for what that does to tail latency during a scale-out.

So the question that opens the chapter is not "what is torch.compile". It is: what does an inference engine buy for those minutes, and why is it worth it? The honest answer has two halves, and they point in different directions depending on batch size. Here is the first half, made concrete.

vLLM's RMSNorm is a CustomOp: it carries a hand-written CUDA kernel and a pure-PyTorch reference. When Inductor is in play, the custom kernel is switched off by default and Dynamo traces the reference:

vllm/model_executor/custom_op.py:L296-L311 vLLM
    def default_on() -> bool:
        """
        Behavior controlled by `CompilationConfig.custom_ops`: On by default if
        'all', off by default if 'none'.
        When PyTorch Inductor is used, 'none' is the default value,
        otherwise 'all'.
        """
        compilation_config = get_cached_compilation_config()
        count_none = compilation_config.custom_ops.count("none")
        count_all = compilation_config.custom_ops.count("all")
        if count_none + count_all != 1:
            raise ValueError(
                "custom_ops must contain exactly one base mode: 'all' or 'none'"
            )

        return not count_none > 0 or count_all > 0

And the reference that Dynamo sees is this:

vllm/ir/ops/layernorm.py:L43-L62 vLLM
@register_op(allow_inplace=True)
def fused_add_rms_norm(
    x: Tensor,
    x_residual: Tensor,
    weight: Tensor | None,
    epsilon: float,
    variance_size: int | None = None,
) -> tuple[Tensor, Tensor]:
    """Fused add and weighted root-mean-square layer normalization"""
    orig_dtype = x.dtype
    x = x.to(torch.float32)
    x = x + x_residual.to(torch.float32)
    x_residual = x.to(orig_dtype)

    x_var = x if variance_size is None else x[..., :variance_size]
    variance = x_var.pow(2).mean(dim=-1, keepdim=True)
    x = x * torch.rsqrt(variance + epsilon)
    if weight is not None:
        x = x.to(weight.dtype) * weight
    return x.to(orig_dtype), x_residual

This is a reference tensor decomposition, not an exact kernel trace. Casts may be no-ops, reductions may launch multiple kernels, and caches can retain intermediates. The production eager baseline already has a fused norm, so this decomposition is not its launch count.

§2

Mental model

torch.compile is three programs wearing one name. Dynamo is a bytecode interpreter: it runs your Python frame symbolically and emits an FX graph of tensor operations plus a set of guards — predicates on the inputs that must hold for the graph to be reused. Anything Dynamo cannot represent as a tensor op (a print, a data-dependent branch, an unsupported builtin) causes a graph break: the traced region ends there and Python resumes. AOTAutograd functionalises the graph, turning mutations into pure operations so a compiler can reason about it. Inductor lowers the functional graph to Triton source, schedules it into as few kernels as the dependence structure allows, and compiles that Triton to PTX.

The serving-relevant consequence: fusion happens at the Inductor scheduling step, and it can only fuse ops that are in the same graph. Everything else in this chapter follows from that one sentence.

Figure 1 — what each stage of the pipeline produces. Shapes and counts are for one LlamaModel.forward at Llama-3-8B (L=32). Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The last box is where this chapter hands off to §8.1. Compilation produces the pieces; CUDA graph capture freezes the pieces. They are separate mechanisms with separate configuration, and the reason they are usually discussed together is that the split points serve both.

§3

First principles: pricing one fusion

Take one fused_add_rms_norm call on Llama-3-8B, hidden size $d = 4096$, activations in bf16, at one token. Write $b_{16} = 2d = 8{,}192$ bytes for a bf16 row and $b_{32} = 4d = 16{,}384$ bytes for an fp32 row. Count logical tensor traffic, assuming materialized intermediates.

Logical tensor traffic for the unfused reference decomposition, not measured HBM traffic. Weight dtype is bf16; scalar epsilon is a constant. Actual caches, allocation reuse and kernel decomposition can change physical traffic and launches.
Tensor opReads (B)Writes (B)Total (B)
x.to(float32)8,19216,38424,576
x_residual.to(float32)8,19216,38424,576
x + x_residual32,76816,38449,152
x.to(bf16) → residual out16,3848,19224,576
x.pow(2)16,38416,38432,768
.mean(dim=-1)16,384416,388
variance + epsilon448
rsqrt448
x * rsqrt16,38816,38432,772
x.to(bf16)16,3848,19224,576
x * weight16,3848,19224,576
Logical total147,468106,508253,976
One fused kernel (x, residual, weight in; normed, residual out)24,57616,38440,960

The fused input/output payload is 40,960 bytes, about 6.2 times smaller than the 253,976-byte logical reference traffic. Across 64 illustrative call sites, the difference is about 13.63 MB. This is not a measured HBM saving or a production compile speedup:

$$ \Delta B_{\text{norm}} = 64 \times 213{,}016\ \text{B} = 13.63\ \text{MB per token} $$

Now the punchline, which is not the one you expect. The book's H100 constant is 3.35 TB/s of HBM bandwidth and a 4.48 ms Llama-3-8B decode-step floor at batch 1 — that floor is 15.0 GB of weight traffic. At batch 1:

4.07 µs
logical-byte difference / peak bandwidth
0.09%
of the 4.48 ms decode floor
Profile
actual launch count depends on baseline

The launch count must come from a trace: this reference contains eleven materializing operations under the stated dtype, not an exact ten-kernel decomposition. A fused eager norm may already use one launch. Compilation can still move fusion boundaries or remove framework overhead, while CUDA graphs separately reduce repeated submission work.

Scale the batch and the arithmetic inverts. Elementwise traffic is proportional to token count; weight traffic is not. At 256 tokens in flight:

$$ 256 \times 13.63\ \text{MB} = 3.49\ \text{GB} \quad\Longrightarrow\quad 1.04\ \text{ms at }3.35\ \text{TB/s} $$

At 256 tokens the reference's extra logical bytes are about 23% of the base weight bytes. Converting that ratio to HBM time assumes all intermediates reach HBM at the stated bandwidth. It is an explanatory traffic model, not evidence for a 23% production speedup.

Figure 2 — reference materialization versus fused payload. Arrows denote logical intermediates, not confirmed HBM round trips. The schematic groups scalar add/rsqrt; actual eager kernels and the production baseline require tracing.

Reference versus fused logical tensor traffic The unfused logical reference totals 253976 bytes, compared with a 40960-byte fused payload. This is not a count of physical HBM transactions or production launches. REFERENCE — grouped operations, 253,976 logical bytes HBM to f32 to f32 add to bf16 pow2 mean rsqrt mul to bf16 mul w 20 HBM crossings · 8 fp32 intermediate rows of 16,384 B each never read outside the chain FUSED — 1 Triton kernel, 1 dispatch, 40,960 B HBM triton_poi_fused_add_rms_norm registers hold every intermediate x 8,192 res 8,192 w 8,192 out 8,192 res 8,192 213,016 fewer logical bytes per call; actual launches require tracing × 64 call sites = 13.63 MB per token on Llama-3-8B
Why not just call the CUDA kernel

vLLM ships a hand-written fused RMSNorm; at -O0 that is exactly what runs, because custom_ops defaults to all without Inductor. The value of routing through Inductor instead is that the fusion boundary can move: a Triton kernel generated from the graph can additionally absorb the following FP8 quantisation, or a reshape, which a fixed CUDA kernel cannot. vLLM encodes exactly this decision — vllm/config/vllm.py's enable_norm_fusion turns on the explicit norm+quant pattern-matching pass only when a custom op is active, on the comment "otherwise Inductor handles fusion".

§4

How production systems do it

Opaque attention and explicit partition boundaries

vLLM registers attention as an opaque custom operator. Dynamo does not trace into custom operators, by design:

vllm/model_executor/layers/attention/attention.py:L743-L772 vLLM
@eager_break_during_capture
@maybe_transfer_kv_layer
def unified_attention_with_output(
    query: torch.Tensor,
    key: torch.Tensor,
    value: torch.Tensor,
    output: torch.Tensor,
    layer_name: LayerNameType,
    output_scale: torch.Tensor | None = None,
    output_block_scale: torch.Tensor | None = None,
    kv_cache_dummy_dep: torch.Tensor | None = None,
) -> None:
    # kv_cache_dummy_dep is not used but accepting it creates a data dependency
    # that ensures torch.compile preserves ordering between KV cache update and
    # attention forward.
    del kv_cache_dummy_dep
    layer_name = _resolve_layer_name(layer_name)
    attn_metadata, self, kv_cache, _ = get_attention_context(layer_name)

    self.impl.forward(
        self,
        query,
        key,
        value,
        kv_cache,
        attn_metadata,
        output=output,
        output_scale=output_scale,
        output_block_scale=output_block_scale,
    )

Three reasons the opacity is load-bearing. get_attention_context(layer_name) pulls attn_metadata and the KV cache out of a global forward context — Python state that changes every step and that Dynamo would have to guard on. self.impl.forward dispatches into an external backend (FlashAttention, FlashInfer, Triton; §3.4) whose kernels Inductor has no lowering for and could not improve on. And attention writes into a paged KV cache whose addresses are chosen at runtime; a compiler that thought it understood that aliasing would reorder loads across it.

Splitting the graph

An opaque custom op can be an FX node, including in fullgraph compilation, with registered schema, mutation/alias contracts and fake/meta behavior. It need not cause a Dynamo graph break. The engine may deliberately split around it for lowering or capture. vLLM lists split ops here:

vllm/config/compilation.py:L763-L779 vLLM
    # Use PyTorch operator format: "namespace::name"
    _attention_ops: ClassVar[list[str]] = [
        "vllm::unified_attention_with_output",
        "vllm::unified_mla_attention_with_output",
        "vllm::mamba_mixer2",
        "vllm::mamba_mixer",
        "vllm::short_conv",
        "vllm::linear_attention",
        "vllm::qwen_gdn_attention_core",
        "vllm::qwen_gdn_attention_core_fused_norm_packed",
        "vllm::gdn_attention_core_xpu",
        "vllm::olmo_hybrid_gdn_full_forward",
        "vllm::sparse_attn_indexer",
        "vllm::rocm_aiter_sparse_attn_indexer",
        "vllm::deepseek_v4_attention",
        "vllm::hpc_rope_norm_forward",
    ]

set_splitting_ops_for_v1 installs that list when the user has not overridden it, additionally appending vllm::unified_kv_cache_update and vllm::unified_mla_kv_cache_update in the Dynamo-split path (vllm/config/compilation.py:L1134-L1147, L1184-L1185). The split itself is a linear scan assigning every FX node a subgraph id, bumping the id at each splitting op:

vllm/compilation/backends.py:L577-L592 vLLM
        if should_split(node, splitting_ops):
            subgraph_id += 1
            node_to_subgraph_id[node] = subgraph_id
            split_op_graphs.append(subgraph_id)

            # keep consecutive splitting ops together
            # (we know node.next exists because node isn't the last (output) node)
            if should_split(node.next, splitting_ops):
                # this will get incremented by the next node
                subgraph_id -= 1
            else:
                subgraph_id += 1
        else:
            node_to_subgraph_id[node] = subgraph_id

The "keep consecutive splitting ops together" branch matters for the count: in Attention.forward the KV-cache update op is emitted immediately before the attention op, so the pair collapses into one opaque region rather than two. Read the right branch of that method: Attention.forward has two, selected by use_direct_call = not current_platform.opaque_attention_op() (attention.py:L430), and CUDA returns True there (vllm/platforms/cuda.py:L577-L579), so on an NVIDIA box use_direct_call is False and the live path is the torch.ops.vllm.* branch at vllm/model_executor/layers/attention/attention.py:L550-L569 — the direct-call branch just above it never runs. Both emit the same two ops in the same order, so the split arithmetic is unchanged; the citation is not. should_split matches on the qualified operator name (vllm/compilation/partition_rules.py:L14-L38). For Llama-3-8B, L=32, that gives 32 opaque regions and 33 compilable ones — 65 subgraphs from one FX graph.

Figure 3 — one Llama-3-8B decoder layer, with the piecewise split points. Shaded regions are compiled by Inductor into fused Triton kernels and are individually CUDA-graph-capturable. The unshaded region is opaque to the compiler and runs eagerly. Shapes for a single token, d=4096, h=32, h_kv=8, d_h=128.

Piecewise split points around attention in a transformer block A decoder layer drawn top to bottom. A shaded compiled region contains the input RMS norm, the QKV projection and rotary embedding. An unshaded opaque region contains the KV cache update and the unified attention op. A second shaded compiled region contains the output projection, the residual add and post-attention norm, and the whole gated MLP, and it continues into the next layer's pre-attention work. submod_2k — COMPILED PIECE fused_add_rms_norm(x, residual, w) [1, 4096] qkv_proj mm [1,4096] x [4096, 6144] split -> q[1,32,128] k[1,8,128] v[1,8,128] rotary_embedding(q, k, positions) Inductor emits ~3 Triton kernels + 1 cuBLAS GEMM SPLITTING REGION — OPAQUE, NOT COMPILED vllm::unified_kv_cache_update(k, v, layer) vllm::unified_attention_with_output(q, k, v, out) submod_2k+2 — COMPILED PIECE o_proj mm [1,4096] x [4096, 4096] fused_add_rms_norm(x, residual, w) gate_up_proj -> silu(g) * u [1, 14336] down_proj → continues into layer k+1 What the split buys Attention gets its own metadata, its own backend, its own launch — unchanged. Everything else is one FX graph per gap, so Inductor can fuse freely inside a gap. What it costs No fusion can cross an attention boundary. A pass that rewrites the residual stream across layers (SequenceParallelismPass) is therefore INCOMPATIBLE with this mode. Llama-3-8B, L = 32 33 compiled pieces 32 opaque attention regions 65 subgraphs total Handoff to §8.1 Each shaded box is wrapped by CUDAGraphWrapper with runtime_mode PIECEWISE, one graph per piece per bucket in the capture ladder.

The handoff to CUDA graphs is one function, called once the interpreter has built a PiecewiseBackend for a compilable submodule:

vllm/compilation/backends.py:L655-L684 vLLM
    if (
        not compilation_config.cudagraph_mode.has_piecewise_cudagraphs()
        or compilation_config.use_inductor_graph_partition
    ):
        return piecewise_backend

    # We're using Dynamo-based piecewise splitting, so we wrap
    # the whole subgraph with a static graph wrapper.
    from .cuda_graph import CUDAGraphOptions

    # resolve the static graph wrapper class (e.g. CUDAGraphWrapper
    # class) as platform dependent.
    static_graph_wrapper_class = resolve_obj_by_qualname(
        current_platform.get_static_graph_wrapper_cls()
    )

    # Always assign PIECEWISE runtime mode to the
    # CUDAGraphWrapper for piecewise_backend, to distinguish
    # it from the FULL cudagraph runtime mode, no matter it
    # is wrapped on a full or piecewise fx graph.
    return static_graph_wrapper_class(
        runnable=piecewise_backend,
        vllm_config=vllm_config,
        runtime_mode=CUDAGraphMode.PIECEWISE,
        cudagraph_options=CUDAGraphOptions(
            debug_log_enable=is_first_graph,
            gc_disable=not is_first_graph,
            weak_ref_output=is_last_graph,
        ),
    )

Everything downstream of that call — capture, the bucket ladder, address pinning, AttentionCGSupport — is §8.1's.

How a model opts in

A model class declares itself compilable, and declares which argument dimensions vary:

vllm/model_executor/models/llama.py:L334-L343 vLLM
@support_torch_compile(
    # TODO[#32068]: Investigate recompilation
    # mark_unbacked_dims={"input_ids": 0},
    dynamic_arg_dims={
        "input_ids": {0: "b"},
        "positions": {0: "b"},
        "intermediate_tensors": {0: "b"},
        "inputs_embeds": {0: "b"},
    },
)

The shared shape id "b" tells Dynamo that all four dimensions are the same symbol. Before the first trace, _mark_dynamic_inputs walks the bound arguments and calls torch._dynamo.mark_dynamic — or mark_unbacked, under DynamicShapesType.UNBACKED — on each of them (vllm/compilation/decorators.py:L414-L446). Without this, Dynamo specialises on the first batch size it sees, and since a serving batch changes every step you would recompile forever.

vLLM then goes further than stock torch.compile and throws the guards away:

vllm/compilation/wrapper.py:L105-L125 vLLM
        if mode != CompilationMode.STOCK_TORCH_COMPILE:
            # Drop all the guards.
            if self.evaluate_guards:
                assert not envs.VLLM_USE_BYTECODE_HOOK, (
                    "compilation_config.dynamic_shapes_config.evaluate_guards "
                    "requires VLLM_USE_BYTECODE_HOOK=0. "
                )
                assert ds_type != DynamicShapesType.UNBACKED, (
                    "UNBACKED dynamic shapes do not add guards"
                )

                options["guard_filter_fn"] = lambda x: [
                    entry.guard_type == "SHAPE_ENV" for entry in x
                ]
            else:
                if hasattr(torch.compiler, "skip_all_guards_unsafe"):
                    # Torch 2.10+ provides skip_all_guards_unsafe
                    options["guard_filter_fn"] = torch.compiler.skip_all_guards_unsafe
                else:
                    # Equivalent fallback for older PyTorch: skip all guards
                    options["guard_filter_fn"] = lambda x: [False for _ in x]

In this path fullgraph=True makes Dynamo graph breaks errors. Trace-once and guard-dropping assumptions do not imply one backend artifact for every shape: pieces and compile ranges can have separate Inductor artifacts. Shape-dependent Python branches are unsafe unless represented correctly or guarded; explicit custom-op boundaries can keep runtime choices out of tracing. The debug evaluate-guards path is an important exception.

SGLang: the same skeleton, a narrower deployment

SGLang's python/sglang/srt/compilation/ is a lineal descendant of vLLM's — the file header says so — and split_graph is the same scan, minus the consecutive-op merge:

python/sglang/srt/compilation/backend.py:L231-L241 SGLang
    split_op_graphs = []
    for node in graph.graph.nodes:
        if node.op in ("output", "placeholder"):
            continue
        if node.op == "call_function" and str(node.target) in ops:
            subgraph_id += 1
            node_to_subgraph_id[node] = subgraph_id
            split_op_graphs.append(subgraph_id)
            subgraph_id += 1
        else:
            node_to_subgraph_id[node] = subgraph_id

The divergence is where the split list comes from. vLLM keeps a central allowlist in config; SGLang registers split ops at the definition site:

python/sglang/srt/compilation/compilation_config.py:L10-L16 SGLang
def register_split_op(op_name: Optional[str] = None):
    def decorator(op_func: Callable):
        name = op_name or op_func.__name__
        SPLIT_OPS.append(f"sglang.{name}")
        return op_func

    return decorator
python/sglang/srt/layers/radix_attention.py:L403-L405 SGLang
@register_custom_op(mutates_args=["output"])
@register_split_op()
def unified_attention_with_output(

Decentralised registration means a new attention variant becomes a split point by adding one decorator — no config edit, no forgotten entry — but there is then no single place to read the full split set, and it depends on which modules got imported. vLLM's list is greppable and must be maintained by hand, which is why it has fourteen entries with names like vllm::olmo_hybrid_gdn_full_forward.

The larger difference is deployment surface. In SGLang the piecewise-compile machinery is one CUDA-graph backend, selected per phase, whose compiler defaults to eager:

python/sglang/srt/model_executor/cuda_graph_config.py:L87-L95 SGLang
@dataclass
class PhaseConfig:
    """Per-phase CUDA graph settings."""

    backend: str = Backend.DISABLED
    max_bs: Optional[int] = None
    bs: Optional[List[int]] = None
    # Only meaningful when backend == tc_piecewise; ignored otherwise.
    tc_compiler: str = "eager"

and at 7d89325 the default prefill backend on CUDA is not tc_piecewise at all: default_prefill_backend() returns Backend.BREAKABLE on CUDA, Backend.TC_PIECEWISE elsewhere (python/sglang/srt/model_executor/cuda_graph_config.py:L110-L119). On a stock CUDA SGLang server, Dynamo does not run. Turning it on is --cuda-graph-backend-prefill=tc_piecewise --cuda-graph-tc-compiler=inductor, and even then it covers prefill only — the flag's own comment says "currently only the prefill phase consumes it" (python/sglang/srt/server_args.py:L1926-L1930).

SGLang's second, older torch.compile path is unrelated to the piecewise backend: it compiles the whole model forward as a black box, inside the decode CUDA-graph capture loop:

python/sglang/srt/compilation/torch_compile_decoration.py:L53-L62 SGLang
        if enable_compile:
            _to_torch(model, reverse=False, num_tokens=num_tokens)
            backup_ca_comm = tp_group.ca_comm
            yield torch.compile(
                torch.no_grad()(model.forward),
                mode=os.environ.get(
                    "SGLANG_TORCH_COMPILE_MODE", "max-autotune-no-cudagraphs"
                ),
                dynamic=_is_hip and get_bool_env_var("SGLANG_TORCH_DYNAMIC_SHAPE"),
            )

dynamic= is False on CUDA, so this path specialises per batch size — which is why it is bounded by torch_compile_max_bs, default 32 (python/sglang/srt/server_args.py:L2087-L2097): compile once per captured bucket up to 32, fall back above it.

Compilation surface at the pinned SHAs. vLLM a556f3f, SGLang 7d89325. Read from config source, not from docs.
QuestionvLLMSGLang
Knob-O0..-O3OptimizationLevel; -cc.modeCompilationMode--cuda-graph-backend-prefill, --cuda-graph-tc-compiler; separately --enable-torch-compile
Enum valuesNONE=0, STOCK_TORCH_COMPILE=1, DYNAMO_TRACE_ONCE=2, VLLM_COMPILE=3backend full | breakable | tc_piecewise | disabled; tc_compiler eager | inductor
Default-O2; mode=VLLM_COMPILE, cudagraph_mode=FULL_AND_PIECEWISEprefill breakable on CUDA; tc_compiler="eager"; enable_torch_compile=False
Is Inductor on by default?yesno
Split-op registrycentral list, CompilationConfig._attention_opsdecorator at definition, @register_split_op()
Guardsnormally filtered in engine trace-once paths; stock mode and compatible evaluate_guards diagnostics differstock guards in the cited torch.compile path
Custom FX passes25 concrete pass classes wired into PostGradPassManager (vllm/compilation/passes/pass_manager.py:L155-L232)FixFunctionalizationPass only — self.passes starts empty and nothing adds to it (python/sglang/srt/compilation/pass_manager.py:L36-L52)

vLLM's custom passes, and one that breaks the piecewise contract

Because vLLM owns the Inductor backend it can insert FX passes after AOTAutograd's functionalisation and before Inductor's lowering; vllm/compilation/passes/pass_manager.py assembles them from PassConfig flags. Take SequenceParallelismPass, which §5.1 flagged as vLLM's route to a fusion SGLang reaches differently. Read it as an opt-in, not as what your server is doing: -O2's pass_config sets "enable_sp": IS_DENSE and IS_DENSE is hardcoded False at this SHA (vllm/config/vllm.py:L147-L154), so on a stock -O2 server this pass does not run for any model. It matches an all-reduce feeding an RMSNorm:

vllm/compilation/passes/fusion/sequence_parallelism.py:L202-L248 vLLM
    def register(self, pm_pass: PatternMatcherPass) -> None:
        def pattern(
            residual: torch.Tensor,
            mm_1: torch.Tensor,
            rms_norm_weights: torch.Tensor,
        ) -> tuple[torch.Tensor, torch.Tensor]:
            all_reduce = self._all_reduce(mm_1)
            rmsnorm = vllm.ir.ops.fused_add_rms_norm(
                all_reduce, residual, rms_norm_weights, self.epsilon
            )
            return rmsnorm[0], rmsnorm[1]

        def replacement(
            residual: torch.Tensor,
            mm_1: torch.Tensor,
            rms_norm_weights: torch.Tensor,
        ) -> tuple[torch.Tensor, torch.Tensor]:
# ...
            reduce_scatter = self._reduce_scatter(mm_1)
            local_len = reduce_scatter.size(0)
# ...
            residual = residual[
                self.tp_rank * local_len : self.tp_rank * local_len + local_len, ...
            ]
            rmsnorm = vllm.ir.ops.fused_add_rms_norm(
                reduce_scatter, residual, rms_norm_weights, self.epsilon
            )
            all_gather = self._all_gather(rmsnorm[0])
            return all_gather, rmsnorm[1]

Pattern: all_reduce → fused_add_rms_norm. Replacement: reduce_scatter → norm on 1/TP of the rows → all_gather. Wire volume is unchanged — an all-reduce is a reduce-scatter plus an all-gather. The class docstring is unusually candid about why the pass exists anyway:

vllm/compilation/passes/fusion/sequence_parallelism.py:L511-L521 vLLM
    While this pass itself does not directly yield performance improvements,
    it lays the groundwork for subsequent fusion passes, such as
    GEMM + ReduceScatter and AllGather + GEMM fusions. These fusions can
    significantly reduce communication overhead and improve overall model
    performance.

    This pass is only supported when compiling the whole graph (fullgraph
    mode, i.e. using Inductor graph partition or empty splitting_ops).
    Piecewise compilation is not supported because the residual tensor
    gets split across TP ranks, causing size mismatches at subgraph
    boundaries.

This is the chapter's sharpest tradeoff, stated by the code itself. The pass makes the residual stream shorter between the reduce-scatter and the all-gather, and a tensor that changes shape cannot cross a subgraph boundary whose shapes were fixed at split time. So enabling sequence parallelism means giving up FX-level piecewise splitting — either splitting_ops=[] or use_inductor_graph_partition=True, which defers partitioning to Inductor codegen time, after all passes have run (vllm/config/compilation.py:L669-L687). Piecewise splitting and whole-graph rewriting want opposite things; the Inductor-partition mode exists to have both.

Caching, and what invalidates it

Compilation time varies with graph, shapes and cache state. The engine uses several outer hash factors to organize artifacts; this alone is not proof that every dependency is covered:

vllm/compilation/backends.py:L1058-L1070 vLLM
        if not self.compilation_config.cache_dir:
            # no provided cache dir, generate one based on the known factors
            # that affects the compilation. if none of the factors change,
            # the cache dir will be the same so that we can reuse the compiled
            # graph.
            factors = [env_hash, config_hash, code_hash, compiler_hash]
            # Use SHA-256 for cache key hashing to be consistent across
            # compute_hash functions. Truncate for a short cache dir name.
            hash_key = hashlib.sha256(str(factors).encode()).hexdigest()[:10]
            cache_dir = os.path.join(
                envs.VLLM_CACHE_ROOT, "torch_compile_cache", hash_key
            )
            self.compilation_config.cache_dir = cache_dir

Four factors, each worth knowing because each is a different way to lose your warm start:

env_hash

Almost every VLLM_ env var

envs.compile_factors() takes every known vLLM env var and subtracts a small ignore list — ports, credentials, logging, location-only paths. Flip an unrelated VLLM_* knob in a deployment template and you cold-start the fleet.

config_hash

The whole VllmConfig

CompilationConfig.compute_hash is opt-out: every declared field except paths and timing counters, with pass_config and dynamic_shapes_config folded in by their own hashes (vllm/config/compilation.py:L780-L812). TP size, quantisation, or one fusion flag is a miss.

code_hash

The bytes of every traced file

vLLM records the files Dynamo traced through and SHA-256s their contents (vllm/compilation/backends.py:L1039-L1056). Editing a model file — even a comment — invalidates the cache. A feature, and also why the dev loop feels slow.

compiler_hash

PyTorch and Inductor themselves

get_inductor_factors() hashes CacheBase.get_system(), torch_key(), and portable dumps of the Inductor and functorch configs (vllm/compilation/compiler_interface.py:L170-L190). A PyTorch patch bump discards the whole fleet's artefacts.

A cache miss can delay readiness, but rolling deployments do not inherently compile serially. Prewarm compatible artifacts and measure startup coverage. SGLang's outer directory uses a compiler-manager hash; compare complete inner compiler/JIT keys and validation before claiming greater staleness risk from the number of outer factors. Use private, access-controlled cache roots and a toolchain/model/shape manifest for reproducible comparisons.

§5

Worked trace: one Llama forward, cold

Follow the very first LlamaModel.__call__ on a cold cache, naming functions in order.

Figure 4 — the cold-start call path, vLLM. Every hop is a real function at a556f3f. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Hop I→J. PiecewiseCompileInterpreter subclasses torch.fx.Interpreter and overrides call_module. For each submodule in compile_submod_names it constructs a PiecewiseBackend, wraps it, and replaces the submodule in self.module.__dict__ with the wrapper (vllm/compilation/backends.py:L730-L775). What comes out is structurally the split graph with each compilable child swapped for a compiled-and-captured callable. That is the entire mechanism of "piecewise": no special runtime, just a graph whose children happen to be CUDA graph replays.

Hop L. The cache key inside CompilerManager is (runtime_shape, graph_index, backend_name) — a Range, a piece index, and the compiler name (vllm/compilation/backends.py:L124-L138). Compilation is therefore per-piece and per compile-range: compile_ranges_endpoints partitions [1, max_num_batched_tokens] into intervals, each getting its own Inductor artefact (vllm/config/compilation.py:L580-L593). Multiply 33 pieces by the number of ranges and you see where the minutes go.

SGLang's trace has one structural difference worth naming: it compiles the general dynamic-shape graph during tracing, inside call_module, and defers shape-specialised compilation to the first real call at that shape (python/sglang/srt/compilation/backend.py:L304-L343). CUDAPiecewiseBackend.__call__ then compiles and captures lazily per bucket:

python/sglang/srt/compilation/cuda_piecewise_backend.py:L114-L126 SGLang
    def __call__(self, *args) -> Any:
        if not self.first_run_finished:
            self.first_run_finished = True
            self.check_for_ending_compilation()
            return self.compiled_graph_for_general_shape(*args)

        if len(self.sym_shape_indices) == 0:
            return self.compiled_graph_for_general_shape(*args)

        runtime_shape = args[self.sym_shape_indices[0]]
        if runtime_shape not in self.concrete_size_entries:
            # we don't need to do anything for this shape
            return self.compiled_graph_for_general_shape(*args)

vLLM went the other way at this SHA: PiecewiseCompileInterpreter's docstring says it "creates a PiecewiseBackend and compiles all ranges up front", and monitor_profiling_run asserts that num_backend_compilations does not change during the profiling run (vllm/compilation/monitor.py:L61-L82). Front-loading everything makes startup slower and steady-state latency more predictable; SGLang's laziness does the opposite.

§6

Pitfalls and war stories

Graph breaks are errors here, not slowdowns

The folklore — "a graph break in a hot loop silently eats your speedup" — is true of stock torch.compile, not of vLLM, which compiles fullgraph=True so Dynamo raises instead of falling back (docs/design/debug_vllm_compile.md:L138-L147). The silent version still bites in two places: code outside the decorated module (sampler, logits processors, multimodal preprocessing) is never compiled at all, and SGLang's --enable-torch-compile path does not force fullgraph.

Dropped guards turn a recompile into a wrong answer

Because vLLM installs skip_all_guards_unsafe, a shape-dependent branch does not cause a recompilation storm — it makes the branch traced at compile time run for every batch size forever. docs/design/debug_vllm_compile.md:L154-L176 gives the canonical shape: if data.size[0] % 128 == 0:. Diagnose with TORCH_TRACE=~/trace_dir plus tlparse and read compilation_metrics; any symbolic constraint on the batch dimension is a bug. Fix by moving the branch into a custom operator. To make the failure loud instead, use the compatible evaluate-guards diagnostic path. The shown wrapper requires VLLM_USE_BYTECODE_HOOK=0 and a shape mode other than UNBACKED, in addition to -cc.dynamic_shapes_config.evaluate_guards=True. Shape guards are then retained; verify the supported configuration rather than assuming that flag alone enables checks.

A pass that needs the whole graph will assert, loudly

SequenceParallelismPass.is_applicable_for_range opens with assert self.compilation_config.use_inductor_graph_partition or not self.compilation_config.splitting_ops, "SequenceParallelismPass requires full-graph compilation" (vllm/compilation/passes/fusion/sequence_parallelism.py:L592-L612). If you enable SP on a default -O2 config with the standard splitting ops, that is what you will see. The fix is -cc.use_inductor_graph_partition=True, not -cc.pass_config.enable_sp=False.

Cache misses look like a hung server

A cold cache produces no output between "Dynamo bytecode transform time" and "torch.compile took N s in total". For a wrong-cache bug rather than a slow one, the documented escape is VLLM_DISABLE_COMPILE_CACHE=1 plus rm -rf ~/.cache/vllm and rm -rf /tmp/torchinductor_$(whoami) (docs/design/debug_vllm_compile.md:L303-L331). The docs are blunt that the layer "is unfortunately not always correct" when a hash factor is missing: if flipping a flag changes nothing, suspect a stale artefact before you suspect the flag.

Compiled kernels do not expose the original Python frames, but FX/generated code, compiler dumps and backend bisection remain useful. Compare eager, tracing-only backend, AOT processing and Inductor to localize the first divergent tensor. Fusion may change floating-point bits, and sometimes tokens, but it does not guarantee token differences. Test numerical tolerances separately from deterministic token equality and sampling randomness.

Ahead-of-time kernel compilation is a different animal

SGLang ships one more thing called compilation that is not torch.compile. python/sglang/compile_deep_gemm.py launches a real server with SGLANG_IN_DEEPGEMM_PRECOMPILE_STAGE and SGLANG_ENABLE_JIT_DEEPGEMM set, sends one four-token request through it, waits, and exits (python/sglang/compile_deep_gemm.py:L31-L37, L58-L90). DeepGEMM JIT-compiles a kernel per problem shape on first use, so on a live server the first request at each new shape pays a compile. Prewarming covers only specializations exercised by the script or its explicit shape sweep. A four-token request alone proves no coverage of all future model/dtype/backend shapes; log later JIT misses and maintain a prewarm manifest.

An opaque operator inside a full FX graph

This CPU example registers a functional operator, supplies fake-tensor shape behavior, checks its schema, and captures it as an opaque node in a full graph. The inspection backend executes that FX graph without claiming Inductor fusion or speedup. A mutating production operator must additionally declare every mutated argument and compatible aliases; hidden cache writes cannot be omitted. Shape-dependent branching and fake stride errors deserve negative tests before removing guards. See PyTorch's custom-operator contracts.

Independent CPU reference; not an engine or GPU benchmark
import torch

torch.set_num_threads(1)
torch.manual_seed(2)
@torch.library.custom_op("curriculum_runtime::scaled_sine", mutates_args=())
def scaled_sine(x: torch.Tensor) -> torch.Tensor:
    return 2 * torch.sin(x)

@scaled_sine.register_fake
def _(x):
    return torch.empty_like(x)

x = torch.randn(3, 5)
torch.library.opcheck(scaled_sine, (x,), test_utils=("test_schema", "test_faketensor"))
seen = []
def inspect_backend(graph, inputs):
    seen.extend(str(node.target) for node in graph.graph.nodes if node.op == "call_function")
    return graph.forward

def model(value):
    return scaled_sine(value) + 1

compiled = torch.compile(model, backend=inspect_backend, fullgraph=True)
torch.testing.assert_close(compiled(x), model(x))
assert any("curriculum_runtime.scaled_sine" in target for target in seen)
print("Opaque custom op remains an FX node under fullgraph=True.")
§7

Hands-on

Everything below is CPU-cheap except the server launches, which need a GPU. Numbers you get are yours, not the book's.

measure the compile cost and see the pieces shell
# 1. Cold compile. Watch for "Dynamo bytecode transform time" and
#    "torch.compile took N s in total".
export VLLM_CACHE_ROOT="$(mktemp -d)"  # private cold trial; retain it for warm restart
vllm serve meta-llama/Meta-Llama-3-8B-Instruct 2>&1 | grep -E "Dynamo|torch.compile took|cache directory"

# 2. Stop the first server, then restart with the same private cache and configuration.

# 3. Read the split graph vLLM wrote for you. This file is the answer to
#    "how many pieces did it make", printed as Python.
find "$VLLM_CACHE_ROOT" -name computation_graph.py  # inspect call_module nodes, not grep line counts

# 4. Turn compilation off, keep CUDA graphs, and compare.
vllm serve meta-llama/Meta-Llama-3-8B-Instruct -cc.mode=0

# 5. Turn both off.
vllm serve meta-llama/Meta-Llama-3-8B-Instruct --enforce-eager

# 6. Full torch.compile logs for a real diagnosis.
pip install tlparse
TORCH_TRACE=~/trace_dir vllm serve meta-llama/Meta-Llama-3-8B-Instruct
tlparse ~/trace_dir/<rank_0_log_file>

On the SGLang side the comparison worth running is the one the defaults do not give you:

turn Inductor on in SGLang shell
# Default on CUDA: prefill uses the breakable backend, no Dynamo at all.
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct

# Piecewise compile the prefill phase with Inductor.
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --cuda-graph-backend-prefill tc_piecewise --cuda-graph-tc-compiler inductor

# The other, unrelated path: whole-forward compile inside decode capture,
# bounded at batch 32 by default.
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --enable-torch-compile --torch-compile-max-bs 32
§8

Exercises

  1. Count the pieces. Read vllm/compilation/backends.py:L553-L627 and vllm/config/compilation.py:L1134-L1185. For Qwen3-32B (L=64, standard attention, no Mamba layers), how many entries end up in piecewise_graphs, and how many of those are passed to PiecewiseCompileInterpreter as compilable? Show your reasoning about the consecutive-splitting-op merge.
  2. Predict, then verify. You set -cc.splitting_ops='[]' with -cc.cudagraph_mode=PIECEWISE. Predict the log line and the resulting cudagraph mode, then check against vllm/config/compilation.py:L1187-L1210.
  3. Break the cache on purpose. Using vllm/compilation/backends.py:L1030-L1073 and vllm/config/compilation.py:L780-L812, name three single-character edits to a deployment — one to an env var, one to a CLI flag, one to a Python file — that each force a full recompile, and one plausible-looking change that does not.
  4. Price a different fusion. Redo §3's table for SiluAndMul.forward_native (vllm/model_executor/layers/activation.py:L133-L137) on Llama-3-8B's MLP, $d_{\text{ff}} = 14336$, bf16. How many bytes per token does fusion save, and how does the answer compare to the 13.63 MB from the norms? Which of the two would you fuse first if you could only have one?
  5. Argue the other side. -O2's pass config sets "enable_sp": IS_DENSE, and IS_DENSE is the module-level constant False — not a predicate on the model (vllm/config/vllm.py:L147-L154, whose own comment says these are "currently set to False in all cases", referencing vLLM issue 25689). So SequenceParallelismPass is off at -O2 for every model, dense or MoE, and the commented-out predicate shows where it would come back. Sketch what would have to be true of an MoE forward pass for SP to be safe once that predicate is restored, and say which piece of the piecewise machinery would have to change.
Answer — 1

64 attention layers, each emitting vllm::unified_kv_cache_update immediately followed by vllm::unified_attention_with_output. Both are in the default splitting_ops, and the "keep consecutive splitting ops together" branch merges the adjacent pair into one splitting subgraph. So 64 splitting subgraphs, 65 compilable ones, 129 SplitItems. submod_names_to_compile filters on not item.is_splitting_graph, so num_piecewise_capturable_graphs_seen reaches 65 and num_piecewise_graphs_seen 129. If the backend sets forward_includes_kv_cache_update the update op is never emitted and the counts are unchanged — the merge had already collapsed it.

Answer — 2

Two warnings fire: "Using piecewise cudagraph with empty splitting_ops", then, because the mode is exactly PIECEWISE, a second saying such a configuration contains no piecewise cudagraph — and cudagraph_mode is set to CUDAGraphMode.NONE, with a hint to ask for FULL or FULL_DECODE_ONLY. You get a compiled model and no CUDA graphs, the worst combination for batch-1 decode. Had you asked for FULL_AND_PIECEWISE, the same branch would downgrade you to FULL, which is usually what you wanted.

Answer — 3

Env: VLLM_USE_AOT_COMPILE=1, or almost any VLLM_* outside envs.compile_factors()'s ignore set, changes env_hash. Flag: -cc.pass_config.fuse_act_quant=False changes pass_config.compute_hash() and so config_hash. File: a blank line in vllm/model_executor/models/llama.py changes code_hash, since vLLM hashes the full contents of every traced file. What does not invalidate: moving VLLM_CACHE_ROOT — explicitly ignored, with the comment that hashing location-only paths would let relocating HOME silently discard every cache.

Answer — 4

F.silu(x[..., :d]) * x[..., d:] with $d = 14336$, bf16 (28,672 B per half-row). Eager: silu reads 28,672 and writes 28,672; the multiply reads 57,344 and writes 28,672. Total 143,360 B. Fused: read both halves (57,344) and write one (28,672) = 86,016 B. Saving 57,344 B per call, one call per layer, 32 layers → 1.84 MB per token. That is 7.4× less than the norms' 13.63 MB, because the norm decomposition materialises eight fp32 intermediates while SiLU materialises one bf16 one. Fuse the norms first. The general lesson: dtype promotion inside a reference implementation is where the intermediate traffic hides.

Answer — 5

SP shortens residual rows between collectives. Whether MoE routing is compatible depends on the specific dispatch/permutation contract: routing can operate on local rows with suitable all-to-all metadata. Do not infer a universal incompatibility from one pass guard. Inspect the selected model forward, communication layout and engine pass. The cited FX splitting restrictions remain implementation-specific. What changes is the pattern set: the current patterns match all_reduce → fused_add_rms_norm, and an MoE block interposes the a2a between them.

§9

Key takeaways

  • The unfused reference has about 6.2 times the logical payload of a fused norm. This does not predict compile-versus-production speedup because the eager production path can already use a fused norm and intermediate traffic need not reach HBM.
  • Opaque custom operators can remain in compiled graphs. Opacity to fusion, a chosen partition boundary, and a Dynamo graph break are three distinct concepts.
  • Piecewise splitting and whole-graph rewriting are mutually exclusive at the FX level. SequenceParallelismPass changes the residual's row count, which cannot cross a fixed subgraph boundary, so vLLM needs use_inductor_graph_partition=True — partitioning after codegen — to run both.
  • Engine trace-once paths deliberately filter guards, with stock/debug exceptions. Fullgraph rejects Dynamo graph breaks; backend artifacts can still specialize by piece and shape range. Validate every removed guard's assumption.
  • The compile cache key is four hashes — env vars, the full config, the bytes of every traced source file, and the PyTorch/Inductor build. Warm starts survive a restart; they do not survive a torch upgrade, a flag change, or a source edit, which is what makes autoscaling on a cold cache an availability problem rather than a latency one.
  • At these SHAs the engines have made opposite default choices: vLLM ships -O2/VLLM_COMPILE with Inductor on for everyone, while SGLang's CUDA default is the breakable backend with no Dynamo, and its piecewise path defaults tc_compiler="eager" and covers prefill only.
§10

Further reading

  • vLLM design doc: torch_compile.md — the maintainers' own overview of the integration, at the pinned SHA.
  • vLLM design doc: debug_vllm_compile.md — the tlparse workflow, the dynamic-shape failure modes, and the cache escape hatches quoted in §6. Read this before your first compile bug, not after.
  • vLLM PR #20059 — why full CUDA graphs are captured outside a piecewise FX graph rather than by flattening it, cited in the set_splitting_ops_for_v1 comment.
  • vLLM issue #33267unified_kv_cache_update's string parameter blocking Inductor from reusing piecewise graphs, the reason it is appended to splitting_ops.
  • vLLM issue #25689 — why IS_QUANTIZED and IS_DENSE are hardcoded False in the optimization-level tables, disabling SP and attention-quant fusion by default.
  • vLLM issue #25094 — the blocked-weights case that forces +quant_fp8 back on even under Inductor; a good example of a custom op that Inductor cannot replace.
  • tlparse — the log renderer that turns TORCH_TRACE output into browsable compilation metrics, guards, and generated Triton.
  • PyTorch: Dynamo core concepts — the reference the vLLM debug doc points you at when you hit a graph break.
  • PyTorch: torch.compile caching — the layer underneath vLLM's cache; worth knowing which of the two you are fighting.

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