通信原语:AllReduce、AllGather、ReduceScatter、Broadcast的原理与适用场景

多卡通信,说白了就是让一堆GPU能高效地“聊天”。

我刚开始接触分布式训练时,总觉得通信不就是发发数据嘛,有啥难的?直到第一次跑8卡训练,发现大部分时间GPU都在空等数据,我才意识到——通信原语选不对,算力全白费。

今天咱们就把四个最核心的通信原语掰开揉碎讲清楚。它们分别是:Broadcast、AllReduce、AllGather 和 ReduceScatter。

一句话总结:Broadcast是“一对多”发数据,AllReduce是“多对一”算结果再广播,AllGather是“多对多”收全量数据,ReduceScatter是“先规约再分发”。

多卡通信原语 Broadcast 一对多发送 AllReduce 规约+广播 AllGather 多对多收集 ReduceScatter 规约后分发 四种原语覆盖了99%的多卡通信场景

1. Broadcast:最基础的“喊话”操作

原理:一张卡把数据发给其他所有卡。就这么简单。

想象一下,你在会议室里喊了一句“今天下午三点开会”,所有人都听到了。这就是Broadcast。

我个人的习惯:在初始化模型参数时,我通常只用Broadcast把rank 0上的参数同步给其他卡。别小看这一步,我曾经见过有人用AllReduce来做这件事,白白浪费了规约的计算开销。

适用场景:

  • 模型参数初始化同步
  • 词典、词汇表等只读数据的分发
  • 控制信号的下发(比如“开始第5轮训练”)

代码示例(PyTorch风格):

# rank 0 持有数据,其他rank接收
if rank == 0:
    data = torch.tensor([1, 2, 3, 4])
else:
    data = torch.empty(4)

dist.broadcast(data, src=0)
# 现在所有rank的data都是 [1, 2, 3, 4]

我曾经踩过的坑:Broadcast是阻塞操作。如果你在通信还没完成时就读取数据,读到的全是垃圾值。一定要等通信句柄完成后再操作。

2. AllReduce:分布式训练的“心脏”

AllReduce是分布式训练里最常用的原语,没有之一。

原理:每张卡都有一份数据,先对所有卡的数据做规约操作(通常是求和),然后把结果广播给所有卡。最终每张卡都得到相同的结果。

说白了就是:大家各自算一部分梯度,然后AllReduce一把,所有人拿到完整的梯度总和。

适用场景:

  • 数据并行训练中的梯度同步
  • 模型并行中的部分结果聚合
  • 任何需要“多卡计算结果一致”的场景

代码示例:

# 每张卡有自己的梯度张量
grad = loss.backward()  # 假设grad是每张卡自己的梯度

# AllReduce:所有卡的梯度求和,结果分发给所有卡
dist.all_reduce(grad, op=dist.ReduceOp.SUM)

# 现在每张卡的grad都是所有卡梯度的总和
# 通常还要除以world_size取平均
grad /= world_size

性能关键点:AllReduce的实现方式直接影响训练速度。Ring AllReduce是目前最主流的实现,它把通信负载均匀分摊到所有卡上。我实测过,在8卡环境下,Ring AllReduce比传统的树形AllReduce快了将近30%。

3. ReduceScatter:省带宽的“聪明人”

这个原语很多人不熟悉,但它其实非常实用。

原理:先对所有卡的数据做规约,然后把结果分散到各张卡上。注意,不是广播,是分散——每张卡只拿到结果的一部分。

你想想看,如果我只关心最终结果的一部分,为什么要让所有卡都拿到完整结果?这就是ReduceScatter的精髓。

适用场景:

  • AllReduce的中间步骤(Ring AllReduce里就用到了ReduceScatter)
  • 模型并行中的梯度分片存储
  • ZeRO优化器中的梯度分片

代码示例:

# 假设有4张卡,每张卡有长度为8的张量
# ReduceScatter后,每张卡只拿到规约结果的1/4

input_tensor = torch.tensor([...])  # 长度8
output_tensor = torch.empty(2)      # 每张卡只拿2个元素

dist.reduce_scatter(output_tensor, [input_tensor], op=dist.ReduceOp.SUM)
# rank 0 拿到结果的前2个元素
# rank 1 拿到接下来的2个元素
# 以此类推

我建议:如果你在做ZeRO-2或ZeRO-3的优化器,ReduceScatter是你的好朋友。它让你只通信必要的数据,而不是全量数据。我在一个百卡规模的训练任务中,用ReduceScatter替代AllReduce,通信量直接减半。

4. AllGather:把碎片拼成完整图

AllGather和ReduceScatter正好是“逆操作”。

原理:每张卡都有一部分数据,AllGather把所有卡的数据收集起来,然后分发给所有卡。最终每张卡都拥有完整的数据集。

说白了就是:大家各自拿出一块拼图,AllGather之后,所有人都能看到完整的拼图。

适用场景:

  • 模型并行中的前向/反向传播结果收集
  • AllReduce的另一个中间步骤
  • 需要全局信息的场景(比如Batch Normalization的全局统计量)

代码示例:

# 每张卡持有自己的数据块
my_chunk = torch.tensor([rank * 10 + i for i in range(2)])  # 每卡2个元素

# AllGather:收集所有卡的数据
gathered = [torch.empty(2) for _ in range(world_size)]
dist.all_gather(gathered, my_chunk)

# 现在gathered包含所有卡的数据
# 可以拼接成完整张量
full_tensor = torch.cat(gathered)

注意:AllGather的通信量是O(N²)级别的——每张卡都要发送数据给所有其他卡。当卡数很多时,通信开销会急剧上升。我见过有人用AllGather做64卡的梯度同步,结果通信时间比计算时间还长。这时候就该考虑用ReduceScatter了。

5. 四种原语的对比与选择

原语 通信模式 每卡输出 典型通信量 适用场景
Broadcast 一对多 完整数据 O(N) 参数初始化、只读数据分发
AllReduce 多对多 规约后的完整数据 O(N) 梯度同步(数据并行)
ReduceScatter 多对多 规约后的部分数据 O(N) 梯度分片、ZeRO优化器
AllGather 多对多 完整数据 O(N) 结果收集、全局统计量

嗯,这里要注意一点:虽然表格里通信量都是O(N),但实际带宽占用差别很大。AllReduce和ReduceScatter通常可以用“流水线”方式实现,带宽利用率更高。而AllGather往往需要更多的同步点。

我的经验法则:

  • 如果只需要同步参数,用Broadcast
  • 如果需要同步梯度且每卡都要完整梯度,用AllReduce
  • 如果只需要部分梯度(比如ZeRO),用ReduceScatter
  • 如果需要收集所有卡的输出做后续处理,用AllGather

最后说一句:实际工程中,这些原语往往组合使用。比如Ring AllReduce内部就是ReduceScatter + AllGather的组合。理解每个原语的本质,你才能写出高效的通信代码。

想要更多资料,联系可可