返回博客列表

从 llama.py 源码出发:vLLM 分布式推理机制深度剖析

·AI

从 llama.py 源码出发:vLLM 分布式推理机制深度剖析

之前两篇 vLLM 文章分别讲了 PagedAttention 和 fused_moe kernel。但有一个核心问题没有展开:当模型大到单卡放不下时,vLLM 是怎么把计算分布到多张 GPU 上的?

这篇文章直接从 vLLM 的 vllm/model_executor/models/llama.py 源码出发,逐层拆解 LlamaForCausalLM → LlamaModel → LlamaDecoderLayer → LlamaAttention → LlamaMLP 的完整前向传播链路,看清每一步中 Tensor Parallel 如何切分权重、如何通信、如何与 PagedAttention 和 Pipeline Parallel 协作。

所有代码片段来自 vLLM 主分支的真实源码,不是伪代码。


一、全景:一个 Transformer 层中的分布式操作

在进入代码之前,先建立心智模型。一个标准的 Llama Decoder Layer 在 Tensor Parallel 下的数据流如下:

输入 hidden_states [batch, seq, hidden_dim](每张 GPU 持有完整副本)
     │
     ▼
┌─────────────────────────────────────────────────────────────┐
│  RMSNorm(每张 GPU 独立执行,无通信)                         │
└─────────────────┬───────────────────────────────────────────┘
                  │
     ┌────────────▼────────────┐
     │    QKV Projection       │
     │  QKVParallelLinear      │  ← Column Parallel: 按 head 维度切分
     │  每张 GPU 只算自己的    │     输出: q,k,v 各自的 head 分片
     │  head 对应的 Q/K/V      │     ★ 无通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │    Attention + RoPE     │
     │  PagedAttention kernel  │  ← 每张 GPU 独立计算自己的 head
     │  KV Cache 也按 head 分  │     ★ 无通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │    Output Projection    │
     │  RowParallelLinear      │  ← Row Parallel: 按 input 维度切分
     │  每张 GPU 计算局部结果  │     输出后做 AllReduce
     │                         │     ★ AllReduce 通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │  RMSNorm + Residual     │  ← 无通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │    Gate+Up Projection   │
     │  MergedColumnParallel   │  ← Column Parallel: 按 output 维度切分
     │  gate 和 up 合并        │     ★ 无通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │    SiLU Activation      │  ← 无通信
     └────────────┬────────────┘
                  │
     ┌────────────▼────────────┐
     │    Down Projection      │
     │  RowParallelLinear      │  ← Row Parallel: AllReduce
     │                         │     ★ AllReduce 通信
     └────────────┬────────────┘
                  │
输出 hidden_states [batch, seq, hidden_dim](每张 GPU 持有完整副本)

每个 Transformer 层有且仅有 2 次 AllReduce 通信——一次在 Attention 的 output projection 后,一次在 MLP 的 down projection 后。这是 Megatron-LM 风格 Tensor Parallel 的标准模式。


二、从最外层开始:LlamaForCausalLM

# vllm/model_executor/models/llama.py

class LlamaForCausalLM(nn.Module, SupportsLoRA, SupportsPP, SupportsEagle, SupportsEagle3):

    def __init__(self, *, vllm_config: VllmConfig, prefix: str = "", ...):
        config = vllm_config.model_config.hf_config

        # 核心模型
        self.model = LlamaModel(vllm_config=vllm_config, prefix="model", ...)

        # LM Head:只在 Pipeline Parallel 的最后一个 rank 上创建
        if get_pp_group().is_last_rank:
            self.lm_head = ParallelLMHead(
                config.vocab_size, config.hidden_size, ...)
            self.logits_processor = LogitsProcessor(config.vocab_size, ...)
        else:
            self.lm_head = PPMissingLayer()  # 占位,不分配显存

    def forward(self, input_ids, positions, intermediate_tensors=None, ...):
        model_output = self.model(input_ids, positions, intermediate_tensors, ...)
        return model_output

    def compute_logits(self, hidden_states):
        logits = self.logits_processor(self.lm_head, hidden_states)
        return logits

两个关键的分布式设计:

ParallelLMHead 的词表并行。 LM Head 将词表维度(vocab_size)沿 TP 维度切分。Llama 3 70B 的词表大小是 128,256,4 卡 TP 时每张卡只持有 ~32,064 个 token 的 logits 权重。

② Pipeline Parallel 的条件创建。 get_pp_group().is_last_rank 确保 LM Head 只在 PP 的最后一个 stage 创建。其他 stage 用 PPMissingLayer() 占位——这个占位层不分配任何显存,forward 时直接返回输入。


