vLLM Explained

"model_executor/layers/attention/attention.py: the Attention layer and its custom ops"

AdvancedCode mapabout 6 min read + 2 min deep dives

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

Purpose#

vllm/model_executor/layers/attention/attention.py is the bridge between a model file and an attention backend: the model calls Attention, and this file finds the per-step data and calls the backend's impl.

Filevllm/model_executor/layers/attention/attention.py, 803 lines.
Main classAttention(nn.Module, AttentionLayerBase) (vllm/model_executor/layers/attention/attention.py:229-239).
Runs inEach worker. The constructor runs once at model build; forward and the ops run in every eager or PIECEWISE step, between the compiled pieces.
Called byModel files, for example LlamaAttention (vllm/model_executor/models/llama.py:212-222); subclasses such as EncoderOnlyAttention; Model Runner V2 for the KV cache spec through get_kv_cache_spec (vllm/v1/worker/gpu/attn_utils.py:138-155).
CallsThe selector get_attn_backend, the backend's impl class, the quantization config, and the forward context.
Not in this fileMLA attention (mla_attention.py), backend classes ("v1/attention/backend.py and selector.py: the backend interfaces and the selector"), kernels.

Where it sits#

Figure: the Attention layer makes the impl (panel 1) and calls it through the custom ops (panel 3).
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLinesRole
validate_kv_sharing_targetvllm/model_executor/layers/attention/attention.py:61-91Check a KV sharing target: earlier layer, same type.
should_load_quant_weights, set_default_quant_scales, _init_kv_cache_quantvllm/model_executor/layers/attention/attention.py:94-226KV cache scales q/k/v/prob; FP8 checkpoint scales.
_largest_kernel_block_withinvllm/model_executor/layers/attention/attention.py:101-130Block size for sliding-window layers that share a padded page.
Attention.__init__vllm/model_executor/layers/attention/attention.py:241-488KV dtype, backend, impl, registration, quantization.
Attention.forwardvllm/model_executor/layers/attention/attention.py:490-581Reshape q/k/v, allocate output, call the two ops.
Attention.get_kv_cache_specvllm/model_executor/layers/attention/attention.py:608-679FullAttentionSpec, SlidingWindowSpec or None for encoder attention.
get_attention_contextvllm/model_executor/layers/attention/attention.py:682-723Layer name → metadata, layer, KV cache, slot mapping.
unified_kv_cache_update (+ fake, registration)vllm/model_executor/layers/attention/attention.py:726-764Write k/v into the cache; return a dummy for ordering.
unified_attention_with_output (+ registration)vllm/model_executor/layers/attention/attention.py:767-803Call impl.forward with the cache and metadata.

Walk through the main methods#

Attention.__init__: data type and backend#

        vllm_config = get_current_vllm_config()
        if cache_config is not None:
            kv_cache_dtype = cache_config.cache_dtype
        else:
            kv_cache_dtype = "auto"

        # llm-compressor models declare an FP8 KV-cache scheme in their
        # checkpoint config. Honor it only when the user did not explicitly
        # pick a kv_cache_dtype; an explicit choice (e.g. bfloat16) must win.
        # The "auto" case is normally resolved upstream in
        # resolve_kv_cache_dtype_string, but we re-apply here defensively in
        # case anything bypassed that path.
        kv_cache_scheme = getattr(quant_config, "kv_cache_scheme", None)
        if kv_cache_scheme is not None and kv_cache_dtype == "auto":
            kv_cache_dtype = "fp8"
            if cache_config is not None:
                cache_config.cache_dtype = "fp8"

        # Check if per-head quant scales are required based on kv_cache_scheme
        use_per_head_quant_scales = (
            kv_cache_scheme is not None
            and kv_cache_scheme.get("strategy") == "attn_head"
        )
  1. Lines 276-279. The KV cache dtype comes from --kv-cache-dtype, else "auto".
  2. Lines 287-291. A checkpoint with an FP8 kv_cache_scheme turns "auto" into "fp8". The comment says this case "is normally resolved upstream".
  3. Lines 294-297. A scheme with strategy == "attn_head" needs per-head scales; the selector checks backend support for it.

