DeepSeek V4 源码剖析

第16章 DeepEP:MoE all-to-all 通信库

作者 杨艺韬 · 9,836 字 · 发布于 · 更新于

没有高效 all-to-all 的 MoE,只是一个很重的稠密模型。

V4 把 384 个 expert 跨 8/16/32 卡部署时,每一层都要做两次 all-to-all(dispatch 与 combine)。DeepEP 的目标不是"比某某快几倍",而是把 NVLink / RDMA 链路吃到接近理论带宽上限——README 的实测里,节点内 dispatch 打到了 H800 NVLink 峰值的 96%。


16.1 引子:MoE 的通信瓶颈

V4 的 MoE 路由产生一个看似无害的需求:每个 token 必须被发到对应 expert 所在的 rank。

具体地,在 8 卡 EP 部署中,384 expert 切分成 48 个 / rank。每 token 选 6 个 expert,分布到 8 个 rank 的不同位置。MoE 一层的工作流:

  1. Dispatch:把每个 token 发送到它选中的 6 个 expert 所在的 rank
  2. Compute:每个 rank 计算自己 expert 的输出
  3. Combine:把 expert 输出收回原始 token 所在的 rank(按权重加权求和)

dispatch + combine 都是all-to-all 通信——每个 rank 都要给所有其他 rank 发送 / 接收数据。

如果直接用 NCCL 的 dist.all_to_all:

  • 通信量大:每 token 最多要发给 6 个 expert,6 × 7168 × 2 bytes (BF16) = 86,016 字节 ≈ 84 KiB;10⁶ 个 token 一次性 dispatch 就是约 86 GB(这是上界:落在本 rank 的 expert 不用发,DeepEP 常规 kernel 对同一 rank 上的多个 expert 也只发一份;实践中 dispatch 常用 FP8、combine 用 BF16,前者能砍一半——DeepEP README 的测试设定正是"FP8 dispatch + BF16 combine")
  • 不规则:每个 rank 接收的 token 数不固定(依赖路由)。dist.all_to_all_single 虽然接受不等长的 input_split_sizes / output_split_sizes,但这两个参数是 host 端的整数列表——得先额外交换一轮每对 rank 的 token 数、同步回 CPU 才能发起
  • 延迟高:NCCL 的 all-to-all 在小消息上延迟显著(启动 + 同步开销)

DeepEP 是 DeepSeek 针对 MoE 的 all-to-all 量身定做的通信库——优化了上面三个问题。

先把它和 V4 的关系说清楚:DeepEP 不是为 V4 新做的。仓库于 2025-02-25 开源(DeepSeek 开源周第二天;首个提交的作者时间是 02-24,提交时间是 02-25),README 的测试配置也全是 DeepSeek-V3/R1 的设定。V4 技术报告第 3.1 节讲的 EP 方案是另一条路:把 dispatch、两次 GEMM 与 combine 融进一个流水线 kernel,按"专家波次"做细粒度的通信-计算重叠,开源实现是 DeepGEMM 里的 MegaMoE(第 13 章);V4 仓库的参考推理代码 inference/model.py 则连 all-to-all 都不做,MoE 层是"每 rank 算本地专家 + 一次 dist.all_reduce"(第 15 章)。本章讲 DeepEP,是因为它是 DeepSeek 开源的独立 EP 通信库,V4 这种 384 专家、top-6 的 MoE 同样可以用它做 dispatch / combine。

flowchart TB
  subgraph 生产部署["384 expert / 8 卡 EP"]
    Token["每 token 选 6 expert"]
    Token --> Disp[Dispatch]
    Disp --> R0[Rank 0: 48 experts]
    Disp --> R1[Rank 1: 48 experts]
    Disp --> Rdot[...]
    Disp --> R7[Rank 7: 48 experts]
    R0 --> Comb[Combine]
    R1 --> Comb
    Rdot --> Comb
    R7 --> Comb
    Comb --> Out[每 token 6 个 expert 输出加权求和]
  end

16.2 DeepEP 仓库结构

DeepEP(github.com/deepseek-ai/DeepEP)@ 567632d 的代码组织比想象中紧凑——csrc/ 下 15 个 C++/CUDA 源文件,Python 包只有 3 个文件:

DeepEP/
├── csrc/
│   ├── deep_ep.cpp / deep_ep.hpp      # PyTorch binding + Buffer 的 C++ 实现
│   ├── config.hpp / event.hpp
│   └── kernels/
│       ├── intranode.cu               # 节点内(NVLink)dispatch + combine
│       ├── internode.cu               # 跨节点(RDMA)dispatch + combine
│       ├── internode_ll.cu            # low-latency 路径(decode 用,纯 RDMA)
│       ├── layout.cu                  # 算 dispatch 布局(每 rank 收发多少 token)
│       ├── runtime.cu
│       ├── ibgda_device.cuh           # IBGDA:GPU 直接发起 RDMA
│       └── buffer.cuh / configs.cuh / launch.cuh / utils.cuh / api.cuh / exception.cuh
├── deep_ep/
│   ├── buffer.py                      # Python 侧的 Buffer 类,本章的主角
│   └── utils.py / __init__.py
├── third-party/                       # NVSHMEM 安装指引(README.md)与补丁(nvshmem.patch)
└── tests/
    ├── test_intranode.py
    ├── test_internode.py
    └── test_low_latency.py

注意 dispatch 与 combine 不是分在不同文件里的——它们成对住在同一个 .cu 里(intranode.cu 同时含节点内的 dispatch 和 combine),因为两者共用同一套 buffer 布局与 handle。真正的切分维度是通信路径:节点内 / 跨节点 / 低延迟,三条路各一个 .cu。

还有一个绕不开的依赖:NVSHMEM。DeepEP 不是直接调 libibverbs,而是站在 NVSHMEM 之上(仓库还留着一个 third-party/nvshmem.patch,不过 pin 版的 third-party/README.md 已改为直接使用上游 NVSHMEM 3.3.9 及以上)。README 说得很明确:不指定 NVSHMEM_DIR 就会禁用所有 internode 与 low-latency 功能,只剩节点内那条路。按 DeepEP 仓库根目录 setup.py 开头探测 NVSHMEM 那段代码,更准确的条件是"既没设 NVSHMEM_DIR,也找不到 pip 装的 nvidia.nvshmem 模块"——两者都没有才会打印警告并关掉这两条路。

