4. Ring算法

聊到多卡通信,Ring AllReduce 绝对是个绕不开的经典。我最早接触它是在做 8 卡 BERT 训练的时候,那时候刚入行,总觉得通信库是个黑盒。后来被逼着排查一次性能瓶颈,才真正把它啃透了。说白了,Ring 算法的核心思想就一句话:让每张卡只和邻居说话,别搞全局广播那一套。

4.1 为什么是环形?

你想想看,如果 8 张卡要互相传数据,最粗暴的方式就是每张卡都发给所有人。那通信量是 O(N²) 的,卡一多直接爆炸。Ring 算法聪明在哪?它把卡排成一个逻辑环,每张卡只跟前后邻居通信。这样通信量就降到了 O(N),而且每张卡的负载非常均衡。

我在项目中遇到过一种情况:有人觉得环形拓扑太慢,非要改成树形。结果呢?树根节点的带宽成了瓶颈,反而比 Ring 还慢。嗯,这里要注意:Ring 不是最快的,但它是扩展性最好的。

核心洞察: Ring AllReduce 的带宽利用率理论上可以达到 100%(在数据量足够大时),而树形结构受限于根节点带宽,利用率通常只有 50% 左右。

4.2 分步拆解:ReduceScatter + AllGather

Ring AllReduce 其实就两步:先 ReduceScatter,再 AllGather。我刚开始学的时候总觉得这两步是独立的,后来才明白它们是一对「镜像操作」。

4.2.1 第一步:ReduceScatter(归约散射)

这一步的目标是:把数据打散,每张卡只负责归约一部分。

假设我们有 4 张卡,每张卡有 4 个数据块。ReduceScatter 的过程是这样的:

  1. 每张卡把自己的数据切成 N 块(N=卡数)
  2. 第 i 张卡把第 j 块数据发给邻居
  3. 每收到一块数据,就做一次归约(比如求和)
  4. 经过 N-1 轮后,每张卡上有一块完整的归约结果

我曾经调试过一个奇怪的问题:ReduceScatter 跑完,结果总是对不上。查了半天发现是数据切分时索引算错了。所以写代码时一定要画图,别光靠脑子想。

个人习惯: 我每次写 Ring 相关的代码,都会先在纸上画一个 4 卡的环,标清楚每轮谁发给谁。画完再写代码,基本不会出错。

4.2.2 第二步:AllGather(全收集)

ReduceScatter 做完后,每张卡手里只有一部分结果。AllGather 就是把这些结果广播给所有人。

过程跟 ReduceScatter 对称:

  • 每张卡把自己手里的那部分结果发给邻居
  • 邻居收到后直接存起来(不需要归约)
  • 同样经过 N-1 轮,每张卡都拿到了完整结果

你看,这两步的通信模式一模一样,区别只在于:ReduceScatter 要做归约,AllGather 只做转发。这也是为什么 NCCL 能把它们优化成同一个 kernel 的原因。

4.3 带宽利用率分析

这是面试常考的点,也是实际调优的关键。我们来算一笔账:

参数 值
卡数 (N) 8
每卡数据量 (D) 1 GB
单卡带宽 (B) 12.5 GB/s (100 Gbps)

Ring 算法的总通信量:每张卡在 ReduceScatter 阶段发送 (N-1) * (D/N) 数据,AllGather 阶段同样发送 (N-1) * (D/N) 数据。所以总发送量 = 2 * (N-1) * D / N。

代入 N=8:总发送量 = 2 * 7 * 1/8 = 1.75 GB。理想时间 = 1.75 / 12.5 ≈ 0.14 秒。

那带宽利用率呢?理论上,Ring 算法在数据量足够大时,利用率接近 100%。为什么?因为每张卡在每一轮都在同时收发数据,没有空闲等待。但实际中会有几个因素拖后腿:

  • 数据量太小: 如果每块数据只有几百字节,通信延迟会占大头,带宽利用率暴跌
  • PCIe 拓扑: 如果卡不在同一个 PCIe switch 下,跨 NUMA 通信会引入额外延迟
  • Ring 大小: 卡越多,轮数越多,延迟累积越明显

避坑指南: 我曾经在一个 32 卡集群上跑小 batch 训练,发现 Ring 算法比 Broadcast 还慢。原因就是数据量太小(每卡只有 256KB),通信延迟占了 90%。后来改用 Hierarchical Ring(分层环)才解决问题。

4.4 可视化理解

下面这张图展示了 4 卡 Ring AllReduce 的完整流程。我特意把 ReduceScatter 和 AllGather 分成了两个阶段,方便你对照理解。

4卡 Ring AllReduce 流程图 Phase 1: ReduceScatter Phase 2: AllGather 卡0 [A0,A1,A2,A3] 卡1 [B0,B1,B2,B3] 卡2 [C0,C1,C2,C3] 卡3 [D0,D1,D2,D3] 卡0: [A0+B0+C0+D0] 卡1: [A1+B1+C1+D1] 卡2: [A2+B2+C2+D2] 卡3: [A3+B3+C3+D3] 卡0 [R0] 卡1 [R1] 卡2 [R2] 卡3 [R3] 最终:每张卡都有完整结果 [R0,R1,R2,R3] R0 = A0+B0+C0+D0, R1 = A1+B1+C1+D1, ...

从图上你可以看到,两个阶段的通信模式完全一样。区别只在于:ReduceScatter 阶段,数据在传递过程中不断累加;AllGather 阶段,数据只是原样转发。

4.5 实际调优建议

基于上面的分析,我给你几个实战中验证过的建议:

  • 数据量阈值: 我一般建议单卡数据量至少 1MB 以上才用 Ring,否则延迟会吃掉带宽优势
  • 环的大小: 超过 8 卡时,考虑分层 Ring(比如 4 卡一个内环,多个内环组成外环)
  • NUMA 亲和性: 尽量让同一个 Ring 里的卡在同一个 NUMA 节点上,跨 NUMA 通信延迟能差 2-3 倍
  • 混合精度: 如果用了 FP16,记得检查 NCCL 的 dtype 设置,别让通信精度拖后腿

一个小技巧: 用 NCCL_DEBUG=INFO 环境变量可以打印出 Ring 的拓扑信息。我每次调优都会先看这个输出,确认 Ring 的构建是否符合预期。

好了,Ring 算法的核心就这些。说白了,它就是通过「分而治之」的思路,把全局通信变成了局部通信。理解了这个,你再看 NCCL 的其他算法(比如 Tree、NVLink 直连)就会觉得一通百通。