vLLM Explained

The tensor-parallel layers (linear.py and vocab_parallel_embedding.py)

AdvancedvLLM conceptsabout 11 min read + 3 min deep dives

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

In one sentence

Each TP layer allocates only its slice, cuts its slice from the checkpoint when it loads, and calls the collective that its split needs.

The idea#

With tensor parallelism (TP) each GPU holds a slice of every weight. A column-parallel linear layer layer splits the output features; a row-parallel linear layer layer splits the input features and adds the partial sums with an all-reduce. The page Tensor and pipeline parallelism in practice shows this with one Llama MLP. This page explains the classes that do it.

A model file never calls a collective and never computes a slice. It builds layers such as QKVParallelLinear(hidden_size, head_size, total_num_heads, total_num_kv_heads) with full sizes (How a vLLM model file is written (llama.py)). Each layer class then does five jobs itself:

  1. It asks the quantization config for a quantize method for its prefix (of a layer).
  2. It computes its per-rank sizes and lets the quantize method make parameters of that size.
  3. It gives each parameter a weight loader that cuts this rank's slice from a full checkpoint tensor ("Weight loading: from checkpoint files to GPU parameters").
  4. In forward, it runs the GEMM through the quantize method.
  5. It calls the collective that the split needs: an all-gather when gather_output is set, an all-reduce when reduce_results is set.

An everyday comparison. Think of a choir that sings one long chord. In a column split, each singer sings different notes of the chord; together they give the full chord. In a row split, every singer sings the same note at part of the volume. The hall adds the voices into one loud note. The comparison stops here. A hall adds the voices for free. On GPUs, the sum is an all-reduce over the links between the GPUs, and it costs time in every layer.

The picture#

Figure: QKVParallelLinear splits the 32 query heads and 8 KV heads of a Llama-3-8B layer. With TP = 2 each rank owns 4 KV heads (panel 1). With TP = 16 there are fewer KV heads than ranks, so each KV head is copied to 2 ranks (panel 2).
Open full size ↗

Colour marks the KV head. A query head always lands on a rank that also holds the KV head it reads. So attention needs no data from other ranks; only o_proj adds the results.

In the code#

Step 1: LinearBase resolves the quantize method#

        if params_dtype is None:
            params_dtype = torch.get_default_dtype()
        self.params_dtype = params_dtype
        self.quant_config = quant_config
        self.prefix = prefix
        self.allow_fp8_block_shape_mismatch = False
        self.quant_method: QuantizeMethodBase
        if quant_config is None:
            self.quant_method = UnquantizedLinearMethod()
        elif quant_method := resolve_quant_method(quant_config, self, prefix=prefix):
            self.quant_method = quant_method
        else:
            raise ValueError("All linear layers should support quant method.")
        self.return_bias = return_bias
        self.disable_tp = disable_tp
        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()
            )
  1. Lines 290-296. Without a quantization config the method is UnquantizedLinearMethod. Else resolve_quant_method(quant_config, self, prefix=prefix) asks the config; it must return a method, or the constructor raises ValueError. The page Quantization methods as plugins explains the methods.
  2. Lines 299-309. disable_tp=True makes the layer act as if TP were 1 (rank 0 of size 1). Else the rank and size come from the TP group, or from explicit arguments.

LinearBase is a PluggableLayer, so a plugin can replace a whole layer class by its registered name, for example column_parallel_linear (vllm/model_executor/layers/linear.py:434-436; CustomOp, out-of-tree ops and IR op providers). The bare LinearBase creates no weights; each subclass calls quant_method.create_weights() with its own sizes.

Step 2: ReplicatedLinear: no split#

A ReplicatedLinear layer makes the full weight on every rank: create_weights(self, input_size, [output_size], input_size, output_size, ...) (vllm/model_executor/layers/linear.py:379-387). Its loader copies the whole tensor after a size check (vllm/model_executor/layers/linear.py:404-412), and its forward needs no communication (vllm/model_executor/layers/linear.py:414-425). 62 model files use it (git grep at this commit). An example is the score head that as_seq_cls_model adds, with one output per class label (vllm/model_executor/models/adapters.py:365-372).

Step 3: ColumnParallelLinear: split the outputs#

        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
            ]