三、LlamaModel:Pipeline Parallel 的层分配

class LlamaModel(nn.Module, EagleModelMixin):

    def __init__(self, *, vllm_config: VllmConfig, prefix: str = "", ...):
        config = vllm_config.model_config.hf_config

        # Embedding:只在 PP 第一个 rank 创建
        if get_pp_group().is_first_rank:
            self.embed_tokens = VocabParallelEmbedding(
                config.vocab_size, config.hidden_size, ...)
        else:
            self.embed_tokens = PPMissingLayer()

        # ★ 核心:按 PP rank 分配 Decoder Layer
        self.start_layer, self.end_layer, self.layers = make_layers(
            config.num_hidden_layers,
            lambda prefix: LlamaDecoderLayer(vllm_config=vllm_config, prefix=prefix),
            prefix=f"{prefix}.layers",
        )

        # Final Norm:只在 PP 最后一个 rank 创建
        if get_pp_group().is_last_rank:
            self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
        else:
            self.norm = PPMissingLayer()

make_layers 是 Pipeline Parallel 的核心分配函数:

# vllm/model_executor/models/utils.py

def make_layers(num_hidden_layers, layer_fn, prefix):
    start_layer, end_layer = get_pp_indices(
        num_hidden_layers,
        get_pp_group().rank_in_group,
        get_pp_group().world_size
    )
    # 只实际创建当前 rank 负责的层,其余用 PPMissingLayer 占位
    modules = torch.nn.ModuleList(
        [PPMissingLayer() for _ in range(start_layer)]                    # 前面的占位
        + [layer_fn(prefix=f"{prefix}.{idx}")
           for idx in range(start_layer, end_layer)]                      # 真实层
        + [PPMissingLayer() for _ in range(end_layer, num_hidden_layers)] # 后面的占位
    )
    return start_layer, end_layer, modules

get_pp_indices 尝试均匀分配层数。例如 Llama 3 70B 有 80 层,PP=4 时:

PP Rank 0: layers 0-19   (20 层) + Embedding
PP Rank 1: layers 20-39  (20 层)
PP Rank 2: layers 40-59  (20 层)
PP Rank 3: layers 60-79  (20 层) + RMSNorm + LM Head

forward 中的 PP 数据流:

def forward(self, input_ids, positions, intermediate_tensors=None, ...):
    if get_pp_group().is_first_rank:
        # PP Rank 0:从 token embedding 开始
        hidden_states = self.embed_tokens(input_ids)
        residual = None
    else:
        # PP Rank 1-3:从上一个 rank 传来的中间结果开始
        hidden_states = intermediate_tensors["hidden_states"]
        residual = intermediate_tensors["residual"]

    # 只执行当前 rank 负责的层
    for layer in islice(self.layers, self.start_layer, self.end_layer):
        hidden_states, residual = layer(positions, hidden_states, residual)

    if not get_pp_group().is_last_rank:
        # 不是最后一个 rank:把中间结果传给下一个 rank
        return IntermediateTensors({
            "hidden_states": hidden_states,
            "residual": residual
        })

    # PP 最后一个 rank:做最终的 RMSNorm
    hidden_states, _ = self.norm(hidden_states, residual)
    return hidden_states

PP 的通信模式: Stage 之间通过 P2P(点对点)传输 IntermediateTensors。每个 micro-batch 的 hidden_statesresidual 从 Rank 0 逐级流向 Rank 3。


四、LlamaDecoderLayer:Residual + Pre-Norm

class LlamaDecoderLayer(nn.Module):

    def __init__(self, vllm_config, prefix="", ...):
        config = vllm_config.model_config.hf_config

        self.self_attn = LlamaAttention(
            config=config,
            hidden_size=config.hidden_size,        # 8192
            num_heads=config.num_attention_heads,   # 64
            num_kv_heads=config.num_key_value_heads,# 8 (GQA)
            ...)
        self.mlp = LlamaMLP(
            hidden_size=config.hidden_size,        # 8192
            intermediate_size=config.intermediate_size, # 28672
            hidden_act="silu", ...)
        self.input_layernorm = RMSNorm(config.hidden_size, ...)
        self.post_attention_layernorm = RMSNorm(config.hidden_size, ...)

    def forward(self, positions, hidden_states, residual):
        # Pre-Norm + Attention
        if residual is None:
            residual = hidden_states
            hidden_states = self.input_layernorm(hidden_states)
        else:
            hidden_states, residual = self.input_layernorm(hidden_states, residual)

        hidden_states = self.self_attn(positions=positions,
                                       hidden_states=hidden_states)

        # Pre-Norm + MLP
        hidden_states, residual = self.post_attention_layernorm(hidden_states,
                                                                residual)
        hidden_states = self.mlp(hidden_states)
        return hidden_states, residual

