vLLM 推理内核深度解析

第14章 分布式并行:TP / PP / EP / DP

作者 杨艺韬 · 15,650 字 · 发布于 · 更新于

分而治之,但前提是分出去的各块还能互相说上话。HBM、NVLink、InfiniBand 三级带宽逐级下降、首尾相差将近两个数量级——并行策略的全部取舍都在这道鸿沟上。

本章要点

  • 理解分布式推理的根本矛盾:GPU 内 HBM、机内 NVLink、跨机 InfiniBand 三级带宽逐级下降、首尾相差将近两个数量级,这个鸿沟决定了并行策略的取舍
  • 读懂 Megatron 式张量并行的数学构造:列并行(Column Parallel)如何切 QKV 投影,行并行(Row Parallel)如何切 output projection + MLP down projection
  • 掌握流水线并行的微批次调度、气泡率公式 PP−1PP−1+mb\frac{PP-1}{PP-1+mb}
  • 理解 MoE 模型的专家并行(Expert Parallel, EP):为什么它是 DeepSeek-V3 / Mixtral 这类模型的刚需
  • 看懂数据并行(DP)——最简单但最赚钱的一级:多副本、无状态同步、线性扩吞吐
  • 读懂 vllm/distributed/parallel_state.py 如何用一个 4D torch.arange 切出 TP / PP / DP 三组 ProcessGroup(EP 由 MoE 层另行创建)
  • 了解 Hopper 上 NCCL 的 SHARP / NVLS 原语:把规约下沉到交换机,从而减少 AllReduce 的网络往返
  • 掌握 Prefill/Decode 分离(Disaggregated Serving)这个 2024-2025 的新范式
  • 拿到四个典型部署场景的 vllm serve 配置样板(本专栏无多卡环境,不给实测数字)

14.1 分布式推理的根本矛盾:带宽鸿沟

在讲任何具体策略之前,必须先理解我们面对的硬件约束。从单卡显存到跨机网络,带宽能差将近两个数量级:

层级 硬件 带宽 相对基准
GPU 内 HBM3 (H100 SXM) 3.35 TB/s 1×(基准)
GPU 间(同机,NVLink 4) H100 NVSwitch 900 GB/s(双向合计) 0.27×
GPU 间(同机,PCIe 5) PCIe Gen5 x16 128 GB/s(双向合计) 0.04×
机间(InfiniBand NDR) 400 Gb/s 单端口 50 GB/s(单向) 0.015×
机间(以太网 100G) 100 GbE 12.5 GB/s(单向) 0.004×

前三行取自 NVIDIA H100 SXM 规格表,后两行是线速换算(400 Gb/s ÷ 8、100 Gb/s ÷ 8);各行口径(单向 / 双向合计)不同,相对值只用来看数量级。

"分布式推理的第一性原理"就是:GPU 内部的计算和访存是免费的;跨 GPU 的通信必须精打细算;跨机器的通信要当宝贝。

每一种并行策略本质上都是在"把什么放在 GPU 内部计算 vs 什么需要通过通信传递"之间做取舍。四种主流策略(TP / PP / EP / DP)各自的通信特征:

graph TB
    subgraph "四种并行的通信指纹"
        TP["TP 张量并行<br/>每 Transformer 层 2 次 AllReduce<br/>每次传 tokens × hidden × dtype<br/>适合 NVLink"]
        PP["PP 流水线并行<br/>阶段边界 1 次 P2P 传 activations<br/>通信稀疏但有 bubble<br/>适合跨机"]
        EP["EP 专家并行<br/>通用方案:MoE 每层 2 次 all_to_all<br/>(v0.8.5 实为 AllReduce,见 §14.4.3)<br/>适合同机 NVLink"]
        DP["DP 数据并行<br/>副本间无模型内通信<br/>(内建 DP 有同步点,§14.5.1)<br/>适合任何位置,线性扩吞吐"]
    end
    style TP fill:#2563eb,color:#fff,stroke:none
    style PP fill:#10b981,color:#333,stroke:none
    style EP fill:#f59e0b,color:#333,stroke:none
    style DP fill:#7c3aed,color:#fff,stroke:none

读懂这张图就掌握了分布式部署的全局观。接下来我们逐个拆解。

14.1.1 四种并行策略的横向对比表

把 TP/PP/EP/DP 的关键属性放在一张表里对照——这是做部署决策的唯一凭据:

维度 TP 张量并行 PP 流水线并行 EP 专家并行 DP 数据并行
切分对象 单层权重(按列 / 按行) 不同层分到不同 rank MoE 的 expert 子模块 完整模型 N 份复制
通信原语 每层 2 次 AllReduce 阶段边界 P2P send/recv 通用方案每层 2 次 all_to_all(v0.8.5 实为每层 1 次 AllReduce,见 §14.4.3) 外部多副本无;vLLM 内建 DP 有周期性同步(§14.5.1)
通信频率 极高(80 层 × 2 = 160 次/step) 低(PP-1 次/step) 中(MoE 层数 × 2) 零
单次通信量 batch × hidden × dtype(KB 级) batch × hidden × dtype(KB 级) batch × hidden × top_k(MB 级) 0
显存占用 1/TP × 模型 1/PP × 模型 非 expert 部分全量 + 1/EP × expert 完整模型 × DP
能否跨机 不建议(跨 IB AllReduce 拖垮 decode) 是(P2P 通信稀疏) 一般不跨(all_to_all 怕 IB) 可以(本来就无通信)
气泡/空转 无 有,公式 (PP-1)/(PP-1+M) 无(但有负载不均) 无
吞吐扩展性 亚线性(通信拖累) 亚线性(bubble 拖累) 亚线性(all_to_all) 线性(瓶颈只在前端 LB)
延迟影响 降单请求延迟 增单请求延迟 略增延迟 不影响单请求延迟
vLLM 源码位置(V1) vllm/model_executor/layers/linear.py 的 ColumnParallelLinear/RowParallelLinear 中间阶段 execute_model 返回 IntermediateTensors,由 Ray compiled DAG 在阶段间传递(vllm/executor/ray_distributed_executor.py 的 _compiled_ray_dag,§14.9.2);V0 则由 vllm/worker/worker_base.py 调 PP 组的 send_tensor_dict/recv_tensor_dict vllm/model_executor/layers/fused_moe/ vllm/v1/engine/core_client.py 的 DPAsyncMPClient(引擎级,不在 Executor 里)
典型使用场景 单机大模型(70B / 405B) 跨机超大模型(671B+) MoE 模型(DeepSeek-V3、Mixtral) 高并发无状态 chat

关键取舍经验法则:

  • 降延迟用 TP——TP 把单个 forward 的算力摊到多卡、单 token 延迟随之下降(到不了 1/TP,通信要吃掉一部分)
  • 扩模型用 PP——PP 把模型层数分摊、显存占用降到 1/PP、但延迟增加(PP 串行累加)
  • 扩吞吐用 DP——DP 线性扩副本、延迟不变、吞吐翻倍
  • MoE 可选 EP——不开 EP 时 vLLM 按 TP 把每个专家的 intermediate 维切成 1/TP;开 EP 则改为每卡持有整块的 1/EP 个专家(§14.4.3)

选型决策树(从上往下问):

flowchart TB
    Q1{"单卡显存能放下?"}
    Q2{"单机总显存能放下?"}
    Q3{"是 MoE 模型?"}
    Q4{"需要跨 AZ / 高可用?"}
    A1[DP=N 横向扩<br/>单卡单副本,最简单]
    A2[TP=单机卡数<br/>纯 TP 吃满 NVLink]
    A3[TP=单机卡数 + EP=单机卡数<br/>专家和张量双维切分]
    A4[TP × PP × DP<br/>机内 TP,机间 PP,外层 DP]
    A5[TP × PP 跨机<br/>DP 默认 1]
    Q1 -->|是| A1
    Q1 -->|否| Q2
    Q2 -->|是| Q3
    Q3 -->|是| A3
    Q3 -->|否| A2
    Q2 -->|否| Q4
    Q4 -->|是| A4
    Q4 -->|否| A5
    style A1 fill:#7c3aed,color:#fff,stroke:none
    style A2 fill:#2563eb,color:#fff,stroke:none
    style A3 fill:#f59e0b,color:#333,stroke:none
    style A4 fill:#10b981,color:#333,stroke:none
    style A5 fill:#10b981,color:#333,stroke:none

这张决策树串起了全章逻辑——每个叶节点都对应 14.9 节的一份 vllm serve 配置样板。

14.1.2 与第 1 章架构总图的串联

第 1 章给出过 vLLM V1 的整体分层:AsyncLLM / LLMEngine → EngineCore → Executor → Worker → ModelRunner → AttentionBackend(vllm serve 走 AsyncLLM,离线 LLM 类走 V1 LLMEngine)。分布式发生在哪一层?

层 是否感知分布式 注释
AsyncLLM / LLMEngine / EngineCore 基本不感知 只产出 SchedulerOutput、不知道下游有几个 Worker;例外是 PP 时按 max_concurrent_batches 开 batch 队列,DP 时 DPEngineCoreProc 要与其他 DP rank 对拍(§14.5.1)
Executor 感知 MultiprocExecutor / RayDistributedExecutor 的职责就是把任务分发到多 Worker
Worker 部分感知 知道自己的 tp_rank / pp_rank / dp_rank、但不知道总拓扑
ModelRunner 基本不感知 只负责 forward 一次、分布式是 model 层面(ColumnParallelLinear)的事;PP 时要判断自己是不是首/末阶段
AttentionBackend 基本不感知 只要 QKV 已经按 TP 切好、attention 计算本身和单卡相同

关键抽象:分布式边界大体落在 Executor ↔ Worker 之间——上面(Engine/Scheduler)基本是单机视角、下面(Worker/ModelRunner)知道自己是多卡的一份子。这是 vLLM 整个代码库保持清晰的核心工程决策——单机调试和千卡生产共享绝大部分代码路径。

对照第 1 章的架构图:TP/PP/EP 在 ColumnParallelLinear + parallel_state.py 级别实现(Worker 内部),而 DP 比 Executor 还要高一层——v0.8.5 里(vllm serve 的 AsyncLLM 路径)它由 vllm/v1/engine/core_client.py:826 的 DPAsyncMPClient 实现,为每个 DP rank 起一整个 EngineCore 进程(各自带自己的 Scheduler 和 Executor),客户端侧负责把请求分派过去。两类并行在代码栈里处于完全不同的层:一个在模型内部切矩阵,一个在引擎外部复制实例。读懂这点,本章所有代码导航就都有了锚点。

14.2 张量并行(TP):Megatron 式矩阵切分

张量并行的数学基础来自 Megatron-LM(Shoeybi et al., 2019 / arXiv:1909.08053)。核心洞察:矩阵乘法 Y=XWY = XW 可以按 W 的列或行切分,再通过通信合并结果。

