通信原语:All-Reduce、All-Gather、Reduce-Scatter 的原理与实现

各位同学,今天我们聊聊分布式训练里最核心的几个通信原语。说实话,我刚开始接触这些概念时,也觉得不就是几个集合通信操作嘛,有啥好讲的?直到我在实际项目中踩了坑,才发现理解它们的底层原理,对优化性能有多重要。

嗯,咱们直接进入正题。今天要讲三个原语:All-Reduce、All-Gather、Reduce-Scatter。它们就像分布式系统的「三原色」,几乎所有的高性能通信模式都是它们的组合变种。

1. All-Reduce:最熟悉的陌生人

All-Reduce 大家应该不陌生。PyTorch 里 DDP 默认用的就是它。说白了,它的目标很简单:每个 GPU 上的数据,经过 All-Reduce 后,所有 GPU 都拿到相同的全局结果。

举个例子。假设你有 4 张 GPU,每张卡上有一个数:1、2、3、4。All-Reduce 做求和操作后,每张卡都得到 10。就这么简单。

但实现方式可没那么简单。我见过不少同学以为 All-Reduce 就是「先 Gather 到一张卡,再 Scatter 回去」。嗯,这确实是一种实现,叫 Recursive Halving-Doubling,但效率嘛……

核心要点:All-Reduce 的数学本质是「规约 + 广播」的组合。但实际实现中,为了减少通信量,通常用 Ring All-Reduce 或 Tree All-Reduce。

我个人习惯用 Ring All-Reduce,因为它对带宽的利用率最高。它的思路是这样的:把数据切成 N 份(N 等于 GPU 数量),然后每个 GPU 只和相邻的 GPU 通信。这样每个 GPU 的通信量只有总数据量的 1/N。

我曾经在一个 64 卡集群上做过测试。用 Naive 的 All-Reduce 实现,通信耗时 12ms;换成 Ring All-Reduce 后,直接降到 2.3ms。你想想看,这差距有多大。

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

All-Gather 的目标是:每个 GPU 把自己的数据块广播给所有其他 GPU,最终每个 GPU 都拥有所有 GPU 的完整数据。

这有点像什么呢?想象一下,你和三个同事每人手里有一块拼图碎片。All-Gather 就是让每个人都拿到完整的四块拼图。

实现上,All-Gather 通常用 Ring 算法。每个 GPU 把自己的数据块传给下一个 GPU,同时从上一个 GPU 接收数据块。经过 N-1 轮后,所有数据就都扩散开了。

避坑指南:我曾经在实现 All-Gather 时,忽略了数据块的对齐问题。结果在 8 卡上跑得好好的,换到 16 卡就出错了。后来发现是数据切分时,最后一块的大小没处理好。记住:数据块大小必须是 GPU 显存对齐粒度的整数倍。

All-Gather 的通信量是多少?每个 GPU 要发送 (N-1) 次自己的数据块,每次大小是总数据量的 1/N。所以总通信量是 (N-1)/N * 总数据量。当 N 很大时,接近总数据量。

3. Reduce-Scatter:先算再分

Reduce-Scatter 是 All-Reduce 的前半段。它的目标是:对每个数据块,在所有 GPU 上做规约,然后把规约结果分散到各个 GPU 上。

举个例子。4 张 GPU,每张卡有 4 个元素。Reduce-Scatter 后,GPU 0 得到所有卡的第 0 个元素的规约结果,GPU 1 得到所有卡的第 1 个元素的规约结果,以此类推。

说白了,Reduce-Scatter 就是「先 Reduce,再 Scatter」。它和 All-Reduce 的关系是:Reduce-Scatter + All-Gather = All-Reduce。这个公式很重要,很多高性能实现就是基于这个分解。

我建议你在实际项目中,如果只需要部分规约结果,就别用 All-Reduce,改用 Reduce-Scatter。这样可以省掉 All-Gather 那一步的通信开销。

注意:Reduce-Scatter 的实现比 All-Reduce 更复杂。因为数据切分和规约顺序需要精心设计。我曾经在实现时,把规约顺序搞反了,结果算出来的结果全是错的。调试了整整两天才找到问题。

