vLLM Explained

"model_executor/layers/linear.py: tensor-parallel linear layers"

AdvancedCode mapabout 8 min read + 3 min deep dives

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

Purpose#

vllm/model_executor/layers/linear.py gives model files linear layers that know their tensor-parallel rank: each layer stores only its slice of the weight, loads that slice from a full checkpoint tensor, and does the communication of its split.

Filevllm/model_executor/layers/linear.py, 1,819 lines of Python.
Main classesLinearBase, ReplicatedLinear, ColumnParallelLinear, MergedColumnParallelLinear, QKVParallelLinear, RowParallelLinear; the quantize methods LinearMethodBase and UnquantizedLinearMethod.
Runs inEach worker. Constructors and loaders run once at model load; forward runs on the GPU in every step.
Called byModel files (for example vllm/model_executor/models/llama.py:95-111), quantization methods that subclass LinearMethodBase, and LoRA wrappers. About 315 files in vllm/ name this module (git grep at this commit).
Callsresolve_quant_method, the TP helpers divide, split_tensor_along_last_dim, tensor_model_parallel_all_gather and tensor_model_parallel_all_reduce, and the parameter classes of parameter.py (vllm/model_executor/layers/linear.py:14-45).
Not in this fileThe embedding and LM head (vocab_parallel_embedding.py), the GEMM kernels (chosen by each quantize method), the communicators (Process groups: how vLLM slices its ranks), and the per-parameter load methods (parameter.py).

The page The tensor-parallel layers (linear.py and vocab_parallel_embedding.py) explains the ideas. This page maps the file and reads the forward and loader methods line by line.

Where it sits#

Figure: the head split of QKVParallelLinear, the most complex layer of this file, for TP = 2 and TP = 16.
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLinesRole
WEIGHT_LOADER_V2_SUPPORTED, register_weight_loader_v2_supported_methodvllm/model_executor/layers/linear.py:50-70Quantize-method class names that use the v2 loaders.
adjust_marlin_shard, adjust_block_scale_shard, adjust_scalar_to_fused_arrayvllm/model_executor/layers/linear.py:73-121Offset fixes for Marlin tiles, block scales and per-part scalar scales.
LinearMethodBasevllm/model_executor/layers/linear.py:124-163Abstract quantize method: create_weights, apply.
UnquantizedLinearMethodvllm/model_executor/layers/linear.py:166-242BF16/FP16 weights; GEMM from dispatch_unquantized_gemm.
LinearBasevllm/model_executor/layers/linear.py:245-324Common fields, quantize method, TP rank and size.
ReplicatedLinearvllm/model_executor/layers/linear.py:327-431Full weight on every rank.
ColumnParallelLinearvllm/model_executor/layers/linear.py:434-642Output split; optional all-gather.
DCPGroupColumnParallelLinearvllm/model_executor/layers/linear.py:645-683Splits across decode-context-parallel groups, not every rank.
MergedColumnParallelLinearvllm/model_executor/layers/linear.py:686-1008Several column parts in one weight; integer shard ids.
QKVParallelLinearvllm/model_executor/layers/linear.py:1011-1362Q, K, V by heads; KV replication; shard ids q, k, v.
MinimaxM3QKVParallelLinearWithIndexervllm/model_executor/layers/linear.py:1365-1545Model-specific: QKV plus indexer heads (MiniMax-M3).
KimiK3MergedQKVGateLinearvllm/model_executor/layers/linear.py:1548-1634Model-specific: replicated latents plus a TP gate (Kimi-K3).
RowParallelLinearvllm/model_executor/layers/linear.py:1637-1819Input split; bias on rank 0; optional all-reduce.

Three classes are registered as PluggableLayer names: replicated_linear, column_parallel_linear and row_parallel_linear (vllm/model_executor/layers/linear.py:328, vllm/model_executor/layers/linear.py:435, vllm/model_executor/layers/linear.py:1638). The merged and QKV classes have no name of their own. A plugin can swap a registered class (CustomOp, out-of-tree ops and IR op providers).

Walk through the main methods#