14.2.1 列并行(Column Parallel)

把 W 沿"输出维度"(列)切成 P 份:W=[W1∣W2∣…∣WP]W = [W_1 | W_2 | \dots | W_P]

每个 GPU 拿一片 WiW_i 的列,输入 XX 在所有 GPU 上相同(已经通过前面的 AllReduce 同步)。计算:

Y=XW=[XW1∣XW2∣…∣XWP]Y = X W = [X W_1 | X W_2 | \dots | X W_P]

输出 YY 天然按列切分——GPU i 持有 YY 的第 i 块列。无需通信。

vLLM 的 ColumnParallelLinear:

# vllm/model_executor/layers/linear.py:345(概念性简化)
class ColumnParallelLinear(LinearBase):
    def __init__(self, input_size: int, output_size: int, ...):
        self.tp_size = get_tensor_model_parallel_world_size()
        # 每张卡持有 output_size / tp_size 列
        self.output_size_per_partition = output_size // self.tp_size

        # 权重 shape: [output_size_per_partition, input_size]
        self.weight = Parameter(torch.empty(
            self.output_size_per_partition, input_size,
            dtype=params_dtype,
        ))

    def forward(self, input_):
        # 每张卡独立计算自己那片 W_i @ X
        output = F.linear(input_, self.weight)
        # gather_output 通常为 False —— 保持输出列切分,给下一层用
        return output

14.2.2 行并行(Row Parallel)

把 W 沿"输入维度"(行)切成 P 份:W=[W1;W2;… ;WP]W = [W_1; W_2; \dots; W_P]

输入 X 也按列切分成 P 份:X=[X1∣X2∣…∣XP]X = [X_1 | X_2 | \dots | X_P]

Y=XW=X1W1+X2W2+⋯+XPWPY = X W = X_1 W_1 + X_2 W_2 + \dots + X_P W_P

每个 GPU 计算自己的 XiWiX_i W_i,结果是完整 YY 的部分和。最后必须通过 AllReduce 把所有 P 片加起来——这就是 TP 每层都要做的那个 AllReduce。

vLLM 的 RowParallelLinear:

# vllm/model_executor/layers/linear.py:1135(概念性简化;实际仅在 reduce_results=True 且 tp_size > 1 时 AllReduce)
class RowParallelLinear(LinearBase):
    def __init__(self, input_size: int, output_size: int, ...):
        self.tp_size = get_tensor_model_parallel_world_size()
        # 每张卡持有 input_size / tp_size 行
        self.input_size_per_partition = input_size // self.tp_size
        self.weight = Parameter(torch.empty(
            output_size, self.input_size_per_partition,
        ))

    def forward(self, input_):
        # 每张卡计算自己的 X_i @ W_i
        output_parallel = F.linear(input_, self.weight)
        # AllReduce 聚合所有 rank 的部分和
        output = tensor_model_parallel_all_reduce(output_parallel)
        return output

14.2.3 Transformer 层的切分方式

Transformer 的每个 decoder block 由两个子模块构成:Attention 和 MLP。Megatron 的切分:

graph TB
    subgraph "Self-Attention"
        A1[LayerNorm]
        A2["QKV Projection<br/>ColumnParallel<br/>[hidden] → [3 × num_heads/P × head_dim]"]
        A3["Attention<br/>(每个 rank 处理自己的 heads)"]
        A4["Output Projection<br/>RowParallel<br/>[num_heads/P × head_dim] → [hidden]<br/>+ AllReduce"]
        A1 --> A2 --> A3 --> A4
    end
    subgraph "MLP"
        M1[LayerNorm]
        M2["Gate/Up Projection<br/>ColumnParallel<br/>[hidden] → [intermediate/P]"]
        M3["Activation (SiLU/GELU)"]
        M4["Down Projection<br/>RowParallel<br/>[intermediate/P] → [hidden]<br/>+ AllReduce"]
        M1 --> M2 --> M3 --> M4
    end
    A4 --> M1
    style A2 fill:#2563eb,color:#fff,stroke:none
    style A4 fill:#10b981,color:#333,stroke:none
    style M2 fill:#2563eb,color:#fff,stroke:none
    style M4 fill:#10b981,color:#333,stroke:none

关键模式:Column 在先、Row 在后。Column 的输出天然切分,直接进入下一步 Row,中间不需要通信;Row 结束时做一次 AllReduce 把 hidden 重新聚回到所有 rank 上。

每个 Transformer block 有 2 次 AllReduce:一次在 Attention 后,一次在 MLP 后。

14.2.4 AllReduce 的通信量

单次 AllReduce 传 batch_tokens × hidden_dim × dtype_size 字节。对 Llama-3-70B(hidden=8192,FP16)、batch_tokens=1(单 decode step):

per AllReduce=1×8192×2=16 KB\text{per AllReduce} = 1 \times 8192 \times 2 = 16 \text{ KB}

80 层 × 2 次 = 160 次 AllReduce / step / request。

注意这里的关键:16 KB 这个体量根本不是带宽问题,而是延迟问题。每次 AllReduce 都要付一次固定的启动开销(kernel launch + 同步 + 网络往返),和传多少字节几乎无关。于是每个 decode step 的 TP 通信成本 ≈ 160 × 单次 AllReduce 延迟。

  • 在 NVLink / NVSwitch 域内,单次延迟是微秒级,160 次加起来仍然远小于一个 decode step 的时长——TP 在 NVLink 域内接近免费
  • 跨机走 InfiniBand,单次延迟要高一个数量级以上(还要叠加网卡、交换机跳数),160 次累加就足以和整个 step 时长同量级

本专栏没有多卡环境,不给具体微秒数——但这个"乘以 160"的放大结构本身就足以说明问题,也正是 "TP 只能跨 NVLink、不能跨机" 的根本原因。想量化自己的机器,用 nccl-tests 的 all_reduce_perf 测 16 KB 这个尺寸的延迟,再乘 2 × 层数。

14.3 流水线并行(PP):阶段切分 + 微批次

14.3.1 核心思想

PP 把模型的不同层放在不同 GPU 上。对 Llama-70B 的 80 层,PP=4 可以切成 [L1-L20, L21-L40, L41-L60, L61-L80]。

每个 decode step 的数据流:

sequenceDiagram
    participant G0 as GPU 0 (L1-20)
    participant G1 as GPU 1 (L21-40)
    participant G2 as GPU 2 (L41-60)
    participant G3 as GPU 3 (L61-80)
    Note over G0: step t=1
    G0->>G1: activations [batch, 20 layers 后]
    G1->>G2: activations
    G2->>G3: activations
    G3-->>G0: sampled tokens (下一步输入)
    Note over G0,G3: step t=2, 重复

每个阶段边界发一次点对点消息——体积是 batch_tokens × hidden × dtype_size。对 70B 模型一个 batch:1×8192×2=16 KB1 \times 8192 \times 2 = 16 \text{ KB},每 step 3 次(3 个边界)= 48 KB/step;最后把采样结果交回调度方,每请求只是几个 token id,可以忽略。

对比 TP=4 每 step 160 次 AllReduce × 16 KB = 2.5 MB/step。

PP 的通信量比 TP 小约 50 倍——这是它能跨机部署的底气。

14.3.2 气泡(Bubble)问题

PP 的阻碍在于 bubble——当一个阶段在计算时其他阶段在等待。看一个 PP=4 naive 调度:

Time:  t=0        t=1        t=2        t=3        t=4
GPU 0: forward L1-20 | idle (waiting L21-80 done)  | forward step2
GPU 1: idle | forward L21-40 | idle | idle | forward step2
GPU 2: idle | idle | forward L41-60 | idle | forward step2
GPU 3: idle | idle | idle | forward L61-80 | forward step2

GPU 利用率 = 1/4 = 25%  (极差!)

解决方法:微批次化(micro-batching)。把一个 batch 切成 M 个 micro-batch 交错流经流水线:

gantt
    title PP=4, 4 个 micro-batch 的流水线调度(推理只有 forward)
    dateFormat X
    axisFormat %L
    section GPU 0
    mb1 :0, 10
    mb2 :10, 20
    mb3 :20, 30
    mb4 :30, 40
    section GPU 1
    mb1 :10, 20
    mb2 :20, 30
    mb3 :30, 40
    mb4 :40, 50
    section GPU 2
    mb1 :20, 30
    mb2 :30, 40
    mb3 :40, 50
    mb4 :50, 60
    section GPU 3
    mb1 :30, 40
    mb2 :40, 50
    mb3 :50, 60
    mb4 :60, 70

在稳态阶段(所有阶段都有活干),GPU 利用率 100%。只有启动期和收尾期有空闲,bubble 占比:

bubble ratio=PP−1PP−1+M\text{bubble ratio} = \frac{PP - 1}{PP - 1 + M}
  • M=1:bubble = 3/4 = 75% 😱
  • M=4:bubble = 3/7 = 43%
  • M=8:bubble = 3/11 = 27%
  • M=16:bubble = 3/19 = 16%

理论上 M 越大越好。实际限制:每个 micro-batch 至少要有一个 request,否则就是空流水。vLLM 并不把一个 batch 再切成 M 份:V1 的 EngineCore 按 max_concurrent_batches 开一个 batch 队列(vllm/v1/engine/core.py:112-118),Ray 执行器把它设为 pipeline_parallel_size(vllm/v1/executor/ray_distributed_executor.py),调度器连续排出最多 PP 个互不重叠的 batch 同时在流水线里;V0 则为每个 PP 阶段各配一个 virtual engine 调度器(vllm/engine/llm_engine.py)。在飞请求不够多时,这些 batch 会变小甚至为空,bubble 随之显现。

14.3.3 什么时候用 PP

PP 只在必须跨机、且模型放不下时才用:

  • 模型 > 单机 GPU 总显存(比如 DeepSeek-V3 671B 需要 >= 2 台 8×H100 主机)

(多副本扛并发、跨 AZ 做备份靠的是 DP,不是 PP。)

另外注意 v0.8.5 的引擎选择:V1 只在 --distributed-executor-backend ray 显式给出时支持 PP,否则 _is_v1_supported_oracle 回退 V0(vllm/engine/arg_utils.py:1476-1481;设了 VLLM_USE_V1=1 则直接报错)——哪怕默认规则本会自动选中 ray 也一样(§14.9.4)。

如果模型能在单机放下,纯 TP 几乎总是更好——PP 的 bubble 再小也是纯损失。

14.4 专家并行(EP):MoE 模型的刚需

14.4.1 MoE 的特殊需求

MoE(Mixture of Experts)架构下每层有若干"专家"子模块。每个 token 只激活 top_k 个专家(通常 k=2 或 k=8)。这导致一个特殊挑战:

  • 专家数量可能很多(DeepSeek-V3 有 256 个专家 / MoE 层)
  • 每个专家是一个 MLP(参数量大)
  • 如果每个 GPU 复制所有专家,显存完全装不下

