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 知识体系总览
下面这张图展示了四个核心原语之间的关系和通信模式:
3.7 实战选择建议
说了这么多,到底什么时候用哪个?我总结了几条经验:
- 模型参数初始化:用Broadcast。只发一次,简单高效。
- 梯度同步:用AllReduce。这是最标准的做法。
- 模型并行中的中间激活:用AllGather。每张卡只算一部分,最后拼起来。
- 数据并行中的梯度分块:用ReduceScatter。先归约再分散,为AllGather做准备。
- 自定义通信模式:用ReduceScatter + AllGather组合。灵活控制内存和通信量。
我的个人习惯:在写分布式训练代码时,我会先用torch.distributed的高级API(如all_reduce),等性能调优时再考虑换成NCCL原语。这样既保证了代码可读性,又能在需要时榨干硬件性能。
记住一句话:通信原语没有银弹。不同的硬件拓扑、数据规模、模型结构,适合的原语都不一样。多跑几个benchmark,用数据说话,才是工程师该做的事。