RMSNorm 的 fused residual add。 注意 self.input_layernorm(hidden_states, residual) 接受两个参数——它在一个 kernel 中同时完成 residual 加法和 RMSNorm,避免了一次额外的 HBM 往返。这个 fused 操作不涉及任何通信(RMSNorm 是 element-wise 的,residual 每张卡都有完整副本)。


五、LlamaAttention:Tensor Parallel 的核心

这是整篇文章的重点。

class LlamaAttention(nn.Module):

    def __init__(self, config, hidden_size, num_heads, num_kv_heads, ...):
        tp_size = get_tensor_model_parallel_world_size()

        self.total_num_heads = num_heads        # 64 (Llama 3 70B)
        self.num_heads = num_heads // tp_size   # 64/4 = 16 per GPU

        # GQA: KV heads 可能少于 TP size
        self.total_num_kv_heads = num_kv_heads  # 8 (Llama 3 70B)
        if self.total_num_kv_heads >= tp_size:
            self.num_kv_heads = num_kv_heads // tp_size  # 8/4 = 2 per GPU
        else:
            self.num_kv_heads = 1  # 不够分时每卡复制一份

        self.head_dim = hidden_size // num_heads  # 8192/64 = 128

        # ★ QKV 合并投影:Column Parallel
        self.qkv_proj = QKVParallelLinear(
            hidden_size=hidden_size,           # 8192
            head_size=self.head_dim,           # 128
            total_num_heads=num_heads,         # 64
            total_num_kv_heads=num_kv_heads,   # 8
            ...)

        # ★ Output 投影:Row Parallel
        self.o_proj = RowParallelLinear(
            input_size=num_heads * self.head_dim,  # 64*128 = 8192
            output_size=hidden_size,                # 8192
            ...)

        self.rotary_emb = get_rope(self.head_dim, ...)
        self.attn = Attention(self.num_heads, self.head_dim, self.scaling,
                              num_kv_heads=self.num_kv_heads, ...)

    def forward(self, positions, hidden_states):
        qkv, _ = self.qkv_proj(hidden_states)       # Column Parallel, 无通信
        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)              # PagedAttention, 无通信
        output, _ = self.o_proj(attn_output)          # Row Parallel, AllReduce!
        return output

5.1 QKVParallelLinear:按 Head 切分

QKVParallelLinear 继承自 ColumnParallelLinear,将 Q、K、V 的投影权重合并为一个矩阵,并按 head 维度切分到各 GPU。

# vllm/model_executor/layers/linear.py

class QKVParallelLinear(ColumnParallelLinear):
    def __init__(self, hidden_size, head_size, total_num_heads,
                 total_num_kv_heads, ...):
        tp_size = get_tensor_model_parallel_world_size()

        # 每张 GPU 分到的 head 数
        self.num_heads = divide(total_num_heads, tp_size)
        if tp_size >= total_num_kv_heads:
            self.num_kv_heads = 1  # KV head 不够分,每卡复制一份
        else:
            self.num_kv_heads = divide(total_num_kv_heads, tp_size)

        # 输出维度 = Q heads + K heads + V heads(每卡的)
        # 再乘 tp_size 是因为父类 ColumnParallelLinear 会再除以 tp_size
        self.output_sizes = [
            self.num_heads * head_size * tp_size,      # Q
            self.num_kv_heads * head_size * tp_size,   # K
            self.num_kv_heads * head_size * tp_size,   # V
        ]

以 Llama 3 70B (TP=4) 为例:

原始权重:
  W_Q: [8192, 8192]    = [hidden_dim, 64 heads × 128 head_dim]
  W_K: [8192, 1024]    = [hidden_dim, 8 heads × 128 head_dim]
  W_V: [8192, 1024]    = [hidden_dim, 8 heads × 128 head_dim]
  合并: [8192, 10240]   = [hidden_dim, (64+8+8) × 128]

TP=4 切分后,每张 GPU 持有:
  W_Q_shard: [8192, 2048]  = [hidden_dim, 16 heads × 128]
  W_K_shard: [8192, 256]   = [hidden_dim, 2 heads × 128]
  W_V_shard: [8192, 256]   = [hidden_dim, 2 heads × 128]
  合并 shard: [8192, 2560]  = [hidden_dim, (16+2+2) × 128]