Per-rank sizes: the full input, and output_size / tp_size. For merged layers, each part is divided on its own.
    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. Line 623. The quantize method multiplies the full input [T, in] with the local weight. The result is [T, out / tp_size].
  2. Lines 625-629. Only with gather_output=True and TP > 1 does the layer call tensor_model_parallel_all_gather. The default is False (vllm/model_executor/layers/linear.py:473), because a row-parallel layer usually comes next.

divide() asserts that the size divides by TP; a wrong size stops the build with an AssertionError (vllm/distributed/utils.py:51-55, vllm/distributed/utils.py:122-126).

Step 4: MergedColumnParallelLinear: several outputs in one GEMM#

MergedColumnParallelLinear(input_size, output_sizes) stacks several column-parallel matrices, for example gate and up with output_sizes=[14336, 14336] (vllm/model_executor/models/llama.py:95-102). Each part must divide by TP, and each part is split on its own (vllm/model_executor/layers/linear.py:727-731). So a rank holds [gate slice; up slice], not a slice of [gate; up]. This is what lets SiluAndMul work locally.

        assert loaded_shard_id < len(self.output_sizes)

        shard_offset = sum(self.output_sizes[:loaded_shard_id])
        shard_size = self.output_sizes[loaded_shard_id]
        shard_offset //= self.tp_size
        shard_size //= self.tp_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
            )

        param.load_merged_column_weight(
            loaded_weight=loaded_weight,
            shard_id=loaded_shard_id,
            shard_offset=shard_offset,
            shard_size=shard_size,
        )
The v2 loader for shard id i: offset = sum of the earlier parts / tp_size, size = part i / tp_size.

The shard id is an integer (0 for gate, 1 for up) or a tuple of consecutive ids. None means that the checkpoint already stores the tensor merged (vllm/model_executor/layers/linear.py:745-770).

Step 5: QKVParallelLinear: split by heads#

        # Divide the weight matrix along the last dimension.
        tp_size = get_tensor_model_parallel_world_size() if not disable_tp else 1
        self.num_heads = divide(self.total_num_heads, tp_size)
        if tp_size >= self.total_num_kv_heads:
            self.num_kv_heads = 1
            self.num_kv_head_replicas = divide(tp_size, self.total_num_kv_heads)
        else:
            self.num_kv_heads = divide(self.total_num_kv_heads, tp_size)
            self.num_kv_head_replicas = 1
        input_size = self.hidden_size
        self.output_sizes = [
            self.num_heads * self.head_size * tp_size,  # q_proj
            self.num_kv_heads * self.head_size * tp_size,  # k_proj
            self.num_kv_heads * self.v_head_size * tp_size,  # v_proj
        ]
        output_size = sum(self.output_sizes)

        super().__init__(
            input_size=input_size,
            output_size=output_size,
            bias=bias,
            gather_output=False,
            skip_bias_add=skip_bias_add,
            params_dtype=params_dtype,
            quant_config=quant_config,
            prefix=prefix,
            return_bias=return_bias,
            disable_tp=disable_tp,
        )
  1. Line 1065. The query heads must divide by TP.
  2. Lines 1066-1068. When TP is at least the number of KV heads, each rank gets one KV head, and num_kv_head_replicas = tp_size / total_num_kv_heads ranks share it. divide() asserts that TP is a multiple of the KV head count.
  3. Lines 1069-1071. Else the KV heads are split, with no copies.
  4. Lines 1073-1077. output_sizes are the sizes across all ranks, including copies: num_kv_heads × head_size × tp_size. With TP = 16 and 8 KV heads, the "total" K size is 16 × 128, twice the 8 × 128 of the checkpoint.

The parameter of a rank is [q | k | v] along dimension 0. The offsets are 0, num_heads × head_size and (num_heads + num_kv_heads) × head_size (vllm/model_executor/layers/linear.py:1101-1117). When it loads, the q part takes slice number tp_rank of the checkpoint tensor; k and v take slice number tp_rank // num_kv_head_replicas (vllm/model_executor/parameter.py:191-195). The page "Weight loading: from checkpoint files to GPU parameters" follows one tensor through this code.

