vLLM Explained

The forward context: how layers find per-step data

AdvancedvLLM conceptsabout 10 min read + 3 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

An attention layer gets no KV cache or metadata arguments. It looks them up by its own name in a context that the runner sets.

The idea#

A model file calls self.attn(q, k, v) and nothing else (How a vLLM model file is written (llama.py)). But attention needs more: the KV cache tensor of this layer, where to write the new keys and values, and the shape of the batch. vLLM passes these through two dictionaries keyed by the layer name, the prefix (of a layer) such as model.layers.0.self_attn.attn:

The attention custom op gets the tensors q, k, v and output, plus the layer name. Inside, it looks up the layer, the KV cache, the metadata and the slot mapping. Why so indirect? For torch.compile, the op is one opaque node with plain inputs (PyTorch for serving: custom ops and torch.compile). The graph does not depend on Python objects that change every step, so vLLM can compile it once and split it at these ops.

An everyday comparison. Think of a theater with a prop table. Each actor has a spot on the table, labeled with the actor's name. Before each show, the stage manager puts that night's props on each labeled spot. During the show, an actor walks to the table and takes the props under the label. The script never lists the props. The comparison stops here: in vLLM, the label also leads to the actor's own permanent locker, the KV cache, which nobody refills between shows.

The picture#

Figure: the two dictionaries behind the attention call. One is filled at start-up (panel 1), one is set around each forward pass (panel 2), and the attention ops read both by layer name (panel 3).
Open full size ↗

Follow the name model.layers.0.self_attn.attn through the three panels. It is a key in every dictionary. The violet parts happen once at start-up; the blue parts happen in every step.

In the code#

Step 1: the layer registers itself by name#

        # For cuda-alike (CUDA and ROCM) and cpu platforms, we control how
        # torch.compile works by registering the attention as one giant
        # opaque custom op. For other platforms, we directly call them
        # and let torch.compile handle them.
        self.use_direct_call = not current_platform.opaque_attention_op()

        compilation_config = vllm_config.compilation_config
        if prefix in compilation_config.static_forward_context:
            raise ValueError(f"Duplicate layer name: {prefix}")
        compilation_config.static_forward_context[prefix] = self
  1. Line 440. use_direct_call is false on platforms that want "one giant opaque custom op" for attention. CUDA, ROCm, CPU and XPU return true from opaque_attention_op() (vllm/platforms/cuda.py:595-596, vllm/platforms/rocm.py:1231-1232, vllm/platforms/cpu.py:640-641, vllm/platforms/xpu.py:609-610); the base Platform returns false (vllm/platforms/interface.py:1286-1290).
  2. Lines 443-445. The prefix is the key. A second layer with the same prefix raises ValueError: Duplicate layer name.

The dict is a field of the compilation config. Its docstring says: "Map from layer name to layer objects that need to be accessed outside model code" (vllm/config/compilation.py:757-760). Attention is not the only user. MLA attention (vllm/model_executor/layers/attention/mla_attention.py:620), the MoE runner (vllm/model_executor/layers/fused_moe/runner/moe_runner.py:68), Mamba layers (vllm/model_executor/layers/mamba/mamba_mixer2.py:528) and LoRA linear layers (vllm/lora/layers/base_linear.py:102) also register.

The runner reads this dict to find all attention layers. get_layers_from_vllm_config filters it by type (vllm/config/vllm.py:3596-3617), and the KV cache spec of the model comes from these layers (vllm/v1/worker/gpu/attn_utils.py:138-155).

Step 2: the runner binds the KV cache tensor#

When the KV cache is allocated, init_kv_cache gets compilation_config.static_forward_context as its first argument (vllm/v1/worker/gpu/model_runner.py:794-797). It allocates one backing buffer and makes a view per layer, then binds each view to its layer:

def bind_kv_cache_to_layers(
    kv_caches: dict[str, torch.Tensor],
    forward_context: dict[str, Attention],
    num_attn_module: int = 1,
    kv_cache_groups: Sequence[KVCacheGroupSpec] | None = None,
) -> None:
    """Bind layer caches and share ReplaySSM trackers in model-layer order."""
    # Bind kv_caches to forward context. Each layer's bind_kv_cache unpacks
    # its raw allocation into the per-layer view(s) it needs (e.g. Mamba
    # splits conv/ssm), so the kv_caches dict can hold a single tensor per
    # layer for the KV connector to register.
    for layer_name, kv_cache in kv_caches.items():
        forward_context[layer_name].bind_kv_cache(kv_cache)