Then the constructor stores the head counts and asserts that num_heads divides by num_kv_heads (vllm/model_executor/layers/attention/attention.py:327-338). It selects the backend unless the caller passed one (lines 349-362). It rejects options the backend cannot do, for example use_alibi_sqrt (vllm/model_executor/layers/attention/attention.py:363-370).

Attention.__init__: impl, registration, placeholder#

        impl_cls = self.attn_backend.get_impl_cls()
        self.impl = impl_cls(  # type: ignore[assignment]  # impl_cls always returns an AttentionImpl subclass
            num_heads,
            head_size,
            scale,
            num_kv_heads,
            alibi_slopes,
            sliding_window,
            kv_cache_dtype,
            logits_soft_cap,
            attn_type,
            kv_sharing_target_layer_name,
            **extra_impl_args,
        )
        self.backend = AttentionBackendEnum[self.attn_backend.get_name()]
        self.dtype = dtype

        # 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
        self.attn_type = attn_type

        if kv_sharing_target_layer_name is not None:
            validate_kv_sharing_target(
                prefix,
                kv_sharing_target_layer_name,
                compilation_config.static_forward_context,
            )
        self.kv_sharing_target_layer_name = kv_sharing_target_layer_name
        # Set by models that write KV before calling attention with key=None.
        self.kv_cache_update_bypass = False
        # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window
        # (read by the Triton backend impl). Default False for all other models.
        self.mm_prefix_clamp_sliding_window = mm_prefix_clamp_sliding_window

        # use a placeholder kv cache tensor during init, which will be replaced
        # by bind_kv_cache
        # this variable will not be accessed if use_direct_call is True
        self.kv_cache = torch.tensor([])

        # Initialize KV cache quantization attributes
        _init_kv_cache_quant(self, quant_config, prefix)
  1. Lines 419-432. The impl gets ten positional arguments plus backend extras.
  2. Line 440. use_direct_call is false on CUDA, ROCm, CPU and XPU, which want the opaque op.
  3. Lines 443-445. Register in static_forward_context by prefix; a duplicate raises.
  4. Lines 448-453. Validate a KV sharing target.
  5. Line 464. An empty placeholder until the runner binds the real cache view. The comment says the attribute is not read with direct calls; in this file the direct path also reads it, through get_attention_context (lines 553, 716-717).
  6. Line 467. KV cache quantization scales.

Attention.forward#

        if output_shape is None:
            # Handle both 2D [num_tokens, hidden] and
            # 3D [num_tokens, heads, head_dim] query
            num_tokens = query.shape[0]
            output_shape = torch.Size((num_tokens, self.num_heads * self.head_size_v))
        output = torch.empty(output_shape, dtype=output_dtype, device=query.device)
        hidden_size = output_shape[-1]
        # Reshape the query, key, and value tensors.
        # NOTE(woosuk): We do this outside the custom op to minimize the
        # CPU overheads from the non-CUDA-graph regions.
        query = query.view(-1, self.num_heads, self.head_size)
        output = output.view(-1, self.num_heads, self.head_size_v)
        if key is not None:
            key = key.view(-1, self.num_kv_heads, self.head_size)
        if value is not None:
            value = value.view(-1, self.num_kv_heads, self.head_size_v)
        kv_cache_dummy_dep = None
        if self.use_direct_call:
            # Skip this if sharing KV cache with an earlier attention layer.
            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 = unified_kv_cache_update(
                    key, value, self.layer_name
                )
            unified_attention_with_output(
                query,
                key,
                value,
                output,
                self.layer_name,
                kv_cache_dummy_dep=kv_cache_dummy_dep,
            )
  1. Lines 525-530. Allocate the output: [num_tokens, num_heads × head_size_v] unless the model gives a shape.
  2. Lines 533-540. Reshape q, k and v to [tokens, heads, head_size] outside the op, "to minimize the CPU overheads from the non-CUDA-graph regions".
  3. Lines 542-552. The direct-call path; the cache write runs only if the backend does not write in its forward and the layer does not share another layer's cache.
  4. Lines 553-560. The attention call. The opaque path (lines 561-580) is the same with torch.ops.vllm.* and an encoded name.

