Tensor and pipeline parallelism in practice
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
Tensor parallelism splits each layer across GPUs; pipeline parallelism gives each GPU different layers. Use them only when one GPU is too small.
The idea#
A model can be too large for one GPU, or too slow on one GPU. Then the model runs on several GPUs. DeepInfer Explained introduces the five kinds: data, tensor, pipeline, context and expert parallelism, with their collectives (Parallelism). This page shows the two kinds that split one model copy, with small real numbers, and how a serving operator picks one.
Tensor parallelism: split every layer#
With tensor parallelism (TP), each GPU holds a slice of every weight matrix. All GPUs work on the same tokens at the same time. A linear layer computes Y = X W. You can split W in two ways:
- By output columns (a column-parallel linear layer). Each GPU computes some of the output features from the full input. The result on each GPU is a slice of Y.
- By input rows (a row-parallel linear layer). Each GPU gets a slice of the input features and multiplies it with its rows of W. The result on each GPU is a partial sum of the full Y. An all-reduce adds the partial sums.
The trick is to put the two splits one after the other. The MLP has two linear layers. The first layer is column-parallel, so each GPU gets a slice of the 14,336 intermediate features. The activation function works on each feature alone, so it needs no data from other GPUs. The second layer is row-parallel, and its input slice is exactly the slice that the GPU already has. So one MLP needs only one all-reduce, at the end.
Small numbers: one MLP with TP = 2#
Take a Llama-3.1-8B-style MLP: hidden size 4,096, intermediate size 14,336, BF16. T is the number of tokens in the step.
| Layer | Full weight [out, in] | Weight on each GPU (TP = 2) | Output on each GPU |
|---|---|---|---|
| input X | – | – | [T, 4096] (same on both) |
gate_up_proj (column-parallel) | [2 × 14336, 4096] | [2 × 7168, 4096] | [T, 2 × 7168] |
| SiLU(gate) × up | – | – | [T, 7168] |
down_proj (row-parallel) | [4096, 14336] | [4096, 7168] | partial sum [T, 4096] |
| all-reduce | – | – | full Y [T, 4096] (same on both) |
Each GPU holds 88.1 million MLP weights (176 MB) instead of 176.2 million (352 MB). It does half of the multiply work. The price is one all-reduce of T × 4096 × 2 bytes: 16 MiB for a step of 2,048 tokens.
Attention: split by heads#
Attention heads are independent of each other, so TP gives each GPU a group of heads. The QKV projection is column-parallel: each GPU computes the Q, K and V of its own heads. The output projection is row-parallel, with one all-reduce (vllm/model_executor/models/llama.py:165-181). So one decoder layer has two all-reduces: one after attention and one after the MLP.
With grouped-query attention, the model has fewer KV heads than query heads (Llama-3.1-8B: 32 query heads, 8 KV heads, head size 128). Two rules follow:
- The number of query heads must divide by TP. With TP = 3, 32 heads do not divide, and vLLM stops at start-up.
- If TP is larger than the number of KV heads, vLLM gives one KV head to each GPU and replicates it. With TP = 16, each of the 8 KV heads is on 2 GPUs. TP must then be a multiple of the KV head count.
The KV cache follows the heads. With TP = 2, each GPU stores 4 KV heads. That is 32 layers × 4 × 128 × 2 (K and V) × 2 bytes = 64 KiB per token, half of 128 KiB. With TP = 16, each GPU stores one replicated head: 16 KiB per token. The 16 GPUs then store 256 KiB per token together, twice the real size.
Pipeline parallelism: split the layers#
With pipeline parallelism (PP), each GPU, or each group of TP GPUs, holds a range of layers. With 32 layers and PP = 2, stage 0 has layers 0 to 15 and stage 1 has layers 16 to 31. Stage 0 also has the embedding. The last stage also has the final norm and the LM head.
A stage sends its hidden states to the next stage once per step. For Llama, these are two tensors: hidden_states and residual, each [T, 4096]. For T = 2,048 that is 32 MiB per step and stage boundary. TP instead sends 64 all-reduces per step (2 per layer) inside every group. So PP needs much less bandwidth, and it works over a slower network between nodes.
The cost of PP is the pipeline bubble. With one batch in flight, stage 1 waits while stage 0 works, and stage 0 waits while stage 1 works. Each GPU is busy only half of the time. vLLM keeps several batches in flight to fill the stages. PP also does not make one step faster: the stages still run one after the other.
Data and expert parallelism#
- Data parallelism (DP) runs full copies of the model. Each copy serves other requests. It adds throughput but does not make one model fit. See Data parallel serving: several engine cores behind one endpoint.
- Expert parallelism (EP) is for Mixture-of-Experts models (Mixture of Experts). It puts different experts on different GPUs, instead of a TP slice of every expert. See Expert parallelism and MoE serving.
A decision table for a serving operator#
| Situation | Use | Example flags |
|---|---|---|
| The model fits on one GPU, with room for the KV cache | one GPU; add DP copies for more throughput | -dp 4 |
| The model does not fit on one GPU, but fits on one node | TP inside the node (fast links between GPUs) | -tp 4 |
| The model does not fit on one node | TP inside each node, PP across the nodes | -tp 8 -pp 2 |
| The GPUs of one node have no fast links, or the GPU count does not divide the heads | PP inside the node | -pp 4 |
| A large MoE model | DP for attention plus EP for the experts | -dp 8 -ep |
The vLLM docs give the same rules for one model replica (docs/serving/parallelism_scaling.md:7-11). They also suggest PP when the GPU count does not divide the model, or when the GPUs have no NVLink (docs/serving/parallelism_scaling.md:23).
An everyday comparison#
Think of a team that must type a long book. With TP, every typist types a part of every page, and after each page they put the parts together. With PP, typist 1 types chapters 1 to 10 and typist 2 types chapters 11 to 20. Typist 2 can only start a book when typist 1 hands it over. With DP, each typist types a different book.
The comparison stops here: in TP, the "put together" step is an addition of numbers (an all-reduce), not a join of pages. And in serving, the pipeline gets a new batch every step, so several batches can be in different stages at the same time.
The picture#
Read each lane from top to bottom: one GPU and its slice of every weight. The only arrow between the lanes is the all-reduce at the bottom. Panel 2 gives the cost of this split for one MLP block.
In vLLM#
This is a basics page, so this part gives only pointers. The pages The tensor-parallel layers (linear.py and vocab_parallel_embedding.py) and Process groups: how vLLM slices its ranks explain the code in full.
- The layers.
LlamaMLPbuildsgate_up_projas aMergedColumnParallelLinearanddown_projas aRowParallelLinear(vllm/model_executor/models/llama.py:95-111).RowParallelLinear.forward()calls the all-reduce whenreduce_resultsis true and TP is larger than 1 (vllm/model_executor/layers/linear.py:1803-1806). - The pipeline stages.
make_layers()builds only the layers of this PP rank. It fills the other places withPPMissingLayer, an identity module (vllm/model_executor/models/utils.py:886-919). A stage that is not the last returnsIntermediateTensors(vllm/model_executor/models/llama.py:443-450). - The rank layout. vLLM numbers all ranks in the order DP × PP × PCP × TP, with TP last (
vllm/distributed/parallel_state.py:2157-2163). So the ranks of one TP group are neighbours, and they normally sit on one node.
self.gate_up_proj = MergedColumnParallelLinear(
input_size=hidden_size,
output_sizes=[intermediate_size] * 2,
bias=bias,
quant_config=quant_config,
disable_tp=disable_tp,
prefix=f"{prefix}.gate_up_proj",
)
self.down_proj = RowParallelLinear(
input_size=intermediate_size,
output_size=hidden_size,
bias=bias,
quant_config=quant_config,
reduce_results=reduce_results,
disable_tp=disable_tp,
prefix=f"{prefix}.down_proj",
)
if hidden_act != "silu":
raise ValueError(
f"Unsupported activation: {hidden_act}. Only silu is supported for now."
)
self.act_fn = SiluAndMul()
def forward(self, x):
x, _ = self.gate_up_proj(x)
x = maybe_fused_act_quant(self.act_fn, x, self.down_proj)
x, _ = self.down_proj(x)
return xgate_up_proj, the local activation, and row-parallel down_proj. The model code does not call any collective itself; the linear layers do.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 diveColumnParallelLinear and RowParallelLinear, line by line
Column-parallel. The class comment states the split: A = [A_1, ..., A_p] along the second dimension (vllm/model_executor/layers/linear.py:436-441). The constructor keeps the full input size and divides the output size by the TP size (vllm/model_executor/layers/linear.py:496-497). divide() raises an error when the size does not divide.
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- Line 623. The quantization method does the matrix multiply on the local slice of the weight. For a BF16 model, this is a plain GEMM.
- Lines 625-627. Only with
gather_output=Truedoes the layer all-gather the slices. The default isFalse(vllm/model_executor/layers/linear.py:473), because the next layer is row-parallel and wants the slice.
Row-parallel. The input size is divided, and the output size stays full (vllm/model_executor/layers/linear.py:1694-1695).
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- Lines 1789-1795. Normally the input is already split (
input_is_parallel=True). Otherwise the layer splits it and takes its own part. - Lines 1798-1801. Only rank 0 adds the bias. Otherwise the all-reduce would add the bias TP times.
- Lines 1803-1806. The all-reduce over the TP group. A model can set
reduce_results=Falseto do the reduction later, in its own code.
Merged layers. MergedColumnParallelLinear keeps several output matrices in one weight, for example gate and up. Each part is split on its own, so each GPU gets 7,168 rows of gate and 7,168 rows of up. QKVParallelLinear does the same for Q, K and V (vllm/model_executor/layers/linear.py:1011).
Deep diveAttention heads and the KV-head limit
LlamaAttention computes the heads of this GPU (vllm/model_executor/models/llama.py:143-156):
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)QKVParallelLinear uses the same rule for the projection sizes (vllm/model_executor/layers/linear.py:1064-1071). Worked out for Llama-3.1-8B (32 query heads, 8 KV heads, head size 128):
| TP | Query heads per GPU | KV heads per GPU | KV replicas | KV cache per token per GPU (BF16) |
|---|---|---|---|---|
| 1 | 32 | 8 | 1 | 32 × 8 × 128 × 2 × 2 B = 128 KiB |
| 2 | 16 | 4 | 1 | 64 KiB |
| 8 | 4 | 1 | 1 | 16 KiB |
| 16 | 2 | 1 | 2 | 16 KiB (each head stored twice) |
| 3 | error: 32 heads do not divide by 3 | |||
The check for the query heads runs at start-up, in ModelConfig.verify_with_parallel_config() (vllm/config/model.py:1557-1564). The same function rejects PP for a model class that does not implement the SupportsPP interface (vllm/config/model.py:1569-1576).
Why the KV cache matters for TP. TP splits not only the weights but also the KV cache of each token. So TP = 2 on two GPUs gives more than twice the free KV memory of one GPU. The weights shrink by half on each GPU, and each token needs half the KV bytes.
Deep divePipeline stages: which layers, which tensors
Which layers. get_pp_indices() splits the layers evenly. If the split is not even, it adds one extra layer to each stage, from the second-to-last stage toward the first stage. So the last stage never gets an extra layer. The docstring gives the reason: the last stage also has the final norm (vllm/distributed/utils.py:190-233). VLLM_PP_LAYER_PARTITION overrides the split, for example "11,11,10" (vllm/distributed/utils.py:206-215).
| Layers | PP | Split |
|---|---|---|
| 32 | 2 | 16, 16 |
| 32 | 3 | 11, 11, 10 (32 // 3 = 10, remainder 2 goes to stages 1 and 0) |
| 32 | 4 | 8, 8, 8, 8 |
Placeholders. make_layers() makes a list of all layers, but only the layers of this stage are real. The others are PPMissingLayer objects, which return their first input (vllm/model_executor/models/utils.py:852-860). So the layer indices stay the same on every stage, and weight names such as model.layers.20.mlp still match. The embedding and the final norm are also PPMissingLayer on the stages that do not need them (vllm/model_executor/models/llama.py:377-397).
Which tensors. The first stage starts from the token embeddings. Every other stage reads hidden_states and residual from the IntermediateTensors of the stage before it (vllm/model_executor/models/llama.py:414-423). The worker receives them with irecv_tensor_dict() (vllm/v1/worker/gpu_worker.py:1337-1347) and sends its own with isend_tensor_dict() (vllm/v1/worker/gpu_worker.py:1375-1378).
Bubbles. The engine core can keep several batches in flight. With async scheduling and Model Runner V2, max_concurrent_batches is pp_size + 1 (vllm/config/vllm.py:586-597). The code comment gives the reason: PP needs PP-size concurrent batches to fill the pipeline. So with PP = 2, up to 3 batches are in flight, and both stages can work at the same time.
Deep diveRanks, groups and the flags
The flags. --tensor-parallel-size / -tp, --pipeline-parallel-size / -pp and --data-parallel-size / -dp (vllm/engine/arg_utils.py:1198-1200, vllm/engine/arg_utils.py:1160-1164, vllm/engine/arg_utils.py:1230-1232). All three default to 1 (vllm/config/parallel.py:140-147). --enable-expert-parallel / -ep is off by default (vllm/engine/arg_utils.py:1294-1298, vllm/config/parallel.py:190-191).
The layout. vLLM puts all ranks in a tensor of shape (ExternalDP, DP, PP, PCP, TP), with reshape(-1, DP, PP, PCP, TP) (vllm/distributed/parallel_state.py:2148-2163). Each group is one line of this tensor along its dimension:
- TP groups:
all_ranks.view(-1, TP)(vllm/distributed/parallel_state.py:2168). - PP groups: move the PP dimension to the end, then reshape (
vllm/distributed/parallel_state.py:2278-2280). - DP groups: the same with the DP dimension (
vllm/distributed/parallel_state.py:2295).
Worked example: -tp 2 -pp 2. The world size is 4. The rank tensor is [[[[[0, 1]], [[2, 3]]]]]. The TP groups are [0, 1] and [2, 3]. The PP groups are [0, 2] and [1, 3].
Take two nodes of two GPUs. The multiprocess executor gives each node a range of neighbour ranks: global rank = GPUs per node × node rank + local rank (vllm/v1/executor/multiproc_executor.py:179-192). So ranks 0 and 1 are on node 0, and ranks 2 and 3 are on node 1. Then each all-reduce stays inside a node, and only the pipeline sends cross the network (derived).
vllm serve meta-llama/Llama-3.1-70B-Instruct --tensor-parallel-size 8 --pipeline-parallel-size 2What can go wrong#
- Heads do not divide. 32 query heads with TP = 3 or TP = 6 fail at start-up (
vllm/config/model.py:1559-1564). Choose a TP that divides the head count, or use PP. - The model has no PP support. PP needs a model class with
SupportsPP. Otherwise vLLM raisesNotImplementedError(vllm/config/model.py:1569-1576). - TP across nodes. Each decoder layer has two all-reduces on the critical path. Over a network between nodes this is slow. Keep TP inside a node; put PP or DP across nodes (
docs/serving/parallelism_scaling.md:9). - TP larger than the KV heads. vLLM replicates KV heads (
vllm/model_executor/layers/linear.py:1066-1068). The KV cache then stores each head several times, so the KV capacity grows less than the GPU count. - An uneven pipeline. A stage with more layers is slower, and the other stages wait for it. Check the "unevenly partitioned" log line, or set
VLLM_PP_LAYER_PARTITION(vllm/distributed/utils.py:220-228). - DP when the model does not fit. DP copies the full model to each rank, so it does not help a model that is too large. Use TP or PP first.
Why it matters. The parallel layout decides how much memory is left for the KV cache, and how much time each step spends on communication. A wrong layout can cost more throughput than any other setting.
Check yourself#
- 1Which linear layer of the Llama MLP is column-parallel, and which is row-parallel?easy
Show answer
gate_up_projis aMergedColumnParallelLinear, anddown_projis aRowParallelLinear(vllm/model_executor/models/llama.py:95-111). - 2Why does the MLP need no communication betweenmedium
gate_up_projanddown_proj?Show answer
The column split gives each GPU a slice of the intermediate features. SiLU and the multiply work on each feature alone. The row-parallel
down_projwants exactly this slice as its input (input_is_parallel=True), so only its partial outputs need an all-reduce (vllm/model_executor/layers/linear.py:1789-1806). - 3A model has 8 KV heads. You start it with TP = 16. What happens to the KV heads?medium
Show answer
Each GPU gets one KV head, and each KV head is on 16 / 8 = 2 GPUs (
vllm/model_executor/layers/linear.py:1066-1068). The start-up checktp_size % total_num_kv_heads == 0passes (vllm/model_executor/models/llama.py:152-156). The KV cache stores each head twice. - 4Withhard
-tp 2 -pp 2, which ranks form the TP groups and which form the PP groups? Why does this order help on two nodes?Show answer
The ranks form a tensor of shape (1, 1, 2, 1, 2) in the order DP, PP, PCP, TP (
vllm/distributed/parallel_state.py:2157-2163). TP groups are rows of the last dimension: [0, 1] and [2, 3]. PP groups take the PP dimension: [0, 2] and [1, 3]. Neighbour ranks share a TP group. When ranks 0-1 run on one node and 2-3 on the other, the frequent all-reduces stay inside a node. - 5A model has 32 layers and runs with PP = 3. How many layers does each stage get, and how many batches can be in flight with Model Runner V2 and async scheduling?hard
Show answer
11, 11 and 10 layers. 32 // 3 = 10 with a remainder of 2, and the code adds the extra layers to the stages before the last one (
vllm/distributed/utils.py:217-222).max_concurrent_batchesispp_size + 1= 4 (vllm/config/vllm.py:591-593).