切分方式(Column Parallel):
┌──────────── 10240 (output_dim) ──────────────┐
     Q (8192)        K (1024)    V (1024)     完整权重
├───────────────────────────────────────────────┤
│Q_0 (2048)|Q_1|Q_2|Q_3|K_0|K_1|K_2|K_3|V_0|..│   head 分给 4  GPU
  GPU 0   |GP1|GP2|GP3|G0 |G1 |G2 |G3 |G0 |  
└───────────────────────────────────────────────┘

Column Parallel 的关键特性:输入不切分,输出切分,无需通信。 每张 GPU 拿到完整的 hidden_states,乘以自己的权重分片,得到自己负责的 head 的 Q/K/V。

5.2 GQA (Grouped Query Attention) 与 TP 的交互

Llama 3 70B 使用 GQA:64 个 Q head,只有 8 个 KV head。这意味着每 8 个 Q head 共享 1 个 KV head。

在 TP=4 时:

  • 每张 GPU 分到 16 个 Q head 和 2 个 KV head
  • 每 8 个 Q head 共享 1 个 KV head → 分配合理

但如果 TP=16(KV head 数量少于 TP size):

  • 每张 GPU 分到 4 个 Q head,但 KV head 只有 8 个不够 16 张卡分
  • 此时 num_kv_heads = 1,每张卡复制一份 KV head(不切分)
# QKVParallelLinear 中的 KV head 处理逻辑
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

5.3 RowParallelLinear:AllReduce 通信

Output Projection(o_proj)使用 RowParallelLinear——按输入维度切分,输出后做 AllReduce。

class RowParallelLinear(LinearBase):
    def __init__(self, input_size, output_size, ...):
        tp_size = get_tensor_model_parallel_world_size()
        # 每张 GPU 的输入维度 = 总输入维度 / TP
        self.input_size_per_partition = divide(input_size, tp_size)
        # 输出维度不切分
        self.output_size_per_partition = output_size

    def forward(self, input_):
        # 输入已经是并行的(来自上一层的 Column Parallel 输出)
        input_parallel = input_

        # 局部矩阵乘:每张 GPU 用自己的 shard 计算
        output_parallel = self.quant_method.apply(self, input_parallel, bias_)

        # ★ AllReduce:把所有 GPU 的局部结果相加
        if self.reduce_results and self.tp_size > 1:
            output = tensor_model_parallel_all_reduce(output_parallel)
        else:
            output = output_parallel

        return output, output_bias

以 o_proj 为例(Llama 3 70B, TP=4):

原始权重 W_O: [8192, 8192]

TP=4 切分(Row Parallel,按 input_dim 切分):
  GPU 0: W_O_0 = [8192, 2048]  (input_dim 0:2048)
  GPU 1: W_O_1 = [8192, 2048]  (input_dim 2048:4096)
  GPU 2: W_O_2 = [8192, 2048]  (input_dim 4096:6144)
  GPU 3: W_O_3 = [8192, 2048]  (input_dim 6144:8192)

计算过程:
  GPU 0: y_0 = x_0 × W_O_0   (x_0  GPU 0  attention 输出)
  GPU 1: y_1 = x_1 × W_O_1
  GPU 2: y_2 = x_2 × W_O_2
  GPU 3: y_3 = x_3 × W_O_3

AllReduce:
  y = y_0 + y_1 + y_2 + y_3   (每张 GPU 得到完整的 y)

为什么 Column Parallel + Row Parallel 能配对? 因为数学上 Y = X × [A_0, A_1, ..., A_p] 等价于把 A 按列切分后各自算再拼接(Column Parallel 的输出拼接在逻辑上就是完整结果的不同部分)。而下一步的 Row Parallel 把这些部分直接作为输入的不同维度,各自乘以权重的行分片,最后 AllReduce 求和得到完整结果。两者串联恰好消除了中间的通信——Column Parallel 的分片输出直接是 Row Parallel 的分片输入。


六、LlamaMLP:相同的切分模式

class LlamaMLP(nn.Module):

    def __init__(self, hidden_size, intermediate_size, hidden_act, ...):
        # Gate 和 Up 合并为一个 Column Parallel 层
        self.gate_up_proj = MergedColumnParallelLinear(
            input_size=hidden_size,                    # 8192
            output_sizes=[intermediate_size] * 2,      # [28672, 28672]
            ...)
        # Down Projection: Row Parallel
        self.down_proj = RowParallelLinear(
            input_size=intermediate_size,               # 28672
            output_size=hidden_size,                    # 8192
            ...)
        self.act_fn = SiluAndMul()

    def forward(self, x):
        x, _ = self.gate_up_proj(x)   # Column Parallel, 无通信
        x = self.act_fn(x)            # SiLU(gate) * up, 无通信
        x, _ = self.down_proj(x)      # Row Parallel, AllReduce!
        return x