DeepEP 的设计哲学:为不同通信路径写独立 kernel。Python 侧只留一层薄接口:Buffer.dispatch / combine 按 RDMA rank 数自动选节点内或跨节点实现(deep_ep/buffer.py:376-380 的 get_num_rdma_ranks() > 1 分支);low-latency 则另有一对单独的方法。


16.3 dispatch / combine 算法

DeepEP 的核心抽象是 Buffer——预分配的 ring buffer 用于 RDMA / NVLink 传输:

from deep_ep import Buffer

# 创建 buffer(大小怎么算见 §16.7)
buf = Buffer(group, num_nvl_bytes, num_rdma_bytes)

# 先算布局:发给每个 rank / 每个 expert 多少 token
num_tokens_per_rank, num_tokens_per_rdma_rank, num_tokens_per_expert, is_token_in_rank, _ = \
    buf.get_dispatch_layout(topk_idx, 384)          # 第二个参数是 num_experts

# Dispatch:把 token 按 expert 路由发送到不同 rank
recv_x, recv_topk_idx, recv_topk_weights, num_recv_tokens_per_expert_list, handle, _ = buf.dispatch(
    x,                                   # [num_tokens, hidden] BF16,或 (FP8 张量, scale) 二元组
    topk_idx=topk_idx,                   # [num_tokens, topk] 每 token 的 expert 选择
    topk_weights=topk_weights,           # [num_tokens, topk] 路由权重
    num_tokens_per_rank=num_tokens_per_rank, num_tokens_per_rdma_rank=num_tokens_per_rdma_rank,
    is_token_in_rank=is_token_in_rank, num_tokens_per_expert=num_tokens_per_expert,
)

# 现在每个 rank 收到了"分配给本 rank expert 的所有 token"
# 跑 expert forward(路由权重要在这里乘上,combine 不乘)
expert_outputs = run_local_experts(recv_x, recv_topk_idx, recv_topk_weights)

# Combine:把 expert 输出(BF16)收回原始 token 所在 rank
combined_x, _, _ = buf.combine(expert_outputs, handle)

这段是按 README 示例(dispatch_forward / combine_forward)压缩的写法,省略了 previous_event、async_finish 等重叠相关参数;run_local_experts 是示意。

DeepEP 的 dispatch 算法:

  1. 在每 rank 算"我要发给每个其他 rank 多少 token"——get_dispatch_layout 由 topk_idx 算出每 rank / 每 RDMA rank / 每 expert 的 token 数和 is_token_in_rank(csrc/kernels/layout.cu)
  2. 用 NVLink 或 IB 发送 token 数据 + meta-info(topk 索引、权重、FP8 scale)
  3. 接收端用环形队列式的 buffer 分块中转(README「Notices」一节:用队列做通信 buffer 省显存,但带来复杂性与潜在死锁)
  4. 接收端按"来源 rank → channel"的顺序把 token 写进 recv_x(intranode.cu 里按 rank_prefix_matrix 算偏移),不按 expert 排序;同时返回每个本地 expert 收到的 token 数 num_recv_tokens_per_expert_list(可按 expert_alignment 对齐),要喂 grouped GEMM 还得自己按 expert 重排

DeepEP 的 combine 算法是 dispatch 的逆向:

  1. 每 rank 把 expert 输出按"原始 token 所在 rank" 分组
  2. 发送回各 rank
  3. 接收端按原始 token 顺序重组、求和——注意是不带权重的求和(deep_ep/buffer.py:413 的文档写明 "addition without weights"),路由权重要在 expert 侧先乘好

16.4 dispatch / combine 双向通信的序列图

把一层 MoE 在 8 卡 EP 部署下的完整通信流画成序列图:

sequenceDiagram
  participant R0 as Rank 0 (expert 0-47)
  participant R1 as Rank 1 (expert 48-95)
  participant Rdot as ...
  participant R7 as Rank 7 (expert 336-383)
  Note over R0,R7: 1. Dispatch 阶段 (DeepEP)
  R0->>R1: 发送选了 expert 50 的 token (NVLink)
  R0->>R7: 发送选了 expert 350 的 token
  R1->>R0: 发送选了 expert 5 的 token
  R7->>R0: 发送选了 expert 20 的 token
  Note over R0,R7: 每 rank 收到属于本 rank expert 的 token
  Note over R0,R7: 2. Compute 阶段 (DeepGEMM grouped_gemm)
  R0->>R0: 跑 expert 0-47 的 SwiGLU
  R1->>R1: 跑 expert 48-95 的 SwiGLU
  R7->>R7: 跑 expert 336-383 的 SwiGLU
  Note over R0,R7: 3. Combine 阶段 (DeepEP)
  R1->>R0: 把 expert 50 输出送回 R0 的原 token
  R7->>R0: 把 expert 350 输出送回
  R0->>R1: 把 expert 5 输出送回
  R0->>R7: 把 expert 20 输出送回
  Note over R0,R7: 4. 每 rank 求和(权重已在 expert 侧乘上)+ 加 shared expert 输出

dispatch 与 combine 是对称的双向通信——DeepEP 用同一套 Buffer 做这两步,避免重复分配。


16.5 节点内(NVLink)vs 跨节点(IB)路径

DeepEP 区分两条物理通信路径:

节点内 (intranode, NVLink):

  • 同一物理服务器内的 8 卡互联
  • H100 走 NVLink 4,单卡双向总带宽 900 GB/s(600 GB/s 是上一代 A100 的 NVLink 3);H800 被限制到 400 GB/s 量级,DeepEP README 的测试平台正是 H800,README 给的 NVLink 最大带宽约 160 GB/s(实测 dispatch 瓶颈带宽 153 GB/s)
  • 延迟在微秒量级
  • 通信用 GPU 直接 P2P,不经过 CPU / 网卡

跨节点 (internode, InfiniBand / RoCE):

  • 不同物理服务器之间
  • IB / RoCE 带宽 200-400 Gbps(25-50 GB/s;README 测试平台是 CX7 400 Gb/s,约 50 GB/s)
  • 延迟同样是微秒量级,但比 NVLink 高
  • 通信经过网卡(NIC),可能通过 GPUDirect RDMA 减少 CPU 介入

