vLLM Explained

How a vLLM model file is written (llama.py)

AdvancedvLLM conceptsabout 15 min read + 5 min deep dives

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

In one sentence

A vLLM model file builds the network from tensor-parallel layers, names every layer with a prefix, and leaves attention, compilation and weight loading to vLLM.

The idea#

A decoder model is a stack of layers: an embedding, N decoder layers and an LM head. Each decoder layer has a norm, attention, a norm and an MLP (Inside a transformer). vLLM does not run the Transformers class of the checkpoint. It runs its own class for the architecture name, for example LlamaForCausalLM in vllm/model_executor/models/llama.py (Out-of-tree models and the model registry).

The vLLM class has the same math and the same parameter tree as the Transformers class. It differs in four ways:

Other work is not in the model file. A decorator adds torch.compile; the compile backend and the model runner add CUDA graphs. A loader streams the weights in, and each parameter cuts its own slice ("Weight loading: from checkpoint files to GPU parameters"). Mixin classes tell the registry what the model supports (Model capability protocols (SupportsPP, SupportsLoRA, ...)).

An everyday comparison. Think of a new office building. The builders put a room number on every door, such as "3.12.B" (floor, corridor, room). Later, the furniture company, the phone list and the cleaning plan all use these numbers. A door with a wrong number gets the wrong desk or no phone. The comparison stops here. In vLLM, nobody checks the number on the door. Two systems read the number, and two systems walk the real module tree. When the number and the path are different, the systems disagree.

The picture#

Figure: the module tree of LlamaForCausalLM. Each row shows the attribute, the class, the prefix that the parent passes, and the per-rank weight shape for Llama-3-8B with TP = 2. Colour shows the kind of layer.
Open full size ↗

Read the tree from the top. Each parent passes a longer prefix to its children, so the prefix of a row is the dotted path to it. Blue and violet rows are the tensor-parallel linear layers. The amber row is the attention layer that registers itself under its prefix.

In the code#

Step 1: the constructor contract#

The worker builds the model in initialize_model(). It checks by name that the constructor has the parameters vllm_config and prefix. Then it calls the class with both, inside set_current_vllm_config (vllm/model_executor/model_loader/utils.py:96-103). A class without them gets a DeprecationWarning and an argument guess; Out-of-tree models and the model registry explains that path. The root gets prefix="": Model Runner V2 calls load_model() without a prefix, so the default "" goes to create_model() (vllm/v1/worker/gpu/model_runner.py:403-406, vllm/model_executor/model_loader/base_loader.py:63-80).

    def __init__(
        self,
        *,
        vllm_config: VllmConfig,
        prefix: str = "",
        layer_type: type[nn.Module] = LlamaDecoderLayer,
    ):
        super().__init__()
        config = vllm_config.model_config.hf_config
        quant_config = vllm_config.quant_config
        self.config = config

        self.model = self._init_model(
            vllm_config=vllm_config,
            prefix=maybe_prefix(prefix, "model"),
            layer_type=layer_type,
        )

        if get_pp_group().is_last_rank:
            self.lm_head = ParallelLMHead(
                config.vocab_size,
                config.hidden_size,
                quant_config=quant_config,
                prefix=maybe_prefix(prefix, "lm_head"),
            )
            if config.tie_word_embeddings:
                self.lm_head = self.lm_head.tie_weights(self.model.embed_tokens)

            logit_scale = getattr(config, "logit_scale", 1.0)
            self.logits_processor = LogitsProcessor(
                config.vocab_size, scale=logit_scale
            )
        else:
            self.lm_head = PPMissingLayer()

        self.make_empty_intermediate_tensors = (
            self.model.make_empty_intermediate_tensors
        )
  1. Lines 487-488. Keyword-only vllm_config and prefix. The registry also checks the keyword vllm_config when it inspects the class (Model capability protocols (SupportsPP, SupportsLoRA, ...)).
  2. Lines 492-493. The model reads what it needs from vllm_config: the Hugging Face config and the quantization config. Inner classes do the same.
  3. Lines 496-500. The inner model gets the prefix maybe_prefix(prefix, "model"), which is "model" for the root (vllm/model_executor/models/utils.py:971-982).
  4. Lines 502-508. Only the last pipeline stage builds the LM head.
  5. Lines 519-521. The model exposes a factory for empty intermediate tensors; pipeline parallelism needs it.