6.1 MergedColumnParallelLinear

MergedColumnParallelLinearColumnParallelLinear 的变体——把多个逻辑独立的 Column Parallel 层合并到一个物理权重矩阵中。在 Llama 的 MLP 中,gate_proj 和 up_proj 共享相同的输入,所以可以合并。

class MergedColumnParallelLinear(ColumnParallelLinear):
    def __init__(self, input_size, output_sizes, ...):
        self.output_sizes = output_sizes  # [28672, 28672]
        tp_size = get_tensor_model_parallel_world_size()

        # 每个子层的输出维度除以 TP
        assert all(size % tp_size == 0 for size in output_sizes)
        # 总输出 = sum(output_sizes) = 57344
        super().__init__(input_size=input_size, output_size=sum(output_sizes), ...)

以 Llama 3 70B (TP=4) 为例:

原始权重:
  W_gate: [8192, 28672]
  W_up:   [8192, 28672]
  合并:    [8192, 57344]

TP=4 切分(Column Parallel,按 output_dim 切分):
  GPU 0: [8192, 14336]  = gate_shard[8192,7168] + up_shard[8192,7168]
  GPU 1: [8192, 14336]
  GPU 2: [8192, 14336]
  GPU 3: [8192, 14336]

6.2 权重加载时的 Shard 映射

HuggingFace 的 checkpoint 中 gate_proj 和 up_proj 是分开存储的,但 vLLM 内部合并为 gate_up_projload_weights 中的 stacked_params_mapping 处理这个映射:

# LlamaModel.load_weights()
stacked_params_mapping = [
    # (vllm内部名, HF权重名, shard_id)
    (".qkv_proj", ".q_proj", "q"),
    (".qkv_proj", ".k_proj", "k"),
    (".qkv_proj", ".v_proj", "v"),
    (".gate_up_proj", ".gate_proj", 0),
    (".gate_up_proj", ".up_proj", 1),
]

# 加载时:
# HF 权重 "layers.0.mlp.gate_proj.weight" → vLLM "layers.0.mlp.gate_up_proj.weight", shard_id=0
# HF 权重 "layers.0.mlp.up_proj.weight"   → vLLM "layers.0.mlp.gate_up_proj.weight", shard_id=1
# weight_loader 根据 shard_id 和 tp_rank 将权重写入正确的位置

七、Llama 3 70B 在 4×A100 上的具体数字

让我们用具体数字算清楚每层的参数分布。

7.1 模型配置

Llama 3 70B 配置:
  hidden_size = 8192
  num_attention_heads = 64
  num_key_value_heads = 8 (GQA)
  head_dim = 128
  intermediate_size = 28672
  num_hidden_layers = 80
  vocab_size = 128256

7.2 TP=4 时每张 GPU 的权重

每张 GPU  Attention 权重:
┌──────────────────────────────────────────────────────────┐
 层名称               完整形状           每卡形状         每卡参数量   
├──────────────────────────────────────────────────────────┤
 qkv_proj (Column)    [8192, 10240]     [8192, 2560]    20.97M      
   Q 部分             [8192, 8192]      [8192, 2048]    16.78M      
   K 部分             [8192, 1024]      [8192, 256]     2.10M       
   V 部分             [8192, 1024]      [8192, 256]     2.10M       
 o_proj (Row)         [8192, 8192]      [2048, 8192]    16.78M      
├──────────────────────────────────────────────────────────┤
 Attention 合计/卡                                      37.75M      
└──────────────────────────────────────────────────────────┘

每张 GPU  MLP 权重:
┌──────────────────────────────────────────────────────────┐
 gate_up_proj (Column)│ [8192, 57344]    [8192, 14336]   117.44M     
   gate 部分           [8192, 28672]    [8192, 7168]    58.72M      
   up 部分             [8192, 28672]    [8192, 7168]    58.72M      
 down_proj (Row)       [28672, 8192]    [7168, 8192]    58.72M      
├──────────────────────────────────────────────────────────┤
 MLP 合计/卡                                            176.16M     
└──────────────────────────────────────────────────────────┘

每层 Decoder 合计/卡:~213.91M 参数
80 层总计/卡:~17.11B 参数

加上 Embedding + LM Head(词表并行):
  Embedding: 128256 × 8192 / 4  262.66M / 
  LM Head:   8192 × 128256 / 4  262.66M / 卡(tie_word_embeddings 时共享)

