最近在一个多 GPU 工作站的分布式训练优化上花了不少时间,目标很简单:把吞吐跑满。结果每次都在同一个地方卡住——PCIe 上挂的 GPU 跑一会儿就掉卡,日志里全是 AER 报错。为了彻底定位,我一边翻 NCCL 源码一边做实验,最后干脆绕开官方库,自己基于 IPC 实现了一版面向 PCIe 传输的 AllReduce。
这篇文章就把我从“会用 NCCL Ring LL”到“手写 PCIe IPC AllReduce”的真实经历写出来,包括算法流动过程、PCIe 链路的坑、实现取舍,以及稳定性排查链路。想搞懂多卡通信原理的朋友,或者被 PCIe 稳定性问题折磨过的人,应该都能从中找到点有用的东西。
1. 真 Ring LLAllReduce 里每个 chunk 到底怎么流动
很多资料把 Ring AllReduce 讲得很玄乎,实际上剥开看就是两个阶段:Reduce-Scatter 和 AllGather。每个 GPU 先把数据切成 N 份(N 等于参与通信的 GPU 数),然后沿着环滚动传递,一边传一边做规约,最后再沿着环把完整结果广播回来。
我以 4 个 GPU(G0、G1、G2、G3)为例,假设每块卡上的原始数据被切成 D0、D1、D2、D3 四个 chunk。
第一阶段 Reduce-Scatter,目的是让每个 GPU 最终只持有一个 chunk 的全局部分和:
- 第 1 步:G0 把 D3 发给 G1,G1 把 D0 发给 G2,G2 把 D1 发给 G3,G3 把 D2 发给 G0。收到后各自做本地相加。
- 第 2 步:继续沿着环方向转发上一轮收到的数据,比如 G1 把刚收到的 D3 发给 G2,同时从 G0 收 D2,再做累加。
- 第 3 步:重复同样的动作。
经过 P-1 = 3 次传递后,每个 GPU 手里的某个 chunk 就已经包含了所有 GPU 对应 chunk 的归约结果。比如 G0 留住了 D0,这个 D0 就是全局意义上的部分和。
第二阶段 AllGather,做的事情正好反过来:每个 GPU 把自己已经归约好的 chunk 完整广播给环上其他节点。
- 第 1 步:G0 把 D0 完整结果传给 G1,G1 把 D1 传给 G2,G2 把 D2 传给 G3,G3 把 D3 传给 G0。
- 第 2 步:继续沿着环转发收到的 chunk。
- 第 3 步:继续转发。
再经过 P-1 次传递,每个 GPU 手里都拿到了 D0、D1、D2、D3 的全局结果,AllReduce 就完成了。
把这两个阶段串起来看,每块 GPU 一共发送 2×(P-1)×(N/P) 字节的数据。随着 GPU 数量增加,单个 GPU 需要承担的数据量不会线性膨胀,而是缓慢趋近总数据量的 2 倍。这就是 Ring 被称为带宽最优算法的原因。
至于 LL 协议,它并不是一种独立的 AllReduce 算法,而是在 Ring 或者 Tree 拓扑上跑的通信协议变体。NCCL 内部会根据消息大小,在 Simple、LL、LL128 之间切换。LL 全称其实就是 Low Latency,主要思路是使用更小的固定大小数据包,并用精细的 flag 机制把 P2P 传输和计算重叠起来,让小消息在 ring 上跑得更快。换句话说,Ring 是骨架,LL 是让骨架在小尺寸消息场景下少磨蹭的通信手法。
我整理了一个简单的对比表,方便看大数据包和 LL 模式的差异:
| 维度 | Simple 模式 | LL 模式 |
|---|---|---|
| 适用消息尺寸 | 较大消息 | 中小消息 |
| 单次传输粒度 | 大块连续数据 | 小包固定长度 |
| 同步方式 | 依赖通道完成信号 | 更细粒度的 flag 控制 |
| 延迟表现 | 高 | 低 |
| 吞吐上限 | 高 | 小包场景占优 |
如果你只是想跑大模型训练,数据量动辄几百 MB,一般走 Simple 或者 LL128 就够了。但如果你的场景是小消息交互很多,比如强化学习里的多进程控制流,那 LL 的作用就非常明显。
2. Ring 的延迟天窗:树状 AllReduce 在什么时候抢班
Ring 的强项是带宽,弱项是延迟。因为每一步都依赖前一步收到的数据,整个 Reduce-Scatter 阶段的延迟是 P-1 步的线性叠加。GPU 数量一多,延迟就会肉眼可见地涨起来。
延迟模型很直观:
- Ring:启动时间和传输时间近似为 (P-1)×(启动延迟) + (P-1)×(单块传输时间)
- Tree:启动时间和传输时间近似为 log(P)×(启动延迟 + 聚合传输时间)
也就是说,当参与节点数从 4 变成 8、再变成 16,Ring 的延迟是翻倍叠加的,而 Tree 只增加一个层级。所以在大规模集群里,NCCL 很少会在所有情况下都死守 Ring,而会切换成 Tree AllReduce。
Tree 的做法是构造一棵逻辑树,叶子节点把自己的数据规约到父节点,父节点再往上一级汇总,最后再从根节点把全局结果广播下去。它牺牲了一部分带宽效率,但换来了更低的延迟墙。以 4 个 GPU 为例,Ring 需要 6 次单向传输完成整个 AllReduce,而 Tree 在理想情况下只需要 2 次层级传递。
NCCL 具体切换逻辑不是写死的,它内部会综合消息长度、拓扑结构、GPU 数量和通道数来做决策。比如在同一个 PCIe 交换机下的 4 卡机器上,Ring 往往已经足够好,因为交换机内部的链路延迟不高;但如果跨机走 RDMA,节点数一多,Tree 的优势就出来了。
我实际调试中的感受是:Ring 和 Tree 没有绝对的好坏,只看你的瓶颈是带宽还是延迟。以 PCIe 交换机为中心的多卡机,数据在机箱内部流动,带宽相对充足,Ring 很合适。一旦数据要跨节点,或者消息本身很小、延迟占比很大,那就值得把 Tree 纳入考虑。
3. PCIe 在传输之前就开始搞事:枚举、带宽与热插拔
我原本以为关注 AllReduce 只要盯算法就行,没想到真正卡住我的反而是 PCIe 链路本身的稳定性。PCIe 这套总线的复杂度,远超多数人的直觉。
先看枚举。系统上电时,BIOS 和内核会按照 PCIe 拓扑结构逐级扫描设备,给每个设备分配总线号、设备号和功能号,最终形成一棵以 Root Complex 为根的树。GPU 插在哪个槽位,就会落在哪个 PCIe Switch 下面,也就决定了它和 CPU、和其他 GPU 之间的实际物理路径。
很多人在写多卡程序时,默认所有 GPU 都是对等直连,其实不一定。同样的 4 张显卡,如果两张挂在 CPU0 的控制器上,两张挂在 CPU1 的控制器上,跨 CPU 访问就要走 QPI/UPI 链路,延迟和带宽都不可控。还有的平台,GPU 之间本身不支持 P2P DMA,NCCL 会退化成走主机内存中转,这样 Ring 的带宽优势直接被打折。
用几条命令就能把拓扑看明白:
lspci -tv lspci -vvv | grep -E "LnkCap|LnkSta"LnkCap显示设备支持的链路速度,LnkSta显示当前协商出来的实际速度和通道数。如果一块 x16 的卡协商出来是 x8,那就是典型的降速。
然后是带宽问题。很多人简单把 PCIe 带宽当成单向速度乘以通道数,实际上 PCIe 是双向对等的,但有效带宽要扣掉编码开销。比如 PCIe 3.0 x16,单向理论上限约 16 GB/s,实际可用约 15.7 GB/s;PCIe 4.0 x16 翻倍到 32 GB/s,实际也打不到标称。多卡通过同一个 Switch 共享上行带宽时,真实可用带宽还要再除。
我碰到过一次很典型的场景:4 张卡全部挂在一个 PCIe Switch 下面,跑 NCCL 的 Ring 测试时,单卡通信速度看起来正常,但整体吞吐一上去,立刻出现卡顿。后来一查枚举结果,Switch 的上行链路只协商到了 x8,成为了整个系统的隐形瓶颈。
热插拔和掉卡更让人头大。PCIe 在服务器场景确实支持热插拔,但这套机制在数据中心里反而容易误报。如果接触不良、供电波动或者链路信号质量差,控制器就可能上报一个“设备已被拔出”的事件,操作系统直接把这颗 GPU 从总线里摘掉,训练进程瞬间崩掉。
常见的 dmesg 现象是:
pcieport ... AER: Corrected error received: 0000:03:00.0 pcieport ... PCIe Bus Error: severity=Corrected, type=Physical Layer一旦出现链路降级,后续训练就会出现随机卡死、速度骤降甚至进程崩溃。这也是我后来决定自己写一版 AllReduce 的原因之一:在不稳定的 PCIe 链路上,我希望能够完全控制通信过程中的每一环,而不是躲在 NCCL 的黑盒后面。
4. 自造 PCIe IPC AllReduce:设计取舍与核心实现
为什么放着 NCCL 不用,非要自己写?说实话,NCCL 已经很强了,但它在某些自定义场景下存在三个问题:第一,通信策略是黑盒,出了问题不好定位;第二,某些平台拓扑上的 P2P 路径没有,但 NCCL 策略可能不会自动绕开,需要手动调很多环境变量;第三,我想在这个 AllReduce 里加入自己的容错和重试逻辑,NCCL 不给我这个自由度。
所以我设计了一套基于 IPC 的 PCIe AllReduce,核心思路是让数据在设备间通过记录好的物理拓扑走,同时用共享内存和标志位来控制同步节奏。
设计上有几个关键决策:
- 使用 CUDA IPC 句柄共享跨进程的设备内存,让每个进程都能拿到其他 GPU 指针的映射。
- 使用主机端共享内存区作为一个轻量的控制块,每个 GPU 的 kernel 通过原子操作访问,实现累加和完成信号。
- 环的顺序不按默认的 rank 号,而是按 PCIe 拓扑排序后的物理邻居来定。先 dump 一次
lspci -tv,把 GPU 所在的 Switch 位置作为排序依据,让环上相邻节点尽量落在同一个 Switch 下。
实现分批次推进。第一步是打通跨进程的设备指针:
// 进程 0 cudaIpcMemHandle_t handle; cudaIpcGetMemHandle(&handle, d_ptr); send_handle_to_peer(&handle); // 进程 1 cudaIpcMemHandle_t recv_handle; recv_handle_from_peer(&recv_handle); void *remote_ptr; cudaIpcOpenMemHandle(&remote_ptr, recv_handle, cudaIpcMemLazyEnablePeerAccess);第二步是初始化控制块。我用一个简单的结构体,记录当前阶段、当前 chunk ID、累计完成计数:
typedef struct { volatile unsigned int flag_reduce; volatile unsigned int flag_gather; volatile unsigned int chunk_offset; volatile unsigned int barrier_count; } ctrl_block;第三步是核心计算。我把 Ring 的 Reduce-Scatter 和 AllGather 拆成两个 kernel,每个 kernel 都通过共享内存里的 barrier_count 做同步。逻辑如下:
// 阶段 1:reduce-scatter for step in range(nranks - 1): send_chunk_id = (rank - step - 1 + nranks) % nranks recv_chunk_id = (rank - step + nranks) % nranks send chunk to next_rank recv chunk from prev_rank accumulate into local chunk // 阶段 2:allgather for step in range(nranks - 1): chunk_id = (rank + step) % nranks send chunk to next_rank recv chunk from prev_rank copy received chunk to local output这里特别要注意的是跨进程同步的实现。我在主机共享内存里放了原子计数器,但设备端的 kernel 不能直接依赖主机端的内存做高频同步,否则一次迭代等一次主机同步,延迟会高到没法看。实际操作中,我是用 GPU 侧共享内存 + 一轮 kernel 内循环完成的:每个 GPU 在自己的控制块里轮询相邻 GPU 写入的 flag,等 flag 翻转再继续下一步。
我把这套实现跑在 4 卡的 PCIe 平台上,小消息场景下的延迟比直接调 NCCL 默认配置稍微高了一点,但稳定性明显好转。最重要的是,一旦某一步通信超时,我可以直接定位到是哪个环节出了问题,而不是盯着 NCCL 的环境变量瞎猜。
5. 稳定性后续排查链路:从 dmesg 到 BIOS 设置
自研 AllReduce 稳定跑起来之后,并没有结束。PCIe 链路本身不稳定的话,任何上层算法都救不了。我必须把之前复现掉卡的根因彻底找出来。
排查链路首先从 dmesg 开始。我把日志抓到文件里:
dmesg -T | grep -iE "pcie|aer|link" > pcie_log.txt常见的结果分几类:
| 现象 | 可能原因 | 初步动作 |
|---|---|---|
| Corrected error 大量重复 | 链路信号质量差或功耗波动 | 检查供电、检查金手指、尝试降低速率 |
| Uncorrected Non-Fatal | 设备 DMA 越界或驱动异常 | 检查驱动版本、Bang 补丁、查看设备事件 |
| 链路协商降速 | 接触问题或插槽带宽不足 | 重新插拔、换槽位、检查 BIOS 设置 |
AER(Advanced Error Reporting)是 PCIe 规范里的错误报告机制,Corrected Error 一般来说可以靠硬件重传恢复,但如果频率太高,整个链路的有效带宽会明显下降。Uncorrected Error 分为致命和非致命,遇到严重的 Fatal 往往直接触发热插拔事件,显卡从系统里消失。
我在排查中发现一个非常典型的坑:BIOS 的 ASPM(Active State Power Management)默认开启时,PCIe 链路会在空闲时进入低功耗状态。在短消息高频通信场景下,链路反复在低功耗和全速状态之间切换,容易触发大量 Corrected Error,甚至直接掉卡。关掉 ASPM,把电源策略改为 Performance,问题立刻缓解一截。
还有MaxPayload设置。有些主板的默认 MaxPayload 是 128 字节,而设备可能支持 512 字节。在多卡通信中,大 payload 对吞吐有帮助,但如果链路质量恰好处在临界状态,大 payload 反而更容易出错。这个值需要实际测试,不是越大越好。
命令查看设备支持的 MaxPayload:
lspci -vvv | grep -i maxpayload我最后的稳定组合是:BIOS 关 ASPM -> 固定 PCIe 速率不让其自动协商降级 -> 重新插拔并清洁金手指 -> 显卡供电走独立线缆。改完之后,同样的训练任务连续跑一周都没再出现掉卡。
这套排查链路对自定义 AllReduce 也很重要,因为 IPC 通信本身就是高频小消息,非常容易踩到链路反复进入低功耗导致的延迟抖动。只要把底层稳定性问题解决了,上层通信性能自然就稳定。
6. 真环 vs 伪环:我最后怎么选传输顺序并省掉一批坑
写到这里,我特别想多说一句关于环顺序的细节,因为这是很多人自己实现 AllReduce 时会忽略的坑。
NCCL 默认的 rank 顺序是按 CUDA 可见设备顺序来的,但物理拓扑不一定跟逻辑 rank 匹配。比如进程 0 和进程 1 看起来相邻,实际上可能一个在 CPU0 的 PCIe 控制器下,另一个在 CPU1 的控制器下,中间要跨 CPU 链路,延迟惨不忍睹。
我在自研版本里先跑了一次 GPU 拓扑发现,再根据lspci -tv的结果重排环的顺序。原则很简单:环上相邻节点尽量共享同一个 PCIe Switch。
调整之后,实际效果非常明显。原来我的 4 卡机器环顺序是 0-1-2-3,改成按物理拓扑重排后的 0-2-1-3 之后,AllReduce 的完成时间下降了大约 20%。这就是因为环上相邻节点从“跨 CPU 总线通信”变成了“同一个 Switch 内部的 P2P 通信”。
重排的伪代码思路大概是:
# 伪代码,表示如何根据 PCIe 交换机位置重排 gpus = get_gpu_list() topo = get_pcie_topo() # 按 topo 中 switch 分组 switch_groups = group_by_switch(gpus, topo) # 组内相邻组间排序,让组内连段优先 ring_order = build_ring_from_groups(switch_groups)这里还有一个容易被忽略的细节:通道数量。如果两个 GPU 挂在同一个 Switch 下,但 Switch 连到 CPU 的上行链路只有 x8,那环上这两个节点通信再快,总出口还是会被 x8 卡住。所以在排序时不能只看同一 Switch,还得顺手把上行链路的速度吃进来作为权重。
我最终实现的逻辑是先构造一个邻接矩阵,权重是测出来的实际双向带宽,然后在上面跑一个简单的贪心来生成环顺序。虽然不保证全局最优,但比固定 CUDA rank 顺序已经好很多。
如果你也想自己写,我的建议是:不要一上来追求最完美的调度,先把你当前的物理拓扑抄清楚,然后基于最简单的大流量优先原则去排序,这已经能覆盖绝大多数情况。
最后再分享一个经验:调试这类问题了,不要一有报错就怀疑硬件。先把 dmesg 里的 AER 信息收集完整,再配合lspci -vvv看链路协商状态,基本能定位八成问题。等底层链路稳定了,再去折腾算法层,否则永远是在沙地上建高楼。