Step 2: how each layer gets its prefix, and why it matters#

Each constructor adds the attribute name to the prefix it got. The decoder layer gives f"{prefix}.self_attn" and f"{prefix}.mlp" (vllm/model_executor/models/llama.py:285-307). The attention class gives .qkv_proj, .o_proj and .attn (vllm/model_executor/models/llama.py:165-181, vllm/model_executor/models/llama.py:212-222). make_layers() builds the list of decoder layers with prefix=f"{prefix}.{idx}" (vllm/model_executor/models/utils.py:911-917). So the QKV layer of layer 0 gets the prefix model.layers.0.self_attn.qkv_proj.

Four parts of vLLM use the name of a layer:

WhoReadsWhat it does with it
Quantization configthe prefix argumentget_quant_method(layer, prefix), where the prefix is "the full name of the layer in the state dict" (vllm/model_executor/layers/quantization/base_config.py:203-217). Each linear layer calls it in its constructor (vllm/model_executor/layers/linear.py:290-296). FP8, for example, leaves a layer unquantized when the prefix is in ignored_layers (vllm/model_executor/layers/quantization/fp8.py:177-187). The default match is an exact string compare (vllm/model_executor/layers/quantization/utils/quant_utils.py:624-625).
Attention layerthe prefix argumentRegisters itself as static_forward_context[prefix]; a second layer with the same prefix raises ValueError (vllm/model_executor/layers/attention/attention.py:443-445). The model runner binds each KV cache tensor by this name (vllm/v1/worker/utils.py:879-880). The layer index for sliding windows comes from the prefix too (vllm/model_executor/models/llama.py:141).
Weight loadingthe module treeAutoWeightsLoader matches checkpoint names to named_children() and named_parameters(), not to the prefix (vllm/model_executor/models/utils.py:402-424).
LoRAthe module treeThe LoRA manager walks model.named_modules() to find the layers to wrap (vllm/lora/model_manager.py:486).

No code checks that the prefix equals the attribute path of the layer. But the quantization config compares the prefix with checkpoint names, and the weight loader and LoRA use the module tree. So use the attribute path as the prefix; then all four parts use the same name (derived from the four rows above). The section What can go wrong gives the failures.

Step 3: tensor-parallel layers#

LlamaAttention divides the heads by the TP size and builds a QKVParallelLinear and a RowParallelLinear:

        tp_size = get_tensor_model_parallel_world_size()
        self.total_num_heads = num_heads
        assert self.total_num_heads % tp_size == 0
        self.num_heads = self.total_num_heads // tp_size
        self.total_num_kv_heads = num_kv_heads
        if self.total_num_kv_heads >= tp_size:
            # Number of KV heads is greater than TP size, so we partition
            # the KV heads across multiple tensor parallel GPUs.
            assert self.total_num_kv_heads % tp_size == 0
        else:
            # Number of KV heads is less than TP size, so we replicate
            # the KV heads across multiple tensor parallel GPUs.
            assert tp_size % self.total_num_kv_heads == 0
        self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)

        head_dim = getattr(config, "head_dim", None)
        self.head_dim = head_dim or self.hidden_size // self.total_num_heads
        self.q_size = self.num_heads * self.head_dim
        self.kv_size = self.num_kv_heads * self.head_dim
        self.scaling = self.head_dim**-0.5
        self.max_position_embeddings = max_position_embeddings

        self.qkv_proj = QKVParallelLinear(
            hidden_size=hidden_size,
            head_size=self.head_dim,
            total_num_heads=self.total_num_heads,
            total_num_kv_heads=self.total_num_kv_heads,
            bias=bias,
            quant_config=quant_config,
            prefix=f"{prefix}.qkv_proj",
        )

        self.o_proj = RowParallelLinear(
            input_size=self.total_num_heads * self.head_dim,
            output_size=hidden_size,
            bias=bias_o_proj,
            quant_config=quant_config,
            prefix=f"{prefix}.o_proj",
        )
  1. Lines 145-146. The query heads must divide by the TP size. Start-up checks this earlier, with a clear error (vllm/config/model.py:1557-1564).
  2. Lines 148-156. KV heads are split when there are enough of them, and replicated when TP is larger (Tensor and pipeline parallelism in practice has the table).
  3. Lines 160-161. q_size and kv_size are per rank. The forward pass splits the QKV output with them.
  4. Lines 165-181. The model gives the total head counts to QKVParallelLinear and the full input size to o_proj. The layers divide by the TP size themselves.

