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

多卡训练的核心,说白了就是卡与卡之间怎么高效地“串门”和“对账”。

我刚开始接触分布式训练时,面对一堆通信原语也是一头雾水。什么AllReduce、AllGather,名字长得像,干的事却完全不同。今天我就把这几个最常用的原语掰开揉碎了讲清楚。

3.1 Broadcast:一卡广播,全员接收

原理:一张卡把数据发给所有其他卡。其他卡只收不发。

这就像老师在黑板上写答案,全班同学抄下来。只有老师一个人在写,其他人都在看。

适用场景:初始化参数广播、加载预训练模型权重。

我遇到过一个问题:每张卡各自加载同一个模型文件,结果因为文件系统缓存不一致,导致参数有微小差异。后来我改用Broadcast,只让rank 0加载,然后广播给所有人,问题就解决了。

// NCCL中Broadcast的典型用法
ncclBroadcast(sendbuf, recvbuf, count, ncclFloat32, 0, comm, stream);

小技巧:root参数指定发送数据的卡号。通常用rank 0。

注意:所有卡上的recvbuf大小必须一致,否则会报错。

3.2 Reduce:多卡归约,结果汇总到一张卡

原理:所有卡的数据经过某种操作(求和、求最大值等),最终结果只保存在一张卡上。

举个例子:4张卡各自算了一部分梯度,现在要把它们加起来,结果只放在rank 0上。这就是Reduce。

我个人习惯把Reduce看作是“单向的AllReduce”。它只把结果送到一张卡,而不是所有卡。

注意:Reduce操作完成后,只有root卡上有完整结果。其他卡上的数据会变成未定义状态。

我曾经踩过这个坑:Reduce之后,非root卡继续用原来的buffer做计算,结果全乱了。

3.3 AllReduce:全员归约,人人有份

原理:Reduce + Broadcast的组合。先归约,再把结果广播给所有人。

说白了就是:大家把数据汇总,然后每个人拿到汇总后的结果。

这是分布式训练中最常用的原语。每次反向传播后的梯度同步,用的就是AllReduce。

实现算法 通信量 延迟 适用场景
Ring AllReduce 2 * (N-1) * K 较高 大模型、数据量大
Tree AllReduce 2 * log(N) * K 较低 小批量、延迟敏感
NVLink Direct K 极低 单机多卡、NVLink互联

你想想看,Ring AllReduce为什么能成为主流?因为它把通信负载均匀分摊到了所有卡上,不会出现某张卡成为瓶颈的情况。

避坑指南:我曾经在8卡机器上跑一个模型,发现AllReduce占了60%的训练时间。后来发现是NCCL版本太老,不支持NVLink的Direct算法。升级后,通信时间直接降到了15%。

// PyTorch中AllReduce的底层调用
torch.distributed.all_reduce(tensor, op=torch.distributed.ReduceOp.SUM)

3.4 ReduceScatter:先归约,再分散

原理:Reduce和Scatter的结合体。把所有卡的数据按块归约,然后每张卡只保留自己负责的那一块。

这有点像:大家把各自的书拆开,相同章节的放在一起汇总,然后每人只拿自己负责的那一章回家。

嗯,这里要注意:ReduceScatter是Ring AllReduce的第一步。它把数据分散到各张卡上,为后续的AllGather做准备。

适用场景:当你想做AllReduce,但中间结果不需要全部数据时,可以用ReduceScatter先做部分归约。

我建议在实现自定义通信模式时,优先考虑ReduceScatter + AllGather的组合,而不是直接AllReduce。因为前者可以更好地控制内存占用。

3.5 AllGather:全员收集,拼成完整数据

原理:每张卡把自己的一块数据发给所有人。最终每张卡都拥有所有卡的数据块,拼成完整数据。

说白了就是:每人拿出一块拼图,最后所有人都拿到完整的拼图。

AllGather是Ring AllReduce的第二步。在ReduceScatter之后,每张卡已经有一块归约好的数据,AllGather把它们收集起来,就得到了完整的AllReduce结果。

// 伪代码:AllGather的通信模式
for i in range(N):
    send data from rank i to all other ranks
    receive data from rank i at all other ranks

内存警告:AllGather的输出buffer大小 = 输入buffer大小 × 卡数。如果你有8张卡,每张卡输入1GB,输出就是8GB。别把显存撑爆了。

我见过有人把AllGather的输出buffer分配小了,结果数据被截断,训练出来的模型完全不能用。

3.6 知识体系总览

下面这张图展示了四个核心原语之间的关系和通信模式:

NCCL四大通信原语关系图 Broadcast 一卡发,全员收 初始化参数 Reduce 全员归约,一卡收 梯度汇总 AllReduce 全员归约,全员收 梯度同步 +Reduce +Broadcast ReduceScatter 归约后分散 Ring第一步 AllGather 收集成完整数据 Ring第二步 Ring AllReduce ReduceScatter + AllGather +AllGather 组合 通信原语选择原则: 数据量小 → Tree算法 | 数据量大 → Ring算法 | NVLink → Direct算法

3.7 实战选择建议

说了这么多,到底什么时候用哪个?我总结了几条经验:

  • 模型参数初始化:用Broadcast。只发一次,简单高效。
  • 梯度同步:用AllReduce。这是最标准的做法。
  • 模型并行中的中间激活:用AllGather。每张卡只算一部分,最后拼起来。
  • 数据并行中的梯度分块:用ReduceScatter。先归约再分散,为AllGather做准备。
  • 自定义通信模式:用ReduceScatter + AllGather组合。灵活控制内存和通信量。

我的个人习惯:在写分布式训练代码时,我会先用torch.distributed的高级API(如all_reduce),等性能调优时再考虑换成NCCL原语。这样既保证了代码可读性,又能在需要时榨干硬件性能。

记住一句话:通信原语没有银弹。不同的硬件拓扑、数据规模、模型结构,适合的原语都不一样。多跑几个benchmark,用数据说话,才是工程师该做的事。

可可精品资料库