每卡总参数:~17.64B(BF16  ~35.3 GB)
4 卡总计:~70.6B(符合 70B 参数量)

7.3 通信量分析

每个 Decoder Layer 的 AllReduce 通信量:
  o_proj 后: hidden_states [batch × seq, 8192] → 2 次传输(reduce-scatter + all-gather)
  down_proj 后: 同上

  单次 AllReduce 数据量 = batch × seq × 8192 × 2 bytes (BF16)

  假设 batch=32, seq=1(Decode 阶段):
  单次 = 32 × 8192 × 2 = 512 KB
  每层 2 次 = 1 MB
  80 层 = 80 MB

  A100 NVLink 带宽: 600 GB/s(双向)
  通信时间 ≈ 80 MB / 600 GB/s ≈ 0.13 ms
  远小于计算时间,通信不是瓶颈

7.4 KV Cache 分配

KV Cache 也按 TP 切分(每张 GPU 只缓存自己负责的 KV head):

每个 token 的 KV Cache(每卡):
  K: num_kv_heads_per_gpu × head_dim = 2 × 128 = 256 元素
  V: 同上 = 256 元素
  每层 KV: 512 × 2 bytes (BF16) = 1024 bytes = 1 KB
  80 层: 80 KB / token / GPU

对比完整 KV Cache(不分片):
  K: 8 × 128 = 1024 元素
  V: 同上 = 1024 元素
  每层 KV: 2048 × 2 = 4096 bytes = 4 KB
  80 层: 320 KB / token

TP=4 时 KV Cache 被均匀分摊到 4 张卡,每卡 80 KB/token。
假设最大上下文 8192 tokens:
  每卡 KV Cache = 8192 × 80 KB = 640 MB

PagedAttention block 分配(block_size=16):
  每个 block 存 16 个 token 的 KV
  每 block 大小 = 16 × 80 KB = 1.28 MB(每卡)
  8192 tokens 需要 512 个 block

八、PagedAttention 与分布式的结合

PagedAttention 的分页 KV Cache 管理在分布式场景下有一个优雅的设计:Block Table 是全局的,KV 数据是按 TP 分片的。

                        BlockManager (CPU 端,全局)
                        ┌─────────────────────────┐
                         Block Table:             
                         Seq 0  [B3, B7, B1]      所有 GPU 共享同一份 Block Table
                         Seq 1  [B5, B2]        
                         Seq 2  [B0, B4, B6]    
                        └────────────┬────────────┘
                                     
              ┌──────────────────────┼──────────────────────┐
                                                          
         GPU 0 KV Cache         GPU 1 KV Cache         GPU 2 KV Cache
   ┌──────────────────┐   ┌──────────────────┐   ┌──────────────────┐
    Block 0: KV 分片     Block 0: KV 分片     Block 0: KV 分片 
    (heads 0-1)          (heads 2-3)          (heads 4-5)      
    Block 1: ...         Block 1: ...         Block 1: ...     
    ...                  ...                  ...              
   └──────────────────┘   └──────────────────┘   └──────────────────┘

   每张 GPU 的每个 block 只存储自己负责的 KV head
   Block 编号在所有 GPU 间一致(Block 3 在所有 GPU 上代表同一段 token)

为什么这样设计?

  1. Block Table 不需要通信。 调度器(Scheduler)在 CPU 端维护全局 Block Table,每次 forward 前通过 InputMetadata 广播给所有 GPU。由于 Block Table 只是整数索引(每个序列几十个),数据量极小。

  2. KV 数据天然按 head 分片。 QKVParallelLinear 的输出已经是按 head 切分的——GPU 0 计算 head 0-15 的 Q/K/V,自然只需要缓存 head 0-1 的 K/V(GQA 下 16 个 Q head 共享 2 个 KV head)。

  3. PagedAttention kernel 内部无通信。 每张 GPU 独立执行自己负责的 head 的 attention 计算,读取自己的 KV Cache block。attention 结果的合并在后续的 o_proj AllReduce 中完成。


九、Embedding 与 LM Head 的词表并行

9.1 VocabParallelEmbedding

Embedding 层按词表维度切分:

class VocabParallelEmbedding(CustomOp):
    def __init__(self, num_embeddings, embedding_dim, ...):
        tp_rank = get_tensor_model_parallel_rank()
        self.tp_size = get_tensor_model_parallel_world_size()
        # 词表按 TP 切分
        # 例如 vocab=128256, TP=4:
        #   GPU 0: token 0-32063
        #   GPU 1: token 32064-64127
        #   GPU 2: token 64128-96191
        #   GPU 3: token 96192-128255