ColumnParallelLinear.__init__: sizes and the loader choice#

        # Divide the weight matrix along the last dimension.
        if disable_tp:
            self.tp_rank, self.tp_size = 0, 1
        else:
            self.tp_rank = (
                tp_rank if tp_rank is not None else get_tensor_model_parallel_rank()
            )
            self.tp_size = (
                tp_size
                if tp_size is not None
                else get_tensor_model_parallel_world_size()
            )
        self.input_size_per_partition = input_size
        self.output_size_per_partition = divide(output_size, self.tp_size)
        self.output_partition_sizes = [self.output_size_per_partition]
        # If QKV or MergedColumn, use output size of each partition.
        if hasattr(self, "output_sizes"):
            self.output_partition_sizes = [
                divide(output_size, self.tp_size) for output_size in self.output_sizes
            ]

        super().__init__(
            input_size,
            output_size,
            bias,
            skip_bias_add,
            params_dtype,
            quant_config,
            prefix,
            return_bias=return_bias,
            disable_tp=disable_tp,
            tp_rank=self.tp_rank,
            tp_size=self.tp_size,
        )

        self._maybe_allow_fp8_block_shape_mismatch()
        self.gather_output = gather_output

        from vllm.model_executor.layers.quantization.online.fp8 import OnlineLinearBase

        quant_method = self.quant_method
        if (
            isinstance(quant_method, OnlineLinearBase)
            and quant_method.requantization_source is not None
        ):
            quant_method = quant_method.requantization_source
        weight_loader = (
            self.weight_loader_v2
            if quant_method.__class__.__name__ in WEIGHT_LOADER_V2_SUPPORTED
            else self.weight_loader
        )

        self.quant_method.create_weights(
            layer=self,
            input_size_per_partition=self.input_size_per_partition,
            output_partition_sizes=self.output_partition_sizes,
            input_size=self.input_size,
            output_size=self.output_size,
            params_dtype=self.params_dtype,
            weight_loader=weight_loader,
        )
  1. Lines 484-495. The rank and size come from the TP group, unless disable_tp or explicit values are given. DCPGroupColumnParallelLinear uses the explicit values to split across groups.
  2. Lines 496-503. The full input and output_size / tp_size. If the subclass set output_sizes (merged and QKV), each part is divided separately.
  3. Lines 524-534. The v2 loader if the quantize method's class name is in the list; an online-quantization method is judged by its source method.
  4. Lines 536-544. The quantize method makes the weight parameters and gives each one the chosen loader. For UnquantizedLinearMethod this is one ModelWeightParameter of shape [sum(output_partition_sizes), input_size] (vllm/model_executor/layers/linear.py:192-205).

ColumnParallelLinear.forward and weight_loader#

    def weight_loader(self, param: Parameter, loaded_weight: torch.Tensor):
        output_dim = getattr(param, "output_dim", None)

        is_sharded_weight = getattr(param, "is_sharded_weight", False)

        param_data = param.data
        if output_dim is not None and not is_sharded_weight:
            shard_size = param_data.shape[output_dim]
            start_idx = self.tp_rank * shard_size
            loaded_weight = loaded_weight.narrow(output_dim, start_idx, shard_size)

        # Special case for loading scales off disk, which often do not
        # have a shape (such as in the case of AutoFP8).
        if len(loaded_weight.shape) == 0:
            loaded_weight = loaded_weight.reshape(1)

        assert param_data.shape == loaded_weight.shape
        param_data.copy_(loaded_weight)

    def weight_loader_v2(self, param: BasevLLMParameter, loaded_weight: torch.Tensor):
        # Special case for loading scales off disk, which often do not
        # have a shape (such as in the case of AutoFP8).
        if len(loaded_weight.shape) == 0:
            assert loaded_weight.numel() == 1
            loaded_weight = loaded_weight.reshape(1)
        param.load_column_parallel_weight(loaded_weight=loaded_weight)

    def forward(
        self,
        input_,
    ) -> torch.Tensor | tuple[torch.Tensor, Parameter | None]:
        bias = self.bias if not self.skip_bias_add else None

        # Matrix multiply.
        output_parallel = self.quant_method.apply(self, input_, bias)

        if self.gather_output and self.tp_size > 1:
            # All-gather across the partitions.
            output = tensor_model_parallel_all_gather(output_parallel)
        else:
            output = output_parallel

        if not self.return_bias:
            return output
        output_bias = self.bias if self.skip_bias_add else None
        return output, output_bias
  1. Lines 595-598. v1 loader: if the parameter has output_dim and is not already sharded, cut shard_size rows at tp_rank × shard_size. shard_size is the parameter's own size, so the cut needs no TP size.
  2. Lines 602-603. A scalar scale from the checkpoint becomes a 1-element tensor.
  3. Lines 608-614. The v2 loader gives the work to param.load_column_parallel_weight.
  4. Line 623. quant_method.apply(self, input_, bias): the GEMM. Every rank gets the same full input.
  5. Lines 625-627. The all-gather only with gather_output and TP > 1; else the output stays a slice [T, out / tp_size].

