DeepSeek V4 源码剖析
第16章 DeepEP:MoE all-to-all 通信库
没有高效 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 一层的工作流:
- Dispatch:把每个 token 发送到它选中的 6 个 expert 所在的 rank
- Compute:每个 rank 计算自己 expert 的输出
- 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 算法:
- 在每 rank 算"我要发给每个其他 rank 多少 token"——
get_dispatch_layout由topk_idx算出每 rank / 每 RDMA rank / 每 expert 的 token 数和is_token_in_rank(csrc/kernels/layout.cu) - 用 NVLink 或 IB 发送 token 数据 + meta-info(topk 索引、权重、FP8 scale)
- 接收端用环形队列式的 buffer 分块中转(README「Notices」一节:用队列做通信 buffer 省显存,但带来复杂性与潜在死锁)
- 接收端按"来源 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 的逆向:
- 每 rank 把 expert 输出按"原始 token 所在 rank" 分组
- 发送回各 rank
- 接收端按原始 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。
常规 kernel(NVLink + RDMA 转发)
设定:每 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 |
两个值得注意的规律:
- combine 比 dispatch 慢约 1.5-2 倍——combine 要搬 BF16(dispatch 是 FP8),数据量翻倍
- 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/:本章主要参考(基准 commit567632d) - 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 优化器与预训练。