DeepEP 给两种路径写了完全独立的 kernel——csrc/kernels/intranode.cu vs csrc/kernels/internode.cu。差别极大:

  • intranode:用 NVLink P2P 直接拷贝,不经过 NIC
  • internode:走 NVSHMEM,底层是 IBGDA(csrc/kernels/ibgda_device.cuh)——让 GPU 直接发起 RDMA 操作,不用 CPU 代理

混合部署(如 16 卡 = 2 × 8 卡 IB 互联)时,DeepEP 用的是 README 里说的"非对称域带宽转发"(asymmetric-domain bandwidth forwarding)——把数据从 NVLink 域转发到 RDMA 域,而不是让每个 rank-pair 都直接走 IB。README 明确说这套 kernel 是为了对齐 V3 论文里的 group-limited gating 而设计的。

这一点放到 V4 上要打个折扣:技术报告第 2.1 节写明 V4 取消了路由目标节点数的限制,config.json 与 inference/model.py 里也没有 V3 那组 n_group / topk_group。转发 kernel 本身照样能用——它按目标节点(RDMA rank)去重,一个 token 发往同一节点只走一次 RDMA(num_tokens_per_rdma_rank),再在节点内用 NVLink 分发;但 V3 那条"每 token 最多发往 4 个节点"的 RDMA 流量上界不再成立,V4 的 top-6 专家最多可能落在 6 个节点上。


16.6 low-latency dispatch:decode 阶段的特化

V4 的 decode 阶段有特殊需求:每条序列每步只出 1 个 token,但通信延迟必须最低(直接影响生成速度)。

DeepEP 提供 low_latency_dispatch:

  • 用 GPU-only 实现(不经过 CPU):常规 dispatch 里 CPU 要等 GPU 报来接收数量(README 的注释与 normal.png),因此与 CUDA graph 不兼容(只有节点内传 num_worst_tokens 时例外);low-latency 不做这次同步,输出按最坏情况预留成 [num_local_experts, num_max_dispatch_tokens_per_rank × num_ranks, hidden] 的按专家分块布局,另给每个专家的有效 token 数,可以进 CUDA graph
  • 用静态 buffer(预分配,避免 malloc;返回的张量直接复用这块 buffer,所以同一时刻最多只能持有两次 low-latency 调用的结果)
  • 按 top-k 逐个专家发送:发送方用 IBGDA 把数据直接写进对端 buffer(nvshmemi_ibgda_put_nbi_warp),再用 RDMA 原子加更新计数(nvshmemi_ibgda_amo_nonfetch_add),接收方轮询计数检测新数据(csrc/kernels/internode_ll.cu)
  • 跳过 NCCL 的 collective 同步开销

按 README 的实测(H800 + CX7、128 token/batch、EP=8):dispatch 77 μs、combine 114 μs,一层 MoE 一来一回约 191 μs。

把这个数乘以 MoE 层数就能看出量级——V3 共 61 层、其中 58 层是 MoE(前 3 层是稠密 FFN,不走 all-to-all):58 × 0.191 ms ≈ 11.1 ms / token。这已经是 decode 预算里相当可观的一块了——而且随 EP 规模上升还会涨(EP=32 时一来一回约 428 μs,58 层就是 25 ms)。

这解释了两件事:为什么 DeepEP 要专门做 low-latency 路径(常规 kernel 为吞吐设计,每次 dispatch 都要一次 CPU 同步),以及为什么 README 要强调那套"基于 hook 的通信-计算重叠方法,且不占用任何 SM 资源"——把这 11.1 ms 藏到计算后面去,比把它压得更小更有价值。

(提醒:以上数字是 V3/R1 的配置在 H800 上测的,V4 的 384 expert / top-6 与之不同,而且 V4 把 V3 开头的稠密 FFN 层也换成了 hash 路由的 MoE 层,61 层全是 MoE,别直接当成 V4 的部署预期。)


16.7 DeepEP 的 buffer 与生命周期

DeepEP 的 Buffer 对象通常在每个进程里只建一个、全局复用(README 示例用模块级的 _buffer 变量缓存它)——典型生命周期(常规 kernel):

# 启动时创建:大小按 README 的 get_buffer 写法用 size hint 算,不手填
Buffer.set_num_sms(24)   # 静态方法:常规 kernel 用多少个 SM(须为偶数,默认 20)
hidden_bytes = 7168 * 2  # README 的 get_hidden_bytes:hidden × max(元素字节数, 2)
num_nvl_bytes, num_rdma_bytes = 0, 0
for config in (Buffer.get_dispatch_config(ep_group.size()), Buffer.get_combine_config(ep_group.size())):
    num_nvl_bytes = max(config.get_nvl_buffer_size_hint(hidden_bytes, ep_group.size()), num_nvl_bytes)
    num_rdma_bytes = max(config.get_rdma_buffer_size_hint(hidden_bytes, ep_group.size()), num_rdma_bytes)
buf = Buffer(ep_group, num_nvl_bytes, num_rdma_bytes)

# 每次 forward 在 MoE 层调用 dispatch / combine(示意)
for layer in transformer.layers:
    ...
    recv_x, recv_topk_idx, recv_topk_weights, _, handle, _ = buf.dispatch(x, ...)   # 参数见 §16.3
    expert_out = run_experts(recv_x, recv_topk_idx, recv_topk_weights)
    combined, _, _ = buf.combine(expert_out, handle)

# 进程退出时销毁(explicitly_destroy=True 时要显式调用 buf.destroy())
del buf

num_nvl_bytes 是节点内 NVLink 通信的 buffer,num_rdma_bytes 是跨节点 RDMA 的 buffer(低延迟模式下节点内也走它)。两种模式的尺寸逻辑不一样:

  • 常规模式的 buffer 是分块中转的队列,不需要容纳单次 dispatch 的全部 token,大小只取决于 hidden、rank 数、SM 数和 chunk 配置(csrc/config.hpp 的 get_nvl_buffer_size_hint / get_rdma_buffer_size_hint)。EP ≤ 8 时 RDMA hint 为 0,而且 num_rdma_bytes > 0 只允许在 rank 数大于 8 或低延迟模式下使用(csrc/deep_ep.cpp:160-161 的断言)。按这两个公式复算 V4-Pro(hidden 7168、BF16):2024 个 SM 时 NVL buffer 约 320430 MiB,EP=1664 时 RDMA buffer 约 90380 MiB
  • 低延迟模式要为每 rank 最多 num_max_dispatch_tokens_per_rank 个 token、每个专家各留一份收发槽位,用 Buffer.get_low_latency_rdma_size_hint(...) 算,别自己估。复算 V4-Pro(每 rank 128 token、384 专家)约 2.7 GiB,且与 EP 规模无关

