“推拉”之间:DeepGEMM Mega MoE 和 DeepEP 通信方案简析

Oct 30, 2026

English version

MoE 的专家并行(EP)在每一层都做三件事:把 token 发到它选中的专家所在的卡上(dispatch),算两层矩阵乘,再把结果发回 token 原来的卡上求和(combine)。

DeepSeek 为这件事写了两套实现:

两者之间有一个容易被忽略的差别:DeepEP 的 dispatch 是发送方把数据推过去,Mega MoE 是接收方把数据拉过来;但 combine 两边都是推。 本文从这一差别出发,比较两套实现的异同,并回答它们为什么不同。

本文基于 DeepGEMM 和 DeepEP 的源码,讨论 Mega MoE 的 SM100 实现。举例时取 DeepSeek V4.1-Flash 的形状:384 个路由专家,每个 token 选 6 个,hidden 5120,8 卡专家并行。

TL;DR

一、相同之处:两者都在做什么

DeepEP + DeepGEMM 用几个 kernel 依次完成:

  1. dispatch:源卡把 token 推到目标卡的接收缓冲区。缓冲区按源卡分区,每张源卡一块。同时交换“每个专家收到多少 token”的计数。
  2. 重排:目标卡把收到的 token 重新排成按专家连续摆放的矩阵,交给 GEMM。
  3. GEMM:DeepGEMM 在另一个 kernel 里算两层矩阵乘。
  4. combine:专家所在的卡把结果推回源卡,源卡在本地求和。

Mega MoE 只用一个 kernel:

  1. 交换计数:每张卡先告诉目标卡“谁要来”和“有几个”。
  2. 拉取:目标卡把 token 从源卡拉过来,直接放到第一层 GEMM 要读的位置上。
  3. GEMM:边拉边算两层矩阵乘。
  4. combine:第二层算完,直接把结果推回源卡,源卡在本地求和。
DeepEP + DeepGEMM · the sender pushes Source tokens in token order push Receive buffer split by source rank reorder Expert matrix all blocks fill slowly kernel boundary DeepGEMM after dispatch ends Mega MoE · the receiver pulls, in one kernel Source tokens stay in place ① Exchange counts who, and how many ② pull Expert matrix low blocks fill first block full GEMM block by block Combine is a push on both sides: results go back to the source rank and are summed there.
图 1:DeepEP 与 Mega MoE 的数据流

两者有很多相同点:

有一处细节不同:发送的粒度。一个 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 的计数。写入的那一刻,没有任何一张卡知道它。

于是有两种做法:

Mega MoE 怎么“等”

在数据动之前,Mega MoE 先往目标卡写两样东西(源码):

两样缺一不可。没有名单,目标卡不知道去哪里读;没有长度,目标卡不知道读到哪里为止,也不知道源卡是否写完。之后所有卡再做一次跨卡同步,才开始拉。

这是拉取的代价:在数据传输开始之前,必须先串行完成这段交换。按源码注释,其中计数约 6.5 us,写名单约 2 us(源码)。

五个约束:为什么两边各自都对

两种设计都不是绝对更好,而是在各自的约束下都合理。可以归纳为五点:

  1. Mega MoE 集成了计算,DeepEP 只负责通信;
  2. Mega MoE 还要算 shared expert,可以趁等计数的时候先算;
  3. Mega MoE 自己就是缓冲区的使用者,知道缓冲区什么时候空出来,可以自己停下来等,DeepEP 做不到;
  4. 拉的一方自然知道数据什么时候到齐;
  5. 拉要直接访问别的卡的显存,只能在同一个 NVLink 域里做。

对应到源码:

约束DeepEPMega 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,源码),之后的到齐判断全部本地化

两点补充:

由这张表可以得到一条规律:通信方向由“谁知道最终地址”和“谁需要控制节奏”决定。 最终地址只有接收方能算出来、而且接收方要按自己的计算顺序取数时,用拉;发送方已经知道最终地址时,用推。所以两边的 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。

③ 的行012345
tokena(卡 0)d(卡 1)c(卡 0)空b(卡 0)f(卡 1)
块E0 第 0 块E0 第 0 块E0 第 1 块E0 第 1 块E1 第 0 块E1 第 0 块

此外,同一个专家内部,来自不同源卡的 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 什么时候在忙

start wait for counts pull ∥ compute rest of compute combine Pull warps×4 exchange counts pull rows in order wait for layer-2 writes clean up Scheduler×1 wait shared expert first, then both layers interleaved Load + MMA×3 ≤ 2 tasks two GEMM layers Epilogue×8 blocked finish both GEMM layers sum results busy waiting or idle Schematic, not to scale. ≤ 2 tasks: before the epilogue is released, at most two shared-expert tasks can run.
图 2:每类 warp 的忙闲时间线(每卡 8192 个 token,示意)

每类 warp 都有一段空闲期:拉取 warp 在收尾阶段,GEMM 相关的 warp 在 combine 阶段,收尾 warp 在开头。Mega MoE 能接受这些闲置,是因为会空闲的角色只占用很少的资源,而最吃资源的收尾 warp 从开始计算到最后几乎一直在忙。同一个 warp 还会随时间换工作:收尾 warp 后来去做 combine,拉取 warp 后来去做清理。

这也回答了 Mega MoE 为什么不单独划一批 SM 来拉取:那样的话,空闲的将是整个 SM,包括它的 tensor core 和全部寄存器。

五、小结

综上,Mega MoE 与 DeepEP 的各处差异,几乎都是同一个选择的不同侧面:用一点别的代价,换数据直接落在计算需要的位置。

DeepEP 没有采用这种做法,原因在于它的下游位于 kernel 之外,并且需要支持跨节点通信,“按块到达”对它没有价值。两种设计都是在各自约束下的合理选择。

仍有几个问题有待厘清:收尾 warp 为什么在开头被阻塞(源码注释只说明它不能与拉取前的同步同时进行);开头的串行交换能否进一步压缩;以及上述差异在 decode 与 prefill 两种负载下各自的影响有多大。最后一个问题将在后续文章中,以“每个专家分到的 token 数”为切入点展开分析。

参考