forward 过程:

  1. 每张 GPU 检查 input_ids 中哪些 token 属于自己的词表分片
  2. 属于自己的 token 正常查 embedding table
  3. 不属于自己的 token 输出零向量
  4. AllReduce 求和(零向量 + 正确 embedding = 正确 embedding)

9.2 ParallelLMHead

LM Head 同样按词表维度切分。最终的 logits 需要 AllGather 拼接成完整的词表分布:

GPU 0: logits_0 [batch, 32064]   (token 0-32063 的分数)
GPU 1: logits_1 [batch, 32064]
GPU 2: logits_2 [batch, 32064]
GPU 3: logits_3 [batch, 32064]
    ↓ AllGather
完整 logits [batch, 128256]

十、完整数据流图

以 Llama 3 70B, TP=4, PP=1 为例,一个 token 在一个 Decoder Layer 中的完整数据流:

hidden_states [B, 8192](每张 GPU 持有完整副本)

├─ RMSNorm ─────────────────────────────── 每卡独立,无通信

├─ QKVParallelLinear ────────────────────── Column Parallel
     GPU 0: [B, 8192] × [8192, 2560]  [B, 2560]  (16 Q + 2 K + 2 V heads)
     GPU 1: [B, 8192] × [8192, 2560]  [B, 2560]
     GPU 2: [B, 8192] × [8192, 2560]  [B, 2560]
     GPU 3: [B, 8192] × [8192, 2560]  [B, 2560]
      无通信
   
   ├─ split  Q[B, 2048], K[B, 256], V[B, 256] per GPU
   ├─ RoPE(Q, K) ───────────────────────── 每卡独立
   
   ├─ PagedAttention(Q, K, V, KV_Cache) ── 每卡独立
       GPU 0: 16 Q heads attend to 2 KV heads (GQA ratio 8:1)
       GPU 1: 同上
       GPU 2: 同上
       GPU 3: 同上
       KV Cache blocks  head 分片,Block Table 全局共享
        无通信
     
     └→ attn_output [B, 2048] per GPU
   
   └─ RowParallelLinear (o_proj) ────────── Row Parallel
        GPU 0: [B, 2048] × [2048, 8192]  [B, 8192] (局部)
        GPU 1: [B, 2048] × [2048, 8192]  [B, 8192] (局部)
        GPU 2: [B, 2048] × [2048, 8192]  [B, 8192] (局部)
        GPU 3: [B, 2048] × [2048, 8192]  [B, 8192] (局部)
      
      └─  AllReduce: output = sum(局部结果)  [B, 8192] per GPU

├─ residual + RMSNorm ──────────────────── 每卡独立

├─ MergedColumnParallelLinear (gate_up) ── Column Parallel
     GPU 0: [B, 8192] × [8192, 14336]  [B, 14336] (gate[7168] + up[7168])
      无通信
   
   ├─ SiLU(gate) * up  [B, 7168] per GPU
   
   └─ RowParallelLinear (down_proj) ───── Row Parallel
        GPU 0: [B, 7168] × [7168, 8192]  [B, 8192] (局部)
      
      └─  AllReduce  [B, 8192] per GPU

└→ hidden_states [B, 8192](每张 GPU 持有完整副本)

通信总结:每层 2  AllReduce,每次传输 B × 8192 × 2 bytes

十一、Pipeline Parallel vs Tensor Parallel

11.1 核心区别

Tensor Parallel (TP):                 Pipeline Parallel (PP):
┌───────┐ ┌───────┐ ┌───────┐        ┌───────┐ ┌───────┐ ┌───────┐
│ GPU 0 │ │ GPU 1 │ │ GPU 2 │        │ GPU 0 │→│ GPU 1 │→│ GPU 2 │
│ Layer │ │ Layer │ │ Layer │        │ Layer │ │ Layer │ │ Layer │
│  0    │ │  0    │ │  0    │        │ 0-26  │ │27-53  │ │54-79  │
│ (1/3) │ │ (1/3) │ │ (1/3) │        │ (完整)│ │(完整) │ │(完整) │
├───────┤ ├───────┤ ├───────┤        └───────┘ └───────┘ └───────┘
│ Layer │ │ Layer │ │ Layer │
│  1    │ │  1    │ │  1    │        每层完整参数在一张 GPU 上
│ (1/3) │ │ (1/3) │ │ (1/3) │        层间 P2P 传输 hidden_states
│  ...  │ │  ...  │ │  ...  │        有 pipeline bubble(空闲等待)
└───────┘ └───────┘ └───────┘