LlamaMLP uses a MergedColumnParallelLinear for gate and up, and a RowParallelLinear for down (vllm/model_executor/models/llama.py:95-111); Tensor and pipeline parallelism in practice shows the shapes. The embedding is a VocabParallelEmbedding and the head is a ParallelLMHead (vllm/model_executor/models/llama.py:382-386, vllm/model_executor/models/llama.py:503-508). The model code calls no collective itself. The page The tensor-parallel layers (linear.py and vocab_parallel_embedding.py) explains each layer class.

Step 4: the attention layer and the CustomOps#

        attn_cls = (
            EncoderOnlyAttention
            if attn_type == AttentionType.ENCODER_ONLY
            else Attention
        )

        self.attn = attn_cls(
            self.num_heads,
            self.head_dim,
            self.scaling,
            num_kv_heads=self.num_kv_heads,
            cache_config=cache_config,
            quant_config=quant_config,
            per_layer_sliding_window=sliding_window,
            attn_type=attn_type,
            prefix=f"{prefix}.attn",
        )

    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
  1. Lines 212-222. Attention gets the per-rank head counts, the scale, the cache and quantization configs, an optional sliding window, and its prefix. When it is built, it selects an attention backend, unless the caller gives one (vllm/model_executor/layers/attention/attention.py:349-350). It also registers itself in the forward context.
  2. Line 229. One GEMM computes Q, K and V for the heads of this rank: [T, q_size + 2 × kv_size].
  3. Lines 230-231. The split, then RoPE on Q and K.
  4. Line 232. self.attn(q, k, v) passes no KV cache and no metadata. The attention op uses the layer name to find the metadata and the layer object (with its KV cache) in the forward context (vllm/model_executor/layers/attention/attention.py:705-717). The page The forward context: how layers find per-step data explains how.
  5. Line 233. The row-parallel o_proj adds the partial sums of the ranks.

Three small layers are CustomOps: RMSNorm (vllm/model_executor/layers/layernorm.py:38-39), the rotary embedding from get_rope() (vllm/model_executor/layers/rotary_embedding/base.py:14-15) and SiluAndMul (vllm/model_executor/layers/activation.py:115-116). Each one picks its forward method when it is built (CustomOp, out-of-tree ops and IR op providers). get_rope() keeps a cache keyed by its arguments, so all 32 layers share one rotary object (vllm/model_executor/layers/rotary_embedding/__init__.py:84-85, vllm/model_executor/layers/rotary_embedding/__init__.py:351).

Step 5: LlamaModel, compilation and pipeline stages#

@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"},
    },
)
@support_torch_compile on the inner model. Dimension 0 of each tensor argument (the token count) is dynamic.

The decorator wraps LlamaModel.forward for torch.compile and CUDA graphs (torch.compile and CUDA graphs in vLLM). It sits on LlamaModel, not on LlamaForCausalLM, so compute_logits stays outside the compiled graph.

