3. 集合通信原语:AllReduce原理与实现、AllGather与ReduceScatter、Broadcast与Gather

好,咱们今天聊聊集合通信原语。说实话,这是多卡训练里最绕不开的东西。你跑大模型,不管是数据并行还是模型并行,底层都在跟这些原语打交道。我刚开始接触分布式训练时,总觉得这些概念差不多,结果一上集群就踩坑——通信时间比计算时间还长,那叫一个难受。

今天我把几个最核心的原语掰开揉碎了讲: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 就够用了。

我曾经在训练 GPT-3 时,把 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 类似但不同 类似但不同 关键区别 Broadcast:root → 所有人(单源) AllGather:所有人 → 所有人(多源)

嗯,这张图把关系理得很清楚。你记住:AllReduce 是大哥,ReduceScatter 和 AllGather 是它的左右手。Broadcast 和 Gather 是基础工具,适合特定场景。

我的建议:实际项目中,别自己手写这些原语。用 NCCL 或 MPI 的现成接口就好。但理解原理能帮你选对算法、调优参数。比如 NCCL 的 ncclAllReduce 底层会自动选 Ring 或 Tree,但你可以通过环境变量 NCCL_ALGO 强制指定。

好了,集合通信原语就讲到这里。你把这些搞明白,多卡通信的底层逻辑就通了。下一节咱们聊聊更高级的通信模式,比如 All-to-All 和 Reduce,到时候你会发现,万变不离其宗。

可可精品资料库