每层按维度切分到多张 GPU
层内 AllReduce 通信
无 pipeline bubble

11.2 对比分析

维度 Tensor Parallel (TP) Pipeline Parallel (PP)
切分方式 每层的权重按维度切分 不同层分配到不同 GPU
通信模式 AllReduce(每层 2 次) P2P(层间传 hidden_states)
通信量/步 O(batch × hidden_dim) O(batch × seq × hidden_dim)
通信频率 每层 2 次 每个 micro-batch 流转时
延迟敏感性 需要高带宽互联(NVLink) 可容忍较高延迟
Pipeline Bubble 有(需要 micro-batching 缓解)
单卡显存 减少到 1/TP 减少到 ~1/PP
适用互联 节点内(NVLink, 600 GB/s) 节点间(InfiniBand, ~400 Gb/s)

11.3 适用场景

选择策略决策树:

模型放得下单卡?
├── 是 → 不需要并行
└── 否
    ├── 放得下单节点(多卡)?
    │   ├── 是 → 纯 TP(性能最优,NVLink 带宽充足)
    │   └── 否 → TP + PP 混合
    │       ├── 节点内 TP(利用 NVLink)
    │       └── 节点间 PP(利用 InfiniBand/RoCE)
    │
    └── 极大模型(MoE 256 experts)?
        → TP + PP + EP(Expert Parallel)

典型配置:

模型 参数量 推荐配置 说明
Llama 3 8B 8B TP=1 单卡 A100 80GB 即可
Llama 3 70B 70B TP=4 或 TP=8 4×A100 (TP=4) 或 8×A100 (TP=8)
Llama 3.1 405B 405B TP=8, PP=2-4 2-4 节点,节点内 TP=8,节点间 PP
DeepSeek V3 671B TP=8, PP=4-8, EP=32-64 多节点,大规模 EP

11.4 PP 的 Bubble 问题

Pipeline Parallel 的固有问题是 pipeline bubble——当一个 micro-batch 在某个 stage 计算时,其他 stage 可能在空闲等待。

PP=4, 1 个 micro-batch 的时间线(最差情况):

Stage 0: [████]
Stage 1:       [████]
Stage 2:             [████]
Stage 3:                   [████]
                                  ↑ 只有 1/4 的 GPU 在工作

PP=4, 4 个 micro-batch(pipeline 填满后):

Stage 0: [M0][M1][M2][M3]         ← bubble 被 micro-batch 填充
Stage 1:     [M0][M1][M2][M3]
Stage 2:         [M0][M1][M2][M3]
Stage 3:             [M0][M1][M2][M3]
          ↑ bubble ↑              ↑ bubble ↑

vLLM 使用 micro-batching 来缓解 bubble。一般建议 micro-batch 数量 >= PP stages 数量,让 pipeline 尽量填满。


十二、总结

回顾 vLLM 中 Llama 的分布式推理,核心设计可以归纳为几个原则:

① Column-Row Parallel 配对消除中间通信。 QKV projection 用 Column Parallel(输出切分),O projection 用 Row Parallel(输入切分),两者串联只需一次 AllReduce。MLP 的 gate_up 和 down 也是同样的模式。

② 每个 Transformer 层恰好 2 次 AllReduce。 不多不少——Attention 结束一次,MLP 结束一次。这是 Megatron-LM 在 2020 年提出的经典方案,至今仍是最高效的 TP 策略。

③ KV Cache 天然按 head 分片。 不需要额外的分片逻辑——QKVParallelLinear 的输出已经是按 head 切分的,PagedAttention kernel 直接操作本地 KV Cache。Block Table 全局共享但数据量极小。

④ Pipeline Parallel 通过层分配实现。 make_layers + PPMissingLayer + IntermediateTensors 构成了一个干净的 PP 抽象——每个 rank 只创建自己负责的层,forward 时通过 P2P 传递中间状态。

⑤ 权重加载时做分片,推理时零额外开销。 所有的切分和分配在 load_weights 阶段完成(通过 weight_loader 从完整 checkpoint 中提取对应的 shard)。推理时每张 GPU 就像在运行一个「小模型」——只是 GEMM 的维度更小,加上两次 AllReduce 通信。

这些设计的共同目标是:让分布式推理的通信开销尽可能小,让每张 GPU 的计算效率尽可能接近单卡水平。 在 NVLink 互联的节点内,通信延迟可以控制在总推理时间的 5% 以内,基本实现了线性 scaling。


参考资源