一种解法是 TP:把每个专家都切成 1/TP 片(vLLM 不开 EP 时就是这样)。另一种解法:把不同专家整块分给不同 GPU——这就是专家并行。

14.4.2 all_to_all 通信

但这带来一个新问题:token 要去它激活的专家所在的 GPU 上算,算完再回来。EP 的通用做法是两次 all_to_all 通信(注意:v0.8.5 的 vLLM 还不是这样实现的,见 §14.4.3):

graph TB
    subgraph "Step 1: Router 决定 token → expert 映射"
        R[Router Gate<br/>每 token 选 top_k 专家]
    end
    subgraph "Step 2: All-to-All Dispatch"
        D["把每个 token 的拷贝<br/>发到它对应专家所在的 GPU"]
    end
    subgraph "Step 3: Expert Forward"
        E1[GPU 0: Expert 0,1,...,31 计算]
        E2[GPU 1: Expert 32,...,63 计算]
        E3[GPU 2: Expert 64,...,95 计算]
        E4[GPU 3: Expert 96,...,127 计算]
    end
    subgraph "Step 4: All-to-All Combine"
        C["每个 token 把在各专家的结果拿回原 GPU<br/>加权求和"]
    end
    R --> D --> E1 & E2 & E3 & E4 --> C
    style D fill:#f59e0b,color:#333,stroke:none
    style C fill:#f59e0b,color:#333,stroke:none

按这种通用方案估算,通信量:每 token 在每次 all_to_all 中传 hidden × dtype_size × top_k 字节。对 DeepSeek-V3(hidden=7168, k=8, FP8)、batch=32:

per all_to_all≈32×7168×1×8≈1.84 MB\text{per all\_to\_all} \approx 32 \times 7168 \times 1 \times 8 \approx 1.84 \text{ MB}

每 MoE 层 2 次 all_to_all ≈ 3.67 MB。DeepSeek-V3 有 58 层 MoE,总 ≈ 213 MB / step。在 NVLink 900 GB/s 下耗时约 0.24 ms——完全可以接受。

但如果跨机器(InfiniBand 50 GB/s)就 4 ms——显著拖累。所以 EP 通常只在同机内用。

14.4.3 vLLM 的 EP 支持