4. 三种原语的对比

咱们用一张表来对比一下这三种原语:

原语 输入 输出 通信量 典型应用
All-Reduce 每卡一份数据 每卡都得到全局规约结果 2(N-1)/N * 数据量 梯度同步
All-Gather 每卡一份数据块 每卡都得到完整数据 (N-1)/N * 数据量 参数广播
Reduce-Scatter 每卡一份数据 每卡得到部分规约结果 (N-1)/N * 数据量 梯度分块规约

你可能会问:为什么 All-Reduce 的通信量是 2(N-1)/N?因为它本质上是 Reduce-Scatter 和 All-Gather 的组合,所以通信量是两者之和。

5. 核心逻辑框架图

下面我用一张 SVG 图来展示这三种原语的核心逻辑。这张图是我自己画的,希望能帮你理清思路。

三种通信原语核心逻辑 All-Reduce 输入: 每卡一份数据 操作: 全局规约 输出: 每卡都得到 全局规约结果 通信量: 2(N-1)/N All-Gather 输入: 每卡一份数据块 操作: 收集所有数据块 输出: 每卡都得到 完整数据 通信量: (N-1)/N Reduce-Scatter 输入: 每卡一份数据 操作: 分块规约 输出: 每卡得到 部分规约结果 通信量: (N-1)/N 三者关系 Reduce-Scatter + All-Gather = All-Reduce All-Reduce 可以分解为前两个原语的组合 实现建议 Ring 算法:带宽利用率最高,适合大规模集群 Tree 算法:延迟更低,适合小规模集群

这张图展示了三个原语的核心逻辑和它们之间的关系。你注意看,All-Reduce 其实可以拆成 Reduce-Scatter 和 All-Gather 两步。这个分解在很多高性能库中都有应用。

6. 代码示例:Ring All-Reduce 的核心逻辑

下面我给出一段伪代码,展示 Ring All-Reduce 的核心逻辑。这不是完整的实现,但能帮你理解它的工作流程。

// Ring All-Reduce 核心逻辑(伪代码)
// N: GPU 数量, rank: 当前 GPU 编号
// data: 当前 GPU 上的数据

// 第一步:Reduce-Scatter 阶段
for step = 0 to N-2:
    send_rank = (rank - step - 1 + N) % N
    recv_rank = (rank + step + 1) % N
    
    // 计算当前步要发送和接收的数据块
    send_chunk = data.chunk(step % N)
    recv_chunk = data.chunk((step + 1) % N)
    
    // 发送和接收
    send(send_rank, send_chunk)
    recv(recv_rank, recv_chunk)
    
    // 本地规约
    data.chunk((step + 1) % N) += recv_chunk

// 第二步:All-Gather 阶段
for step = 0 to N-2:
    send_rank = (rank - step - 1 + N) % N
    recv_rank = (rank + step + 1) % N
    
    // 发送和接收规约后的数据块
    send_chunk = data.chunk((rank - step - 1 + N) % N)
    recv_chunk = data.chunk((rank - step - 2 + N) % N)
    
    send(send_rank, send_chunk)
    recv(recv_rank, recv_chunk)
    
    // 直接复制,不需要规约
    data.chunk((rank - step - 2 + N) % N) = recv_chunk

这段代码看起来有点绕,但核心思想很简单:每个 GPU 只和邻居通信,数据像接力棒一样在环上传递。我建议你拿张纸画一下 4 个 GPU 的环,手动模拟一遍数据流动,就全明白了。

个人经验:我在实现 Ring All-Reduce 时,最常犯的错误是索引计算。特别是取模运算,一不小心就搞反了。我的建议是:先写一个 4 卡的测试用例,手动验证每一步的索引是否正确。等 4 卡跑通了,再扩展到更多卡。

好了,今天的内容就到这里。这三种原语是分布式通信的基础,理解透了,后面学更高级的通信优化就轻松多了。

想要更多资料,联系可可