The model splits the GEMM output with the same per-rank sizes: qkv.split([q_size, kv_size, kv_size], dim=-1) (vllm/model_executor/models/llama.py:229-230). QKVParallelLinear always uses gather_output=False (vllm/model_executor/layers/linear.py:1084).

Step 6: RowParallelLinear: split the inputs, add the outputs#

    def forward(
        self,
        input_,
    ) -> torch.Tensor | tuple[torch.Tensor, Parameter | None]:
        if self.input_is_parallel:
            input_parallel = input_
        else:
            split_input = split_tensor_along_last_dim(
                input_, num_partitions=self.tp_size
            )
            input_parallel = split_input[self.tp_rank].contiguous()

        # Matrix multiply.
        # Only fuse bias add into GEMM for rank 0 (this ensures that
        # bias will not get added more than once in TP>1 case)
        bias_ = None if (self.tp_rank > 0 or self.skip_bias_add) else self.bias
        output_parallel = self.quant_method.apply(self, input_parallel, bias_)

        if self.reduce_results and self.tp_size > 1:
            output = tensor_model_parallel_all_reduce(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 1789-1795. With input_is_parallel=True (the default) the input is already this rank's slice, [T, in / tp_size]. Else the layer splits the last dimension and keeps its part.
  2. Line 1800. Only rank 0 adds the bias; with TP ranks adding it, the all-reduce would add it TP times.
  3. Lines 1803-1806. With reduce_results=True (the default) and TP > 1, the layer all-reduces the partial sums [T, out]. With False, the caller must reduce later, so a bias is then not allowed unless skip_bias_add is set (vllm/model_executor/layers/linear.py:1736-1740).

The per-rank sizes are the reverse of the column case: input_size / tp_size and the full output (vllm/model_executor/layers/linear.py:1692-1696). The bias is not split (vllm/model_executor/layers/linear.py:1742-1745).

Step 7: VocabParallelEmbedding and ParallelLMHead#

The embedding splits the vocabulary rows across the ranks. First it applies vocab padding: the vocabulary is rounded up to a multiple of padding_size, 64 by default (vllm/model_executor/layers/vocab_parallel_embedding.py:36, vllm/model_executor/layers/vocab_parallel_embedding.py:95-97, vllm/model_executor/layers/vocab_parallel_embedding.py:286-291). Each rank holds padded_vocab / tp_size rows (vllm/model_executor/layers/vocab_parallel_embedding.py:304-306).

        else:
            masked_input, input_mask = get_masked_input_and_mask(
                input_,
                self.shard_indices.org_vocab_start_index,
                self.shard_indices.org_vocab_end_index,
                self.shard_indices.num_org_vocab_padding,
                self.shard_indices.added_vocab_start_index,
                self.shard_indices.added_vocab_end_index,
            )
            output_parallel = self.quant_method.embedding(self, masked_input.long())

        output_dtype = output_parallel.dtype
        if is_fp8(output_dtype):
            # Each vocab token has one owner, so FP8 bytes can use int8 SUM.
            output_parallel = output_parallel.view(torch.int8)
        if not self.use_fused_embedding:
            output_parallel.masked_fill_(input_mask.unsqueeze(-1), 0)
        if self.reduce_results:
            output_parallel = (
                self.parallel_group.all_reduce(output_parallel)
                if self.parallel_group is not None
                else tensor_model_parallel_all_reduce(output_parallel)
            )
        return output_parallel.view(output_dtype)
  1. Lines 542-550. get_masked_input_and_mask shifts each token id into the local row range and marks ids that other ranks own. On CUDA with an unquantized weight, a fused kernel does this instead (lines 527-536).
  2. Line 557. Rows for ids of other ranks become zero.
  3. Lines 558-563. The all-reduce adds the ranks: exactly one rank gives a non-zero row for each token, so the sum is the embedding.

ParallelLMHead has the same weight layout, but its forward raises: the weight is used by LogitsProcessor (vllm/model_executor/layers/vocab_parallel_embedding.py:640-642). LogitsProcessor multiplies the hidden states with the local rows, gathers the vocabulary slices across the TP group, and cuts off the padding (vllm/model_executor/layers/logits_processor.py:180-199). It uses an all-gather: Platform.use_all_gather() returns True, and no built-in platform overrides it (vllm/platforms/interface.py:1275-1278). tie_weights makes the head use the embedding's weight object (vllm/model_executor/layers/vocab_parallel_embedding.py:88-92).

Step 8: the collectives and the communicator#

The layers call two small functions. Both use the TP group of this process:

def tensor_model_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
    """All-reduce the input tensor across model parallel group."""
    return get_tp_group().all_reduce(input_)


def tensor_model_parallel_all_gather(
    input_: torch.Tensor, dim: int = -1
) -> torch.Tensor:
    """All-gather the input tensor across model parallel group."""
    return get_tp_group().all_gather(input_, dim)

GroupCoordinator.all_reduce returns the input unchanged when the group has one rank. On CUDA, ROCm, XPU and TPU it calls the registered op torch.ops.vllm.all_reduce, so torch.compile sees one opaque op. Other platforms call the device communicator directly (vllm/distributed/parallel_state.py:740-746, vllm/distributed/parallel_state.py:571-573, for example vllm/platforms/cuda.py:708-709). The CUDA communicator then picks one of several all-reduce implementations, such as custom all-reduce, symmetric-memory NCCL or plain NCCL (vllm/distributed/device_communicators/cuda_communicator.py:362-446). The page Process groups: how vLLM slices its ranks explains the groups and the communicators.

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

Per-rank weight shapes of one Llama layer

Llama-3.1-8B (and Llama-3-8B) from its Hugging Face config.json: hidden 4,096, intermediate 14,336, 32 query heads, 8 KV heads, head size 128. Weights are [out, in]. These are the per-rank shapes that the formulas of Steps 3-6 give (derived).

TPqkv_projo_projgate_up_projdown_projKV replicas
1[6144, 4096][4096, 4096][28672, 4096][4096, 14336]1
2[3072, 4096][4096, 2048][14336, 4096][4096, 7168]1
4[1536, 4096][4096, 1024][7168, 4096][4096, 3584]1
8[768, 4096][4096, 512][3584, 4096][4096, 1792]1
16[512, 4096][4096, 256][1792, 4096][4096, 896]2

Read the qkv_proj column: (num_heads + 2 × num_kv_heads) × 128. At TP = 8 that is (4 + 2) × 128 = 768. At TP = 16 it is (2 + 2) × 128 = 512: the query part halves again, but the KV part cannot get smaller than one head. The MLP column for TP = 2 is the table of Tensor and pipeline parallelism in practice.

Deep dive

Vocab padding and masking with real numbers

The class docstring gives an example with an original vocabulary of 1,010, 16 LoRA tokens and padding 64 (vllm/model_executor/layers/vocab_parallel_embedding.py:216-233). Without LoRA tokens it goes like this:

  1. pad_vocab_size(1010, 64) = 1,024. With TP = 2, each rank holds 512 rows.
  2. Rank 0 owns ids 0-511. Rank 1 owns ids 512-1009 in its rows 0-497; rows 498-511 are padding.
  3. The loader copies 498 rows into rank 1 and fills the rest with zeros (vllm/model_executor/layers/vocab_parallel_embedding.py:518-520).
  4. Token id 700 in a batch: rank 0 masks it (out of range) and returns a zero row. Rank 1 looks up row 700 − 512 = 188. The all-reduce gives the row of id 700 on both ranks.

For Llama-3 the vocabulary is 128,256, which is 2,004 × 64, so there is no padding. With TP = 2 each rank holds 64,128 rows: [64128, 4096], about 501 MiB in BF16 (derived). The LM head after the gather gives [R, 128256] logits; the slice [..., :org_vocab_size] removes padding columns when there are any (vllm/model_executor/layers/logits_processor.py:196-198).

Deep dive

weight_loader and weight_loader_v2

Every linear class has two loaders. The class picks one when it is built (vllm/model_executor/layers/linear.py:522-534):

weight_loader (v1)weight_loader_v2
Used whenthe quantize method's class name is not in WEIGHT_LOADER_V2_SUPPORTEDthe name is in the list (vllm/model_executor/layers/linear.py:50-64), for example UnquantizedLinearMethod, Fp8LinearMethod, CompressedTensorsLinearMethod
Parameter typeplain Parameter with attributes output_dim, input_dim, packed_dimBasevLLMParameter subclasses, for example ModelWeightParameter
Where the cut isin the layer method (vllm/model_executor/layers/linear.py:589-606)in the parameter method, for example load_column_parallel_weight (vllm/model_executor/parameter.py:145-151)

A method can add itself to the list with register_weight_loader_v2_supported_method (vllm/model_executor/layers/linear.py:67-70). The bias of a column-parallel layer always uses v1 (line 555).

Both families handle quantized storage. packed_dim and packed_factor shrink offsets for packed INT4 weights. Marlin tiles scale them up, and block scales divide by the block size (vllm/model_executor/layers/linear.py:73-94, vllm/model_executor/layers/linear.py:1292-1309).

Deep dive

Errors and checks

SituationResultWhere
A size does not divide by TPAssertionError "x is not divisible by y"vllm/distributed/utils.py:51-55
A quant config returns no method for a linear layerValueError "All linear layers should support quant method."vllm/model_executor/layers/linear.py:295-296
reduce_results=False with a bias that is addedValueErrorvllm/model_executor/layers/linear.py:1736-1740
Shard id other than q, k, v for QKVValueErrorvllm/model_executor/layers/linear.py:1093-1099
Shard id out of range, or a non-consecutive tuple, for merged layersValueErrorvllm/model_executor/layers/linear.py:745-770
A parameter without output_dim in a merged or QKV layer (v1 loader)WARNING "assume the weight is the same for all partitions"vllm/model_executor/layers/linear.py:1327-1334
ParallelLMHead.forward calledRuntimeErrorvllm/model_executor/layers/vocab_parallel_embedding.py:640-642
Embedding with a quantize method that has no embedding()NotImplementedErrorvllm/model_executor/layers/vocab_parallel_embedding.py:326-334

What can go wrong#

Why it matters. These layer classes decide the memory per GPU, the number of collectives per step and the rules for valid TP sizes. When you know them, you can predict the per-GPU shapes of any model and read the errors of a wrong TP size.

Check yourself#

  1. 1
    Which flag makes a RowParallelLinear add the partial sums of the ranks, and what is its default?
    easy
    Show answer

    reduce_results, default True (vllm/model_executor/layers/linear.py:1684). The all-reduce runs only when TP > 1 (vllm/model_executor/layers/linear.py:1803-1806).

  2. 2
    Why does MergedColumnParallelLinear split gate and up separately instead of splitting the stacked matrix once?
    medium
    Show answer

    So each rank holds matching slices of gate and up, and SiluAndMul can combine them without data from other ranks. The loader puts part i at offset sum(output_sizes[:i]) / tp_size (vllm/model_executor/layers/linear.py:965-983).

  3. 3
    Why does only rank 0 add the bias in RowParallelLinear.forward?
    medium
    Show answer

    The all-reduce sums the outputs of all ranks. If every rank added the bias, the sum would contain it TP times (vllm/model_executor/layers/linear.py:1798-1801).

  4. 4
    A model has 32 query heads and 8 KV heads. What is the shape of qkv_proj.weight on each rank with TP = 16, and which checkpoint rows of v_proj does rank 9 load?
    hard
    Show answer

    num_heads = 2, num_kv_heads = 1, replicas = 2 (vllm/model_executor/layers/linear.py:1065-1068), so the weight is [(2 + 1 + 1) × 128, 4096] = [512, 4096]. For v the slice index is 9 // 2 = 4 (vllm/model_executor/parameter.py:191), so rank 9 loads rows 512-639 (KV head 4).

  5. 5
    With TP = 2, token id 70,000 enters the embedding of a Llama-3 model (vocabulary 128,256). What does each rank compute?
    hard
    Show answer

    Each rank holds 64,128 rows. Rank 0 owns ids 0-64,127, so it masks the id and returns a zero row. Rank 1 looks up local row 70,000 − 64,128 = 5,872. The all-reduce sums both rows, so both ranks get the embedding of id 70,000 (vllm/model_executor/layers/vocab_parallel_embedding.py:541-563).