The constructor builds the parts of this pipeline stage only:

        if (
            get_pp_group().is_first_rank
            or (config.tie_word_embeddings and get_pp_group().is_last_rank)
            or spec_decode_needs_target_embed(vllm_config)
        ):
            self.embed_tokens = VocabParallelEmbedding(
                self.vocab_size,
                config.hidden_size,
                quant_config=quant_config,
            )
        else:
            self.embed_tokens = PPMissingLayer()
        self.start_layer, self.end_layer, self.layers = make_layers(
            config.num_hidden_layers,
            lambda prefix: layer_type(vllm_config=vllm_config, prefix=prefix),
            prefix=f"{prefix}.layers",
        )
        if get_pp_group().is_last_rank:
            self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
        else:
            self.norm = PPMissingLayer()

        self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
            ["hidden_states", "residual"], config.hidden_size
        )
  1. Lines 377-388. The embedding is real on the first stage. It is also real on the last stage when the head shares its weight, or when a speculative drafter needs it. Elsewhere it is a PPMissingLayer.
  2. Lines 389-393. make_layers() builds real layers only for indices start_layer to end_layer - 1. All other slots are PPMissingLayer, so the indices and names stay the same on every stage (vllm/model_executor/models/utils.py:886-919).
  3. Lines 394-397. The final norm exists on the last stage only.
  4. Lines 399-401. Empty intermediate tensors have the keys hidden_states and residual, each [batch_size, hidden_size] of zeros (vllm/model_executor/models/utils.py:955-968).
    def forward(
        self,
        input_ids: torch.Tensor | None,
        positions: torch.Tensor,
        intermediate_tensors: IntermediateTensors | None,
        inputs_embeds: torch.Tensor | None = None,
        **extra_layer_kwargs,
    ) -> torch.Tensor | IntermediateTensors | tuple[torch.Tensor, list[torch.Tensor]]:
        if get_pp_group().is_first_rank:
            if inputs_embeds is not None:
                hidden_states = inputs_embeds
            else:
                hidden_states = self.embed_input_ids(input_ids)
            residual = None
        else:
            assert intermediate_tensors is not None
            hidden_states = intermediate_tensors["hidden_states"]
            residual = intermediate_tensors["residual"]

        remote_aux = self.collect_remote_aux_hidden_states(intermediate_tensors)

        aux_hidden_states: list[torch.Tensor] = []
        if get_pp_group().is_first_rank:
            self._maybe_add_hidden_state(
                aux_hidden_states, self.start_layer, hidden_states, residual
            )
        for idx, layer in enumerate(
            islice(self.layers, self.start_layer, self.end_layer),
            start=self.start_layer,
        ):
            hidden_states, residual = layer(
                positions, hidden_states, residual, **extra_layer_kwargs
            )
            self._maybe_add_hidden_state(
                aux_hidden_states, idx + 1, hidden_states, residual
            )

        if not get_pp_group().is_last_rank:
            return IntermediateTensors(
                {
                    "hidden_states": hidden_states,
                    "residual": residual,
                    **self.pack_local_aux_hidden_states(aux_hidden_states),
                }
            )

        hidden_states, _ = self.norm(hidden_states, residual)

        aux_hidden_states = remote_aux + aux_hidden_states
        if len(aux_hidden_states) > 0:
            return hidden_states, aux_hidden_states
        return hidden_states
  1. Lines 414-423. The first stage starts from the token embeddings, or from inputs_embeds when the runner gives them. Other stages read hidden_states and residual from the stage before.
  2. Lines 432-438. The loop runs only the layers of this stage. Each layer returns the new hidden states and the residual.
  3. Lines 443-450. A stage that is not the last returns IntermediateTensors.
  4. Line 452. The last stage applies the final norm. It returns hidden states of shape [T, hidden_size], not logits.

The decoder layer keeps the residual outside the norm. input_layernorm(hidden_states, residual) adds the residual and normalizes in one call, and returns both tensors (vllm/model_executor/models/llama.py:320-328, vllm/model_executor/layers/layernorm.py:85-99).

Step 6: LlamaForCausalLM: forward, logits and mixins#

    def forward(
        self,
        input_ids: torch.Tensor | None,
        positions: torch.Tensor,
        intermediate_tensors: IntermediateTensors | None = None,
        inputs_embeds: torch.Tensor | None = None,
    ) -> torch.Tensor | IntermediateTensors:
        model_output = self.model(
            input_ids, positions, intermediate_tensors, inputs_embeds
        )
        return model_output

    def compute_logits(
        self,
        hidden_states: torch.Tensor,
    ) -> torch.Tensor | None:
        logits = self.logits_processor(self.lm_head, hidden_states)
        return logits

    def compute_logits_local(
        self,
        hidden_states: torch.Tensor,
    ) -> torch.Tensor:
        return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)

    def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
        loader = AutoWeightsLoader(self)
        return loader.load_weights(weights)
  1. Lines 541-543. forward(input_ids, positions, intermediate_tensors, inputs_embeds) only calls the inner model. The model runner builds these four arguments (vllm/v1/worker/gpu/model_runner.py:1984-2009).
  2. Line 550. compute_logits applies the head through LogitsProcessor. To sample, the runner calls it only on the rows that sample a token, hidden_states[logits_indices] (vllm/v1/worker/gpu/model_runner.py:1637-1638). Prompt logprobs call it on prompt rows too (vllm/v1/worker/gpu/model_runner.py:2177-2178).
  3. Line 557. compute_logits_local skips the gather. The runner uses it for batch-sharded sampling.
  4. Lines 560-561. load_weights hands the checkpoint stream to AutoWeightsLoader ("Weight loading: from checkpoint files to GPU parameters").