QKVParallelLinear.weight_loader (v1)#

        assert loaded_shard_id in ["q", "k", "v"]

        # If output dim is defined, use the default loading process.
        if output_dim is not None:
            if loaded_shard_id == "q":
                shard_offset = 0
                shard_size = self.num_heads * self.head_size
            elif loaded_shard_id == "k":
                shard_offset = self.num_heads * self.head_size
                shard_size = self.num_kv_heads * self.head_size
            elif loaded_shard_id == "v":
                shard_offset = (self.num_heads + self.num_kv_heads) * self.head_size
                shard_size = self.num_kv_heads * self.v_head_size

            if isinstance(param, BlockQuantScaleParameter):
                weight_block_size = getattr(self, "weight_block_size", None)
                shard_size, shard_offset = adjust_block_scale_shard(
                    weight_block_size, shard_size, shard_offset
                )

            # Special case for Quantized Weights.
            # If quantized, we need to adjust the offset and size to account
            # for the packing.
            packed_dim = getattr(param, "packed_dim", None)
            if packed_dim == output_dim:
                shard_size = round(shard_size // param.packed_factor)
                shard_offset = round(shard_offset // param.packed_factor)

                # Special case for Marlin.
                shard_size, shard_offset = adjust_marlin_shard(
                    param, shard_size, shard_offset
                )

            is_sharded_weight = getattr(param, "is_sharded_weight", False)
            param_data = param_data.narrow(output_dim, shard_offset, shard_size)
            if loaded_shard_id == "q":
                shard_rank = self.tp_rank
            else:
                shard_rank = self.tp_rank // self.num_kv_head_replicas
            start_idx = shard_rank * shard_size

            if not is_sharded_weight:
                loaded_weight = loaded_weight.narrow(output_dim, start_idx, shard_size)
  1. Lines 1282-1290. Offset and size of the shard inside this rank's parameter: q first, then k, then v, all in per-rank head counts.
  2. Lines 1292-1309. Fixes for block scales and packed or Marlin weights (see the deep dive).
  3. Line 1312. The view of the parameter for this shard.
  4. Lines 1313-1317. The slice index in the checkpoint tensor: tp_rank for q, tp_rank // num_kv_head_replicas for k and v.
  5. Lines 1319-1320. The cut of the checkpoint tensor; then the assertion and the copy at lines 1336-1337.

A None shard id means the checkpoint already stores QKV fused (Phi-3 style). Then the loader cuts the full tensor into q, k and v by the total head counts and calls itself once per part (lines 1225-1276). The v2 loader does the same through param.load_qkv_weight, with num_heads=self.num_kv_head_replicas (vllm/model_executor/layers/linear.py:1191-1209).

RowParallelLinear.weight_loader and forward#

    def weight_loader(self, param: Parameter, loaded_weight: torch.Tensor):
        input_dim = getattr(param, "input_dim", None)
        is_sharded_weight = getattr(param, "is_sharded_weight", False)

        param_data = param.data
        if input_dim is not None and not is_sharded_weight:
            shard_size = param_data.shape[input_dim]
            start_idx = self.tp_rank * shard_size
            loaded_weight = loaded_weight.narrow(input_dim, start_idx, shard_size)

        # Special case for loading scales off disk, which often do not
        # have a shape (such as in the case of AutoFP8).
        if len(loaded_weight.shape) == 0:
            loaded_weight = loaded_weight.reshape(1)

        assert param_data.shape == loaded_weight.shape
        param_data.copy_(loaded_weight)
  1. Lines 1763-1766. The cut is along input_dim (dimension 1 of [out, in]) at tp_rank × shard_size.
  2. Lines 1770-1774. Scalars become 1-element tensors; then a shape assertion and the copy.

The bias is a full [output_size] parameter with the v1 loader (lines 1742-1753). Its output_dim is 0 and it has no input_dim, so the row loader copies it whole on every rank. In forward, only rank 0 adds it (vllm/model_executor/layers/linear.py:1798-1801). forward is on The tensor-parallel layers (linear.py and vocab_parallel_embedding.py): optional split of the input, GEMM, and the all-reduce when reduce_results and TP > 1 (vllm/model_executor/layers/linear.py:1785-1811).

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: types and shapes

LayerInput (per rank)Weight (per rank, unquantized)Output (per rank)Collective
ReplicatedLinear[T, in][out, in][T, out]none
ColumnParallelLinear[T, in][out / tp, in][T, out / tp], or [T, out] with gatherall-gather if gather_output
MergedColumnParallelLinear[T, in][Σ out_i / tp, in][T, Σ out_i / tp]same
QKVParallelLinear[T, hidden][(h_q + 2 h_kv) × d, hidden][T, (h_q + 2 h_kv) × d]none
RowParallelLinear[T, in / tp][out, in / tp][T, out] (full after the all-reduce)all-reduce if reduce_results

h_q and h_kv are the per-rank head counts and d the head size (vllm/model_executor/layers/linear.py:1065-1077). Every forward returns (output, output_bias) unless return_bias=False; output_bias is not None only with skip_bias_add (for example vllm/model_executor/layers/linear.py:631-634). This is why llama.py writes x, _ = self.gate_up_proj(x).

Deep dive

Worked example: gate_up_proj and down_proj of Llama-3-8B, TP = 2

From the Llama-3-8B config.json on the Hugging Face Hub: hidden 4,096, intermediate 14,336. llama.py builds MergedColumnParallelLinear(4096, [14336, 14336]) and RowParallelLinear(14336, 4096).

  1. gate_up, constructor. output_partition_sizes = [7168, 7168] (lines 500-503). The weight is [14336, 4096], 112 MiB in BF16.
  2. Load gate_proj (shard 0) on rank 1. Offset 0, size 7168 in the parameter; checkpoint rows 7168-14335 (lines 966-983 and load_merged_column_weight).
  3. Load up_proj (shard 1) on rank 1. Offset 14336 / 2 = 7168, size 7168 in the parameter; checkpoint rows 7168-14335 of up_proj.
  4. down, constructor. input_size_per_partition = 7168; the weight is [4096, 7168], 56 MiB.
  5. Load down_proj on rank 1. Columns 7168-14335 of the checkpoint tensor (lines 1763-1766, or load_row_parallel_weight).
  6. Forward with T = 2048. gate_up gives [2048, 14336]; SiluAndMul gives [2048, 7168]; down gives a partial [2048, 4096]; the all-reduce moves 2048 × 4096 × 2 B = 16 MiB.

These are the numbers of Tensor and pipeline parallelism in practice, now with the line of each step (derived).

Deep dive

Quantized storage in the loaders

Deep dive

Error paths

SituationResultWhere
No quantize method for a linear layerValueErrorvllm/model_executor/layers/linear.py:295-296
An output part does not divide by TP (merged)AssertionErrorvllm/model_executor/layers/linear.py:731
Heads, or TP and KV heads, do not divide (QKV)AssertionError from dividevllm/model_executor/layers/linear.py:1065-1070
Bad shard idValueErrorvllm/model_executor/layers/linear.py:745-770, vllm/model_executor/layers/linear.py:1093-1099
Slice and parameter shapes differAssertionErrorvllm/model_executor/layers/linear.py:605, vllm/model_executor/layers/linear.py:1773
reduce_results=False with an added biasValueErrorvllm/model_executor/layers/linear.py:1736-1740
Parameter without output_dim in a merged or QKV layer (v1 loader)WARNING; the same tensor on all ranksvllm/model_executor/layers/linear.py:870-877
Deep dive

Environment variables

VariableDefaultRead atEffect
VLLM_BATCH_INVARIANT0 (vllm/envs.py:637)vllm/model_executor/layers/linear.py:238-241On CUDA-like and XPU platforms, the unquantized GEMM uses a batch-invariant kernel.
VLLM_XPU_FORCE_N_CONTIG_WEIGHT0 (vllm/envs.py:2221-2223)vllm/model_executor/layers/linear.py:222-228On XPU, re-lays out an unquantized weight after loading.

The GEMM backend of UnquantizedLinearMethod comes from kernel_config.linear_backend, default "auto" when no config is current (vllm/model_executor/layers/linear.py:171-176).

Check yourself#

  1. 1
    What is the default of gather_output in ColumnParallelLinear, and why?
    easy
    Show answer

    False (vllm/model_executor/layers/linear.py:473). The next layer is usually row-parallel and wants the slice; a gather would only add traffic.

  2. 2
    Which loader does a BF16 QKVParallelLinear weight use, and where is the cut done?
    medium
    Show answer

    weight_loader_v2, because UnquantizedLinearMethod is in WEIGHT_LOADER_V2_SUPPORTED (vllm/model_executor/layers/linear.py:50-51, vllm/model_executor/layers/linear.py:530-534). It calls param.load_qkv_weight, which cuts the parameter and the checkpoint tensor (vllm/model_executor/parameter.py:175-198).

  3. 3
    Why does every linear forward return a tuple?
    medium
    Show answer

    With return_bias=True (the default), it returns (output, output_bias). output_bias is the bias only with skip_bias_add, so a caller can fuse the bias into a later op (vllm/model_executor/layers/linear.py:631-634).

  4. 4
    A checkpoint stores qkv_proj.weight already fused, for a model with 32 query heads, 8 KV heads and head size 128, and TP = 2. Which rows of the fused tensor does rank 1 copy for its k part?
    hard
    Show answer

    For a BF16 weight the v2 loader runs. The shard id is None, so it calls _load_fused_module_from_checkpoint (vllm/model_executor/layers/linear.py:1174-1189). That method cuts the fused tensor by total head counts: k is rows 4096-5119 (vllm/model_executor/layers/linear.py:1130-1143). It then loads that part with shard id k. Rank 1 takes slice 1 // 1 = 1 of size 512 (vllm/model_executor/parameter.py:191-195): rows 512-1023 of the k part, which are rows 4608-5119 of the fused tensor. The v1 loader gives the same rows (lines 1237-1320).