The default bind_kv_cache just stores the view: self.kv_cache = kv_cache (vllm/model_executor/layers/attention_layer_base.py:27-33). Until then, the layer holds an empty placeholder tensor (vllm/model_executor/layers/attention/attention.py:461-464). The binding happens once; the tensor stays the same for the whole life of the server. How the views are laid out is on Attention backends: backend, metadata builder and implementation.

Step 3: the runner sets the forward context each step#

For PIECEWISE and eager steps, Model Runner V2 wraps the model call:

            # For piecewise and eager mode, just call model().
            batch_descriptor = BatchDescriptor(
                num_tokens=input_batch.num_tokens_after_padding,
                has_lora=self.lora_config is not None,
                num_active_loras=batch_desc.num_active_loras,
            )

            with set_forward_context(
                attn_metadata,
                self.vllm_config,
                num_tokens=input_batch.num_tokens_after_padding,
                cudagraph_runtime_mode=batch_desc.cg_mode,
                num_tokens_across_dp=dp_sync_state
                and dp_sync_state.num_tokens_across_dp,
                batch_descriptor=batch_descriptor,
                ubatch_slices=ubatch_slices,
                slot_mapping=slot_mappings_by_layer,
                skip_compiled=skip_compiled,
                is_padding=input_batch.is_padding,
            ):

set_forward_context builds a ForwardContext and installs it in a module-level variable for the duration of the with block:

@dataclass
class ForwardContext:
    # copy from vllm_config.compilation_config.static_forward_context
    no_compile_layers: dict[str, Any]
    attn_metadata: dict[str, AttentionMetadata] | list[dict[str, AttentionMetadata]]
    slot_mapping: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]]
    """
    Type Dict[str, AttentionMetadata] for v1, map from layer_name of each
    attention layer to its attention metadata
    Type List[Dict[str, AttentionMetadata]] for DBO. List of size two, one
    for each microbatch.
    Set dynamically for each forward pass
    """
    # set dynamically for each forward pass
    dp_metadata: DPMetadata | None = None
    # determine the cudagraph style at runtime to be FULL, PIECEWISE, or NONE.
    # by default NONE, no cudagraph is used.
    cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE
    batch_descriptor: BatchDescriptor | None = None

The context manager restores the previous context on exit (vllm/forward_context.py:262-274). Any code inside the forward pass reads it with get_forward_context() (vllm/forward_context.py:208-214).

Step 4: the attention layer calls a custom op with its name#

        else:
            # Skip this if sharing KV cache with an earlier attention layer.
            encoded = _encode_layer_name(self.layer_name)
            if (
                not self.attn_backend.forward_includes_kv_cache_update
                and self.kv_sharing_target_layer_name is None
                and key is not None
                and value is not None
            ):
                kv_cache_dummy_dep = torch.ops.vllm.unified_kv_cache_update(
                    key, value, encoded
                )
            torch.ops.vllm.unified_attention_with_output(
                query,
                key,
                value,
                output,
                encoded,
                kv_cache_dummy_dep=kv_cache_dummy_dep,
            )
        return output.view(-1, hidden_size)
  1. Line 563. The name is wrapped by _encode_layer_name. On PyTorch 2.11 or newer with VLLM_USE_LAYERNAME=1 (the default), it becomes a LayerName object; else it stays a str (vllm/utils/torch_utils.py:990-995, vllm/utils/torch_utils.py:1045-1047).
  2. Lines 564-572. Only when the backend does not write the KV cache inside its own forward, a first op writes k and v into the cache. It returns an empty tensor, the "dummy dependency".
  3. Lines 573-580. The attention op takes the dummy as an argument, so the compiler cannot move it before the cache write (vllm/model_executor/layers/attention/attention.py:779-782). It returns nothing and writes into output.

At this commit, the FlashAttention, FlashInfer and Triton backends set forward_includes_kv_cache_update = False, so they use the separate update op (vllm/v1/attention/backends/flash_attn.py:367, vllm/v1/attention/backends/flashinfer.py:708, vllm/v1/attention/backends/triton_attn.py:347). The base class default is True (vllm/v1/attention/backend.py:69-70).