The class header lists the mixins (vllm/model_executor/models/llama.py:464-472):

MixinWhat it adds
SupportsLoRALoRA support; with packed_modules_mapping and embedding_modules (vllm/model_executor/models/llama.py:474-482)
SupportsPPpipeline parallelism; the factory of lines 519-521 is its required member
SupportsEagle, SupportsEagle3the model can be the target of EAGLE drafts; EAGLE-3 reads auxiliary hidden states (vllm/model_executor/models/interfaces.py:1684-1740)
SupportsQuantits __new__ stores the quant config on the model and gives the config the rename rules of hf_to_vllm_mapper and the packed_modules_mapping (vllm/model_executor/models/interfaces.py:1260-1299). For Llama the rename part is empty, because get_rename_mapper() drops the stacked rules (vllm/model_executor/models/utils.py:180-199).
LocalArgmaxMixinget_top_tokens for a speculative-decoding draft: each rank takes the argmax of its vocabulary slice, and the ranks all-gather only (value, index) pairs, not the full logits (vllm/model_executor/models/interfaces.py:1570-1605, vllm/model_executor/layers/logits_processor.py:231-250)

LlamaModel also inherits EagleModelMixin, which collects the hidden states of chosen layers for EAGLE-3 (vllm/model_executor/models/interfaces.py:1608-1681). The page Model capability protocols (SupportsPP, SupportsLoRA, ...) explains how the registry detects each protocol.

Step 7: how other files reuse llama.py#

Nine registry names point to llama.py. Seven of them use LlamaForCausalLM directly, for example InternLM3ForCausalLM and TeleChat3ForCausalLM (vllm/model_executor/models/registry.py:140-155, vllm/model_executor/models/registry.py:212). The embedding table also copies every name that maps to LlamaForCausalLM (vllm/model_executor/models/registry.py:231-238). (Counts from an evaluation of the tables at this commit.)

Other model files subclass the Llama classes. MistralForCausalLM subclasses LlamaForCausalLM (vllm/model_executor/models/mistral.py:236), and so does Phi3ForCausalLM (vllm/model_executor/models/phi3.py:10). TeleChat2ForCausalLM adds its own name mapper and builds TeleChat2Model, a subclass of LlamaModel (vllm/model_executor/models/telechat2.py:107-127, vllm/model_executor/models/telechat2.py:35). 21 files in vllm/ import from llama.py (git grep at this commit). The file has two hooks for this reuse:

A second model tree: vllm/models/#

Some new model families do not live in vllm/model_executor/models/. They have one package per family in vllm/models/, with sub-packages per hardware vendor. The package __init__.py imports the implementation for current_platform, for example the NVIDIA, AMD, XPU or CPU variant of DeepSeek V4 (vllm/models/deepseek_v4/__init__.py:1-48). For the registry they are normal lazy entries (Out-of-tree models and the model registry). This site does not explain these packages further; llama.py is the one model example.

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: shapes through one step, Llama-3-8B with TP = 2

The numbers come from the Llama-3-8B config.json on the Hugging Face Hub, not from the vLLM repository. Hidden size 4,096, intermediate size 14,336, 32 layers. 32 query heads, 8 KV heads, head size 128, vocabulary 128,256. Take a step with T = 2,048 tokens, of which R = 16 sample a token. All shapes are per rank.

