"model_executor/layers/linear.py: tensor-parallel linear layers"
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.
| File | vllm/model_executor/layers/linear.py, 1,819 lines of Python. |
|---|---|
| Main classes | LinearBase, ReplicatedLinear, ColumnParallelLinear, MergedColumnParallelLinear, QKVParallelLinear, RowParallelLinear; the quantize methods LinearMethodBase and UnquantizedLinearMethod. |
| Runs in | Each worker. Constructors and loaders run once at model load; forward runs on the GPU in every step. |
| Called by | Model 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). |
| Calls | resolve_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 file | The 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#
Who calls it#
- Model constructors build the layers with full sizes, a
quant_configand a prefix (vllm/model_executor/models/llama.py:165-181). AutoWeightsLoadercallsload_weightsof the merged and QKV layers, and theweight_loaderof other parameters (vllm/model_executor/models/utils.py:387-424).- Quantization methods subclass
LinearMethodBase, for exampleFp8LinearMethod(vllm/model_executor/layers/quantization/fp8.py:225). - LoRA wraps these layers; it finds a replaced class with
maybe_get_oot_by_class(vllm/lora/layers/column_parallel_linear.py:169).
What it calls#
resolve_quant_method(quant_config, layer, prefix)inLinearBase.__init__(vllm/model_executor/layers/linear.py:293).quant_method.create_weights(...)andquant_method.apply(...)(vllm/model_executor/layers/linear.py:536-544,vllm/model_executor/layers/linear.py:623).- The collectives in
forward(vllm/model_executor/layers/linear.py:627,vllm/model_executor/layers/linear.py:1804). - The parameter methods
load_column_parallel_weight,load_merged_column_weight,load_qkv_weightandload_row_parallel_weightin the v2 loaders (vllm/model_executor/parameter.py:145-226).
Key classes and functions#
| Name | Lines | Role |
|---|---|---|
WEIGHT_LOADER_V2_SUPPORTED, register_weight_loader_v2_supported_method | vllm/model_executor/layers/linear.py:50-70 | Quantize-method class names that use the v2 loaders. |
adjust_marlin_shard, adjust_block_scale_shard, adjust_scalar_to_fused_array | vllm/model_executor/layers/linear.py:73-121 | Offset fixes for Marlin tiles, block scales and per-part scalar scales. |
LinearMethodBase | vllm/model_executor/layers/linear.py:124-163 | Abstract quantize method: create_weights, apply. |
UnquantizedLinearMethod | vllm/model_executor/layers/linear.py:166-242 | BF16/FP16 weights; GEMM from dispatch_unquantized_gemm. |
LinearBase | vllm/model_executor/layers/linear.py:245-324 | Common fields, quantize method, TP rank and size. |
ReplicatedLinear | vllm/model_executor/layers/linear.py:327-431 | Full weight on every rank. |
ColumnParallelLinear | vllm/model_executor/layers/linear.py:434-642 | Output split; optional all-gather. |
DCPGroupColumnParallelLinear | vllm/model_executor/layers/linear.py:645-683 | Splits across decode-context-parallel groups, not every rank. |
MergedColumnParallelLinear | vllm/model_executor/layers/linear.py:686-1008 | Several column parts in one weight; integer shard ids. |
QKVParallelLinear | vllm/model_executor/layers/linear.py:1011-1362 | Q, K, V by heads; KV replication; shard ids q, k, v. |
MinimaxM3QKVParallelLinearWithIndexer | vllm/model_executor/layers/linear.py:1365-1545 | Model-specific: QKV plus indexer heads (MiniMax-M3). |
KimiK3MergedQKVGateLinear | vllm/model_executor/layers/linear.py:1548-1634 | Model-specific: replicated latents plus a TP gate (Kimi-K3). |
RowParallelLinear | vllm/model_executor/layers/linear.py:1637-1819 | Input 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,
)- Lines 484-495. The rank and size come from the TP group, unless
disable_tpor explicit values are given.DCPGroupColumnParallelLinearuses the explicit values to split across groups. - Lines 496-503. The full input and
output_size / tp_size. If the subclass setoutput_sizes(merged and QKV), each part is divided separately. - 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.
- Lines 536-544. The quantize method makes the weight parameters and gives each one the chosen loader. For
UnquantizedLinearMethodthis is oneModelWeightParameterof 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- Lines 595-598. v1 loader: if the parameter has
output_dimand is not already sharded, cutshard_sizerows attp_rank × shard_size.shard_sizeis the parameter's own size, so the cut needs no TP size. - Lines 602-603. A scalar scale from the checkpoint becomes a 1-element tensor.
- Lines 608-614. The v2 loader gives the work to
param.load_column_parallel_weight. - Line 623.
quant_method.apply(self, input_, bias): the GEMM. Every rank gets the same full input. - Lines 625-627. The all-gather only with
gather_outputand 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)- 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.
- Lines 1292-1309. Fixes for block scales and packed or Marlin weights (see the deep dive).
- Line 1312. The view of the parameter for this shard.
- Lines 1313-1317. The slice index in the checkpoint tensor:
tp_rankfor q,tp_rank // num_kv_head_replicasfor k and v. - 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)- Lines 1763-1766. The cut is along
input_dim(dimension 1 of[out, in]) attp_rank × shard_size. - 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 diveData flow: types and shapes
| Layer | Input (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 gather | all-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 diveWorked 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).
- gate_up, constructor.
output_partition_sizes = [7168, 7168](lines 500-503). The weight is[14336, 4096], 112 MiB in BF16. - Load
gate_proj(shard 0) on rank 1. Offset 0, size 7168 in the parameter; checkpoint rows 7168-14335 (lines 966-983 andload_merged_column_weight). - Load
up_proj(shard 1) on rank 1. Offset 14336 / 2 = 7168, size 7168 in the parameter; checkpoint rows 7168-14335 ofup_proj. - down, constructor.
input_size_per_partition= 7168; the weight is[4096, 7168], 56 MiB. - Load
down_projon rank 1. Columns 7168-14335 of the checkpoint tensor (lines 1763-1766, orload_row_parallel_weight). - Forward with T = 2048. gate_up gives
[2048, 14336];SiluAndMulgives[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 diveQuantized storage in the loaders
- Packed weights. INT4 formats pack several values into one element. A parameter with
packed_dim == output_dimdivides the offset and size bypacked_factor(vllm/model_executor/layers/linear.py:1301-1304). - Marlin.
adjust_marlin_shardmultiplies offset and size bymarlin_tile_sizewhen the parameter has one (vllm/model_executor/layers/linear.py:73-82). - Block scales. For
BlockQuantScaleParameter, offsets and sizes are rounded up to whole blocks ofweight_block_size[0]rows (vllm/model_executor/layers/linear.py:85-94)._maybe_allow_fp8_block_shape_mismatchallows parts that do not fill whole blocks, with a DEBUG log (vllm/model_executor/layers/linear.py:562-587). - Per-part scalars. A stacked layer with per-tensor scales keeps one scale per part;
adjust_scalar_to_fused_arraypicks slot 0, 1 or 2 from the shard id (vllm/model_executor/layers/linear.py:97-121). - After the copy. A quantize method may replace parameters in
process_weights_after_loading.update_param_tp_statusthen resets the rank fields of the new parameters (vllm/model_executor/layers/linear.py:311-324,vllm/model_executor/model_loader/utils.py:187-193).
Deep diveError paths
| Situation | Result | Where |
|---|---|---|
| No quantize method for a linear layer | ValueError | vllm/model_executor/layers/linear.py:295-296 |
| An output part does not divide by TP (merged) | AssertionError | vllm/model_executor/layers/linear.py:731 |
| Heads, or TP and KV heads, do not divide (QKV) | AssertionError from divide | vllm/model_executor/layers/linear.py:1065-1070 |
| Bad shard id | ValueError | vllm/model_executor/layers/linear.py:745-770, vllm/model_executor/layers/linear.py:1093-1099 |
| Slice and parameter shapes differ | AssertionError | vllm/model_executor/layers/linear.py:605, vllm/model_executor/layers/linear.py:1773 |
reduce_results=False with an added bias | ValueError | vllm/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 ranks | vllm/model_executor/layers/linear.py:870-877 |
Deep diveEnvironment variables
| Variable | Default | Read at | Effect |
|---|---|---|---|
VLLM_BATCH_INVARIANT | 0 (vllm/envs.py:637) | vllm/model_executor/layers/linear.py:238-241 | On CUDA-like and XPU platforms, the unquantized GEMM uses a batch-invariant kernel. |
VLLM_XPU_FORCE_N_CONTIG_WEIGHT | 0 (vllm/envs.py:2221-2223) | vllm/model_executor/layers/linear.py:222-228 | On 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).
Related concepts#
- The tensor-parallel layers (linear.py and vocab_parallel_embedding.py): the ideas and the vocabulary layers.
- "Weight loading: from checkpoint files to GPU parameters": how tensors reach these loaders.
- Quantization methods as plugins: the quantize methods that make and use the weights.
- CustomOp, out-of-tree ops and IR op providers:
PluggableLayerand how a plugin replaces a layer class. - Process groups: how vLLM slices its ranks: the TP group behind the collectives.
Check yourself#
- 1What is the default ofeasy
gather_outputinColumnParallelLinear, and why?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. - 2Which loader does a BF16medium
QKVParallelLinearweight use, and where is the cut done?Show answer
weight_loader_v2, becauseUnquantizedLinearMethodis inWEIGHT_LOADER_V2_SUPPORTED(vllm/model_executor/layers/linear.py:50-51,vllm/model_executor/layers/linear.py:530-534). It callsparam.load_qkv_weight, which cuts the parameter and the checkpoint tensor (vllm/model_executor/parameter.py:175-198). - 3Why does every linearmedium
forwardreturn a tuple?Show answer
With
return_bias=True(the default), it returns(output, output_bias).output_biasis the bias only withskip_bias_add, so a caller can fuse the bias into a later op (vllm/model_executor/layers/linear.py:631-634). - 4A checkpoint storeshard
qkv_proj.weightalready 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?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 idk. 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).