Step 5: get_attention_context resolves the name#

    forward_context: ForwardContext = get_forward_context()
    attn_metadata_raw = forward_context.attn_metadata
    attn_metadata: AttentionMetadata
    if isinstance(attn_metadata_raw, dict):
        attn_metadata = attn_metadata_raw[layer_name]
    elif isinstance(attn_metadata_raw, list):
        # list[dict[str, AttentionMetadata]]: used in speculative decoding
        # where [0] is the base-model (non-speculative) metadata dict.
        attn_metadata = attn_metadata_raw[0][layer_name]
    else:
        attn_metadata = attn_metadata_raw
    attn_layer: Attention | MLAAttention = forward_context.no_compile_layers[layer_name]
    kv_cache = attn_layer.kv_cache
    slot_mapping = forward_context.slot_mapping
    assert isinstance(slot_mapping, dict), (
        f"Expected slot_mapping to be a dict, got {type(slot_mapping)}. "
    )
    layer_slot_mapping = slot_mapping.get(layer_name)
    return attn_metadata, attn_layer, kv_cache, layer_slot_mapping
  1. Line 709. The normal case: attn_metadata is a dict, and the layer's entry is taken.
  2. Line 713. A list of dicts: the comment says index 0 holds the base model's metadata in speculative decoding.
  3. Lines 716-717. The layer object comes from no_compile_layers, and the KV cache from the attribute bound in Step 2.
  4. Line 722. The slot mapping may be None; then unified_kv_cache_update writes nothing (vllm/model_executor/layers/attention/attention.py:736).

Then the two ops call the backend: impl.do_kv_cache_update(...) (vllm/model_executor/layers/attention/attention.py:740-746) and impl.forward(self, query, key, value, kv_cache, attn_metadata, output=...) (vllm/model_executor/layers/attention/attention.py:786-796). The backend classes are on Attention backends: backend, metadata builder and implementation.

Step 6: why a name and not the tensors#

The two ops are registered once, at import, in the vllm library (vllm/model_executor/layers/attention/attention.py:759-764, vllm/model_executor/layers/attention/attention.py:799-803). For the compiler, three facts follow:

  • Opaque. Dynamo records one node per call and does not trace into the backend code. The node has only tensors and a name as inputs.
  • A splitting point. The op names are in the default splitting ops, so vLLM cuts the graph here. Attention then runs eagerly between piecewise CUDA graphs and can use any batch shape (vllm/config/compilation.py:768-770, vllm/config/compilation.py:1184).
  • One graph for 32 layers. A str argument is a constant in the graph. The LayerName class is an opaque type. torch.compile "lifts [it] as a graph input instead of baking it as a constant", which avoids "per-layer recompilation" (vllm/utils/torch_utils.py:1003-1010).

The code also says that the string in unified_kv_cache_update "prevents Inductor from reusing piecewise graphs"; this is why that op is a splitting op too (vllm/config/compilation.py:1186-1213).

Step 7: FULL CUDA graph steps do not read the context#

In FULL mode the runner replays one graph and does not call set_forward_context (vllm/v1/worker/gpu/model_runner.py:2024-2032). No Python code of the model runs, so no layer looks anything up. The comment says the inputs "are already copied to the CUDA graph input buffers" (lines 2026-2027). The context was used at capture time: the capture runs the model inside set_forward_context, with the dummy metadata of that batch shape (vllm/v1/worker/gpu/cudagraph_utils.py:754-771).

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

Worked example: Llama-3-8B, one step

Llama-3-8B has 32 decoder layers. So the static dict has 32 attention entries, from model.layers.0.self_attn.attn to model.layers.31.self_attn.attn (prefix rules on How a vLLM model file is written (llama.py)). All 32 layers use full attention and one backend, so there is one KV cache group and one attention group (derived).

  1. Metadata dict. build_attn_metadata builds one metadata object for the group and stores the same object under all 32 names (vllm/v1/worker/gpu/attn_utils.py:574-580).
  2. Slot mapping dict. build_slot_mappings_by_layer stores the same row tensor of group 0 under all 32 names (vllm/v1/worker/gpu/attn_utils.py:447-455).
  3. Lookups. Each layer does three dict lookups and one attribute read per op call. With two ops per layer, a step does 64 calls of get_attention_context (derived, eager or PIECEWISE mode).

A model with several KV cache groups, for example sliding-window and full layers, gets one metadata object per group. Each layer name points to the object of its group (The hybrid KV cache: specs, groups and one manager per layer type).

Deep dive

All fields of ForwardContext

FieldTypeSet by
no_compile_layersdict[str, Any]the static dict (vllm/forward_context.py:142-143)
attn_metadatadict by layer name, or a list of two dicts for dual-batch overlapthe runner (vllm/forward_context.py:144-152)
slot_mappingdict by layer name (or list)the runner; an empty dict if not given (vllm/forward_context.py:251)
dp_metadataDPMetadata or Noneonly for MoE models with DP > 1 or sequence-parallel MoE (vllm/forward_context.py:299-328)
cudagraph_runtime_modeNONE, PIECEWISE or FULLthe runner; checked in __post_init__ (vllm/forward_context.py:199-202)
batch_descriptorBatchDescriptorthe runner, or made from num_tokens (vllm/forward_context.py:331-332)
ubatch_slicesmicro-batch slices or Nonedual-batch overlap only
is_paddingbool tensor [num_tokens_padded]the runner (vllm/forward_context.py:162-165)
skip_compiledbooltrue when the compiled model must be bypassed, for example an encoder-decoder step with encoder inputs (vllm/forward_context.py:167-168)
all_moe_layers, moe_layer_indexlist of names, counteronly with fast_moe_cold_start (vllm/forward_context.py:242-245)
additional_kwargsdictthe platform hook set_additional_forward_context (vllm/forward_context.py:334-343)