StepCodeShape per rank
input ids, positionsforward(input_ids, positions, ...)[2048] each (one flat list of tokens)
embedding (after its all-reduce)llama.py:418[2048, 4096]
qkv_proj outputllama.py:229[2048, 2048 + 512 + 512] = [2048, 3072]
splitllama.py:230q [2048, 2048], k [2048, 512], v [2048, 512]
attention outputattention.py:529[2048, 16 × 128] = [2048, 2048]
o_proj (after all-reduce)llama.py:233[2048, 4096]
gate_up_projllama.py:119[2048, 2 × 7168]
SiluAndMulllama.py:120[2048, 7168]
down_proj (after all-reduce)llama.py:121[2048, 4096]
final normllama.py:452[2048, 4096]
rows that samplemodel_runner.py:1637[16, 4096]
logits on this ranklogits_processor.py:188[16, 64128]
logits after the gatherlogits_processor.py:193-198[16, 128256]

Each decoder layer does two all-reduces of [2048, 4096] BF16 values: one in o_proj and one in down_proj. The embedding adds one more all-reduce per step (vllm/model_executor/layers/vocab_parallel_embedding.py:558-563). So a step has 2 × 32 + 1 = 65 all-reduces of 16 MiB each (derived). The logits add one all-gather of [16, 64128] per rank. Batch-sharded sampling, which is off by default, changes this last step (vllm/config/parallel.py:192-198).

Deep dive

All prefixes of one decoder layer

The root prefix is "". These are the prefixes that the constructors build for layer 0 (derived by following the f-strings of llama.py):

ModulePrefixBuilt at
LlamaModelmodelvllm/model_executor/models/llama.py:498
LlamaDecoderLayermodel.layers.0vllm/model_executor/models/llama.py:389-393, vllm/model_executor/models/utils.py:914
LlamaAttentionmodel.layers.0.self_attnvllm/model_executor/models/llama.py:297
QKVParallelLinearmodel.layers.0.self_attn.qkv_projvllm/model_executor/models/llama.py:172
RowParallelLinear (o)model.layers.0.self_attn.o_projvllm/model_executor/models/llama.py:180
Attentionmodel.layers.0.self_attn.attnvllm/model_executor/models/llama.py:221
LlamaMLPmodel.layers.0.mlpvllm/model_executor/models/llama.py:306
MergedColumnParallelLinearmodel.layers.0.mlp.gate_up_projvllm/model_executor/models/llama.py:101
RowParallelLinear (down)model.layers.0.mlp.down_projvllm/model_executor/models/llama.py:110
ParallelLMHeadlm_headvllm/model_executor/models/llama.py:507
VocabParallelEmbeddingnone passed (default "")vllm/model_executor/models/llama.py:382-386, vllm/model_executor/layers/vocab_parallel_embedding.py:260

The Llama embedding gets the prefix "". So a name in a quantization config, such as model.embed_tokens, cannot match it: an exact or suffix compare with "" fails (vllm/model_executor/layers/quantization/utils/quant_utils.py:624-637). compressed-tensors can still match it by its class name, for example with the target Embedding (vllm/model_executor/layers/quantization/compressed_tensors/utils.py:105-109, vllm/model_executor/layers/quantization/utils/config_utils.py:444-445). (Derived.)

extract_layer_index() finds the one integer in a prefix: model.layers.0.self_attn gives 0. A prefix with two integers fails its assertion, unless the caller says that a layer has several attention modules (vllm/model_executor/models/utils.py:1010-1042).

Deep dive

Pipeline parallelism in the model file, step by step

Take 32 layers and PP = 2. get_pp_indices() gives stage 0 the layers 0-15 and stage 1 the layers 16-31 (Tensor and pipeline parallelism in practice explains the split rule).

  1. Build, stage 0. embed_tokens is real. layers[0:16] are real; layers[16:32] are PPMissingLayer. norm and lm_head are PPMissingLayer, and there is no logits_processor (vllm/model_executor/models/llama.py:502-517).
  2. Build, stage 1. embed_tokens is PPMissingLayer unless the head shares its weight or a speculative drafter needs it. layers[0:16] are placeholders; norm, lm_head and logits_processor are real.
  3. Forward, stage 0. Embed, run layers 0-15, return IntermediateTensors({"hidden_states", "residual"}), each [T, 4096].
  4. Forward, stage 1. The runner fills its own buffers from the received tensors and passes them as intermediate_tensors (vllm/v1/worker/gpu/model_runner.py:1993-2009). The model reads them (llama.py lines 421-423), runs layers 16-31, then the final norm.
  5. Weights. AutoWeightsLoader skips every PPMissingLayer, so the checkpoint tensors of other stages are read but not stored (vllm/model_executor/models/utils.py:382-383). Hand-written loaders call is_pp_missing_parameter() instead (vllm/model_executor/models/utils.py:944-952).

