MoE 模型这几年在消费级 GPU 上跑得越来越多,大家第一反应是显存不够:一张卡装不下,就堆两张、四张。但装下了之后,真正的瓶颈往往不在算力,而是 PCIe 通信。Token 在专家之间搬来搬去,每次前向都要跨卡传好几轮数据,消费级 GPU 又没有 NVLink,只能靠主板上的 PCIe 链路硬扛。ThunderEP 这篇论文讲的就是这个场景下的通信优化,核心卖点很直白:在消费级 GPU 上把专家并行的 PCIe 开销砍掉一半。它不是改模型结构,而是改通信调度和内存排布,让同一份数据在链路上尽量只走一次。这篇文章我会从问题背景、核心设计、实操复现到排坑经验展开,适合正在做 LLM 推理优化、租 GPU 跑模型的同学,也适合对系统通信优化感兴趣的工程师。
1. MoE 专家并行为什么被 PCIe 卡住脖子
1.1 消费级 GPU 的通信边界比想象中更紧
先对齐一个基础认知:MoE(Mixture of Experts)模型的思路是,把网络中的一部分 FFN 层替换成一组专家子网络,每个 token 只被路由到其中 top-k 个专家上。这样做的好处是,模型总参数量可以做得很大,但单次推理的实际计算量不大。于是很多人觉得,MoE 天生适合在显存和算力都有限的消费级卡上省着用。
但真正跑起来就发现,问题从“显存装不下”变成了“通信扛不住”。专家并行(Expert Parallelism)要求把不同专家分布到不同 GPU 上,每个 token 经过 gate 网络选路之后,如果目标专家在别的卡上,就必须先把 token 数据搬过去。一张 RTX 4090 的 PCIe 4.0 x16 理论带宽是 32GB/s,实际可用大概在 22GB/s 到 26GB/s 之间;如果你插的是第二条 x8 槽,直接掉到一半。而数据中心里常用的 A100 NVLink 带宽是 300GB/s 起步,H100 更是到了 900GB/s 的量级。消费级平台和服务器平台的互联差距,比芯片算力的差距还要夸张。
更要命的是,MoE 的通信不是一次性的。一次前向传播中,token 要先被发送到目标专家所在的卡(dispatch),算完之后结果又要传回原始卡(combine),这还没算训练时的梯度同步。也就是说,一个 token 在 PCIe 上至少要往返一次。如果实现得粗糙,数据可能被拷贝三四次。通信耗时占单层计算耗时的 50% 到 70%,在我自己测过的模型里一点都不夸张。
1.2 专家并行通信的两种核心模式
专家并行的通信可以抽象成两种操作。
第一种是 dispatch,也叫 token 分发。假设第 0 号 GPU 上有一个 attention 层,它算完当前层输出后,gate 网络决定其中一部分 token 要给 1 号 GPU 上的专家 A,另一部分要给 2 号 GPU 上的专家 B。这时 0 号 GPU 需要把 token 按照“目标专家”重新分组,然后发给对应 GPU。
第二种是 combine,也叫结果回收。专家算完各自的 token 之后,这些 token 的结果必须返回到原来的 GPU 上,因为下一层 attention 需要的是“每个 token 的完整序列上下文”,而不是分散在多个卡上的碎片。所以 combine 本质上是一次反方向的数据搬运。
如果画成一个流程,就是“分桶-发送-计算-回传-重组”。我用一个生活化的类比:食堂打饭。所有学生(token)先按照窗口(专家)排队,阿姨(GPU)把菜给学生,学生吃完后必须回到自己原来的座位,才能继续上下一节课。每一轮 MoE 前向,学生都要从座位跑到窗口再跑回来。消费级 GPU 平台上,这条走廊只有 PCIe 这么宽。
1.3 “一半”的开销是怎么算出来的
论文标题里说砍掉一半 PCIe 开销,这个“一半”不是营销话术,而是有明确结构依据的。
朴素实现下,一个 MoE 层的前向通信成本主要包含两块:dispatch 阶段把 token 数据从源 GPU 搬到目标 GPU,combine 阶段把计算结果从目标 GPU 搬回源 GPU。这两次搬运的数据量几乎一样大,而且都是全量传输。如果能把 dispatch 和 combine 中的某一次“copy”去掉,或者是把两次传输压缩成一次有效传输,那通信时间就能砍半。ThunderEP 的思路本质上就是从这里切入的。它让数据在链路上尽量少走无意义的来回,而不是单纯提升带宽利用率。
注意:这里说的“砍掉一半”是指通信开销减半,不是端到端推理时间减半。通信减半后,整体耗时能下降多少,取决于通信在总耗时中的占比。MoE 层越多、专家分布越分散,收益越明显。
2. ThunderEP 的核心设计:把 PCIe 当成有向图来调度
2.1 反面教材:把 PCIe 当成一根“大水管”来用
我在看这篇论文之前,一直在想一个问题:为什么 PCIe 通信会这么慢?明明理论上也有几十 GB/s。后来发现,问题出在实现方式上。
大多数朴素实现把 PCIe 当成一根对称的大水管来看待:我从 A 卡往 B 卡发数据,带宽是一定的;B 卡往 A 卡发数据,带宽也是一定的。然后代码里就只管调 all-to-all API,把数据从一个 buffer 拷贝到另一个 buffer,至于数据是从哪条链路走的、什么时候走的、走了哪个方向,完全不管。
但 PCIe 实际上是一个由多条上游/下游链路组成的有向拓扑。数据从一个设备到另一个设备,可能经过 root complex,也可能经过 PCIe switch;DMA 读操作和 DMA 写操作占用的总线资源也不一样。如果同一时刻多个方向的数据在链路上交叉申请资源,就会互相挤占,最后实测带宽远低于理论值。这就像一条双向两车道的路,如果大家没有错峰,对面来车一多,谁都走不快。
ThunderEP 的突破口就在这里:预先拿到路由矩阵,知道每个 token 要发到哪个专家,也就知道每个 GPU 下一个通信周期要往哪些目标发多少数据。既然目标是已知的,通信路径就是可规划的。
2.2 报文聚合与亲缘性排布
MoE 通信有个容易被忽略的问题:token 分片太碎。一个专家可能只分到 128 个 token,每个 token 的 hidden state 可能也就 4096 个 float,换算下来才几 MB 的数据。但一次 send/recv 的开销是固定的,包括同步开销、DMA 描述符的建立、框架层的调用成本。数据量小的时候,通信开销完全被这些边角料吃掉了。
ThunderEP 的做法是先聚合再传输。在 dispatch 阶段,不是每个专家一个包往外发,而是把同一批要发往同一个目标 GPU 的 token 数据合并成一个较大的报文,做一次传输。大报文的带宽利用率远高于小报文。这个和文件系统的批量写入是一个道理:一次写 1MB 比写 1024 次 1KB 快得多,因为大部分开销都在“启动”。
亲缘性排布是另一个层面。消费级主板上的 PCIe switch 和 CPU root complex 是有层级关系的。两张卡如果挂在同一个 PCIe switch 下面,通信就走 switch 内部高速通道;如果分别挂在两个 CPU 的 root complex 下,数据可能要跨处理器走 NUMA 路径,延迟和带宽都会有明显劣化。ThunderEP 在做专家放置时,会把通信量大的专家对尽量放到共享同一条上游链路的卡上。这个思路类似分布式系统的“数据局部性”,只是这里的“局部性”是物理链路的局部性。
2.3 双向环形流水:让计算和通信重叠起来
我见过太多实现,计算和通信是串行的:先等 dispatch 完成,再跑专家 kernel,再等 combine 完成。这相当于一条流水线上只有一个人在干活,其他人都在等。
ThunderEP 的做法是双向环形流水。把通信拆成多个阶段,让第 i 层的 dispatch 和第 i+1 层的计算重叠起来。具体实现上,它会维护一组 pinned memory 上的环形缓冲区,每个缓冲区同时有“发送 stream”和“接收 stream”在使用。dispatch 阶段的数据不再需要先拷贝到一个“通信暂存区”,再从这里发出去;而是直接在计算 kernel 的输出缓冲上建立 DMA 传输描述符,算完一小块就发一小块。
更细节一点说,它还利用了消费级 GPU 上 DMA 引擎的读写特性。常规的跨卡传输逻辑是:A 卡把数据从显存拷到主机内存,再从主机内存拷到 B 卡显存。ThunderEP 会尽量让 DMA 引擎在读取 A 卡数据的同时,直接把数据写入 B 卡的可访问地址,减少主机内存这一道中转。这中间有很多驱动层面的限制,不是所有平台都能完整做到,但思路是对的。
2.4 “砍掉一半”的本质逻辑
到这里可以总结一下:ThunderEP 把 PCIe 开销砍掉一半,靠的是三件事的组合。
第一,消除 dispatch 和 combine 之间的重复全量传输。常规实现中,dispatch 阶段把整块数据发过去,combine 阶段再把整块数据发回来。ThunderEP 通过目标侧归约和共享内存缓冲,把这两次搬运合并成一次有效数据移动,语义上仍然保证每个 token 的结果回到原始卡。
第二,把通信从“同步等待”改成“异步流水”。常规实现里,PCIe 链路经常处于空闲状态,因为计算在等通信、通信在等计算。流水化之后,链路在大部分时间内都有数据在流动,等效利用带宽提升了一倍左右。
第三,把报文从“碎片化小包”改成“聚合大包”。这不改变搬运的数据总量,但大幅减少了传输次数和同步开销,让链路一直处于满载状态。
三件事叠加,量变引起质变,通信耗时降一半是完全合理的。
注意:如果你要复现这个收益,不要指望直接替换一两个 API 就能看到效果。它需要从内存分配、stream 组织到专家放置做一整套改动,任何一个环节拖后腿,最终效果都会打折扣。
3. 在消费级 GPU 上做通信优化的实操参考
3.1 动手前先搞清楚你的卡接在哪条路上
很多人的通信优化失败,不是代码写得不对,而是压根不知道自己的卡实际跑在什么链路状态上。消费级主板的 PCIe 通道分配非常不直观,尤其是上了两张卡以上,经常出现你以为的 x16 实际是 x8 的情况。
第一步,确认每张卡的链路状态。在 Linux 下用 nvidia-smi 就能看到基础信息:
nvidia-smi -q -d PCIE重点看三个字段:Current Link Speed、Current Link Width、Max Link Speed、Max Link Width。如果 Current 和 Max 不一致,先查主板 BIOS 是不是开了省电模式,或者卡是不是插在不正确的槽位上。
第二步,看整机拓扑。nvidia-smi topo -m 会输出一张卡间拓扑关系表。PIX 表示同一 PCIe switch 下的直连,PXB 表示经过 PCIe bridge,SOC 表示板上直连但要走其他路径,NODE 则是跨 NUMA 节点。一般消费级平台会出现多个 PXB 甚至多个 NODE。
GPU0 GPU1 GPU2 GPU3 GPU0 X PIX NODE NODE GPU1 PIX X NODE NODE GPU2 NODE NODE X PIX GPU3 NODE NODE PIX X看到这种拓扑,你就知道 GPU0 和 GPU1 是亲兄弟,GPU2 和 GPU3 是亲兄弟,但 GPU0 和 GPU2 之间要跨 CPU 走长路。这时候做专家放置,就该考虑把高频通信的专家放在同一组里。
第三步,检查 P2P 支持。消费级 GPU 的 P2P 支持情况比较微妙,不能只看官方参数。用 PyTorch 直接测:
import torch print(torch.cuda.can_device_access_peer(0, 1))如果返回 True,说明两块卡之间可以直接走 P2P DMA;返回 False 的话,就只能退化为 host 中转,效率和延迟都会差一截。
3.2 用最小 MoE 层验证通信优化收益
我在复现这类通信优化时,不会一上来就改完整模型,而是先做一个“最小可通信 MoE 层”:一层自定义的专家并行 FFN,8 个专家,分布在两张卡上,每张卡 4 个专家。然后分别实现朴素 all-to-all 和优化版本,对比端到端延迟。
朴素版本通常长这样:
# 伪代码,仅展示通信模式 def moe_layer_naive(tokens, expert_weights): # tokens: [num_tokens, hidden] dispatch = torch.empty_like(tokens) torch.distributed.all_to_all_single(dispatch, tokens) # 第一次全量传输 output = expert_forward(dispatch, expert_weights) result = torch.empty_like(output) torch.distributed.all_to_all_single(result, output) # 第二次全量传输 return result优化版本的核心改动有三个地方:一是把 tokens 按照目标 rank 分桶后,用一次性大报文发送;二是给通信分配独立 CUDA stream,和专家计算 stream 并行;三是用 pinned memory 做中间缓冲,避免默认的 pageable memory 在 DMA 前额外拷贝。
实测中,当 token 数量较少、通信数据量较小时,优化版本和朴素版本差距不大,因为此时瓶颈在延迟而不是带宽。但当 token 数量增大到 thousands 级别、单次搬运超过 100MB 时,优化版本的耗时能显著下降,甚至能看到接近一半的通信时长缩减。
一个重要经验:验证收益时不要只测端到端时间,要用 profiler 看 PCIe 传输吞吐。NVIDIA Nsight Systems 里可以直接看 DMA 传输的带宽和时间。优化前后对比,观察链路空闲率是否大幅下降。只有在链路上跑的数据量没有明显减少、但耗时明显缩短的情况下,才能确认优化吃到了“带宽利用率”和“方向错峰”的红利。
3.3 几个容易被忽视的配置细节
消费级平台做多卡通信优化,有几个配置踩坑点,几乎每次换机器都会遇到。
Resizable BAR 和 Above 4G Decoding 必须开启。这个选项在 BIOS 里默认通常是关闭的。不开启时,PCIe 设备只能访问低 4GB 地址空间,大显存的 DMA 操作会多出很多兼容性处理,P2P 性能和稳定性都受影响。
ASPM(Active State Power Management)建议在 BIOS 里关掉。这个是 PCIe 链路省电机制,它在空闲时会把链路降到低功耗状态,等数据传输时再唤醒。问题是,MoE 通信是突发式的,链路频繁休眠和唤醒,会导致第一次请求的延迟明显增加,甚至在多卡互拷时出现掉速现象。
多卡插槽顺序也值得较真。消费级主板上,靠近 CPU 的第一条槽是 x16,第二条槽通常是 x8,如果插四张卡,有些主板会变成 x8/x8/x8/x8,有些则是 x16/x8/x4/x4。如果你有两张高频通信的卡,尽量插在共享同一个 PCIe switch 分叉的槽位上;如果两卡之间隔了一整个 root complex,那通信优化再怎么折腾,效果都会受限。
4. 常见通信问题与排查技巧
4.1 PCIe 掉速、掉 lane、AER 报错怎么处理
多卡消费级平台上,最常见也最头疼的问题是“掉卡”和“掉速”。表现为:跑着跑着,某张卡从 x16 掉到 x8,甚至直接消失;dmesg 里出现 AER(Advanced Error Reporting)相关错误。
我的排查顺序是这样的:先用 nvidia-smi -q -d PCIE 确认当前链路状态,再查看 dmesg 里的报错信息。AER 错误往往和供电不稳定、插槽接触不良、PCIe 链路信号完整性有关。多张高功耗卡同时满载时,12V 供电波动会直接影响 PCIe 链路的稳定性。
实操层面,几个有效手段:
- 确认电源供应足够,且每张卡的供电线是独立的,不要用一分二线缆。
- 清理显卡金手指和插槽,这听起来像玄学,但真能解决大量间歇性掉速。
- 在 BIOS 里固定 PCIe 速率,不要用 Auto。比如平台支持 PCIe 4.0,就锁死在 4.0;省电相关选项全部关掉。
- 如果卡本身没问题,可以尝试换一个物理槽位,排除主板插槽本身的信号质量问题。
4.2 P2P 不可用的降级路径
消费级 GPU 的 P2P 支持一直是个谜。有些卡对同一 root complex 下的 P2P 支持良好,跨 root complex 就不行;有些卡的 P2P 被驱动限制,即使返回 True,实际带宽也很惨。
当 P2P 不可用时,跨卡通信只能走 host 中转:数据先从源卡显存拷到主机内存,再从主机内存拷到目标卡显存。这个路径下,pinned memory 变得至关重要。默认的 pageable memory 是操作系统的虚拟内存页,驱动进行 DMA 前必须先把数据锁定并拷贝到物理连续的内存区域,等于额外多一次全量拷贝。用 pinned memory 能跳过这一步,直接给 DMA 引擎发地址。
分配 pinned memory 的代码很简单:
buf = torch.cuda.PinnedMemory(num_bytes)但分配 pinned memory 有几个坑:它是不可换页的物理内存,分配过多会导致系统内存压力大;在容器里分配时要注意容器的内存限制;分配和释放频率高时会碎片化。建议是启动时一次性分配一个大的通信缓冲池,后续复用。
4.3 为什么链路看着是 x16,通信带宽还是跑不满
这是一个非常隐蔽的问题。很多人拿 nvidia-smi 一看,链路状态正常,x16 满速,但跑 all-to-all 通信时吞吐就是上不去。
排查思路第一层是看方向。PCIe 的 DMA 读写带宽不一定对称,大量读和大量写同时发生时,总带宽可能被 read/write turnaround 开销吃掉。ThunderEP 里的方向错峰策略,本质上就是在规避这个问题。如果你在代码里同时让多张卡相互发数据,没有做方向上的错峰,带宽会有明显下降。
排查思路第二层是看报文大小。如果消息长度小于 64KB,带宽利用率通常很低。这时候要把多个小消息合并成一个大消息批量发送。MoE 场景下,合并的思路是:把多个专家分片的数据在逻辑上拼接成一个大的连续缓冲,一次性发送。
排查思路第三层是看有没有隐性同步。检查代码里是否有隐藏的 .item()、.cpu().numpy()、torch.cuda.synchronize(),这些调用会强制 GPU 和 CPU 同步,把本来应该异步的通信流水打断。我见过不少“优化后反而更慢”的案例,最后都是因为这些隐性同步把流水线拆掉了。
4.4 驱动和 CUDA 版本的影响
消费级显卡的驱动在大版本更新后,P2P 行为、DMA 引擎调度策略都会有一些细微变化。做通信优化时,最忌讳频繁升级驱动。一个稳定的环境比新特性更重要。我通常会把环境锁定在某个 535 或 550 系列的驱动,CUDA 版本跟着 PyTorch 官方推荐的配对走,不追新。
如果你在租用的 GPU 云环境上做实验,更要注意:云主机上的“GPU 被物理移除”提示往往不是真的硬件被拔走,而是驱动层面的链路复位或 PCIe AER 错误触发了系统对设备的重新枚举。遇到这种情况,第一反应是检查宿主机的驱动版本和 PCIe 电源管理策略,而不是一味换卡。
5. 对工程实践的启发与适配场景
5.1 做 GPU 调度和机型的同学能学到什么
现在很多平台提供 GPU 租用服务,用户可以在上面跑大模型。我看到不少租用者只关心“几张卡、多少显存”,从来不问“这几张卡之间是什么样的互联拓扑”。
如果你要在云平台或内网调度平台上跑 MoE 推理,有一个建议:优先选同一个 PCIe root complex 下的卡。如果调度系统能感知到 GPU 拓扑,尽量把 MoE 专家并行的实例放在拓扑相连的卡组里。这就像在分布式系统里调度任务时,优先把有数据依赖的任务放在同一个机架里。物理拓扑感知是通信优化的一半。
另外,平台的售前页面有义务写清楚卡间的互联链路是 x16 还是 x8、是 PCIe 3.0 还是 4.0、是否支持 P2P。这些参数对 MoE 工作负载的影响,远比 GPU 型号数字后面的一点点频率差异来得大。
5.2 给框架接入和模型推理的启发
ThunderEP 给出的抽象思路可以迁移到很多现有框架里。比如 vLLM、SGLang 这些推理引擎在支持多卡 MoE 时,通信层往往只实现了基本的 all-to-all,并没有对 PCIe 方向做调度。按 ThunderEP 的思路,可以在框架内加一个“通信计划器”,在启动推理时根据模型的路由配置和硬件拓扑生成一份调度策略,包括专家放置、消息批次大小、stream 数量。
低精度量化也是一个直观的减负手段。如果把 dispatch 的数据从 FP16 换成 INT8,带宽需求直接减半。很多 MoE 推理框架已经在这么做了。值得注意的是,量化本身也是一种“砍半”,但它砍的是传输字节数,ThunderEP 砍的是传输次数和链路浪费。两者可以叠加。
5.3 什么时候收益最大
最后说场景边界。ThunderEP 这类方案的收益与模型结构强相关。专家数量多、单次路由 token 多、hidden size 大的场景收益最大;如果模型本身专家就少,通信数据量小,那优化空间有限。推理场景比训练场景收益更立竿见影,因为推理的延迟敏感度更高,任何一次多余拷贝都能被用户直接感知到。训练场景下的梯度同步也可以套用类似的聚合和方向调度思路,但额外的复杂度更高,收益被 optimizer 和梯度压缩部分抵消。
如果只是单卡跑小模型,甚至不需要考虑这些。但当你开始往两张、四张消费级卡上塞 MoE 模型时,PCIe 通信就从一个“可以忽略的细节”变成了“决定系统效率的主战场”。
我在实际项目中有一个很深的体会:通信优化的第一课不是学 API,而是先搞清楚数据在你的机器上到底是怎么走的。有时候,把一张卡从 x8 的槽挪到 x16 的槽,收益比改半天代码还大。ThunderEP 这篇论文最大的价值,不是给了你一个可以直接抄的代码库,而是逼着你去重新审视物理链路的每个细节。建议大家动手前,先把自己机器的拓扑图跑出来,再决定从哪里开始优化。