The comment on all_moe_layers explains a second way around strings in the graph. MoE custom ops pop the next layer name from this list instead of taking it as a constant (vllm/forward_context.py:170-195). On PyTorch 2.11 or newer, vLLM turns fast_moe_cold_start off, because the LayerName approach replaces it (vllm/config/vllm.py:1913-1918).

Deep dive

set_forward_context, step by step

  1. If batch-size logging is on and attn_metadata is not None, take the start time (vllm/forward_context.py:295-297).
  2. For an MoE model with DP > 1 (or sequence-parallel MoE), make DPMetadata. If the caller gave no token counts across ranks, they are computed with a collective here, and padding across ranks is off (vllm/forward_context.py:308-322).
  3. If num_tokens is given and no descriptor, make BatchDescriptor(num_tokens=num_tokens) (lines 331-332).
  4. Ask the platform for extra fields (lines 334-343).
  5. Build the context and install it with override_forward_context (lines 345-360).
  6. On exit, in the same case, synchronize the device and record the forward time per batch size. Every VLLM_LOG_BATCHSIZE_INTERVAL seconds, log the median time per size (vllm/forward_context.py:362-392).

VLLM_LOG_BATCHSIZE_INTERVAL defaults to -1, which turns the logging off (vllm/envs.py:1445-1447). The module reads it once, at import (vllm/forward_context.py:22-25).

Deep dive

KV sharing and the KV connector hooks

KV sharing. A layer built with kv_sharing_target_layer_name reuses the cache of an earlier layer. The constructor checks the target in the static dict: it must exist, come before this layer and have the same attention type (vllm/model_executor/layers/attention/attention.py:61-91). Such a layer skips the cache write (line 566), and its KV cache entry points to the target's tensor (vllm/v1/worker/gpu/attn_utils.py:427-428).

KV connector. unified_attention_with_output has the decorator @maybe_transfer_kv_layer. With a KV connector, it waits for the layer's KV to load before the attention and saves it after (vllm/model_executor/layers/attention/kv_transfer_utils.py:15-55). It finds the layer data through the same get_attention_context. See KV connectors.

What can go wrong#

Why it matters. The forward context is the contract between the model runner, the model code and the attention backends. A model file stays free of serving details, the compiler sees a clean graph, and a new backend only needs to read its metadata. When you write a model or a backend plugin, keep the layer name unique, and read per-step data from the context, never from arguments.

Check yourself#

  1. 1
    Which key does the first Llama attention layer use in static_forward_context?
    easy
    Show answer

    Its prefix, model.layers.0.self_attn.attn. The constructor stores static_forward_context[prefix] = self (vllm/model_executor/layers/attention/attention.py:445).

  2. 2
    Where does unified_attention_with_output get the KV cache tensor from?
    medium
    Show answer

    From get_attention_context: it takes the layer object from forward_context.no_compile_layers[layer_name] and reads layer.kv_cache (vllm/model_executor/layers/attention/attention.py:716-717). The runner set that attribute once, in bind_kv_cache_to_layers (vllm/v1/worker/utils.py:879-880).

  3. 3
    Why does unified_kv_cache_update return an empty tensor?
    medium
    Show answer

    As a data dependency. The attention op takes it as kv_cache_dummy_dep, so torch.compile keeps the cache write before the attention (vllm/model_executor/layers/attention/attention.py:731-733, vllm/model_executor/layers/attention/attention.py:779-782).

  4. 4
    In a FULL CUDA graph step, how many times is get_attention_context called in Python, and why?
    hard
    Show answer

    Zero. The runner replays the captured graph without set_forward_context and without Python model code (vllm/v1/worker/gpu/model_runner.py:2024-2032). The lookups ran once, at capture time, inside set_forward_context (vllm/v1/worker/gpu/cudagraph_utils.py:754-771).

  5. 5
    On PyTorch 2.14, how does the layer name reach the attention op, and what would go wrong with a plain string?
    hard
    Show answer

    As a LayerName object, because PyTorch is 2.11 or newer and VLLM_USE_LAYERNAME defaults to 1 (vllm/utils/torch_utils.py:990-995, vllm/envs.py:1451). It is a hoisted opaque type that becomes a graph input. A plain string is baked in as a constant, which the code says causes per-layer recompilation (vllm/utils/torch_utils.py:1003-1010).