v0.8.5 的 EP 实现在 vllm/model_executor/layers/fused_moe/(V0/V1 共用的模型层;通信复用 TP/DP 组已有的原语,device_communicators/ 里没有 EP 专用代码)。开关 --enable-expert-parallel 在 v0.8.0 引入(#14305,取代此前的 VLLM_TEST_ENABLE_EP 环境变量):

vllm serve deepseek-ai/DeepSeek-V3 \
    --tensor-parallel-size 8 \
    --enable-expert-parallel \
    --max-model-len 8192

关键代码在 vllm/model_executor/layers/fused_moe/layer.py:435-457:

use_ep = (vllm_config.parallel_config.enable_expert_parallel
          and self.tp_size * self.dp_size > 1)
...
if use_ep:
    # Set TP size to 1 to adjust for EP and adjust EP size and rank
    # for DP attention.
    self.ep_rank = tp_rank + self.tp_size * self.dp_rank
    self.tp_rank = 0
    self.ep_size = self.tp_size * self.dp_size
    self.tp_size = 1
    self.local_num_experts, self.expert_map = determine_expert_map(...)

三个要点:

  1. EP size = tp_size × dp_size,不是单独的 CLI 参数。所以 --tensor-parallel-size 8 --enable-expert-parallel 就是 EP=8,每张卡拿 256/8 = 32 个专家
  2. 在 MoE 层内部,EP 是"替换" TP 而不是"叠加"在 TP 上——注意那两行 self.tp_rank = 0 / self.tp_size = 1:这一层不再做张量切分了,改成按专家切。非 MoE 的部分(attention、KV Cache、layer norm)仍然是正常 TP
  3. 专家数不必整除——determine_expert_map(vllm/model_executor/layers/fused_moe/layer.py:327)会处理除不尽的情况(余数归最后一个 rank),并返回一个 expert_map 做全局专家号到本地下标的映射,不归本 rank 的专家记为 -1

v0.8.5 的 EP 并不做 all_to_all——全树搜不到 all_to_all / all2all 调用。FusedMoE.forward_impl(vllm/model_executor/layers/fused_moe/layer.py:843-888)的通信是这样的:DP=1 时,进入 MoE 层的 hidden states 在 TP 组各 rank 上本来就是完整一份(前面的 RowParallel AllReduce 刚做完),每个 rank 只算 expert_map 里归自己的专家,最后用一次 tensor_model_parallel_all_reduce 把各 rank 的部分结果加起来(DeepSeek-V2/V3 设 reduce_results=False,由模型层加上共享专家后自己做这次 AllReduce,vllm/model_executor/models/deepseek_v2.py:175)——通信形态和不开 EP 时一样,每个 MoE 层一次 AllReduce;DP>1 时先用 naive_multicast 在 DP 组内逐 rank broadcast,把所有 DP rank 的 token 拼到一起,算完在 DP 组 all_reduce,再切回本 rank 那一段。§14.4.2 的 all_to_all 估算描述的是通用 EP 方案,不是 v0.8.5 的实际开销。

vllm/model_executor/layers/fused_moe/ 里的 fused kernel 把各专家的 GEMM 合并成按专家分组的 kernel(fused_moe.py 的 fused_experts),避免逐专家起 kernel,这在 MoE 推理效率上是决定性的。

14.5 数据并行(DP):最赚钱的那一级

DP 和上面三种完全不同——它不切模型,而是完整地复制多份。N 个 DP rank 各自是一个独立的 vLLM 实例,请求通过前端 Load Balancer 分发到不同 rank。

graph TB
    LB[Load Balancer<br/>nginx / envoy / DPAsyncMPClient]
    subgraph "DP Rank 0"
        R0[完整 Llama-70B<br/>TP=4 in 4 GPUs]
    end
    subgraph "DP Rank 1"
        R1[完整 Llama-70B<br/>TP=4 in 4 GPUs]
    end
    subgraph "DP Rank 2"
        R2[完整 Llama-70B<br/>TP=4 in 4 GPUs]
    end
    LB --> R0
    LB --> R1
    LB --> R2
    note["外部多副本:副本间无通信<br/>内建 DP:有同步点(见下)<br/>线性扩展吞吐<br/>支持独立扩缩容"]
    style R0 fill:#7c3aed,color:#fff,stroke:none
    style R1 fill:#7c3aed,color:#fff,stroke:none
    style R2 fill:#7c3aed,color:#fff,stroke:none
    style note fill:#10b981,color:#333,stroke:none

14.5.1 DP 的好处

  1. 无模型内通信——副本之间不需要 AllReduce / all_to_all,模型前向完全独立
  2. 线性扩展吞吐——N 个副本 ≈ N 倍吞吐(只要 LB 够聪明)
  3. 独立扩缩容——忙时加副本,闲时缩回
  4. 故障隔离——一个副本崩不影响其他副本

"零通信"要看是哪种 DP

上面第 1 条只对外部多实例 + 自己的负载均衡器这种 DP 完全成立。vLLM 内建的 --data-parallel-size 是另一回事——vllm/distributed/parallel_state.py:955-961 的注释写得很清楚:

DP is the data parallel group that is part of the model, all the ranks in the same DP group should generate simultaneously, i.e. the generate call in the same DP group should be called together, otherwise it will cause deadlock。

也就是说内建 DP 组里的各 rank 是要对齐拍子的,存在同步点:本 rank 没活而别的 rank 还在跑时,DPEngineCoreProc 要跑 dummy batch 陪跑,并每 24 步在 DP 组做一次「还有没有未完成请求」的 all-reduce(vllm/v1/engine/core.py:645-700、vllm/config.py:1681);MoE 层在 DP>1 时还要在 DP 组内广播并 all_reduce hidden states(§14.4.3)。wave 机制的细节见第 2 章 §2.9。真正"各自独立地 generate"的是注释里另外提到的 ExternalDP(verl 这类框架的外层 DP)。做容量规划时别把两者混为一谈。

14.5.2 DP 的代价

  1. 显存冗余——每个副本都要完整装一份模型
  2. 没有前缀缓存共享——副本间的 KV 不互通
  3. LB 路由决定均衡度——sessionAffinity 处理不好会让前缀缓存命中率降低

14.5.3 DP 与 TP/PP/EP 的叠加

DP 可以叠加在任何一种之上。一个 DP=2 × TP=4 的部署:

  • 共 8 张 GPU
  • 2 个独立副本
  • 每个副本内部做 4 卡 TP

前端把请求分发到两个副本,每个副本内部同步做 TP。这是生产环境最常见的拓扑——DP 扩吞吐,TP 扩单实例能力。

vLLM V1 对 DP 的原生支持在 DPAsyncMPClient(vllm/v1/engine/core_client.py:826):

vllm serve meta-llama/Llama-3.1-70B-Instruct \
    --tensor-parallel-size 4 \
    --data-parallel-size 2 \
    --distributed-executor-backend mp

先把它的层次弄对:DPAsyncMPClient 继承自 AsyncMPClient,是 EngineCore 的客户端,不是 Executor。它的 _init_core_engines 按 data_parallel_size 循环,为每个 DP rank 起一个完整的 EngineCore 进程——每个进程里有自己的 Scheduler、KVCacheManager 和 Executor(Executor 内部才是那 4 个 TP rank)。客户端侧再把请求分派到不同 engine,策略是挑在飞请求数最少的那个(get_core_engine_for_request,vllm/v1/engine/core_client.py:884),不看前缀缓存。

v0.8.5 的 DP 只支持单机

_init_core_engines 里那行注释写得很明白:# Multi-node not yet supported so local_dp_rank == dp_rank。也就是说这个内建 --data-parallel-size 在 v0.8.5 里还没有跨机能力。在线服务要跨机做 DP,仍然是起多个独立的 vllm serve 实例,前面挂一个自己的负载均衡器——这也是本节开头那张图里 "nginx / envoy" 的由来。(离线批处理另有一条路:每个进程自己设 VLLM_DP_SIZE / VLLM_DP_RANK 等环境变量后各起一个 LLM,ParallelConfig.__post_init__ 会读这些变量(vllm/config.py:1717-1722),examples/offline_inference/data_parallel.py 给了跨机示例。)

14.6 parallel_state.py:并行拓扑的进程组管理

vllm/distributed/parallel_state.py 用多个 torch.distributed.ProcessGroup 管理并行拓扑——initialize_model_parallel 建的是 TP / PP / DP 三组(EP 由 MoE 层自行推导,见 §14.4.3)。对 DP=2 × PP=2 × TP=4 这样的拓扑(总 16 卡):

Rank layout (total_world_size = 16):
  Rank 0-7:   DP rank 0, PP=0,1 each with TP=4
  Rank 8-15:  DP rank 1, PP=0,1 each with TP=4

Process Groups:
  TP groups (each of size 4):
    [0,1,2,3], [4,5,6,7], [8,9,10,11], [12,13,14,15]
  PP groups (each of size 2):
    [0,4], [1,5], [2,6], [3,7], [8,12], [9,13], [10,14], [11,15]
  DP groups (each of size 2) — used for DP coordination:
    [0,8], [4,12], ...
  World group: all 16

每个 rank 同时属于多个组:

# vllm/distributed/parallel_state.py(概念性简化)
def init_distributed_environment(world_size, rank, ..., backend="nccl"):  # :860
    # 只建全局 process group 和 _WORLD;DP>1 时先按 DP rank 偏移 rank / world_size
    torch.distributed.init_process_group(backend=backend, ...)
    _WORLD = init_world_group(ranks, local_rank, backend)

def initialize_model_parallel(tensor_model_parallel_size, pipeline_model_parallel_size, ...):  # :915
    # 子组在这里建(group_ranks 的切法见 §14.6.4)
    _TP = init_model_parallel_group(tp_group_ranks, ..., group_name="tp")
    _PP = init_model_parallel_group(pp_group_ranks, ..., group_name="pp")
    _DP = init_model_parallel_group(dp_group_ranks, ..., group_name="dp")
    # 没有 EP 组:EP 由 MoE 层按 tp_size × dp_size 推导(§14.4.3)

AllReduce 调用时指定 group,就只在那个组内做:

# TP 内的 AllReduce —— 只在 TP group 里
output = tensor_model_parallel_all_reduce(output_parallel)

# PP 边界 send/recv —— 只在 PP group 里(V0 的写法,vllm/worker/worker_base.py:411-437;
# V1 在 v0.8.5 由 Ray compiled DAG 传 IntermediateTensors,见 §14.9.2)
get_pp_group().send_tensor_dict(output.tensors, all_gather_group=get_tp_group())
intermediate_tensors = IntermediateTensors(
    get_pp_group().recv_tensor_dict(all_gather_group=get_tp_group()))

这种"一个 rank 同时是多个组的成员"的设计让多维并行变得可组合。想换成 DP=4 × TP=8?改配置参数,其他一律不动。

14.6.1 GroupCoordinator 真身:device_group + cpu_group 双通道

上一节伪代码只写到 init_model_parallel_group 为止。vLLM 不会裸用 torch.distributed.new_group——它把所有组包在一个叫 GroupCoordinator 的类里(vllm/distributed/parallel_state.py:173),每个逻辑组对应两个真实 process group:

# parallel_state.py:220
for ranks in group_ranks:
    device_group = torch.distributed.new_group(
        ranks, backend=torch_distributed_backend)   # 通常 NCCL,走 GPU
    # a group with `gloo` backend, to allow direct coordination between
    # processes through the CPU.
    cpu_group = torch.distributed.new_group(ranks, backend="gloo")
    if self.rank in ranks:
        self.ranks = ranks
        self.world_size = len(ranks)
        self.rank_in_group = ranks.index(self.rank)
        self.device_group = device_group
        self.cpu_group = cpu_group

双通道设计是关键工程选择:

  • device_group(NCCL 后端) —— 大张量走 GPU 直通、打满 NVLink/NVSwitch 带宽。这是 AllReduce、Broadcast activations 等数据平面操作的通路。
  • cpu_group(Gloo 后端) —— 小对象走 CPU 端点对点。source 注释原文:"to allow direct coordination between processes through the CPU"——控制平面操作(比如 pynccl 初始化时广播 NCCL unique id、custom AllReduce 交换 IPC handle、broadcast_object 广播 Python 对象)用它更便宜。NCCL 大 tensor 优化、Gloo 小对象灵活,没必要把控制信号塞进 NCCL 走 GPU 一圈。

两个组基于同一批 ranks 构建,保证"TP 的 NCCL 组"和"TP 的 Gloo 组"永远覆盖同一组进程——物理拓扑一致、只是用不同 protocol。

14.6.2 三层 rank 体系:global / local / in-group

GroupCoordinator 还暴露了三种不同的 rank 概念——这是多维并行里最容易搞混的部分。类体开头的属性注释(vllm/distributed/parallel_state.py:188-195)直接给了一张表:

Process | Node | Rank | Local Rank | Rank in Group
  0     |   0  |  0   |     0      |       0
  1     |   0  |  1   |     1      |       1
  2     |   1  |  2   |     0      |       2
  3     |   1  |  3   |     1      |       3
  • rank(全局 rank)——torch.distributed.get_rank() 得到的那个、0..world_size-1。唯一标识跨所有节点的一个进程。
  • local_rank——本节点内的 rank,用来绑 GPU 设备 ID(torch.device(f"cuda:{local_rank}"))。进程 0 和进程 2 都有 local_rank=0 因为它们各自是节点 0 和节点 1 的第一个进程。
  • rank_in_group——本 GroupCoordinator 视角下的 rank。如果这 4 个进程构成一个 TP group、rank_in_group 就是 0/1/2/3;如果它们同时属于另一个 PP group、PP group 的 rank_in_group 可能完全不同。

日常编程错误最多的一处:误用 rank 当成 rank_in_group 去 index 某个本地 tensor。比如 tensor[self.rank] 在 world_size=16 的场景下 rank=14 但 TP group 里该进程的 rank_in_group 只有 2——下标越界。如果你看懂了这三层 rank、多维并行代码就瞬间变得可读。

14.6.3 group name 和 torch.ops.vllm.all_reduce 定制算子

GroupCoordinator.__init__ 里这两行:

self.unique_name = _get_unique_name(group_name)
_register_group(self)

——把每个组注册到一个全局 dict _groups,key 是 unique_name(_get_unique_name 在 vllm/distributed/parallel_state.py:84,会给同名组加 :0 / :1 后缀)。注意注册进去的是 weakref(vllm/distributed/parallel_state.py:100-101):

def _register_group(group: "GroupCoordinator") -> None:
    _groups[group.unique_name] = weakref.ref(group)

用弱引用是为了让注册表不阻止 GroupCoordinator 被回收——否则 destroy_model_parallel() 之后这些组会因为全局 dict 持有强引用而永远活着。代价是查表时要处理"组已销毁"的情况,all_reduce 里就明确写了(vllm/distributed/parallel_state.py:104-109):

def all_reduce(tensor: torch.Tensor, group_name: str) -> torch.Tensor:
    assert group_name in _groups, f"Group {group_name} is not found."
    group = _groups[group_name]()
    if group is None:
        raise ValueError(f"Group {group_name} is destroyed.")
    return group._all_reduce_out_place(tensor)

为什么要搞 "按名字 lookup"? 因为 vLLM 把 all_reduce 注册成 torch 自定义算子(torch.ops.vllm.all_reduce)——注册代码在 vllm/distributed/parallel_state.py:148-156,而且是三个算子一起注册(all_reduce / reduce_scatter / all_gather):

if supports_custom_op():
    from vllm.platforms import current_platform
    direct_register_custom_op(
        op_name="all_reduce",
        op_func=all_reduce,
        mutates_args=[],
        fake_impl=all_reduce_fake,
        dispatch_key=current_platform.dispatch_key,
    )

all_reduce_fake 的实现只有一行 return torch.empty_like(tensor)(vllm/distributed/parallel_state.py:112-113)——因为 AllReduce 不改形状,fake 实现只要给出正确的 shape/dtype 就够了。

这样 torch.compile 在 trace 模型时能把 all_reduce 当成一个算子符号保留下来——不会把它"穿透"到 NCCL 的 Python wrapper 里把 trace 弄坏。fake_impl(all_reduce_fake)在 symbolic shape inference 时被调用——返回和真实输出同 shape 的 fake tensor、让 torch.compile 的 shape propagator 能顺利算出下游形状。

Process group 对象 无法直接序列化进 FX graph(它们是 C++ binding 的 opaque 对象)、但 group_name: str 能序列化——所以算子签名是 (tensor, group_name: str) 而不是 (tensor, process_group)。跟随 name 再在算子实现里 lookup 是 "可序列化 token + 运行时 dict 查找"的标准模式——上面已经看到,那张注册表用的正是 weakref.ref。

这种细节是 vLLM 能利用 PyTorch 2 torch.compile 全图优化但又不放弃 NCCL 通信的工程关键——通信算子和计算算子在同一张 FX graph 里、编译器能看到完整数据流。没这个 trick 你要么牺牲 torch.compile 优化、要么牺牲分布式通信——vLLM 用 GroupCoordinator 的名字注册机制把两者缝合在一起。

14.6.4 initialize_model_parallel() 的 4D 拓扑构造

读源码最震撼的一幕在 vllm/distributed/parallel_state.py:915-1006——vLLM 用一个 4D 的 torch.arange + transpose + reshape + unbind 把 world_size 切成 TP/PP/DP 三组 group_ranks:

# parallel_state.py:955  注释原文:"the layout order is: ExternalDP x DP x PP x TP"
all_ranks = torch.arange(world_size).reshape(
    -1, data_parallel_size, pipeline_model_parallel_size,
    tensor_model_parallel_size)

# 切 TP 组:把 TP 维(最后一维)留在最后,flatten 前面所有维度
group_ranks = all_ranks.view(-1, tensor_model_parallel_size).unbind(0)

# 切 PP 组:transpose(2,3) 把 PP 维搬到末尾,再 reshape
group_ranks = all_ranks.transpose(2, 3).reshape(-1, pipeline_model_parallel_size).unbind(0)

# 切 DP 组:transpose(1,3) 把 DP 维搬到末尾
group_ranks = all_ranks.transpose(1, 3).reshape(-1, data_parallel_size).unbind(0)

三点非显然的细节——

  1. 注释里 ExternalDP 是最外那一维——reshape 的第一个参数写 -1,留给 verl 这类 RL 框架集成时的外层 DP。源码注释解释得很清楚:ExternalDP 的各个 rank 可以独立 generate,而 _DP 这一维的所有 rank 必须同时调用 generate,否则会死锁
  2. 只建了 _TP / _PP / _DP 三个 group,没有 EP——EP 是 MoE 模型才有的运行时拓扑,不属于这里的"基础三维"。所以本章开头说的"四维并行",在 initialize_model_parallel 这一层其实只落地了三维
  3. 只有 TP 组开启 use_message_queue_broadcaster=True(vllm/distributed/parallel_state.py:978,上面还专门写了一行注释 # message queue broadcaster is only used in tensor model parallel group)——TP 组的 broadcast_object 会优先走它(vllm/distributed/parallel_state.py:422-424),V0 的 driver worker 每一拍经 TP 组广播调度元数据时就用上了;走 shm_broadcast.py 的共享内存 ring buffer 比走 Gloo 的 broadcast 划算。V1 的 MultiprocExecutor 不经这个组,而是自己另建一个广播队列,把调度输出发给全部 Worker(self.rpc_broadcast_mq = MessageQueue(...),vllm/v1/executor/multiproc_executor.py:72),用的是同一套 shm_broadcast 机制

initialize 之后设 3 个全局变量 _TP、_PP、_DP。销毁分两个函数、分两层:destroy_model_parallel()(vllm/distributed/parallel_state.py:1081)依次销毁 _TP / _PP / _DP 这三个子组,destroy_distributed_environment()(1099)才销毁 _WORLD 并调 torch.distributed.destroy_process_group()。先拆子组、后拆 world group——子组是在 world group 之上建出来的,源码按这个顺序固定拆除。三个子组内部则按创建顺序(TP→PP→DP)销毁。

14.6.5 device_communicators/ 14 个文件:一张接口 + 六种平台后端

vllm/distributed/device_communicators/ 目录总计 2483 行(含空的 __init__.py)——按抽象层与后端切开——

文件 行 角色
base_device_communicator.py 151 基类 DeviceCommunicatorBase——给出 all_reduce / all_gather / reduce_scatter / gather / send / recv 的默认实现(直接调 torch.distributed),各平台按需覆盖
cuda_communicator.py 131 NVIDIA GPU 后端——AllReduce 先试 CustomAllreduce,再走 pynccl,最后才退回 torch.distributed(vllm/distributed/device_communicators/cuda_communicator.py:52-68)
cpu_communicator.py 139 CPU 推理后端——走 Gloo backend
tpu_communicator.py 93 Google TPU 后端——走 XLA
hpu_communicator.py 45 Intel Gaudi 后端
xpu_communicator.py 54 Intel Xe GPU 后端
neuron_communicator.py 19 AWS Trainium/Inferentia 后端——19 行是这里最小的 communicator,只覆盖 all_reduce / all_gather 两个方法,直接转给 torch_xla 的 xm.all_reduce / xm.all_gather
custom_all_reduce.py 301 小数据 P2P AllReduce——world_size ∈ {2,4,6,8} 且数据 < 8 MB 等条件满足时绕过 NCCL(完整条件见 §14.7.4),用 CUDA IPC + 自实现 reduce kernel 直读对方显存
custom_all_reduce_utils.py 257 custom AR 的 P2P capability 探测 + IPC handle 管理
pynccl.py + pynccl_wrapper.py 217 + 340 用 ctypes 直接调 NCCL C API(绕过 torch.distributed)——文件头注释给的理由:torch.distributed.all_reduce 里夹带了 CUDA Graph 捕获期间不允许的 CUDA API;纯 Python 封装还能用 VLLM_NCCL_SO_PATH 换 NCCL 版本而无需重编译
shm_broadcast.py 557 本机 broadcast 走 POSIX shm ring buffer——不走 NCCL、因为 NCCL 的 broadcast 对小消息开销过大
cuda_wrapper.py 179 CUDA runtime API 的 ctypes wrapper(用于检测 P2P 能力等)

两条非显然的设计原则——

  1. 本机小消息不走 NCCL——shm_broadcast.py 的 ShmRingBuffer 用共享内存 ring buffer + reader flag 实现本机 broadcast(跨机的 reader 改走 ZMQ)。每一拍要广播的调度元数据体量小,走集合通信要付一次固定开销,走共享内存就是一次 memcpy 加一个标志位。在 GroupCoordinator 里这条路径只在 TP 组启用,V1 的 MultiprocExecutor 另建一个(见 §14.6.4 第 3 点)
  2. 小数据 AllReduce 专门走 custom_all_reduce——TP 每层触发一次、是全系统频率最高的通信;小尺寸下 NCCL 的 ring 要逐跳搬运,custom 版本只启动一次 kernel 直接读对端显存。两条路径的共同动机都是同一件事:小消息的成本由固定开销主导,而不是带宽

14.7 Hopper 上的 NCCL 加速:SHARP 与 NVLS

Hopper 平台(H100/H200 配第三代 NVSwitch)起,NCCL 在机内机间都能用上 in-network reduction:机内是 Hopper 一代新增的 NVLS(NVLink SHARP),跨机是 InfiniBand 交换机上早已有之的 SHARP(NCCL 以 CollNet 算法调用它):

14.7.1 SHARP(Scalable Hierarchical Aggregation Reduction Protocol)

传统 all_reduce = reduce-scatter + all_gather,两个阶段,每个阶段的数据都走完一遍网络。N 张 GPU 做 all_reduce,每张 GPU 需要发送 2(N-1)/N × data_size 字节。

SHARP 把 all_reduce 的规约部分offload 到交换机(switch)上——数据在交换机里就聚合了,不需要送回每个 GPU 再各自聚合一遍。省掉的是一整个 all_gather 阶段的网络往返,所以既减少了流量也减少了延迟。

NVIDIA 的 NVSwitch(DGX 的互联芯片)支持 SHARP/NVLS。这条路径由 NCCL 在探测拓扑时自行决定是否启用,vLLM 自己不做这个选择、也没有开关——它只是调 NCCL。具体能快多少完全取决于机型、NCCL 版本和消息大小,本专栏没有多机环境,不给倍数。

NVLS 在 NVSwitch 内做规约,覆盖范围就是一个 NVLink 域。普通 DGX H100 集群的机间走 InfiniBand,NVLink 域止于单机 8 卡;要把 NVLink 域扩到单机之外,需要 NVLink Switch 系统做跨箱互联(例如 Blackwell 一代的 GB200 NVL72,一个 NVLink 域 72 张 GPU),NVLS 在这个扩展域内也能做 in-network reduction。

结合 SHARP + NVLS,超过单机 8 卡的规模也能享受 in-network reduction,有望让 16 卡以上规模的 TP/EP 部署在延迟上更可行。具体收益请用 nccl-tests 在目标机型上实测,不要照搬任何一篇博客的倍数。

14.7.3 vLLM 的 NCCL 后端配置

默认 NCCL 已经足够好,但极端场景有几个可调旋钮:

# 先看 NCCL 自己选了什么:拓扑探测、算法(Ring/Tree/NVLS/CollNet)、走的是 IB 还是 Socket
export NCCL_DEBUG=INFO

# 强制只走 NVLink P2P,避免悄悄 fallback 到 PCIe
export NCCL_P2P_LEVEL=NVL

# 跨机时确保用上 IB 而不是 TCP(接口名换成你机器上真实的 IB 网卡)
export NCCL_IB_DISABLE=0
export NCCL_SOCKET_IFNAME=ib0

别照抄"开启 SHARP"的偏方

网上常见 export NCCL_ALGO=Tree,Ring 配 NCCL_COLLNET_ENABLE=1 并称之为"启用 SHARP"。这两条其实互相打架——NCCL_ALGO 一旦被显式限制成 Tree,Ring,NVLS/CollNet 这些正是 SHARP 走的算法就被排除在候选之外了。正确做法是不要设 NCCL_ALGO,让 NCCL 自己按拓扑选,然后用 NCCL_DEBUG=INFO 看它到底选了什么。所有 NCCL_* 都是 NCCL 自己的开关,不是 vLLM 的参数,调之前先读 NCCL 官方文档。

vLLM 另外还有一层 NCCL 封装 vllm/distributed/device_communicators/pynccl.py(217 行)+ pynccl_wrapper.py(340 行)——用 ctypes 直接调 NCCL 的 C API,绕开 torch.distributed。pynccl_wrapper.py 文件头注释写明了主要目的:让 NCCL 能和 CUDA Graph 一起用(torch.distributed.all_reduce 内含 graph 捕获期间不允许的 CUDA API);纯 Python 封装还便于用 VLLM_NCCL_SO_PATH 切换 NCCL 版本。CUDA 平台上 TP 组的 AllReduce 在 custom AllReduce 不适用时走的就是它(vllm/distributed/device_communicators/cuda_communicator.py:52-68)。

14.7.4 custom_all_reduce 的边界条件

vllm/distributed/device_communicators/custom_all_reduce.py(v0.8.5 共 301 行)是 vLLM 小数据 AllReduce 专用的"逃生通道"。CustomAllreduce 类从第 48 行一直到文件末尾,启用条件写在两处。

在进这个类之前还有几道闸:只有 TP 组会创建它(vllm/distributed/device_communicators/cuda_communicator.py:19-21 按组名判断),--disable-custom-all-reduce 能整体关掉(vllm/v1/worker/gpu_worker.py:321 / V0 vllm/worker/worker.py:523 调 set_custom_all_reduce),非 CUDA 类平台则由 ParallelConfig 自动关闭(vllm/config.py:1802-1806)。

构造期的门槛(__init__,vllm/distributed/device_communicators/custom_all_reduce.py:53-176):

_SUPPORTED_WORLD_SIZES = [2, 4, 6, 8]          # line 50

def __init__(self, ..., max_size=8192 * 1024):  # line 53-56,8 MB
    ...
    if not all(in_the_same_node_as(group, source_rank=0)):         # line 82
        ...  # 组跨节点:disabled
    if world_size not in CustomAllreduce._SUPPORTED_WORLD_SIZES:   # line 96
        ...  # 直接 disabled
    if world_size > 2 and not fully_connected:                     # line 135
        ...  # 2 卡以上却没有 NVLink 全互联:disabled
    if not current_platform.is_rocm() and not _can_p2p(rank, world_size):  # line 145
        ...  # P2P 能力测试不过:disabled

每次调用的门槛(should_custom_ar,vllm/distributed/device_communicators/custom_all_reduce.py:213-225):

def should_custom_ar(self, inp: torch.Tensor):
    if self.disabled:
        return False
    inp_size = inp.numel() * inp.element_size()
    # custom allreduce requires input byte size to be multiples of 16
    if inp_size % 16 != 0:
        return False
    if not is_weak_contiguous(inp):
        return False
    # for 4 or more non NVLink-capable GPUs, custom allreduce provides
    # little performance improvement over NCCL.
    if self.world_size == 2 or self.fully_connected:
        return inp_size < self.max_size
    return False

整理成表:

条件 取值 出处 理由
world_size ∈ {2, 4, 6, 8} _SUPPORTED_WORLD_SIZES(line 50) C++ kernel 只为这几种规模实例化了模板
消息大小 < 8 MB max_size=8192*1024(line 56) 更大的消息 NCCL 的 ring/tree 更划算
字节对齐 inp_size % 16 == 0 line 217-219 kernel 按 16 字节向量化访存
内存布局 is_weak_contiguous(inp) line 220-221 注意不是 is_contiguous(),而是允许有 offset 的弱连续
互联拓扑 world_size == 2 或 fully_connected line 224-225 源码注释:4 卡以上若没有 NVLink 全互联,相对 NCCL 提升有限(构造期 line 135 已把这种情况 disabled)

注意这个检查是每次 AllReduce 都跑一遍的——不满足就自动退回普通路径。对用户完全透明、不符合就 fallback,是很保守的设计。

__init__ 里最重的一件事是预注册所有 rank 的 CUDA IPC handle:每张 GPU 分一块共享 buffer(create_shared_buffer,line 156/161),其他 rank 拿到指针后,reduce kernel 就能直接读写对方显存,不经过任何 host 跳板,也不需要像 NCCL 的 ring 算法那样分多步逐跳转发。这就是它能比 NCCL 省下延迟的机制来源——具体快多少取决于机型和消息尺寸,请自行实测。

至于 _SUPPORTED_WORLD_SIZES 为什么只到 8:C++ 侧的 kernel 是以 ngpus 为模板参数写的,csrc/custom_all_reduce.cuh 的 switch 只实例化了 2/4/6/8 四档(其余直接抛错),同步用的 Signal 结构里的标志数组也按最多 8 个 rank 定长(第 54-55 行)。同一个 switch 里还按消息大小在单阶段(1stage)和两阶段(2stage)kernel 之间切换。

14.7.5 CUDA Graph 捕获下的特殊处理

vLLM 的 decode 路径几乎全部跑在 CUDA Graph 里(第 8 章详细讲过 piecewise CUDA graph)——这对分布式通信带来一个挑战:AllReduce 也必须能被 graph 捕获,而且 replay 时用的 buffer 地址不能变。

vllm/distributed/device_communicators/custom_all_reduce.py:178-191 的 capture 上下文管理器就是干这个的,docstring 直接说明了它的唯一职责:

@contextmanager
def capture(self):
    """
    The main responsibility of this context manager is the
    `register_graph_buffers` call at the end of the context.
    It records all the buffer addresses used in the CUDA graph.
    """
    try:
        self._IS_CAPTURING = True
        yield
    finally:
        self._IS_CAPTURING = False
        if not self.disabled:
            self.register_graph_buffers()

register_graph_buffers(vllm/distributed/device_communicators/custom_all_reduce.py:193-210)把 capture 阶段用到的 buffer 地址通过 get_graph_buffer_ipc_meta 取出来、在各 rank 之间 broadcast 一圈、再交给 C++ 侧登记。如果不登记,下次 replay 时对端拿到的 IPC 指针就对不上了。

这里还埋了一个很具体的坑,源码注释指名道姓:

We cannot directly use dist.all_gather_object here because it is incompatible with gloo backend under inference mode. see https://github.com/pytorch/pytorch/issues/126032 for details.

所以它退而求其次,用一串 broadcast_object_list 手工凑出 all-gather 的效果。这类"绕开上游 bug"的注释是读源码时最值钱的东西——它记录了一条别人已经踩过的坑。

这些细节对普通用户完全不可见,但少了它们,decode 路径就没法同时享受 CUDA Graph 和 custom AllReduce。

14.8 Prefill / Decode 分离:2024-2025 新范式

传统部署把 prefill 和 decode 放在同一组 GPU 上。2023-2024 年 DistServe、Splitwise 等论文提出了分离架构:

graph LR
    subgraph "传统架构"
        U1[用户] --> T1[vLLM 引擎<br/>prefill + decode 混合]
    end
    subgraph "分离架构"
        U2[用户] --> LB[Router]
        LB --> P[Prefill 专用引擎<br/>高算力 GPU 如 H100]
        P -->|KV Cache 传输| D[Decode 专用引擎<br/>高带宽 GPU 如 H200]
        D --> U2
    end
    style P fill:#2563eb,color:#fff,stroke:none
    style D fill:#10b981,color:#333,stroke:none

14.8.1 动机

  • Prefill 更偏 compute-bound,吃的是算力(TensorCore 的 FLOPs)
  • Decode 更偏 memory-bound,吃的是显存带宽——H200 的 HBM3e 是 141 GB / 4.8 TB/s,相对 H100 SXM 的 80 GB / 3.35 TB/s,容量和带宽都更有优势
  • 传统架构下两种形态挤在同一批卡上互相干扰(chunked prefill 缓解了调度层面的干扰,但没法让同一张卡同时既是"算力型"又是"带宽型")

分离后:

  • Prefill 集群专门跑 H100(算力型)
  • Decode 集群专门跑 H200(带宽型)
  • 请求先走 prefill,KV Cache 传给 decode 集群继续生成

14.8.2 KV Cache 传输成本

Llama-70B, context=1000:
  KV size = 2 × 80 layers × 8 heads × 128 dim × 1000 tokens × 2 bytes
         = 328 MB per request

RDMA 100 Gbps ≈ 12.5 GB/s,传 328 MB 需要约 26 ms。

这个数量级说明了 PD 分离可行的条件:KV 传输时间必须显著小于 prefill 本身的耗时,否则分离带来的收益会被搬运吃掉。本专栏没有多机环境,不给具体的 prefill 毫秒数——但这个不等式给了明确的评估方法:先测目标模型在目标 prompt 长度下的 prefill 时间,再用上面的公式算 KV 体积除以实际网络带宽,两者一比就知道值不值。

工程上还有两个放大器:KV 量化(第 13 章的 FP8 KV 直接把这 328 MB 砍半)和 overlap(在 prefill 最后几层算完时就开始 RDMA write,把传输藏进计算里)。

14.8.3 vLLM 的实现

vllm/distributed/kv_transfer/ 下是 KV 跨机传输的基础设施:

  • kv_connector/ 定义了传输的抽象(类似 Executor 的抽象层)
  • 具体实现有 PyNcclConnector / MooncakeConnector(同一个 SimpleConnector,底下分别走 NCCL 与 Mooncake 传输引擎)、MooncakeStoreConnector(基于 Mooncake 分布式 KV 存储)、LMCache 等(v0.8.5 还没有 vLLM 自带的 NIXL connector,树里的 NIXL 只出现在 LMCache 的示例配置中)

这是一个活跃发展的方向。v0.8.5 的状态是:V1 引擎已经有了 KVConnectorBase_V1 这套接口,调度器里也留好了钩子(见下一小节),但可用的 V1 实现还很少。

14.8.4 kv_transfer/ 模块的三层拆分

vllm/distributed/kv_transfer/ 总计 3033 行、三个子目录对应三层解耦——

子目录 层 核心文件 实现
kv_pipe/ 最底:字节流传输 base.py (66) / pynccl_pipe.py (279) / mooncake_pipe.py (279) 定义 send_tensor / recv_tensor 接口
kv_lookup_buffer/ 中:KV 查找缓冲 base.py (174) / simple_buffer.py (236) / mooncake_store.py (160) 按 (input_tokens, roi)(token id + 掩码)做 insert/drop_select;mooncake_store.py 是 put/get 式的远端 KV store
kv_connector/ 最上:业务层 connector V0:base.py (127) / simple_connector.py (328) / mooncake_store_connector.py (201) / lmcache_connector.py (98);V1:v1/base.py (209) / v1/shared_storage_connector.py (383) / v1/lmcache_connector.py (131) 拼装 pipe + buffer,对上提供高层 API

三层之间的依赖方向——connector → buffer → pipe——上层组合下层、下层不知道上层存在。

为什么做成三层——每一层都能单独替换:pynccl_pipe 与 mooncake_pipe 都是点对点的 send_tensor / recv_tensor,区别只在底下走 NCCL 还是 Mooncake 传输引擎,SimpleConnector 把任一种 pipe 和 SimpleBuffer 组合起来用;MooncakeStoreConnector 则直接用 buffer 层的 MooncakeStore(put/get 到远端 KV store),不再经过 pipe 层。如果把 buffer 逻辑直接写进每个 connector,这些组合就得各写一遍。

但读这个目录时最重要的一件事是分清 V0 和 V1。 kv_connector/ 下还有一个 v1/ 子目录(731 行),里面是完全独立的一套抽象 KVConnectorBase_V1——接口都不一样:V0 是 send_kv_caches_and_hidden_states 这种"整批发送",V1 是 start_load_kv / wait_for_layer_load / save_kv_layer / get_num_new_matched_tokens 这种按层、可异步、能告诉调度器"我这边还能多命中几个 token" 的细粒度接口(第 3 章 §3.8.5 里 scheduler 调的 self.connector.get_num_new_matched_tokens 就是它)。

KVConnectorFactory 把两套注册在同一张表里(vllm/distributed/kv_transfer/kv_connector/factory.py:79-107),但用 envs.VLLM_USE_V1 严格隔开:create_connector_v0 在 V1 下直接抛错,create_connector_v1 在 V0 下也抛错。v0.8.5 注册了 6 个名字:

名字 实现 世代
PyNcclConnector / MooncakeConnector simple_connector.SimpleConnector V0
LMCacheConnector lmcache_connector.LMCacheConnector V0
MooncakeStoreConnector mooncake_store_connector.MooncakeStoreConnector V0
SharedStorageConnector v1/shared_storage_connector.py V1
LMCacheConnectorV1 v1/lmcache_connector.py V1

也就是说:在 v0.8.5 的 V1 引擎上,能用的 connector 只有 SharedStorageConnector 和 LMCacheConnectorV1 两个——Mooncake 那条线此时还只挂在 V0 路径上。看到网上教程写 MooncakeStoreConnector 配 V1 的,先核对你所在版本的 factory.py。

14.9 Ray Distributed Executor:跨机 PP 的执行底座

跨机部署的 Executor 实现分成两个文件,读源码时别找错地方:

  • vllm/executor/ray_distributed_executor.py(700 行)——主体实现,V0/V1 共用
  • vllm/v1/executor/ray_distributed_executor.py(61 行)——V1 的薄壳,class RayDistributedExecutor(RayDistributedExecutorV0, Executor),只重写了 max_concurrent_batches 和 execute_model 两处,让 PP 场景能返回 FutureWrapper 给调度器继续排下一批

它和单机 MultiprocExecutor(vllm/v1/executor/multiproc_executor.py,480 行)互补——后者走进程 + 管道,只能单机。

14.9.1 职责边界

V1 的 RayDistributedExecutor 同时继承 V0 版和 Executor——和单机版暴露同样的接口(execute_model、collective_rpc、max_concurrent_batches、shutdown 等)、只是内部实现换成了 Ray Actor 方式。

vllm/executor/ray_distributed_executor.py 的关键方法(v0.8.5 实际行号):

方法 行号 作用
_init_executor 73 建 Ray cluster 连接、placement group、启动 Actor
_init_workers_ray 157 按 TP × PP 在 Ray Actor 里创建 Worker、注入 rank 信息
_driver_execute_model 431 非 compiled-DAG 路径下由 driver worker 执行
execute_model 444 V0 入口:SPMD 模式下把 ExecuteModelRequest 编码后送入 compiled DAG、阻塞取回结果;V1 由薄壳重写,直接送 SchedulerOutput,PP>1 时返回 FutureWrapper
_run_workers 464 向全部 Ray worker 广播一次方法调用
_check_ray_cgraph_installation 530 校验 Ray 版本是否带 compiled graph 支持
_compiled_ray_dag 556 构造静态图描述 PP 阶段间的 activation 流向

注意 collective_rpc 不在这个文件里——它的抽象声明在 vllm/executor/executor_base.py:61(ExecutorBase),实现在同文件 :326 的 DistributedExecutorBase.collective_rpc,只有一行:转调 self._run_workers。_run_workers 在基类(:334)只是抽象方法,Ray 版的真正实现就是上表 464 行那个。想找"广播一次方法调用到所有 Worker"的入口,先往基类看,再回到本文件。

14.9.2 Compiled DAG:为什么不直接 RPC

naive 做法是每个 decode step 都用一次 Ray RPC 调用每个 Worker 的 execute_model。问题在于 decode 是每秒几十次的高频循环,而 Ray 的通用 RPC 每次都要走一遍序列化、调度、对象存储——这个固定开销在 decode 这种"单步很短、调用很密"的模式下占比会变得难看。

解法是 Ray 的 compiled graph(cgraph)。V1 下 _init_executor 会强制设 VLLM_USE_RAY_SPMD_WORKER=1、VLLM_USE_RAY_COMPILED_DAG=1(vllm/executor/ray_distributed_executor.py:75-78),总是走这条路;V0 默认两者都是 0,走 driver worker + _run_workers 的普通 Ray 调用:把"rank 0 发 activations 给 rank 1、rank 1 发给 rank 2……"这个每一拍都完全相同的调用模式编译成静态 DAG,一次 setup 反复复用,跳过通用 RPC 的调度路径。

_compiled_ray_dag 的构造逻辑:

# 概念性伪码,对应 ray_distributed_executor.py _compiled_ray_dag
from ray.dag.input_node import InputNode
from ray.dag.output_node import MultiOutputNode

with InputNode() as input_data:
    # 第一阶段 rank 组(PP=0 的所有 TP rank)接收请求
    stage_outputs = [w.execute_model_ray.bind(input_data) for w in pp0_workers]

    # 后续阶段:每个阶段接收前阶段的 output
    for pp_idx in range(1, pp_size):
        stage_workers = self.pp_workers[pp_idx]
        stage_outputs = [
            w.execute_model_ray.bind(prev)
            for w, prev in zip(stage_workers, stage_outputs)
        ]

    # 最后阶段输出 sampled tokens
    dag = MultiOutputNode(stage_outputs)

self.forward_dag = dag.experimental_compile()

compile 之后 execute_model 只要 self.forward_dag.execute(scheduler_output)——Ray 在后台用 channel-based 数据通路做阶段间传递,不走通用 RPC。

_compiled_ray_dag 开头几行暴露了两个可调项,都是环境变量:

  • VLLM_USE_RAY_COMPILED_DAG_CHANNEL_TYPE——阶段间通道类型,默认 "auto",合法值只有 "auto" / "nccl" / "shm",传别的直接 ValueError。跨机走 nccl,同机可以走共享内存
  • VLLM_USE_RAY_COMPILED_DAG_OVERLAP_COMM——是否让 GPU 通信和计算重叠(envs.py 注释标为实验性功能,默认关)
  • 另外它还会 os.environ.setdefault("RAY_CGRAPH_get_timeout", "300"),把 Ray 取结果的超时从默认 10 秒提到 300 秒——注释解释得很直白:这条 DAG 里包含整个模型 forward 和中间张量通信,10 秒根本不够

源码里的注释画出了 PP=2、TP=4 时 V1 的实际数据流,比任何文字描述都清楚:

SchedulerOutput -> 0 -> (SchedulerOutput, IntermediateTensors) -> 4 -> ModelRunnerOutput
SchedulerOutput -> 1 -> (SchedulerOutput, IntermediateTensors) -> 5 -> ModelRunnerOutput
SchedulerOutput -> 2 -> (SchedulerOutput, IntermediateTensors) -> 6 -> ModelRunnerOutput
SchedulerOutput -> 3 -> (SchedulerOutput, IntermediateTensors) -> 7 -> ModelRunnerOutput

四条并行的链——TP 的 4 个 rank 各自独立地把 activation 交给下一个 PP 阶段的对应 rank,PP 边界上并没有先聚合再分发。

14.9.3 Placement Group:GPU 调度

Ray 在多机场景下不能让 Worker 随便落地——要尽量让 TP=8 的 8 个 Worker 落在同一台机器(吃 NVLink 带宽),PP 的不同阶段则可以落在不同机器(用 IB 通信没毛病)。

真正干这件事的是 vllm/executor/ray_utils.py 的 initialize_ray_cluster。它在没有现成 placement group 时新建一个(vllm/executor/ray_utils.py:368-369):

current_placement_group = ray.util.placement_group(
    placement_group_specs, strategy="PACK")

每个 bundle 只放一张卡——如果传进来的 placement group 里有 bundle 占了多于 1 个设备,会直接抛错("Placement group bundle cannot have more than 1 GPU")。PACK 策略让 Ray 尽量把 bundle 挤在同一台机器上,这样 TP 组自然落在一个节点内。

保险丝在 _verify_bundles(vllm/executor/ray_utils.py:165-208):它按 node 统计 bundle 数,只要某个节点上的 bundle 数少于 tensor_parallel_size 就打 warning——因为那意味着某个 TP 组被劈到了两台机器上,AllReduce 要走网络,性能会崩。这条 warning 值得在启动日志里专门 grep 一下。

还有 _wait_until_pg_ready(vllm/executor/ray_utils.py:211),带 PG_WAIT_TIMEOUT 超时——这是 --distributed-executor-backend ray 启动时比 mp 多出的一段等待:Ray 必须先等 placement group 全部就绪才能起 Actor。

14.9.4 何时选 Ray

场景 推荐 原因
单机多卡(TP) mp Ray 纯粹负担、开销多余
单机大模型(405B FP8) mp 同上
跨机 PP ray mp 不支持跨机、ray 是唯一选择;v0.8.5 的 V1 还要求显式写 --distributed-executor-backend ray,否则回退 V0(单机用 mp 做 PP 同样回退 V0,§14.3.3)
跨机 DP mp + 外层 LB 或 ray DP 本不用 ray;但如果要统一运维平面 ray 更顺
verl 这类 RL framework 集成 ray verl 本身基于 ray、复用同一 cluster 最自然

默认 backend 选择规则在 vllm/config.py:1742-1774,实际有三档:

  • world_size == 1 → uni(单进程,连 mp 都不起)
  • world_size > 1 且本机 GPU 数够(cuda_device_count_stateless() >= world_size)→ mp
  • world_size > 1 但本机 GPU 数不够 → ray;此时如果没装 Ray,直接抛 ValueError 要求 pip install ray

还有一条容易忽略的分支:即使本机放得下,只要进程已经处在一个 Ray placement group 里(比如被 RL 框架拉起),也会自动选 ray。用户可以用 --distributed-executor-backend 强制覆盖。

14.10 跨章源码账本:vllm/distributed/ 总览

把整个 vllm/distributed/ 树的文件和行数做一张源码账本——按本专栏锚定的 v0.8.5(commit ba41cc9)实测,读者 checkout 同一 tag 可逐行复核:

路径 行 职责
vllm/distributed/parallel_state.py 1209 全章核心——GroupCoordinator + TP/PP/DP 初始化(EP 不在这里建组)
vllm/distributed/communication_op.py 40 对 GroupCoordinator 方法的门面封装:tensor_model_parallel_all_reduce 等
vllm/distributed/utils.py 366 stateless init、split_tensor_along_last_dim 等工具
vllm/distributed/device_communicators/base_device_communicator.py 151 DeviceCommunicatorBase 抽象
vllm/distributed/device_communicators/cuda_communicator.py 131 NVIDIA GPU 实现
vllm/distributed/device_communicators/cpu_communicator.py 139 CPU 推理实现
vllm/distributed/device_communicators/tpu_communicator.py 93 TPU 实现
vllm/distributed/device_communicators/custom_all_reduce.py 301 小数据 AllReduce 专用——world_size ∈ {2,4,6,8}、数据 < 8 MB
vllm/distributed/device_communicators/custom_all_reduce_utils.py 257 P2P capability 探测 + IPC handle
vllm/distributed/device_communicators/pynccl.py 217 直接 NCCL C API(绕过 torch.distributed)
vllm/distributed/device_communicators/pynccl_wrapper.py 340 NCCL ctypes 封装
vllm/distributed/device_communicators/shm_broadcast.py 557 本机 broadcast 走 POSIX shm ring buffer
vllm/distributed/device_communicators/cuda_wrapper.py 179 CUDA runtime ctypes wrapper
vllm/distributed/kv_transfer/kv_pipe/*.py 624 字节流传输层(pynccl_pipe / mooncake_pipe)
vllm/distributed/kv_transfer/kv_lookup_buffer/*.py 570 KV 查找缓冲层
vllm/distributed/kv_transfer/kv_connector/(含 v1/) 1682 业务 connector 层:V0 部分 951 行(simple / mooncake_store / lmcache),已含表中 kv_connector/v1/ 那 731 行,勿重复相加
vllm/executor/ray_distributed_executor.py 700 Ray 后端 Executor 主体(V0/V1 共用)
vllm/v1/executor/ray_distributed_executor.py 61 V1 薄壳,只重写 execute_model / max_concurrent_batches
vllm/distributed/kv_transfer/kv_connector/v1/*.py 731 V1 的 KV connector 抽象与实现(KVConnectorBase_V1 + SharedStorage + LMCache)
vllm/v1/executor/multiproc_executor.py 480 mp 后端 Executor(单机)

账本的三个启示——

  1. parallel_state.py 一个文件 1209 行承载了 TP/PP/DP 三组进程组的全部元数据——想改 vLLM 的进程组拓扑、这里是主入口(EP 的切法在 fused_moe/layer.py,DP 的请求分派在 core_client.py)
  2. device_communicators/ 14 个文件里 custom_all_reduce(301)+ shm_broadcast(557)= 858 行在干"绕过 NCCL"——说明 vLLM 对 NCCL 启动延迟极其敏感、小消息场景愿意自己造轮子
  3. kv_transfer/ 全目录 3033 行(三层子目录合计 2876 行)独立存在——这不是传统意义的"集合通信",而是按内容寻址的 KV 搬运,是 PD 分离新范式落地的基础设施。而且它内部还并存着 V0 和 V1 两套 connector 抽象(V1 那套 731 行在 kv_connector/v1/),读的时候先确认自己在看哪一代

14.11 四种部署场景的配置模板

14.11.1 单机 8 卡,Llama-70B 低延迟 chat

vllm serve meta-llama/Meta-Llama-3-70B-Instruct \
    --tensor-parallel-size 8 \
    --gpu-memory-utilization 0.92 \
    --max-num-seqs 128 \
    --enable-prefix-caching \
    --enable-chunked-prefill

纯 TP=8,所有 AllReduce 都在机内 NVSwitch 上,不跨网络。--enable-prefix-caching 和 --enable-chunked-prefill 在 V1 上本来就是默认开启的(见第 10、11 章),这里显式写出来只是为了让配置自解释。

14.11.2 2 机 16 卡,DeepSeek-V3 671B

# 两台 8×H100,机内 TP=8 + EP=8,机间 PP=2
vllm serve deepseek-ai/DeepSeek-V3 \
    --tensor-parallel-size 8 \
    --pipeline-parallel-size 2 \
    --enable-expert-parallel \
    --distributed-executor-backend ray \
    --gpu-memory-utilization 0.92 \
    --max-model-len 8192

机内 NVLink 做 TP+EP(通信重),机间 InfiniBand 做 PP(通信轻)。这是 670B 量级模型的标准打法。

14.11.3 4 副本高并发 Llama-8B

# 方案 A:用 vLLM 内建 DP(单机 4 卡,一个进程组)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
    --data-parallel-size 4 \
    --quantization fp8 \
    --gpu-memory-utilization 0.92
# 方案 B:4 个完全独立的实例 + 自己的负载均衡器(可跨机)
#   在每台/每卡上分别起:
CUDA_VISIBLE_DEVICES=0 vllm serve meta-llama/Llama-3.1-8B-Instruct \
    --port 8000 --quantization fp8 --gpu-memory-utilization 0.92
#   ... 依次 --port 8001 / 8002 / 8003,前面挂 nginx/envoy

两者都能把吞吐往上推,但语义不同:方案 A 的 4 个 DP rank 需要同拍 generate(见 §14.5.1 的提醒)且 v0.8.5 只支持单机;方案 B 的实例彼此毫无关系,能跨机、能独立扩缩容、也更容易做 sticky 路由保住前缀缓存命中率(见 §14.12 第 6 条)。在线服务要跨机 DP,v0.8.5 上只能选方案 B。

14.11.4 8 卡单机 Llama-405B FP8

# 405B 权重按 FP8(1 字节/参数)算约 405 GB,8×H100 80GB = 640 GB 显存
vllm serve meta-llama/Llama-3.1-405B-Instruct-FP8 \
    --tensor-parallel-size 8 \
    --gpu-memory-utilization 0.95 \
    --max-model-len 4096

榨干单机极限:权重占掉大约 2/3 显存,剩下的留给 KV Cache——所以 --max-model-len 要压住,否则 KV 池会小到没法支撑并发。再大的模型就只能上 PP 跨机了。

14.12 生产踩坑:分布式推理的 10 个故障模式

把分布式部署里最常见的 10 类问题整理成表——每条都给定位方法和处置方向,方便线上救火时直接翻。表里的环境变量都是 NCCL / 系统层面的开关,不是 vLLM 参数。

# 症状 根因 定位方法 Fix
1 启动卡死在 NCCL init 10 min 不动 跨机 NCCL 走了 TCP fallback、没识别到 IB NCCL_DEBUG=INFO 看是否出现 Using network Socket 设 NCCL_IB_DISABLE=0 + NCCL_SOCKET_IFNAME=ens3(替换成实际 IB 接口)
2 decode 延迟阶跃式变差 NCCL 选中的算法/通道变了(比如 NVLS/CollNet 没启用,退回 Ring) NCCL_DEBUG=INFO 看它实际选了哪个算法和通道 确认硬件与驱动支持,不要用 NCCL_ALGO 手工锁死(见 §14.7.3 的提醒)
3 跨机 PP 吞吐远低于"卡数 × 单卡"的直觉预期 bubble + 阶段边界 P2P 的固定开销 nsys profile 看各 rank 的 kernel gap 增大 max_num_seqs 提高在飞请求数,让同时在流水线里的 PP 个 batch 都更满(§14.3.2)
4 TP=8 启动时提示 "too many open files" 进程打开的 fd(ZMQ socket、共享内存等)超过上限;vllm serve 启动时会自动尝试把软上限提到 65535(vllm/utils.py:2253 的 set_ulimit),失败会打 warning,多半是硬上限更低 ulimit -n / ulimit -Hn 查看 启动前 ulimit -n 65536;持久化在 /etc/security/limits.conf 加 * soft nofile 65536 与 * hard nofile 65536
5 Ray 启动超时、Actor 永远不 ready placement group PACK 策略等不到足够 GPU ray status 看资源占用 确认 ray start --num-gpus=8、清理旧的 dangling actor
6 DP=N 下前缀缓存命中率暴跌 LB 没做 sessionAffinity、同一 session 落到不同副本 Grafana 看 hit rate per rank 在 nginx 里按 $remote_addr 或 session cookie 做 sticky
7 EP 启用后 MoE 层报 OOM fused_experts 的中间缓存按 min(tokens, VLLM_FUSED_MOE_CHUNK_SIZE) × top_k × intermediate 宽度 分配(vllm/model_executor/layers/fused_moe/fused_moe.py:1275-1302),开 EP 后该层 tp_size=1、intermediate 不再按 TP 切分;DP>1 时每个 rank 还要收下全部 DP rank 的 token OOM 栈落在 fused_moe 相关 kernel 降 max_num_seqs / VLLM_FUSED_MOE_CHUNK_SIZE,或调低 --gpu-memory-utilization 给临时 buffer 留余量
8 跨机 TP 下 token 间延迟成倍上升 用户错误地把 TP 切到了跨机 nvidia-smi topo -m 看网络拓扑 绝对禁止跨机 TP——改成机内 TP + 机间 PP
9 custom_all_reduce 不生效、NCCL 慢 两张卡之间没 NVLink / 走了 PCIe nvidia-smi topo -m 看卡间是 NV#(NVLink)还是 PIX / PHB / SYS(PCIe 路径) 换机器、或接受 NCCL 性能(没救)
10 decode 阶段偶现 NCCL hang 某个 rank 在集合通信上掉队/出错,但错误被吞掉,其余 rank 一直等 TORCH_NCCL_ASYNC_ERROR_HANDLING=1(旧版为 NCCL_ASYNC_ERROR_HANDLING)让错误抛出来而不是 hang 先用 --enforce-eager 关掉 CUDA Graph 判断是否与 graph 捕获相关,再定位具体 rank

一条经验法则:线上分布式问题多数是网络拓扑配置错——nvidia-smi topo -m + NCCL_DEBUG=INFO 的组合能定位大多数问题。其余多半是软件 bug,往往跟 PyTorch 某个小版本的 NCCL 组件有关——锁死 PyTorch + NCCL + vLLM 的版本组合是生产稳定性最便宜的手段。

14.12.1 版本信息与参考

本章引用的所有文件路径、行号和行数,都基于本专栏统一锚定的 vLLM v0.8.5(commit ba41cc9)。读者 checkout 同一个 tag 就能逐行复核;换成别的版本,行号一定会漂,请用 grep -n 按函数名重新定位。

行号会变,但架构不会变——parallel_state.py 的核心概念(GroupCoordinator、device_group / cpu_group 双通道、把通信注册成 torch 自定义算子、custom_all_reduce、shm_broadcast)在 V0 到 V1 的迁移中完整保留了下来。本章花大量篇幅解释为什么这么设计而不只是代码长什么样,就是为了让这些理解在下一次重构后仍然成立。

14.13 本章小结

分布式并行是 vLLM 从"单机实验室玩具"走向"千卡 production 系统"的关键:

  • 带宽鸿沟:HBM3(TB/s 级)→ NVLink/NVSwitch(百 GB/s 级)→ InfiniBand(几十 GB/s 级)三级逐档下降,决定了一切策略取舍
  • TP 张量并行:Megatron 式 Column + Row 切分;每层 2 次 AllReduce;只在 NVLink 内部用
  • PP 流水线并行:阶段切分;bubble 公式 PP−1PP−1+M\frac{PP-1}{PP-1+M};跨机部署的标准手段
  • EP 专家并行:MoE 模型的常用选项;通用方案每层 2 次 all_to_all,v0.8.5 的实现则是每个 MoE 层一次 AllReduce(DP>1 时另有 DP 组广播);同机 NVLink 内执行
  • DP 数据并行:复制整模型;外部多副本零通信,内建 DP 各 rank 要周期性对拍;由 DPAsyncMPClient 在 EngineCore 之上实现,v0.8.5 的 --data-parallel-size 仅支持单机
  • 拓扑构造:initialize_model_parallel 用一个 4D torch.arange + transpose/reshape/unbind 切出 TP/PP/DP 三组 ProcessGroup;EP 由 MoE 层按 tp_size × dp_size 另行推导
  • Hopper 加速:NVLS 把规约下沉到 NVSwitch、SHARP 把规约下沉到 IB 交换机,省掉一整个 all_gather 往返;由 NCCL 自行选择,vLLM 不提供开关
  • Prefill/Decode 分离:2024-2025 新范式,用不同硬件特性匹配不同计算形态
  • 部署模板:给出 8 卡单机 / 2 机 16 卡 / 4 副本 / 405B 四种典型场景

源码导航

  • 并行状态管理:vllm/distributed/parallel_state.py
  • 通信操作:vllm/distributed/communication_op.py
  • 设备通信器:vllm/distributed/device_communicators/(NCCL, shm, XLA, HPU 等)
  • pynccl(直接 NCCL):vllm/distributed/device_communicators/pynccl.py
  • MoE 融合 kernel:vllm/model_executor/layers/fused_moe/
  • KV 跨机传输:vllm/distributed/kv_transfer/
  • Ray Executor(跨机 PP 用):主体 vllm/executor/ray_distributed_executor.py(700 行),V1 薄壳 vllm/v1/executor/ray_distributed_executor.py(61 行);Ray 集群/placement group 工具 vllm/executor/ray_utils.py
  • DP 实现(EngineCore 之上):vllm/v1/engine/core_client.py 的 DPAsyncMPClient
  • custom AllReduce 的 CUDA 侧实现:csrc/custom_all_reduce.cu / csrc/custom_all_reduce.cuh
  • 线性层并行实现:vllm/model_executor/layers/linear.py

论文

  • Shoeybi et al., "Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism", 2019 (arXiv:1909.08053)
  • Narayanan et al., "Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM", 2021 (arXiv:2104.04473)
  • Huang et al., "GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism", NeurIPS 2019 (arXiv:1811.06965)
  • Zhong et al., "DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving", OSDI 2024
  • Patel et al., "Splitwise: Efficient Generative LLM Inference Using Phase Splitting", ISCA 2024