“推拉”之间:DeepGEMM Mega MoE 和 DeepEP 通信方案简析
Oct 30, 2026
MoE 的专家并行(EP)在每一层都做三件事:把 token 发到它选中的专家所在的卡上(dispatch),算两层矩阵乘,再把结果发回 token 原来的卡上求和(combine)。
DeepSeek 为这件事写了两套实现:
- DeepEP:只负责通信。dispatch 和 combine 各是一个 kernel,中间的矩阵乘交给 DeepGEMM。
- Mega MoE(在 DeepGEMM 里):把 dispatch、两层矩阵乘和 combine 放进同一个 kernel。
两者之间有一个容易被忽略的差别:DeepEP 的 dispatch 是发送方把数据推过去,Mega MoE 是接收方把数据拉过来;但 combine 两边都是推。 本文从这一差别出发,比较两套实现的异同,并回答它们为什么不同。
本文基于 DeepGEMM 和 DeepEP 的源码,讨论 Mega MoE 的 SM100 实现。举例时取 DeepSeek V4.1-Flash 的形状:384 个路由专家,每个 token 选 6 个,hidden 5120,8 卡专家并行。
TL;DR
- 同一件事,两种做法。 DeepEP 的 dispatch 是发送方把 token 推过去,到了再重排;Mega MoE 是接收方先交换计数,再把 token 直接拉到矩阵乘要读的位置。combine 两边都是推。
- 为什么只有 dispatch 的方向相反:看写的那一刻,写方知不知道最终地址。 combine 的地址早已随 token 带到,所以推。dispatch 的最终位置要等所有卡的计数到齐才能算出:DeepEP 选择不等,先推后重排;Mega MoE 选择先等,再直接拉到位。
- 拉的本质是从 scatter 变成 gather。 拉取按矩阵的行号依次进行,行号小的块先满,矩阵乘可以边拉边算。但这只在通信和计算处于同一个 kernel 时才有价值,这正是 DeepEP 不这样做的原因。
- Mega MoE 用一点代价,换数据“直接落位”: 多一段计数交换,多约 1.35 倍的 NVLink 流量,只能在 NVLink 域内使用;换来的是数据一到就落在计算要读的位置。
一、相同之处:两者都在做什么
DeepEP + DeepGEMM 用几个 kernel 依次完成:
- dispatch:源卡把 token 推到目标卡的接收缓冲区。缓冲区按源卡分区,每张源卡一块。同时交换“每个专家收到多少 token”的计数。
- 重排:目标卡把收到的 token 重新排成按专家连续摆放的矩阵,交给 GEMM。
- GEMM:DeepGEMM 在另一个 kernel 里算两层矩阵乘。
- combine:专家所在的卡把结果推回源卡,源卡在本地求和。
Mega MoE 只用一个 kernel:
- 交换计数:每张卡先告诉目标卡“谁要来”和“有几个”。
- 拉取:目标卡把 token 从源卡拉过来,直接放到第一层 GEMM 要读的位置上。
- GEMM:边拉边算两层矩阵乘。
- combine:第二层算完,直接把结果推回源卡,源卡在本地求和。
两者有很多相同点:
- 流程都是 dispatch → 两层矩阵乘 → combine;
- 都在发送前按专家筛选,只发到被选中的专家所在的卡,不会广播给所有卡;
- 都靠对称内存直接读写别的卡的显存;
- 都要先交换计数,再做跨卡同步;
- combine 都是推。
有一处细节不同:发送的粒度。一个 token 选中的 6 个专家里,可能有几个在同一张卡上。DeepEP 按卡去重,同一张卡只发一份,到了再展开(源码)。Mega MoE 不去重,每个“token–专家”对各占一行,同一张卡上的两个专家会把这个 token 拉两次(源码)。按均匀路由算,DeepEP 平均每个 token 发 4.43 份,Mega MoE 发 6 份,Mega MoE 多用了约 1.35 倍的 NVLink 流量。后面会看到,这是 Mega MoE 为了让数据一到达就能参与计算而付出的代价之一。
二、不同之一:推与拉
只有 dispatch 的方向相反
DeepEP 的 combine 根据 dispatch 时记下的来源信息,把结果写回源卡(源码)。Mega MoE 也是这样做的(源码)。
如果拉总是更好,combine 也该改成拉;如果推总是更好,dispatch 就不该改。所以答案一定藏在这两次传输的某个差别里。
判据:写的那一刻,写方知不知道最终地址
combine 的目标地址由三个数确定:源卡、源 token 编号、它是这个 token 的第几个选择。这三个数在 dispatch 时已经随 token 带到了专家所在的卡上(源码)。写回时只需查本地的表,不依赖任何别的卡。写方知道地址,所以推。
dispatch 的目标地址是“专家 E 的矩阵里的第 k 行”。k 等于所有源卡里排在它前面、也选了 E 的 token 数。要算出 k,必须先拿到所有源卡对 E 的计数。写入的那一刻,没有任何一张卡知道它。
于是有两种做法:
- DeepEP:不等。 数据立刻推到“按源卡分区”的位置,这个位置只用本卡的信息就能算出来。计数和数据同时传,数据到了之后再在本地重排一次。
- Mega MoE:等。 先把计数交换完,目标卡自己算出 k,再把 token 直接拉到最终位置,不用再重排。
Mega MoE 怎么“等”
在数据动之前,Mega MoE 先往目标卡写两样东西(源码):
- 名单:每个“token–专家”对写一个 4 字节的整数,记录“源卡 r 的第 i 个 token 选了专家 E”。
- 长度:每张源卡给每个专家送了几个,外加一个“所有卡都写完了”的完成信号。
两样缺一不可。没有名单,目标卡不知道去哪里读;没有长度,目标卡不知道读到哪里为止,也不知道源卡是否写完。之后所有卡再做一次跨卡同步,才开始拉。
这是拉取的代价:在数据传输开始之前,必须先串行完成这段交换。按源码注释,其中计数约 6.5 us,写名单约 2 us(源码)。
五个约束:为什么两边各自都对
两种设计都不是绝对更好,而是在各自的约束下都合理。可以归纳为五点:
- Mega MoE 集成了计算,DeepEP 只负责通信;
- Mega MoE 还要算 shared expert,可以趁等计数的时候先算;
- Mega MoE 自己就是缓冲区的使用者,知道缓冲区什么时候空出来,可以自己停下来等,DeepEP 做不到;
- 拉的一方自然知道数据什么时候到齐;
- 拉要直接访问别的卡的显存,只能在同一个 NVLink 域里做。
对应到源码:
| 约束 | DeepEP | Mega MoE |
|---|---|---|
| ① dispatch 的消费者在哪 | 另一个 kernel;kernel 边界即全局屏障,到达顺序对它无价值,只该优化“整次搬运最早结束” → 不等计数,先推,后重排 | 同一个 kernel,可按块等待(源码)→ 到达顺序 = 计算顺序才有价值 → 拉 |
| ② 开头等计数的空隙 | kernel 里没有计算可填,等就是纯空等 | 先发不依赖 dispatch 的 shared expert L1(及一波能放下的 L2),注释称 “dispatch gap”(源码) |
| ③ 能否直接寻址对端显存 | 要支持跨节点 RDMA/hybrid,不能假设 | 只在 NVLink 域内(源码;DeepInfer 的启用条件见源码) |
| ④ 缓冲区能否有界 | 接收方无法让发送方停 → 按最坏情况开满 [卡数, Tmax, 记录] | 等槽空的是接收方自己,读本地 l1_empty_count(源码)→ 天然反压 → 环形池,容量按最多同时活着的块数算(源码) |
| ⑤ 谁知道“数据到齐了” | 推:接收方只能靠每个发送方的完成信号才知道到齐,信号数随 EP 规模增长(MoK 称最多等 71 个 peer) | 拉:到齐是接收方的本地事实(本地 l1_full_count 加满,源码)。注意 Mega MoE 并非零跨卡信号:跨卡同步集中在开头一次(计数交换 + NVLink barrier,源码),之后的到齐判断全部本地化 |
两点补充:
- 关于 ②:shared expert 只能填满这段空隙的一部分。负责收尾处理(epilogue)的 warp 一开始要等 dispatch 通过第一次跨卡同步(源码),而存放矩阵乘结果的 TMEM 只有 2 份(源码)。所以每个 SM 在空隙里最多只能先算 2 个 shared expert 任务,之后就得等。第四节的时间线里能看到这一点。
- 关于 ⑤:这一条来自 Cursor 的 Mixture-of-Kittens。他们在训练场景(NVL72,EP64)里独立做出了同样的选择:dispatch 拉,combine 推,理由与 ①④⑤ 基本一致。
由这张表可以得到一条规律:通信方向由“谁知道最终地址”和“谁需要控制节奏”决定。 最终地址只有接收方能算出来、而且接收方要按自己的计算顺序取数时,用拉;发送方已经知道最终地址时,用推。所以两边的 combine 都是推。
此外,拉取与融合相互依存。融合让“按什么顺序拉”有了意义;拉又让接收方能控制节奏,缓冲区可以做成循环复用的有限大小,一个 kernel 才能容纳大批 token。
三、不同之二:从 scatter 到 gather
约束 ① 说,到达顺序等于计算顺序时,拉才有价值。那么,拉是怎么让数据按计算顺序到达的?
块只存在于最终的矩阵里
GEMM 不会一次算完一个专家,而是把这个专家的 token 切成若干块,每块 BLOCK_M 行,一块一块地算。一块能不能开始算,只看这一块的 BLOCK_M 个 token 是否都已经到了。
一个 token 从源卡到 GEMM,最多经过三种摆放方式:
| 排布 | 在哪 | 行的顺序 | 有没有块 |
|---|---|---|---|
① 源卡输入 x | 源卡 HBM | 源 token 号 | 没有 |
| ② DeepEP 接收缓冲区 | 目标卡 HBM | [源卡][槽],槽由源卡分配(源码) | 没有 |
| ③ 专家池 = L1 的 A 矩阵 | 目标卡 HBM | 按本地专家排,专家内补齐到 BLOCK_M | 有,每 BLOCK_M 行一块 |
Mega MoE 走 ① → ③,DeepEP 走 ① → ② → ③。
循环按谁的下标走,谁就按那个顺序完成
Mega MoE:拉取循环的下标就是 ③ 的行号(源码)。所有负责拉取的 warp 从 ③ 的第 0 行开始往后拉,所以行号小的块先满。调度器分配 GEMM 任务时,也是从行号小的块开始(源码)。拉的顺序和算的顺序一致。每拉完一个 token,这一块的计数器就加 1(源码);加载数据的 warp 看到计数满了,就开始算这一块(源码)。
DeepEP:在源卡上,循环的下标是源 token 号(源码);到了目标卡,重排的循环按 ② 的顺序逐行扫描,每扫一行就用一次原子加在 ③ 里占一个位置(源码)。两步都是“按输入的顺序走、往输出里散”,所以 ③ 的各个块是同时、零星地被填上的,几乎都要到最后才满。
| 谁跑循环 | 循环下标 | 什么先完成 | |
|---|---|---|---|
| 拉 = gather | 接收方 | 输出位置 | 输出的低端先完成 |
| 推 = scatter | 发送方 | 输入位置 | 输入的低端先发完,输出到处零星 |
gather 有一个前提:要能从输出的位置反推出输入在哪里。第二节说的名单和长度,就是为这个前提付的代价。
对 DeepEP 来说,块按什么顺序满并不重要。它的下游 DeepGEMM 是另一个 kernel,要等 DeepEP 全部结束才能开始,即使某一块提前到齐,也无法被提前使用。所以 DeepEP 只需要让整次搬运尽早结束,这正是约束 ①。
一个小例子
卡 1 上有专家 E0 和 E1,BLOCK_M = 2。卡 0 的 token:a 选 E0、b 选 E1、c 选 E0;卡 1 的 token:d 选 E0、f 选 E1。
| ③ 的行 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| token | a(卡 0) | d(卡 1) | c(卡 0) | 空 | b(卡 0) | f(卡 1) |
| 块 | E0 第 0 块 | E0 第 0 块 | E0 第 1 块 | E0 第 1 块 | E1 第 0 块 | E1 第 0 块 |
- Mega MoE 按行 0、1、2、4、5 的顺序拉,E0 的第 0 块最先满,可以马上开始算。
- DeepEP 重排时按 ② 的顺序扫 a、b、c、d、f,E0 的第 0 块要扫到 d 才满。而且 E0 内部谁排前面,取决于各次原子操作的先后。
此外,同一个专家内部,来自不同源卡的 token 是轮流排的(a、d、c,而不是 a、c、d)。这是 Mega MoE 的轮转规则(源码)。它决定的不是哪一块先满,而是每一块去哪张卡读:让同一时刻的读请求分散到所有源卡上,避免集中到某一张卡上。
由此可以得到一条更一般的经验:想让下游尽早开工,就按下游需要的顺序来组织上游的循环。 GEMM 里每个线程块负责一块固定输出(output-stationary),也是同样的道理。
四、不同之三:SM 怎么分工
确定了由接收方来拉,下一个问题是:接收方用 GPU 上的哪些资源来拉?
三种分工方式
| 分工方式 | 例子 | 闲置放在哪里 |
|---|---|---|
| 按时间分:一个 kernel 做完再做下一个 | DeepEP dispatch → DeepGEMM → DeepEP combine | 通信时 tensor core 全闲,计算时 NVLink 全闲 |
| 按 SM 分:一部分 SM 通信,一部分 SM 计算 | DeepEP 的 combine 与 shared expert 同时跑时,DeepEP 只占一部分 SM(DeepInfer 的上限是 min(理论值, 总数 − min(32, 总数//4)),源码);Cursor 的 MoK 也划出专门的通信 SM | 通信 SM 上的 tensor core 始终空闲;比例固定,需要调 |
| 在 SM 内按 warp 分 | Mega MoE:每个 SM 有 4 个拉取 warp、4 个 GEMM 相关 warp(两个加载数据、一个发射矩阵乘指令、一个调度)、8 个收尾 warp | 分摊到每个 SM,且会空闲的角色只占很少的资源 |
注意,“在 SM 内分工”并不意味着一个 SM 拉来的 token 就由这个 SM 来算。拉取 warp 拉完一个 token,更新的是全局计数器(源码),由哪个 SM 来算这一块,是调度器动态决定的(源码)。两种分工方式都要通过全局计数器交接。真正的区别在于资源怎么切、闲置放在哪里。
Mega MoE 的资源账
寄存器:拉取 warp 每个线程 48 个,GEMM 相关 warp 40 个,收尾 warp 208 个(源码)。加起来 128×48 + 128×40 + 256×208 = 64512,恰好用满整个寄存器堆(源码)。
共享内存:拉取要占约 22 KB,包括专家计数 2 KB 和 4 个 warp 各 5 KB 的中转缓冲。而 GEMM 每多一级流水大约要 19 KB(源码,按 BLOCK_M = 32 算),所以拉取大约占掉了一级流水。
也就是说,Mega MoE 只给拉取留了最少的资源,把每个 SM 的大部分留给矩阵乘和收尾。这样做行得通,是因为拉取主要靠 TMA 硬件完成,本身几乎不需要计算。
每类 warp 什么时候在忙
- 拉取 warp:先交换计数(源码),再按矩阵行号的顺序拉取(源码)。拉完后停下来,等所有第二层的结果写完(源码),最后清理工作区并做第二次跨卡同步,这一段和 combine 同时进行(源码)。
- 调度 warp:先安排 shared expert 的第一层(源码),等计数到齐(源码),再安排各专家的第一层,之后两层交替。
- 加载数据和发射矩阵乘指令的 warp:有任务就执行;调度结束后,在整个 combine 阶段处于空闲。
- 收尾 warp:开头被挡住(源码),之后处理两层矩阵乘的结果,最后变成 combine warp,负责求和(源码 起)。
每类 warp 都有一段空闲期:拉取 warp 在收尾阶段,GEMM 相关的 warp 在 combine 阶段,收尾 warp 在开头。Mega MoE 能接受这些闲置,是因为会空闲的角色只占用很少的资源,而最吃资源的收尾 warp 从开始计算到最后几乎一直在忙。同一个 warp 还会随时间换工作:收尾 warp 后来去做 combine,拉取 warp 后来去做清理。
这也回答了 Mega MoE 为什么不单独划一批 SM 来拉取:那样的话,空闲的将是整个 SM,包括它的 tensor core 和全部寄存器。
五、小结
综上,Mega MoE 与 DeepEP 的各处差异,几乎都是同一个选择的不同侧面:用一点别的代价,换数据直接落在计算需要的位置。
- 拉而不推:多一段串行的计数交换,换来数据按计算顺序到达;
- 不按卡去重:多用约 1.35 倍的 NVLink 流量,换来数据一到达就能计算,无需再次展开;
- 在 SM 内按 warp 分工:每个 SM 都让出一点资源,换来所有 tensor core 都在算;
- 循环复用的缓冲区:要求接收方自己控制节奏,所以只能在能直接访问对端显存的 NVLink 域内使用。
DeepEP 没有采用这种做法,原因在于它的下游位于 kernel 之外,并且需要支持跨节点通信,“按块到达”对它没有价值。两种设计都是在各自约束下的合理选择。
仍有几个问题有待厘清:收尾 warp 为什么在开头被阻塞(源码注释只说明它不能与拉取前的同步同时进行);开头的串行交换能否进一步压缩;以及上述差异在 decode 与 prefill 两种负载下各自的影响有多大。最后一个问题将在后续文章中,以“每个专家分到的 token 数”为切入点展开分析。
参考
- DeepGEMM:PR #304(引入 Mega MoE)、PR #316(官方基准)
- Cursor,Mixture-of-Kittens: our open-source MoE megakernel for NVL72s(2026-08)
- 木子知,DeepGEMM Mega MOE算子详解