(以上尺寸是照 config.hpp 公式用 Python 复算的,未在 GPU 上实际分配。)

Buffer.set_num_sms(24) 给 DeepEP 的常规 kernel 分配 24 个 SM 做通信——H100 有 132 个 SM,剩下的留给 GEMM,两者并行。低延迟 kernel 没有 SM 控制接口(README 注释原话),README 给它配的是另一条路:基于 hook 的通信-计算重叠,接收在后台进行,不占用任何 SM。


16.8 README 里的实测数字

DeepEP 的 README 给了两张实测表——这是本章唯一可以引用的性能数据来源。注意测试平台是 H800(README 写明 NVLink 峰值约 160 GB/s,每卡配一张 CX7 InfiniBand 400 Gb/s 网卡,RDMA 峰值约 50 GB/s),配置沿用 DeepSeek-V3/R1 的设定——不是 V4 的配置,V4 是 384 expert / top-6、不限路由节点数,V3 是 top-4 组 / top-8 expert。

设定:每 batch 4096 token、hidden 7168、FP8 dispatch + BF16 combine。

类型 Dispatch #EP 瓶颈带宽 Combine #EP 瓶颈带宽
Intranode 8 153 GB/s (NVLink) 8 158 GB/s (NVLink)
Internode 16 43 GB/s (RDMA) 16 43 GB/s (RDMA)
Internode 32 58 GB/s (RDMA) 32 57 GB/s (RDMA)
Internode 64 51 GB/s (RDMA) 64 50 GB/s (RDMA)

看 intranode 那行:153 / 158 GB/s 对着 H800 约 160 GB/s 的 NVLink 峰值——分别约 96% 与 99%。这才是 DeepEP 值得讲的地方:不是"比某某快几倍",而是把链路吃到接近理论上限。

low-latency kernel(纯 RDMA)

设定:每 batch 128 token、hidden 7168、top-8 expert、FP8 dispatch + BF16 combine。

Dispatch #EP 延迟 RDMA 带宽 Combine #EP 延迟 RDMA 带宽
8 77 μs 98 GB/s 8 114 μs 127 GB/s
16 118 μs 63 GB/s 16 195 μs 74 GB/s
32 155 μs 48 GB/s 32 273 μs 53 GB/s
64 173 μs 43 GB/s 64 314 μs 46 GB/s
128 192 μs 39 GB/s 128 369 μs 39 GB/s
256 194 μs 39 GB/s 256 360 μs 40 GB/s

两个值得注意的规律:

  1. combine 比 dispatch 慢约 1.5-2 倍——combine 要搬 BF16(dispatch 是 FP8),数据量翻倍
  2. EP 规模翻倍,延迟涨得比线性慢很多——EP 8 → 256 是 32 倍,dispatch 延迟只从 77 μs 涨到 194 μs

