用消费级显卡跑 MoE 模型的人,基本都会经历一次“显存塞不下,拆到多卡却发现更慢了”的挫败。把专家拆到两张卡上之后,每个 MoE 层的 token 激活值要先通过 PCIe 发给对应专家,专家算完再把结果运回来——这一来一回每层都在发生,Mixtral 这种模型有几十层,总线上的流量轻松到 GB 级。ThunderEP 这篇论文盯的就是这笔账:它在消费级 GPU 的 MoE 专家并行场景下,把 PCIe 开销砍掉了一半。在 NVLink 集群上看,这数字没什么了不起;但在 PCIe 4.0 只有单向 16GB/s 的消费平台上,通信往往占掉 step 时间的四成以上,砍掉一半意味着整体吞吐实实在在涨一截。这篇文章我会先拆开专家并行通信的账本,再反推 ThunderEP 最可能靠什么做到减半,最后落到我们自己机器上能抄走哪些东西。适合正在用多卡消费 GPU 调 MoE 的朋友参考。
1. MoE 专家并行在消费卡上的瓶颈:显存不是唯一问题
1.1 专家并行到底在并什么
MoE 的核心是把传统的 FFN 层换成一个路由网络(gate/router)加一堆专家 FFN,token 输入后由 router 打分,选出 top-1 或 top-2 专家来计算。稀疏激活的好处是参数量可以很大,但每个 token 实际算的 FLOPs 有限。这也是 Mixtral 8x7B 这类模型能用消费卡跑推理的原因——名义参数 46B,实际激活只有十几 B。
要把这么一堆专家放进多张卡,就是专家并行(Expert Parallelism, EP)。它和 tensor parallel / pipeline parallel 的最大区别在于通信模式:tensor parallel 每层都要做一圈大粒度的规约通信,EP 则是 token 粒度的 all-to-all。
具体来说,每张卡本地有一部分 token。router 算完后,哪些 token 被分到了本地专家,哪些该送去另一张卡的专家,是确定的。前者直接在本地算,后者要把整段激活(hidden states)通过网络搬到目标卡。专家算完,结果又要搬回来,参与后续的残差和下一层注意力结算。这就是 dispatch(令牌分发)和 combine(结果回收)两个阶段,一篇 MoE 前向里每层都会出现。
1.2 一次 MoE 层的前向要搬多少数据
很多人直觉认为 MoE 通信量不大,因为每个 token 只激活少数专家。但这个直觉在字节数上是错的。看一笔最简单的账。
假设双卡专家并行,模型 hidden size 4096,激活用 fp16(每元素 2 字节),每个 GPU 每个 batch 处理 B 个 token。以 top-1 路由并且一半 token 要去远端专家为例:
- dispatch 阶段:每张卡要发出 B/2 个 token 的激活,单卡发出量 = B/2 × 4096 × 2 字节。
- 同时反向也有等量数据到来,所以链路两个方向各 B×4096 字节。
- combine 阶段:结果回传,同样再各来一轮 B×4096 字节。
B=4096 时,单个 MoE 层的总线双向流量大约 64MB。Mixtral 这类模型有 32 个 MoE 层,于是单次前向在总线上搬运的数据就是 2GB 上下。RTX 4090 跑 Mixtral 推理,整个前向的计算时间也就在几百毫秒量级,而 2GB 的数据要跨 PCIe 4.0,理想双向同时跑大约也要 70 毫秒,如果实现得糙——方向上串行、小消息一堆——150 毫秒都不意外。
再强调一下:这里算的还是推理激活值。训练还要把每层的梯度、路由选择的 mask 也搬一圈,流量至少再翻倍。所以真实系统的 PCIe 开销只会比这个例子更沉重。
1.3 消费级 GPU 的 PCIe 现实:没有 NVLink,全靠总线
数据中心里 MoE 专家并行没这么痛苦,是因为有 NVLink/NVSwitch 把通信兜住了。消费卡完全是另一个世界。我先给一张表,大家自己感受差距:
| 链路类型 | 双向总带宽 | 单方向理论 | 常见平台 |
|---|---|---|---|
| PCIe 4.0 x16 | 32GB/s | 约 16GB/s | RTX 3090 / 4090 |
| PCIe 5.0 x16 | 64GB/s | 约 32GB/s | RTX 5090、新主板 |
| RTX 3090 NVLink | 约 100GB/s | 约 50GB/s | 消费卡中最后一代 NVLink |
| A100 NVLink(SXM) | 600GB/s | 300GB/s | 数据中心 |
| H100 NVLink | 900GB/s | 450GB/s | 数据中心 |
RTX 3090 是消费卡里最后带 NVLink 的,但依然比数据中心卡少了一个数量级的互联带宽;RTX 4090 及之后干脆物理上砍掉了 NVLink。也就是说,现在想在家用工作站的几张卡之间做专家并行,PCIe 就是唯一的互联通道。
更要命的是,消费级主板上 PCIe P2P 的路径通常走 CPU 的 root complex,带宽受 CPU 通道限制,双卡还经常被挤成 x8 + x8 的组合。你在 nvidia-smi 里看到的链路是 PCIe 4.0 x16,实际跑起来可能只有一半 lane。这个我在第 5 章还会专门展开。
2. 通信开销的结构拆解:那些钱究竟花在哪一笔
说“PCIe 开销”,得先把它拆开。总线流量的方向、消息的粒度、同步的方式,每一项都会把开销放大。我分三块讲。
2.1 dispatch 与 combine 的双向账本
第一笔大开销是字节本身。dispatch 阶段,卡 A 要把路由到卡 B 专家的 token 激活发过去,卡 B 也要把路由到卡 A 专家的 token 发过来,两者是对称的。combine 阶段几乎是 dispatch 的镜像:卡 B 把结果回传,卡 A 也把结果回传。所以一个 MoE 层实际是两轮 all-to-all,而且两轮里的每个方向都要承担真实字节。
这还没算路由表的代价。router 输出的 top-k 索引和门控值虽然小,但也要送到远程卡才能完成后续的梯度计算或 combine 的加权求和。很多实现把索引和激活分开走,每个 token 多出一份 metadata 传输,摊到单个 token 上不算多,但在大 token 数下积少成多。
如果你用的是 top-2 路由,情况还要再坏一点:一个 token 被两个专家瓜分,跨卡的比例更高,通信量可以接近单卡把所有 token 都发一遍的量级。所以我说 MoE 的通信瓶颈不是“稀疏”两个字能掩盖的——路由稀疏了计算,但没稀疏通信,这两个是独立的维度。
2.2 小消息、方向串行、屏障等待:PCIe 被浪费的三种姿势
字节数是下限,真正让开销翻倍的是另外三件事。
第一,小消息的碎片化。专家并行里每张卡要把自己的 token 按目标专家分组,分别发给不同的卡。如果系统里有 8 个专家分布在两张卡、每张卡路由去对端的 token 又分成多组,每一次收发都是一条 DMA 描述符、一组协议开销。PCIe 的最小事务开销和尾延时对几 KB 的小消息非常不友好,经典的做法是把同一目标专家的一堆 token 拼成一个大 buffer 一起发,但不少 naive 实现是每个 token 一个 send/recv。
第二,方向的串行化。物理上 PCIe 一条链路是 full-duplex 的,同一时刻上行下行各走各的带宽。但很多实现写成了“先发完、等对端确认,再开始收”,或者干脆两个方向用同一个同步点串起来。结果就是链路的吞吐实际只有单向能力,另一半带宽被白白空置。对一个收发对称的 all-to-all 来说,这恰好就是“砍掉一半开销”的空间所在。
第三,全局屏障和层间等待。MoE 层和层之间是有数据依赖链的:第 L 层 combine 的结果是第 L+1 层 router 的输入。但不少框架引入了不必要的大同步,比如每次 dispatch 之前先做一次全局 all-reduce 确认路由结果,或者用固定的 send-then-wait 收尾,把本可以流水线化的层间传递变成了“暂停—搬运—恢复”。这种同步点会让 PCIe 的利用率雪上加霜。
2.3 一个可对照的估算例子
把上面三件事量化一下。沿用 1.2 的例子:hidden size 4096、fp16、双卡、B=4096 token。
| 实现质量 | 双向同时搬? | 消息合并? | 单 MoE 层通信耗时(估算) | 32 层合计 |
|---|---|---|---|---|
| 朴素串行 send-then-recv,小消息 | 否 | 否 | 约 4.5ms | 约 145ms |
| 双向并发 + 消息合并,无重叠 | 是 | 是 | 约 2.3ms | 约 74ms |
| 双向并发 + 微批流水重叠 | 是 | 是 | 可降到 1ms 以下暴露时间 | 30ms 左右暴露 |
上面这些是估算值,真实数字取决于 expert GEMM 的规模。但结构上的结论很清晰:方向串行空置的带宽、小消息打不满的有效载荷、没有重叠的暴露时间,三个因素叠加会让实际通信远超字节下限。ThunderEP 号称砍掉一半开销,本质上就是在这个结构里做文章。
3. 从标题反推 ThunderEP 的技术支柱:四步砍掉一半开销
先说明一句:我手上只有标题,没有论文原文,这一节不是转述原文的实验细节,而是基于标题承诺和行业常见做法做的技术还原。你可以把它当作“如果让我来设计会怎么做”的推演,方向是有把握的,具体实现未必逐行一致。在消费级 GPU 上想把 MoE 专家并行的 PCIe 开销砍掉一半,绕不开下面四个方向,按我认为的收益大小排列。
3.1 全双工调度:让上行和下行同时占满链路
最直接、最普适、几乎没有任何副作用的一招:把 dispatch/combine 的收发从串行改成并发。
具体到 CUDA 层面,就是不要写“先发完再等收”,而是把接收 buffer 一次性 post 好(用流里的异步 recv),再把发送任务丢进另一个流或同一批 DMA 描述符里,让硬件自己把两个方向同时跑起来。对收发对称的 all-to-all,把串行改成并发,通信墙钟时间理论上直接减半。
为什么这么简单的优化很多系统却没做?因为 NVLink 场景下不一定划算——NVLink 的带宽调度、拓扑和传输层与 PCIe 不同,数据中心机器还常有 NVSwitch,通信模式复杂得多。不少为 NVLink 优化的库默认假设两个方向会竞争资源,于是干脆串行化保证稳定。这个假设在 NVLink 上是有道理的取舍,搬到消费级 PCIe 上就成了枷锁。ThunderEP 如果只做一件事,我认为就是在这里:针对 PCIe 全双工特性重写 all-to-all 的调度。
3.2 微批流水线:用计算时间掩盖搬运时间
第二招是把 batch 切成 micro-batch,让第 i 个 micro-batch 的搬运和第 i-1 个 micro-batch 的专家计算重叠。这就是经典的 comm/computation overlap。MoE 场景的特殊之处在于:专家 GEMM 是稀疏的——每个 token 只走两个专家,很多 SM 其实是空闲的。用空闲的 SM 去跑 copy kernel,或者干脆用独立的 copy engine / DMA,把 PCIe 搬运藏到 GEMM 后面。
这一招不减少字节,但能大幅减少暴露在关键路径上的 PCIe 时间。很多论文会把“通信时间占比”从 step time 里算出来,重叠做得好的话,这个占比能降到很低。ThunderEP 标题说“砍掉一半 PCIe 开销”,如果这里的开销指的是暴露时间,微批流水线就是主力。
要注意的是,微批切多细有讲究。batch 太小,专家 GEMM 的启动开销会反噬;太大,流水线尾部暴露又变长。一般的经验是切 4~8 个 micro-batch,让每个 micro-batch 的 token 数仍然足够把专家 GEMM 塞满。
3.3 传输侧降比特:直接把送出去的数据减半
第三招最字面:把 dispatch/combine 的激活精度从 fp16 降到 fp8。同一批 token,字节数硬砍一半,PCIe 流量自然减半。
这招在 RTX 4090 上是顺手的,Ada 架构的 tensor core 原生支持 FP8 计算;RTX 3090(Ampere)没有原生 FP8,只能用 FP8 传输、接收后转回 BF16/FP16 计算,照样能省传输字节。真正的问题在量化误差——MoE 层之间反复 dispatch/combine,激活被反复量化反量化,误差会不会累积。惯性做法是逐层按 tensor 做 per-scale 缩放或动态量化,实测下来对大多数生成任务影响很小,但论文里一般会附带质量对比表,读的时候值得盯着看,因为这是唯一可能伤害模型效果的优化。
如果 ThunderEP 的“砍掉一半”是字节口径,那 FP8 就是最直白的解释。
3.4 零拷贝与消息合并:从协议层挤出剩余油水
最后还有几件零碎但真实的优化。一个是零拷贝——把 GEMM 的输出 buffer 直接作为 DMA 源地址,不要先拷进 staging buffer 再发;配合 peer memory 映射,P2P 传输能少一次本地拷贝。另一个是消息合并——把发往同一张卡同一专家的一组 token 排序后拼进一个连续 buffer,用一条 DMA 描述符送出去,消息越少,协议开销越少。还有一个是路由 metadata 的 piggyback——把 top-k 索引和门控值塞在激活包头上,省掉单独一轮小消息。
这几项单独看每项都只有几个百分点的收益,但合起来是压死骆驼的最后一根稻草。系统优化论文里那些“额外收益”往往就来自这里。读的时候我建议大家重点关注它有没有做单独 ablation,把每一项贡献拆开,否则很容易把 message merging 和 FP8 的收益混在一起算。
4. “砍掉一半”这个数字该怎么读:实验口径与性能还原
对一篇论文的标题数字,我向来带着怀疑去读。“砍掉一半”至少有三个不同的含义,理解错一个,结论全偏。
4.1 一半到底是相对什么基准
第一种口径是字节量减半。比如引入 FP8 传输后,总线上流动的字节确实少了一半。这种口径最干净,但也最没有“调度技术含量”——它只是把精度砍了。
第二种口径是通信墙钟时间减半。同样的字节量,靠全双工并发和流水线重叠,把一次 all-to-all 从 5ms 压到 2.5ms。这才是调度优化的功劳,也最符合系统论文的叙事。
第三种口径是通信在 step time 中的占比减半。比如原来一个 step 里通信占 40%,优化后占 20%。这种口径的缺点是它同时会被计算时间变化影响——如果专家 GEMM 也变快了,通信占比会显得更小,但通信本身的绝对时间可能没怎么变。
回到标题。“砍掉一半的 PCIe 开销”字面上更像第一种或第二种:字节或者时间。但关键要看这篇论文是写给谁看的。如果实验对比的是“通信时间占 step 比例从 45% 降到 20%”,那它对 NVLink 用户没意义,对消费级用户意义巨大——因为通信正是我们最痛的区域。
4.2 从通信减半到整体提速:算给你看
通信减半,端到端提速多少?用 Amdahl 定律式的估算:设通信时间占 step 时间的比例是 s,通信时间减半后,step 时间变为 1 - s/2,加速比 = 1 / (1 - s/2)。
| 通信占比 s | 通信减半后的整体加速比 |
|---|---|
| 20% | 1.11x |
| 30% | 1.18x |
| 40% | 1.25x |
| 50% | 1.33x |
| 60% | 1.43x |
可以看到,通信占比越高,减半的收益越大。消费级 PCIe 平台上,MoE 专家并行场景的通信占比普遍在 30%~50%,所以“砍掉一半 PCIe 开销”对应的端到端收益就是 1.2~1.5 倍。这恰好解释了为什么这篇论文只在“消费级 GPU”这个定语下有意义——同样的技术放到 NVLink 集群上,通信占比可能不到 10%,减半也就带来 5% 的整体提升,没人会在意;但在消费平台上,这就是从“没法跑”到“能跑”的区别。
4.3 论文实验的观察重点与应警惕的夸大
真要读这篇论文的实验部分,我的建议是盯五个点。
第一,基准是谁。对比基线是 naive 的 send-then-recv,还是调好参数的 NCCL all-to-all?前者的对照太弱,容易赢。第二,报告的是什么。是纯通信 microbenchmark,还是端到端吞吐?两者都可能很好看,但只有端到端数值说明真实价值。第三,FP8 质量。如果用了低精度传输,有没有附模型质量对比(困惑度、下游任务)?没有的话要打问号。第四,硬件细节。PCIe 代数、lane 数、是否开了 P2P、卡的型号,这些直接决定数字可不可迁移。第五,token 数和 batch 大小。小 batch 下通信占比天然低,论文选的 batch 是否代表你的使用场景?
这些不是挑刺,是读系统方向论文的基本姿势——数据好看可能是真的有用,也可能只是口径选得好。
5. 回到自己的机器:我们能从 ThunderEP 偷到什么经验
论文归论文,最后还是要落在自己的机器上。我过去一年在消费级 GPU 上折腾 MoE 推理,从这类工作里学到不少能直接用的小技巧,按硬件和软件分开说。
5.1 硬件层面:P2P、ReBAR、PCIe 代数,一样都不能少
先说插槽。很多人的双卡工作站,主板看着有两条 x16 物理槽,实际电气上是 x8 + x8,甚至是 x8 + 芯片组通道。PCIe 4.0 x8 的单方向只有 8GB/s,比 x16 直接少一半。排查方法很简单:装好卡后跑 nvidia-smi -q -d PCI,看 CurrentLinkWidth 和 LinkSpeed,确认是不是 16.0 GT/s x16。如果显示 x8 或 x4,先去 BIOS 里把 CPU 通道配置和 bifurcation 改对。
再开 ReBAR / Above 4G Decoding。这玩意儿对 GPU 的 PCIe 大块 DMA 搬运有帮助,尤其影响大 buffer 的效率。现在主流主板默认都开了,但老 BIOS 里经常是关的,顺手确认一下不亏。
还有一条非常现实的消费级经验:多卡 + PCIe 压力测试之后,链接动不动掉速甚至掉卡。AER 报错、link 从 16.0 GT/s 掉到 8.0 GT/s,我见过太多次。这种问题通常是主板的布线、供电或卡槽金手指接触不良导致,先换槽位、换供电线,再不行把 PCIe Gen 强制设成保守一档,稳定优先。
最后,P2P 是否真的生效要测过才知道。用 nvidia-smi topo -m 看 GPU 之间的路径,再用 cuMemcpy 做一次 P2P 带宽测试。消费平台的 CPU root complex 之间有差异,不同 root port 之间的 P2P 可能走不了直连,也可能带宽打折。别想当然,测了才算数。
5.2 软件层面:三招可以直接抄的优化
第一,把 all-to-all 的收发写成并发。PyTorch 里用 dist.batch_isend_irecv 而不是逐个 send/wait/recv/wait;或者手动给发送和接收各开一个 stream,一起 post 后一起 wait。这一步能立竿见影地把双向带宽用起来。
第二,自己实现 micro-batch 流水线。不用改模型结构,就把一个大 batch 的 forward 拆成 4 个 micro-batch,用 CUDA stream 让第 i 个 micro-batch 的拷贝和第 i-1 个的计算并行。注意要为 copy 分配独立 stream,并且把 GEMM 放进计算 stream,别让它们抢同一个流里的顺序。拆之前先 profile:如果专家 GEMM 已经占了 90% 的 SM,没有空闲资源给 copy,这招就失效;SM 有富余,收益立刻出来。
第三,激活传输降精度。如果模型接受 activation 量化,把 dispatch 的 tensor 在发之前 cast 成 FP8,接收后再 cast 回计算精度。FP8 只在发收两边各加一次 cast,成本低,换来的是总线字节减半。用 per-tensor 的动态 scale 控制误差,一般生成任务实测影响可忽略。vLLM、DeepSpeed 这类框架里如果支持,直接开。
还有一条看起来土但很有效的:token 排序。发送前把发往同一专家的 token 归拢到连续内存,少发几个碎片化的小消息,PCIe 实际吞吐能高不少。专家数多、token 分布散的时候,收益往往被低估。
5.3 什么场景下这套东西白搭
不是所有 MoE 场景都能吃到这些红利,我列几个反例,省得大家盲目照搬。
单卡跑 MoE 没有专家并行,PCIe 主要是加载权重的路径,这篇论文的思路用不上。单请求在线推理(batch=1)的 token 数太少,dispatch 量小,通信占比低,优化通信不如优化 kernel 和 KV cache。另外,如果你的专家 GEMM 已经把 GPU 塞满到没有富余 SM,微批流水线的重叠空间会很小——这时真正的瓶颈是算力,不是通信。最后,如果 batch 大到把 PCIe 双向都打满,那“砍掉一半”只能靠降精度,调度优化已经没有余量。所以先 profile 出你的通信/计算占比,再决定要不要照搬。
我自己在两张 4090 上做 MoE 推理压测时,最直观的一次改观就是把整 batch 的 send-then-recv 改成 micro-batch 双流重叠,再叠加 FP8 传输,端到端吞吐大约提升了 1.3 倍——和通信占比 40% 左右场景下减半的估算基本吻合。ThunderEP 这种工作真正的价值,不是让你记住某个论文里的数字,而是把 PCIe 这条“以为无解、其实可以绕着走”的瓶颈,拆成了几个能动手的部分。真到调优那天,建议大家先把自己模型的 dispatch/combine 账本算一遍:多少字节、多少方向、多少暴露时间,算完你就知道该抄哪一招了。