get_kv_cache_spec#

        else:
            return FullAttentionSpec(
                block_size=block_size,
                num_kv_heads=self.num_kv_heads,
                head_size=self.head_size,
                head_size_v=self.head_size_v,
                dtype=self.kv_cache_torch_dtype,
                kv_quant_mode=quant_mode,
            )

A normal decoder layer returns a FullAttentionSpec with the current block size, per-rank KV heads, head sizes and the KV dtype. Encoder and encoder-only layers return None: they keep no KV cache (vllm/model_executor/layers/attention/attention.py:616-617). A sliding-window layer returns a SlidingWindowSpec and may choose its own block size (vllm/model_executor/layers/attention/attention.py:620-670); see The hybrid KV cache: specs, groups and one manager per layer type.

get_attention_context and the two ops#

@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,
    )


direct_register_custom_op(
    op_name="unified_attention_with_output",
    op_func=unified_attention_with_output,
    mutates_args=["output", "output_block_scale"],
)
  1. Lines 767-768. Two decorators: @eager_break_during_capture makes the op a break point of breakable CUDA graphs; @maybe_transfer_kv_layer adds the KV connector's per-layer load and save. The breakable decorator must be outermost (vllm/compilation/breakable_cudagraph.py:82-96).
  2. Line 782. The dummy dependency is dropped; it only ordered the ops.
  3. Line 784. One lookup gives metadata, layer, KV cache (the slot mapping is not needed here).
  4. Lines 786-796. The impl call; the result lands in output.
  5. Lines 799-803. Registration: mutates_args=["output", "output_block_scale"] tells the compiler which inputs change.

get_attention_context itself is explained line by line on The forward context: how layers find per-step data (Step 5).

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

Data flow: shapes for Llama-3-8B, TP = 2

Per rank: 16 query heads, 4 KV heads, head size 128 (from How a vLLM model file is written (llama.py)). A step with T tokens:

TensorShapeWhere
q in, k in, v in[T, 2048], [T, 512], [T, 512]from the QKV split in the model
q, k, v to the ops[T, 16, 128], [T, 4, 128], [T, 4, 128]lines 535-540
output[T, 16, 128] inside, [T, 2048] returnedlines 527-536, 581
KV cache view[num_blocks, 4, 16, 256] BF16 with FlashAttentionAttention backends: backend, metadata builder and implementation
slot mappingint64 [T (padded)]from the forward context
Deep dive

Error paths

SituationResultWhere
two layers with one prefixValueError: Duplicate layer namevllm/model_executor/layers/attention/attention.py:443-444
heads do not divide by KV headsAssertionErrorvllm/model_executor/layers/attention/attention.py:329-331
KV sharing target is wrongValueError with the reasonvllm/model_executor/layers/attention/attention.py:69-91
use_alibi_sqrt on a backend without itValueErrorvllm/model_executor/layers/attention/attention.py:365-369
fp8_e5m2 KV cache with an FP8 checkpoint that has KV scalesValueErrorvllm/model_executor/layers/attention/attention.py:205-220
impl without do_kv_cache_update on the update pathAssertionErrorvllm/model_executor/layers/attention/attention.py:737-739
FlexAttention block size above the cache block size with batch invarianceValueErrorvllm/model_executor/layers/attention/attention.py:398-410
Deep dive

Interaction with prefix caching, compile and KV transfer

Check yourself#

  1. 1
    What does Attention.forward return, and where is the attention result written first?
    easy
    Show answer

    It returns output.view(-1, hidden_size) (vllm/model_executor/layers/attention/attention.py:581). The impl writes into the output tensor that forward allocated, because the op mutates output (vllm/model_executor/layers/attention/attention.py:799-803).

  2. 2
    When does Attention.forward skip the cache write op?
    medium
    Show answer

    When the backend writes the cache inside its own forward (forward_includes_kv_cache_update), when the layer shares another layer's cache, or when key or value is None (vllm/model_executor/layers/attention/attention.py:564-572).

  3. 3
    Which decorator must be outermost on unified_attention_with_output, and why?
    hard
    Show answer

    @eager_break_during_capture. The KV connector's wait and save calls in @maybe_transfer_kv_layer must run in the eager segment; inside a captured graph they would hang on replay (vllm/compilation/breakable_cudagraph.py:82-96).