README 还提了两条后续优化:2025.04.22 腾讯网络平台部的贡献把常规 kernel 提升最多 30%(PR #130);2025.06.05 起 low-latency kernel 也尽量利用 NVLink(PR #173)。

本节不给 NCCL 的对比数字——README 没有提供,任何"比 NCCL 快 N 倍"的说法都没有一手出处。DeepEP 相对通用 collective 的优势机制(见 §16.11)是清楚的,但倍数要你在自己的拓扑上测。


16.9 集成到 vLLM / SGLang 的工程接缝

把 DeepEP 集成到 vLLM 等推理引擎的工作:

事项 1:替换 MoE 层的通信逻辑

先看本专栏对照的 vLLM v0.8.5 现状:它的 EP 既不做 all-to-all,也没有接入 DeepEP(全树搜不到 all_to_all / all2all 调用——唯一命中是 csrc/cpu/shm.cpp 里的一句注释——也搜不到 deep_ep)。FusedMoE.forward_impl(vllm/model_executor/layers/fused_moe/layer.py:843-888)里,每个 rank 手上本来就是完整的一份 token(DP>1 时先用 naive_multicast 在 DP 组内逐 rank broadcast 拼齐),只算 expert_map 里归自己的专家,最后靠 all_reduce 把各 rank 的部分结果加起来——细节见《vLLM 推理内核深度解析》第 14 章讲 EP 的那一节。要改成 DeepEP,需要做的改造是:

  • 在初始化时按 EP 组建 Buffer(尺寸按 §16.7 的 size hint 算)
  • 把"全量 token + all_reduce"改成"get_dispatch_layout → buf.dispatch → 本地专家 → buf.combine",token 只发往选中专家所在的 rank
  • 自己处理 dispatch 后的 token 分组:常规 dispatch 的输出按来源 rank 排列、不按 expert 排序(§16.3),要自己重排;low-latency dispatch 的输出本身就是按专家分块的布局(§16.6)
  • 路由权重在 expert 侧乘好:DeepEP 的 combine 只做不带权重的求和

事项 2:与 grouped GEMM 配合

这不是巧合——DeepGEMM 的 README 直接点名了:m_grouped_fp8_gemm_nt_masked 的一个示例用法"就是把 DeepEP 低延迟 kernel 的输出当输入"。两个仓库在接口上是对着设计的:decode 阶段 CPU 不知道每个 expert 收到几个 token,DeepEP 给出按专家分块的输出和每个专家的有效 token 数(recv_count),DeepGEMM 的 masked 版拿它当 mask,正好只算有效部分。prefill 阶段则对应 _contiguous 那一族。

事项 3:与 PagedAttention 的并存

PagedAttention 是 attention 的 KV cache 抽象。DeepEP 是 MoE 的通信抽象。两者完全解耦——可以独立集成,调度器不需要为此区分层类型。真正要协调的是 CUDA graph:常规 dispatch 要 CPU 等 GPU 报接收数量,与 CUDA graph 不兼容(§16.6),decode 若要用 CUDA graph,就得走 low-latency 那组 kernel。

事项 4:编译与运行时依赖

DeepEP 的 README「Requirements」原文口径:

  • GPU:Ampere (SM80)、Hopper (SM90),或其他支持 SM90 PTX ISA 的架构——注意 A100 是支持的,但只有节点内那条路(用 DISABLE_SM90_FEATURES=1 关掉 SM90 特性;setup.py 此时断言 NVSHMEM 必须禁用,README Roadmap 也写着 "A100 support (intranode only)")
  • CUDA:SM80 需 11.0+,SM90 需 12.3+
  • PyTorch 2.1+
  • NVSHMEM(README 在 Requirements 之后单列一节)——这是最容易被漏掉的一项。不设 NVSHMEM_DIR、也没装 pip 版 NVSHMEM,internode 与 low-latency 两条路会被直接禁用
  • 节点内要 NVLink,跨节点要 RDMA 网络(README 说充分测试过 InfiniBand,RoCE 理论兼容)

安装也不是 pip install -e .:

NVSHMEM_DIR=/path/to/installed/nvshmem python setup.py install

部署到生产时这些依赖必须齐全——NVSHMEM 的安装(见仓库 third-party/README.md,除了装库还要开启 IBGDA:改 NVIDIA 驱动参数,或装 GDRCopy 走 CPU 辅助的 IBGDA)是跨节点部署时最容易卡住的一环。


16.10 动手实验:跑 DeepEP intranode benchmark

# 单机 8 卡
git clone https://github.com/deepseek-ai/DeepEP.git
cd DeepEP

# 先装 NVSHMEM(见 third-party/README.md),否则只有节点内那条路可用
NVSHMEM_DIR=/path/to/installed/nvshmem python setup.py build
ln -s build/lib.linux-x86_64-cpython-38/deep_ep_cpp.cpython-38-x86_64-linux-gnu.so

# 跑测试。注意 README 提醒:要按你自己的集群改 tests/utils.py 里的 init_dist
python tests/test_intranode.py
python tests/test_internode.py      # 需要多节点
python tests/test_low_latency.py

跑完你会拿到自己这套拓扑上的 dispatch / combine 带宽与延迟——这比照抄 README 里那张 H800 的表有用得多,因为 NVLink 代次、NIC 型号、EP 规模每一项都会改变结果。


16.11 DeepEP 设计的 4 个潜规则

DeepEP 的代码与 NCCL 的设计哲学差异巨大。把 DeepEP 的几个潜规则归纳出来:

潜规则一:用 polling 取代 collective 同步

NCCL 的 collective 操作(all_reduce / all_to_all 等)本质是同步原语——所有 rank 必须到达"通信点"才能开始传输,而且每对 rank 收发多少要在发起前由 host 端定好。MoE 的 dispatch 恰恰是"每个 rank 收多少 token 取决于路由结果",与这套语义天然不合。

DeepEP 的 low-latency 路径用纯 RDMA + polling 替代 collective 同步:发送方直接写到接收方的预分配 buffer,接收方在 GPU 上轮询。README 实测下来 EP=8 时 dispatch 77 μs、combine 114 μs。

代价是通信 kernel 要占 GPU 的 SM。常规 kernel 用 Buffer.set_num_sms(n) 这个静态方法显式限定(须为偶数,默认 20);low-latency kernel 没有 SM 控制接口,README 给的是一种更进一步的做法:基于 hook 的通信-计算重叠,不占用任何 SM 资源——return_recv_hook=True 时 kernel 只发出 RDMA 请求、不等数据到达,等真正要用数据时再调 hook()。

潜规则二:静态 buffer 而非动态分配

NCCL 的 all_to_all 每次调用都要 PyTorch 分配 output tensor、计算 metadata、launch kernel。这些 host-side 操作的累积开销在小消息上非常显著。

DeepEP 的 Buffer 一次性预分配 NVL/RDMA 内存——所有后续 dispatch / combine 的中转都在这块预分配空间里跑。low-latency 模式连返回的张量都直接复用这块 buffer;常规模式的输出张量(recv_x 等)仍由 PyTorch 分配。代价是常驻显存:按 §16.7 的复算,V4-Pro 的常规模式 NVL buffer 约 0.3~0.4 GiB,low-latency 模式约 2.7 GiB(每 rank 128 token、384 专家),换来的是通信路径上不再为中转空间做分配与注册。

潜规则三:放弃通用接口,特化 MoE

NCCL 是通用 collective 库——支持各种张量形状、数据类型、reduction op。DeepEP 仅服务 MoE dispatch / combine 这一对操作——但深度优化。

特化的好处:DeepEP 知道每个 rank 持有哪些 expert、知道 token 数量是 batch_size × topk、知道 hidden_dim、知道 dtype。这些"已知约束"让 kernel 可以预先做静态分析、提前 vectorize、提前规划 SMEM。NCCL 这种通用库做不到。

潜规则四:把可靠性交给硬件与 NVSHMEM

DeepEP 不自己实现 ack / 重传(常规 kernel 里环形队列的 head/tail 指针只管 buffer 空间,不管丢包)——它站在 NVSHMEM 之上,底层靠 IBGDA 让 GPU 直接发起 RDMA,可靠性由 IB / RoCE 的硬件传输层保证。README 里还有一条相关的取舍:拥塞控制是关闭的,理由是"我们在生产环境中没有观察到显著拥塞"。

这类简化的代价是对网络质量的假设变强。README 给了两条配套的运维建议:用 InfiniBand 的 Virtual Lane(NVSHMEM_IB_SL 环境变量)把常规 kernel、low-latency kernel 和其他负载隔离到不同虚拟通道,避免互相干扰;以及按网络负载选路由策略——重载环境开自适应路由消除路由冲突导致的拥塞,轻载环境用静态路由避免它引入的额外延迟。

仓库里还有一节叫 "Undefined-behavior PTX usage",对应 DISABLE_AGGRESSIVE_PTX_INSTRS 这个开关——DeepEP 可以用一些超出文档保证的激进 load/store 指令来换性能。注意 pin 版 DeepEP 的 setup.py 里这个开关默认是 1,即默认不用这类指令(目标架构不是 SM90 时还会强制为 1);想要这份性能得显式设 DISABLE_AGGRESSIVE_PTX_INSTRS=0,跑出问题再关回去。这是"信任底层"这条路线的另一个体现。

这四条潜规则让 DeepEP 不是"通用通信库的更快版",而是"为 MoE 量身定做的特化方案"。如果你的项目不是 MoE,DeepEP 帮不了你——但如果你做 MoE 的专家并行,它是一套现成、且默认配置按 DeepSeek 内部集群调过的方案(README「Auto-tuning」一节)。


16.12 DeepEP 与 V4 训练栈的协同

DeepEP 主要在推理 + 训练的 forward 阶段发挥作用——但它在训练 backward 阶段同样关键。

训练 forward:与推理 prefill 一致,用常规 kernel 的 dispatch / combine 承担 MoE 的 token 通信。

训练 backward:MoE 的反向传播需要把"expert 输出的梯度"反向传回"token 原始位置"——这是 dispatch 的逆向。DeepEP 的 README 给了对偶的例子代码:dispatch_backward 的注释写着"MoE dispatch 的反向其实是一次 combine",combine_backward 则反过来"combine 的反向其实是一次 dispatch"。两者都吃 forward 时留下的 handle。(README 那句 "without the backward part" 修饰的是推理 prefill——训练要用 backward,推理 prefill 用不到;这两个 backward 函数就在同一段示例代码里。)

expert 权重梯度的同步:EP 下每个 expert 只住在 EP 组里的一个 rank 上,它从各 token 收到的梯度在本 rank 的 GEMM 里就累加完了,EP 维度上不需要再做 reduce;需要跨 rank 同步的是数据并行维度——不同 EP 组里持有同一个 expert 副本的那些 rank。这是形状固定的梯度通信,不属于 DeepEP 的 dispatch / combine 接口。

V4 的具体做法(技术报告第 3.5.1 节):Muon 需要完整的梯度矩阵才能算更新,与按元素切分的传统 ZeRO 冲突。稠密参数用背包算法把整块矩阵分给有限个 ZeRO rank,桶补齐到同样大小后做 reduce-scatter;MoE 参数逐专家独立优化,跨数据并行 rank 同步的 MoE 梯度先用随机舍入量化到 BF16(通信量减半),再把 reduce-scatter 换成两阶段做法——先 all-to-all 交换本地梯度,各 rank 再在 FP32 下本地求和,避免低精度累加的误差。

按接口能力划分,DeepEP 负责 MoE 的 token 路由通信,集合通信库负责梯度 / 状态的同步通信,两者不互相替代。至于 V4 训练时 EP 的 token 通信实际用的是 DeepEP 还是技术报告第 3.1 节那套融合 kernel,报告没有点名。


16.13 DeepEP 在不同硬件配置下的退化路径

DeepEP 的默认配置是在 DeepSeek 内部集群上调出来的(README「Auto-tuning」一节),README 的测试平台是"H800 + NVLink + 每卡一张 CX7 InfiniBand"——但生产部署可能没这么完美。当硬件配置降级时 DeepEP 的退化路径:

配置 A:H800(H100 中国版)

H800 的 NVLink 带宽被限制到 400 GB/s 量级,而 H100 的 NVLink 4 是单卡双向 900 GB/s。DeepEP 的 intranode kernel 完全兼容,只是带宽上限低。有意思的是:README 那两张实测表本身就是在 H800 上跑的——153 GB/s 打到 H800 约 160 GB/s NVLink 峰值的 96%。换到 H100 上限更高,但"打满百分比"这个指标未必更好看。

配置 B:A100(SM80)

这条要纠正一个常见误解:DeepEP 是支持 A100 的,但只支持一半。README 的 Requirements 第一条就写着 "Ampere (SM80), Hopper (SM90) GPUs",安装时用 DISABLE_SM90_FEATURES=1 关掉 SM90 特性(CUDA 11.0+ 就够);可这个开关会连带要求禁用 NVSHMEM(DeepEP 的 setup.py 里 assert disable_nvshmem),README Roadmap 也写着 "A100 support (intranode only)"——A100 上只有节点内那条路,跨节点与 low-latency kernel 都没有。计算侧同样有门槛:DeepGEMM 只支持 SM90/SM100,V4 的 FP8/FP4 原生指令 A100 也没有。所以"V4 在 A100 上不好跑",计算侧和多机通信侧都卡。

配置 C:消费级 GPU(RTX 4090 / 5090)

README 的 Requirements 明确写着"节点内需要 NVLink、跨节点需要 RDMA 网络"。消费级卡两样都没有,DeepEP 的核心前提不成立。何况 V4 Pro 的显存需求本来就把消费级卡排除在外了。

配置 D:异构集群(H100 + H800 混合)

pin 版主线代码与 README 都没有针对异构集群的设计(README 的 Community Forks 里,uccl-ep 专门做异构 GPU / NIC 支持);混合部署时各 rank 的通信与计算速度会被最慢的那张卡拖齐,生产中应该尽量保持硬件同构。

这些退化路径说明 DeepEP 对硬件是有门槛的:Hopper + NVLink + 支持 IBGDA 的 RDMA 网络才是完整形态;A100 只剩节点内,没有 NVLink 的卡在 pin 版主线上基本用不上(README 的实验分支 Hybrid-EP 另有 PCIe kernel)。


16.14 DeepEP 与 torch.distributed / NVSHMEM 的关系

DeepEP 不是从零开始的通信库,但它踩在两层地基上,很多人只看到其中一层:

  • torch.distributed:只用来做拓扑发现与元信息同步(deep_ep/buffer.py:32 的 Buffer.__init__ 收一个 ProcessGroup,:97/:101/:131 三次 all_gather_object 分别交换 device id、CUDA IPC handle 与 NVSHMEM 的 unique id)
  • NVSHMEM:真正的数据路径。跨节点与低延迟 kernel 全部建在它之上,编译时找不到 NVSHMEM(§16.2)这两条路直接被禁用

数据不流经 NCCL,也不流经 torch.distributed。

torch.distributed 提供的基础设施:

  • ProcessGroup 抽象(标识"哪些 rank 是一组")
  • NCCL backend(实际跨 GPU 通信的执行)
  • 各种 collective 原语(all_reduce / all_gather / broadcast 等)
  • 通信器初始化与销毁(init_process_group / destroy_process_group)

DeepEP 从 torch.distributed 拿的东西:

  • 用 ProcessGroup 的 group.rank() / group.size() 拿到本 rank 号与组大小(deep_ep/buffer.py:70-72;不传 group 时也可以改传 mpi4py 的 comm)
  • 用现成的 ProcessGroup 做初始化期的元信息交换(buffer 地址、NVSHMEM 的 unique id 之类)

DeepEP 自己做的事:

  • dispatch / combine 的全部数据传输——节点内直接 NVLink P2P,跨节点走 NVSHMEM + IBGDA
  • 自己的 buffer 生命周期管理(Buffer 一次性预分配 NVL / RDMA 内存)
  • 自己算 dispatch 布局(csrc/kernels/layout.cu,对应 Python 侧 deep_ep/buffer.py:293 的 get_dispatch_layout)

这种"元信息借 torch.distributed,数据路径自己走"的设计让 DeepEP 与 NCCL 共存——你既可以用 DeepEP 跑 V4 的 MoE 路由,又可以用 NCCL 跑 V4 的 attention all_reduce(第 15 章那几处)。

编程模型的差异要留意:Buffer.dispatch 返回的不只是张量,还有一个 handle——combine 必须把这个 handle 传回去,backward 也要靠它。这与 dist.all_to_all 那种"进去张量出来张量"的无状态语义不同:dispatch 与 combine 是一对有状态的操作。

如果你想深入理解 PyTorch 的 distributed 通信抽象(ProcessGroup、Backend、Work、async_op 等),可以参考《PyTorch 训练框架内核深度解析》第 16 章(进程组与 NCCL 通信)——那个专栏会从 c10d 的 C++ 抽象一路讲到 NCCL backend 的实现。读完之后回来看 DeepEP,会对它的"绕开 NCCL"路径理解更深。


16.15 DeepEP 在不公开训练栈中的角色

DeepEP 在推理路径上的角色前面已经讲清——MoE all-to-all 通信。放到训练里,这类 EP 通信库的角色更复杂——它不只负责前向通信,还是梯度路由的关键组件。

训练时 MoE 每层经历三个通信阶段:

阶段 1:forward dispatch:与推理一样,每 rank 把它的 token 按 expert 分发到对应 rank。

阶段 2:backward 路由梯度:反向传播需要把"expert 输出的梯度"路由回"token 原始所在 rank"。这是 dispatch 的逆向操作——README 给出的对偶接口是 dispatch_backward 与 combine_backward,各自复用 forward 留下的 handle。

阶段 3:expert weight 梯度同步:来自多个 token 的梯度在本 rank 的 GEMM 里就累加完了;要跨 rank 同步的是数据并行维度上同一 expert 的各个副本(§16.12)。V4 在这里用的是"BF16 随机舍入 + all-to-all 交换 + FP32 本地求和"(技术报告第 3.5.1 节),不走 DeepEP 的 dispatch / combine。

阶段 1 + 2 用 DeepEP 这类 EP 通信(不规则、按路由动态变化),阶段 3 用集合通信(规则、固定形状)。

梯度路由的复杂度:dispatch 时 token 被打散到各 rank、按来源 rank 重新排布——backward 时必须沿相反路径送回去。DeepEP 的 handle 记着 dispatch 时的布局(rank_prefix_matrix、recv_src_idx、is_token_in_rank 等,deep_ep/buffer.py:398),让 backward 可以精确逆向。

与 ZeRO 优化器分片的协同:如果 expert weight 用 ZeRO 分片,weight 梯度还要按分片做 reduce-scatter 或等价的交换(V4 用的就是上面那种两阶段做法)——再多一次通信。DeepEP 只负责 token 路由这一层,不管优化器状态,这部分要由训练框架自己处理。

(本节的阶段 1/2 有 README 的接口作依据,阶段 3 有技术报告第 3.5.1 节作依据;"V4 训练时 EP 的 token 通信是否就用 DeepEP、三个阶段具体怎么编排"则不公开,上面的分工描述是按接口能力做的合理推断。)

理解这些训练通信的复杂度,才能体会到 DeepSeek 公开 DeepEP 的工程意义——README 开头就说它的常规 kernel"同时适用于训练和推理 prefill",不是个纯推理工具。


16.16 DeepEP 在不同生产场景下的配置建议

部署 V4 时 DeepEP 的几个关键参数需要根据场景调整。

先说清楚:下面这些具体数值都是示意,不是官方推荐值——DeepEP 的 README 没有给场景化的配置建议。真正该照做的是"怎么把它们算出来":Buffer.get_low_latency_rdma_size_hint(num_max_dispatch_tokens_per_rank, hidden, num_ranks, num_experts) 这个静态方法会替你算低延迟模式该开多大的 RDMA buffer——用它,别拍脑袋填数。常规模式的 buffer 是分块中转的队列,不需要容纳单次 dispatch 的全部 token,用 Buffer.get_dispatch_config / get_combine_config 返回的 Config 调 get_nvl_buffer_size_hint / get_rdma_buffer_size_hint 算(§16.7),同样是算出来的。

下面的场景划分只用来说明方向(prefill 走常规 kernel、decode 走 low-latency kernel、SM 数在通信与 GEMM 之间此消彼长):

场景 1:高吞吐 prefill(长 prompt)

  • num_nvl_bytes / num_rdma_bytes:按 size hint 取(V4-Pro 下 NVL 约 0.3~0.4 GiB;EP ≤ 8 时 RDMA 必须为 0)。buffer 大小与 token 量无关,开大了也不会让 prefill 更快
  • num_sms:32(给常规 kernel 更多 SM 换吞吐;须为偶数,size hint 会随之变大)
  • 不启用 low_latency 路径(prefill 不在意延迟)

场景 2:低延迟 decode(短 prompt)

  • low_latency_mode=True,num_nvl_bytes:0
  • num_rdma_bytes:get_low_latency_rdma_size_hint(...) 的结果(每 rank 128 token、384 专家时约 2.7 GiB)
  • num_qps_per_rank:等于本地专家数(README 注释说为了最佳性能必须如此)
  • num_sms 对 low-latency kernel 不起作用;想把通信藏到计算后面,用 return_recv_hook=True

场景 3:混合工作负载(生产典型)

  • 同一个 Buffer 可以同时承载两种 kernel:以 low_latency_mode=True 创建,num_nvl_bytes / num_rdma_bytes 同时满足两种模式的尺寸需求(tests/test_internode.py 的 --test-ll-compatibility 就是这样测的)
  • 从常规 kernel 切到 low-latency kernel 之前,要先调 clean_low_latency_buffer(...) 清掉低延迟区域
  • num_sms:20-24(折中,只影响常规 kernel)

场景 4:批量评估(offline batch)

  • 与场景 1 同一套常规 kernel,buffer 同样按 size hint 取
  • num_sms:48(最大化 dispatch / combine 速度)
  • 不启用 low_latency(不在意延迟)

配置错误的影响:

  • buffer 设小:C++ 侧在发起 kernel 前直接断言失败(例如 csrc/deep_ep.cpp 的节点内 dispatch 会校验各段中转空间之和不超过 num_nvl_bytes),没有自动回退的慢路径
  • num_sms 设少:dispatch 成为瓶颈,GEMM 等待
  • num_sms 设多:GEMM 被挤压,整体变慢

调优的方法是按典型 workload 跑 tests/ 里的脚本,找出"DeepEP + GEMM 总时间最短"的配置(README「Auto-tuning」一节也建议跑全部测试、用自动调出的最佳配置)。别忘了 README 提到的那条更优路径:基于 hook 的通信-计算重叠不占用任何 SM——不过它只属于 low-latency 那组 kernel,常规 kernel 仍要在 num_sms 上做取舍。


16.17 DeepEP 底下的 RDMA:NVSHMEM 与 IBGDA

DeepEP 的跨节点路径对很多工程师是黑盒。先把最重要的一件事说清楚:DeepEP 不直接写 libibverbs 代码。它建在 NVSHMEM 之上(仓库自带 third-party/nvshmem.patch),PD、QP、内存注册这些 verbs 层的事情由 NVSHMEM 负责——所以你在 csrc/kernels/ 下找不到任何 QP 状态机。

真正的关键文件是 csrc/kernels/ibgda_device.cuh(506 行,跨节点数据路径的核心就压在这里)。IBGDA(InfiniBand GPUDirect Async)让 GPU 自己发起 RDMA 操作——不需要 CPU 代理去敲门铃(doorbell),kernel 自己写门铃(ibgda_device.cuh 的 ibgda_ring_db)。这是 low-latency 路径能把一次 dispatch 压到百微秒上下(EP=8 时 77 μs)的根本原因:整条链路上没有 CPU(指按驱动参数开启的标准 IBGDA;third-party/README.md 另给了一种借助 GDRCopy、由 CPU 辅助发送的 IBGDA 模式,性能略有损失)。

对照一下三种做法:

做法 谁发起传输 每次传输的 CPU 参与
传统 verbs CPU 调 ibv_post_send 每次都要
GPUDirect RDMA CPU 发起,但数据不过 host 内存 每次都要
IBGDA(DeepEP 用的) GPU 在 kernel 里直接发起 不需要

在这套模型下,dispatch 的语义变成:发送方的 GPU kernel 直接把 token 写进接收方 GPU 的预分配 buffer,接收方的 kernel 轮询这块 buffer。没有 collective、没有 CPU 往返、没有 ack 等待——延迟自然下来了。

运维上要关心的两个环境变量,README 都点了名:

  • NVSHMEM_IB_SL:控制 InfiniBand 的 Virtual Lane。建议把常规 kernel、low-latency kernel、其他负载分到不同 VL 上做流量隔离
  • DISABLE_AGGRESSIVE_PTX_INSTRS:DeepEP 用了一些超出官方文档保证的激进 PTX load/store 指令(README 有专门一节叫 "Undefined-behavior PTX usage")。pin 版 DeepEP 的 setup.py 默认把它设为 1,即默认不用;显式设 0 才会启用,出问题再关回去换稳定性

还有一条要记住:拥塞控制是关闭的(README 说生产中没观察到显著拥塞),路由策略按负载选——重载开自适应路由,轻载用静态路由。

理解这一层让你 debug 跨节点问题时找对方向:报错大概率来自 NVSHMEM 的初始化或 IB 网络配置,而不是 DeepEP 的 kernel 代码。


16.18 延伸阅读

  • DeepEP 仓库 README 与 tests/:本章主要参考(基准 commit 567632d)
  • NVSHMEM 安装指引:仓库的 third-party/README.md——跨节点路径的前置依赖
  • NCCL 文档(NVIDIA NCCL):理解 collective 通信的标准实现
  • GShard 论文(Lepikhin 等 2020,arXiv:2006.16668):MoE 的早期 all-to-all 工程
  • 本专栏第 13 章:DeepGEMM 的 m_grouped_*_masked 可以直接吃 DeepEP 低延迟 kernel 的输出(DeepGEMM README 给的示例用法);V4 技术报告第 3.1 节那套融合 EP 方案的开源实现 MegaMoE 也在那一章
  • 本专栏第 19 章:部署时 DeepEP 的集成与依赖

16.19 本章小结

  • V4 的 MoE 在多卡 / 多节点部署时需要 all-to-all——NCCL 的通用 collective 并不合身
  • DeepEP 是 DeepSeek 在 V3 时期(2025 年 2 月)开源、为 MoE all-to-all 量身定做的通信库;V4 技术报告自己的 EP 方案是 DeepGEMM 里的融合 kernel MegaMoE,参考推理代码则只用 all_reduce
  • 区分 intranode (NVLink) / internode (RDMA) / low-latency (decode) 三条路径,分别是 csrc/kernels/ 下的 intranode.cu / internode.cu / internode_ll.cu——dispatch 与 combine 成对住在同一个文件里
  • README 实测(H800 + CX7、V3/R1 配置):节点内 dispatch 打到 153 GB/s,是 H800 NVLink 峰值的 96%;low-latency 路径 EP=8 时 dispatch 77 μs / combine 114 μs
  • 底层是 NVSHMEM + IBGDA(GPU 直接发起 RDMA),不是自己写 verbs;没有 NVSHMEM 就只剩节点内那条路
  • Buffer 对象是核心抽象——预分配 NVL / RDMA 内存,尺寸用 size hint 算;combine 只做不带权重的求和
  • vLLM v0.8.5 的 EP 既没有 all-to-all 也没有 DeepEP;接入需要把"全量 token + all_reduce"改成 dispatch / combine、与 grouped_gemm 配合、解决依赖

第 17 章我们离开推理基础设施,进入 V4 的训练栈:Muon 优化器与预训练。