3. 集合通信原语
好,咱们今天聊聊集合通信原语。说实话,这是多卡训练里最绕不开的东西。你跑大模型,不管是数据并行还是模型并行,底层都在跟这些原语打交道。我刚开始接触分布式训练时,总觉得这些概念差不多,结果一上集群就踩坑——通信时间比计算时间还长,那叫一个难受。
今天我把几个最核心的原语掰开揉碎了讲:AllReduce、AllGather、ReduceScatter、Broadcast、Gather。你搞懂了它们,集群通信这块基本就通了。
3.1 AllReduce:最常用的“求和”操作
AllReduce 说白了就是:每个卡算出一份梯度,然后大家把梯度加起来,最后每张卡都拿到完整的总和。数据并行里,反向传播后各卡梯度不同,必须做 AllReduce 才能同步更新参数。
我个人习惯把 AllReduce 分成两步理解:
- 第一步:Reduce —— 把各卡的梯度“归约”到一起,比如求和、求最大值。
- 第二步:Broadcast —— 把归约后的结果广播给所有卡。
但实际实现中,没人傻到先 Reduce 再 Broadcast。为什么?因为这样通信量翻倍。业界主流用的是 Ring AllReduce 算法。
Ring AllReduce 核心思想:把 N 张卡连成一个环,每张卡只跟邻居通信。数据切成 N 份,分 N-1 步完成 Reduce,再分 N-1 步完成 Broadcast。总通信量是 2*(N-1)/N * 数据量,接近线性扩展。
我曾在 8 卡 A100 集群上测试过,Ring AllReduce 比朴素实现快了将近 4 倍。嗯,这里要注意:Ring 算法对网络带宽要求高,如果节点间跨交换机,延迟会变大。
来个简单的伪代码,帮你理解 Ring AllReduce 的 Reduce-Scatter 阶段:
// 假设有 N 个 rank,每个 rank 持有数据块 data
// 数据被切分成 N 个 chunk
for step in range(N-1):
send_chunk = (rank - step) % N
recv_chunk = (rank - step - 1) % N
// 向邻居发送 send_chunk,从邻居接收 recv_chunk
send(data[send_chunk], next_rank)
recv(temp, prev_rank)
// 累加接收到的数据
data[recv_chunk] += temp
你看,每步只传一个 chunk,没有全局同步,效率很高。但有个坑:如果数据量小,Ring 的启动开销反而大。这时候用 Tree AllReduce 更合适。
我的经验:数据量小于 1MB 时,用 Tree 算法;大于 10MB 时,Ring 算法更优。中间区间可以测一下再定。
3.2 AllGather 与 ReduceScatter:一对“好兄弟”
AllGather 和 ReduceScatter 其实是 AllReduce 的两个“半成品”。你想想看:
- AllGather:每张卡把自己的一份数据发给所有人,最后每张卡都拿到完整集合。相当于只做 Broadcast 那一步。
- ReduceScatter:每张卡把数据归约后,只保留自己负责的那一份。相当于只做 Reduce 那一步。
为什么需要它们?因为有些场景不需要完整的 AllReduce。比如模型并行中,各卡只维护部分参数,你只需要把梯度归约到对应的卡上,ReduceScatter 就够用了。
我曾经在训练 时,把 AllReduce 拆成 ReduceScatter + AllGather,通信量直接减半。为什么?因为 AllReduce 要传 2 倍数据,而 ReduceScatter 只传 1 倍,AllGather 再传 1 倍,但中间可以跟计算重叠。
来看个 AllGather 的 Ring 实现思路:
// 每个 rank 持有自己的 chunk
for step in range(N-1):
send_chunk = (rank - step) % N
recv_chunk = (rank - step - 1) % N
send(data[send_chunk], next_rank)
recv(data[recv_chunk], prev_rank)
跟 Ring AllReduce 的 Broadcast 阶段一模一样。说白了,AllGather 就是 Broadcast 的“全员版”。
避坑指南:我曾经在 64 卡集群上做 AllGather,发现通信时间随卡数线性增长。后来发现是网络拓扑没优化——跨机通信走的是同一个交换机,带宽被打满了。解决办法是:用分层 AllGather,先机内再机间。
3.3 Broadcast 与 Gather:最基础的一对一/多对一
Broadcast 和 Gather 是集合通信的“基本功”。
- Broadcast:一张卡(root)把数据发给所有其他卡。常用于分发模型参数或初始化数据。
- Gather:所有卡把数据发给一张卡(root)。常用于收集日志或汇总结果。
你可能会问:这跟 AllGather 有什么区别?区别在于:Gather 只有 root 拿到完整数据,其他卡只知道自己那份。而 AllGather 是所有人都拿到完整数据。
Broadcast 的实现很简单,但效率差别很大。朴素做法是 root 依次发给每个节点,复杂度 O(N)。更好的做法是用 树形 Broadcast:root 发给两个节点,这两个节点再各发两个,以此类推,复杂度 O(log N)。
我习惯在初始化参数时用 Broadcast,因为只需要一次。但如果你频繁广播小数据,建议合并成一次大广播,减少启动开销。
来个树形 Broadcast 的伪代码:
// 假设 rank 0 是 root
if rank == 0:
for i in range(1, N):
send(data, i) // 朴素做法
else:
recv(data, 0)
// 树形优化版
if rank == 0:
send(data, 1)
send(data, 2)
else:
recv(data, parent)
if 2*rank+1 < N:
send(data, 2*rank+1)
if 2*rank+2 < N:
send(data, 2*rank+2)
你看,树形广播把通信时间从 O(N) 降到 O(log N),在大规模集群上效果明显。
核心总结:
- AllReduce = ReduceScatter + AllGather
- AllGather = 全员版 Broadcast
- ReduceScatter = 分片版 Reduce
- Broadcast 和 Gather 是基础,适合小规模或单次操作
最后,我画了一张图,帮你理清这些原语的关系:
嗯,这张图把关系理得很清楚。你记住:AllReduce 是大哥,ReduceScatter 和 AllGather 是它的左右手。Broadcast 和 Gather 是基础工具,适合特定场景。
我的建议:实际项目中,别自己手写这些原语。用 NCCL 或 MPI 的现成接口就好。但理解原理能帮你选对算法、调优参数。比如 NCCL 的 ncclAllReduce 底层会自动选 Ring 或 Tree,但你可以通过环境变量 NCCL_ALGO 强制指定。