PPMissingLayer is a torch.nn.Identity whose forward returns its first argument (vllm/model_executor/models/utils.py:852-860). IntermediateTensors is a thin wrapper around a dict of tensors, with indexing by key or by slice (vllm/sequence.py:12-34). With EAGLE-3 and PP, the dict also carries keys aux_hidden_states_<i> (vllm/model_executor/models/interfaces.py:1652-1661).

Deep dive

A checklist for your own model file (derived from llama.py)

  1. Constructors take vllm_config and prefix; children get f"{prefix}.{attr}" or maybe_prefix(prefix, attr).
  2. Linear layers are QKVParallelLinear, MergedColumnParallelLinear, RowParallelLinear or ReplicatedLinear, each with quant_config and prefix.
  3. Attention is an Attention layer with the per-rank head counts and a unique prefix.
  4. The inner model has embed_input_ids and forward(input_ids, positions, intermediate_tensors, inputs_embeds); the outer class has compute_logits. The registry checks these names (Model capability protocols (SupportsPP, SupportsLoRA, ...)).
  5. For PP: build layers with make_layers, use PPMissingLayer, return IntermediateTensors, set make_empty_intermediate_tensors, and inherit SupportsPP.
  6. For loading: a load_weights with AutoWeightsLoader and a WeightsMapper for stacked parameters ("Weight loading: from checkpoint files to GPU parameters").
  7. For LoRA: packed_modules_mapping that lists the parts of each stacked layer.

What can go wrong#

Why it matters. A new architecture, a fork of a model, or an out-of-tree model plugin starts with a file like llama.py. Learn the contract, the prefixes and the TP layer classes. Then you can read any model file in vLLM. You can also write a new one that works with quantization, LoRA, PP and torch.compile.

Check yourself#

  1. 1
    Which two constructor parameters must every vLLM model class have, and who checks them?
    easy
    Show answer

    vllm_config and prefix. initialize_model() checks the constructor signature by name; without both, it warns and guesses the arguments (vllm/model_executor/model_loader/utils.py:96-112).

  2. 2
    What prefix does the QKV layer of decoder layer 5 get, and which component would break if the prefix were "qkv" only?
    medium
    Show answer

    model.layers.5.self_attn.qkv_proj (llama.py lines 172, 297, 498 and make_layers). With "qkv", the quantization config would look up the wrong name, for example in FP8's ignored_layers check (vllm/model_executor/layers/quantization/fp8.py:177-187). Weight loading would still work, because AutoWeightsLoader uses the module tree.

  3. 3
    Why does LlamaAttention.forward not pass the KV cache or the attention metadata to self.attn?
    medium
    Show answer

    The Attention layer registered itself under its prefix in static_forward_context (vllm/model_executor/layers/attention/attention.py:443-445). The runner binds the KV cache by that name. It also sets the metadata in the forward context before each step, so the attention op finds both by the layer name (vllm/model_executor/layers/attention/attention.py:705-717).

  4. 4
    A Llama with 32 layers runs with PP = 2 and TP = 2. How many all-reduces does the first stage do per step, and what does it return?
    hard
    Show answer

    Stage 0 runs layers 0-15, each with two all-reduces (o_proj, down_proj), plus one in the embedding: 33 (derived from vllm/model_executor/layers/linear.py:1803-1806 and vllm/model_executor/layers/vocab_parallel_embedding.py:558-563). It returns IntermediateTensors with hidden_states and residual, each [T, 4096] (vllm/model_executor/models/llama.py:443-450).

  5. 5
    Why is @support_torch_compile on LlamaModel and not on LlamaForCausalLM? Name one consequence.
    hard
    Show answer

    The decorator wraps the forward of the class it decorates (vllm/model_executor/models/llama.py:337-347). compute_logits is a method of LlamaForCausalLM, so the runner calls it outside the compiled graph of LlamaModel. To sample, it passes only the rows in logits_indices (vllm/v1/worker/gpu/model_runner.py:1637-1638).