通信原语:All-Reduce、All-Gather、Reduce-Scatter 原理与对比

多卡训练里,通信原语是绕不开的核心。说白了,你让多张卡协同干活,总得有个“沟通机制”。今天咱们就聊聊三个最常用的:All-Reduce、All-Gather、Reduce-Scatter。我当年刚接触分布式训练时,被这几个名字绕得头晕,后来亲手调过几次通信瓶颈,才算真正吃透。

1. 先搞清楚:它们到底在解决什么问题?

想象一下,你有4张GPU卡,每张卡算完自己的梯度后,需要把结果汇总。最笨的办法是:卡0收齐所有数据,算完再广播回去。但这样卡0就成了瓶颈,带宽也浪费了。

通信原语就是解决这个问题的——用更高效的拓扑和算法,让数据在卡之间“流”起来。我个人习惯把这三个原语分成两类:

  • All-Reduce:每张卡最终拿到所有卡的“汇总结果”(比如求和后的梯度)。
  • All-Gather:每张卡最终拿到所有卡的“原始数据”(比如每张卡的特征向量)。
  • Reduce-Scatter:每张卡只拿到汇总结果的“一部分”(比如把求和后的梯度分片存储)。

嗯,这里要注意:Reduce-Scatter 和 All-Gather 组合起来,其实就是 All-Reduce。这个关系我后面会细讲。

2. All-Reduce:最常用的“求和大师”

All-Reduce 是分布式训练里出现频率最高的原语。它的目标很简单:每张卡输入一个向量,经过通信后,每张卡都输出所有向量的“和”(或者平均值、最大值等)。

我在项目中遇到过一个问题:刚开始用 PyTorch DDP 时,默认就是 All-Reduce。但有一次训练大模型,发现通信耗时占了总时间的 40%。后来一查,原来是 Ring All-Reduce 的带宽利用率没跑满。

Ring All-Reduce 的核心思想是:把数据切成 N 份(N 等于卡数),每张卡只和相邻的卡通信。整个过程分两步:

  1. Reduce-Scatter 阶段:每张卡把自己的一份数据传给邻居,同时接收邻居的另一份数据,做累加。经过 N-1 轮后,每张卡都持有了某个分片的“全局和”。
  2. All-Gather 阶段:每张卡把自己持有的全局和分片广播给所有卡。经过 N-1 轮后,每张卡都拿到了完整结果。

你想想看,这个算法把通信量从 O(N) 降到了 O(1)(每张卡只发送和接收固定大小的数据)。但代价是延迟增加了——需要 2*(N-1) 轮通信。

关键指标:All-Reduce 的总通信量 = 2 * (N-1) * 数据量 / N。当 N 很大时,接近 2 * 数据量。

3. All-Gather:把碎片拼成完整拼图

All-Gather 的场景很直观:每张卡持有自己的一份数据,通信后每张卡都拥有所有卡的完整数据。比如在模型并行中,每张卡算完一部分前向结果后,需要把结果广播给所有卡。

它的实现比 All-Reduce 简单。还是用 Ring 拓扑:每张卡把自己的一份数据传给邻居,同时接收邻居传来的数据。经过 N-1 轮后,每张卡都收集到了所有分片。

我记得有一次做多卡推理加速,需要把每张卡的中间特征拼起来。一开始我用 All-Reduce,结果发现数据被求和了,完全不对。后来换成 All-Gather,一次搞定。

小技巧:如果你只需要“收集数据”而不需要“聚合计算”,用 All-Gather 而不是 All-Reduce。后者会多做一次无意义的求和。

4. Reduce-Scatter:分而治之的“中间人”

Reduce-Scatter 是 All-Reduce 的前半段。它把数据分片,每张卡只负责计算某个分片的全局结果。比如你有 4 张卡,每张卡有 4 个梯度值。Reduce-Scatter 后,卡0持有所有卡的第0个梯度的和,卡1持有所有卡的第1个梯度的和,以此类推。

这个原语在 ZeRO 优化器里用得很多。ZeRO 把优化器状态分片到不同卡上,每张卡只更新自己负责的那部分参数。这时候就需要 Reduce-Scatter 来聚合梯度。

我曾经踩过一个坑:在实现自定义通信时,把 Reduce-Scatter 和 All-Reduce 搞混了。结果每张卡只拿到了部分梯度,更新参数时直接崩了。后来加了个断言检查数据形状,才定位到问题。

注意:Reduce-Scatter 的输出是分片的,每张卡只持有完整结果的一部分。如果你需要完整结果,必须再接一个 All-Gather。

5. 三者的对比:一张表说清楚

原语 输入 输出 通信量 典型场景
All-Reduce 每卡一个向量 每卡都拿到全局和 2 * (N-1) * 数据量 / N 数据并行梯度同步
All-Gather 每卡一个分片 每卡都拿到完整数据 (N-1) * 数据量 / N 模型并行特征收集
Reduce-Scatter 每卡一个向量 每卡只拿到一个分片的全局和 (N-1) * 数据量 / N ZeRO 优化器梯度分片

从通信量上看,All-Gather 和 Reduce-Scatter 都是 All-Reduce 的一半。所以如果你只需要其中一步,就别用 All-Reduce 浪费带宽。

6. 核心逻辑图:Ring 通信的完整流程

下面我用 SVG 画了一张图,展示 4 张卡在 Ring All-Reduce 中的通信过程。你可以看到数据是如何分片、传递、累加的。

Ring All-Reduce 通信流程(4卡) 卡0 卡1 卡2 卡3 分片0 分片1 分片2 分片3 阶段1:Reduce-Scatter(3轮) 阶段2:All-Gather(3轮) 最终:每张卡都持有所有分片的全局和

这张图里,上半部分是 Reduce-Scatter,数据沿着环传递并累加。下半部分是 All-Gather,把累加后的分片广播给所有卡。虚线表示 All-Gather 阶段的通信方向与 Reduce-Scatter 相同,但传递的是已经聚合好的数据。

7. 实战中的选择建议

说了这么多原理,最后给点实际建议。我个人习惯这样选:

  • 数据并行训练:直接用 All-Reduce。PyTorch DDP 和 Horovod 都封装好了,别自己造轮子。
  • 模型并行或流水线并行:用 All-Gather 收集中间结果。如果后续还要做计算,可以考虑把 All-Gather 和计算重叠起来。
  • ZeRO 或 FSDP:用 Reduce-Scatter 做梯度分片。注意分片后的数据要跟优化器状态对齐,否则会出 bug。
  • 自定义通信:如果带宽是瓶颈,优先考虑 Reduce-Scatter + All-Gather 的组合,而不是直接 All-Reduce。前者可以让你在中间步骤插入计算,隐藏通信延迟。

我曾经在一个 64 卡集群上做过测试:把 All-Reduce 拆成 Reduce-Scatter + 本地计算 + All-Gather,通信耗时降低了 30%。当然,这取决于你的计算/通信比,不是所有场景都适用。

一句话总结:All-Reduce 是“汇总+广播”,All-Gather 是“只广播”,Reduce-Scatter 是“只汇总”。选哪个,取决于你下一步要做什么。

好了,通信原语就聊到这儿。记住,多卡通信的核心不是算法多炫酷,而是让数据流动起来,别让任何一张卡闲着。下次遇到通信瓶颈,先想想:我到底需要完整结果,还是只需要部分结果?