通信原语:拼出多卡训练的几个动词
训大模型时,最先把人逼疯的常常不是算子精度,也不是显存 OOM,而是几张卡之间永远调不齐的通信。反向跑着跑着就卡住,看 profile 才发现整张卡在等隔壁那张把梯度聚合完——算力还有富余,但所有卡都得等一个 AllReduce 走完才能进下一步。
NCCL 和 HCCL 这一层把”卡间收发数据”抽成了八九个动词:AllReduce、AllGather、ReduceScatter、Broadcast、Scatter、Gather、Reduce、Send / Recv。MindSpeed、Megatron、DeepSpeed 这些训练框架做的事,本质上就是把这些动词按 DP / TP / PP / ZeRO 的拓扑拼成完整的训练 step。理解每个动词的语义、相互之间的可替换关系、什么时候带宽会爆,是大模型训练 debug 的基本盘。设备层面 GPU、NPU、TPU 各家实现不同,但接口语义高度一致——这套抽象是为数不多在不同硬件栈之间能直接照搬的部分。
TL;DR
八个原语本质上分四组:聚合(Reduce / AllReduce)、收集分发(Gather / AllGather / Scatter / Broadcast)、点对点(Send / Recv)、组合大招(ReduceScatter)。AllReduce ≈ ReduceScatter + AllGather,这条等式是 ZeRO 之所以能把通信带宽砍一半的根本原因。
八个动词的语义图谱
聚合家族(AllReduce、Reduce)让所有卡共识同一个数。 AllReduce 出现频率最高:每张卡上有一份数据——最常见是局部梯度——做完一次 AllReduce 之后,所有卡都拿到对应位置求和或求均值后的结果,且完全一致。数学上 ,工程上由 ring 或 tree 算法把通信摊到每条带宽路径上。把”全员都收”这层去掉就是 Reduce——结果只送到指定的 root 卡,其余卡不持有结果;把 loss 汇总到 rank 0 写 tensorboard 用 Reduce 就够了,没必要往每张卡上灌一份。
支持的归约操作通常是 sum / mean / max / min。“sum 之后自己除以 world_size” 与直接 mean 在数学上等价,但有的实现会在 mean 内部用累加器避免大数吃小数的精度损失,混合精度训练里这点差异偶尔会咬人,所以梯度聚合写 mean 比写 sum + scale 更稳一些。
收集与分发家族(AllGather、Gather、Scatter、Broadcast)在散和整之间切换,不做数值运算。 AllGather 把所有卡上的一小块张量按指定维度拼成完整的大张量,每张卡都拿到完整版本——张量并行的前向是典型场景,Q / K / V 切到不同卡分别算,算完用 AllGather 把每片隐藏态沿 hidden 维度拼回去。Gather 是 AllGather 的”只收到主卡”版本;Scatter 是它的反向,把主卡张量切多份分发给子卡;Broadcast 又更简化,根本不切,直接把主卡数据广播到所有卡,常见于初始化阶段同步随机权重、配置、种子。
设计上的对偶性
AllGather 与 ReduceScatter 在 ring 算法下互为反向;Scatter 与 Gather 互为反向;Broadcast 是退化了运算的 AllReduce。这种对偶性让框架能把一个 AllReduce 拆成 ReduceScatter + AllGather 两段执行——这正是下面这条带宽账单的来源。
点对点(Send / Recv)是两张卡之间一对一通信,不涉及通信组里的其他参与者。 流水线并行 PP 完全靠它撑:上游 rank 算完一段子图,把激活值 send 给下一 rank;反向阶段,下游 rank 把梯度 send 回上游。GPT、LLaMA 这种几十上百层的网络切成几段塞到不同节点,每个 micro-batch 就在 send / recv 接力中走完前向反向,难点在调度——micro-batch 之间的 send 必须配合 receiver 提前发起 recv,否则 1F1B / interleaved 这些 schedule 会形成 bubble。
组合大招(ReduceScatter)把”先全局求和、再切分发回各卡”两步打包成一个原语。 表面上 ,前者人人都拿完整结果,后者每张卡只拿到属于自己那片分片。千亿规模训练里 ReduceScatter 的优先级实际上压过了 AllReduce,无论是 TP 反向的权重梯度、ZeRO Stage 2/3 的优化器分片、还是某些自定义并行策略,凡是”既要全局聚合又要分片下发”的地方都首选它。
AllReduce ≈ AllGather × 2
AllReduce 的耗时大约是 AllGather 的两倍。原因藏在那条等式里:AllReduce 等价于顺序执行 ReduceScatter + AllGather,而 ReduceScatter 和 AllGather 的通信逻辑完全一致(区别只在中间这一步是否做累加),所以两段的耗时几乎相同。直接调一次 AllReduce 等于”两段全做”,而 ZeRO 之类的方案只把”自己用得到的那一半”拿出来——所以能把通信砍一半。
把原语堆成训练流水线
理解了单个动词,回看四种并行策略,就是它们对原语的不同排列组合。数据并行 DP 每张卡都有完整模型副本,每步算完局部梯度跑一次 AllReduce 汇总,是最简单粗暴的形态。张量并行 TP 把单层权重切到多卡,前向用 AllGather 把激活拼回完整张量,反向用 ReduceScatter 把梯度切回分片,前后两个动作正好互为反向。
流水线并行 PP 几乎只用 Send / Recv,复杂度全部体现在 schedule 上——1F1B、interleaved-1F1B、ZeroBubble 这些都是在用相同的两个原语调度更紧凑的依赖图。ZeRO 显存优化则更激进:把 DP 的 AllReduce 直接替换成 ReduceScatter + AllGather 的组合,权重和梯度都按 rank 切分,每张卡只持有自己负责的那一片,要用的时候再 AllGather 取齐,用完即刻释放。这一拆带来的不只是显存收益,按上一节的带宽账单算,通信也只剩一半。
| 原语 | 核心行为 | 结果是否全卡一致 | 主打场景 |
|---|---|---|---|
| AllReduce | 求和后全发 | 全部相同 | DP 梯度聚合、loss 同步 |
| AllGather | 收集拼接 | 全部完整 | TP 前向合并、ZeRO 取权重 |
| Reduce | 求和单发 | 仅主卡有 | 全局统计 |
| Broadcast | 一播多 | 全部相同 | 权重 / 种子初始化 |
| Scatter | 拆分下发 | 各自分片 | 数据分片分发 |
| Gather | 收集单发 | 仅主卡完整 | 结果汇总 |
| Send / Recv | 点对点 | 仅双方 | PP 流水线层间传递 |
| ReduceScatter | 先归约再分片 | 各自分片 | ZeRO、TP 反向梯度 |
逻辑层面还有 Split 和 Concat 两个动作。它们本身不是网络通信原语(单卡上就能完成),但在分布式训练的编排层里和 Scatter / AllGather 互为影子:TP 切权重时调用 Split,AllGather 拿到结果之后调用 Concat 拼接。把它们和真正的通信原语放在同一张图里理解,能少绕很多弯路——尤其是看到框架源码里 _split / _gather 与 dist.all_gather 交替出现时,知道哪个是真在跑跨卡通信、哪个只是单卡上的张量重排。
收尾
通信原语本身并不复杂,难的是把它们和并行策略的拓扑、显存预算、单卡算力对齐起来。训练 hang 住时第一反应往往是查算子精度,但更多时候问题出在通信拓扑——某条边带宽不够、某个 group 的成员算漏了、某个原语和 stream 的同步关系没处理好。把这八个动词刻进肌肉记忆,再去读 Megatron 或 DeepSpeed 的 communicator 抽象,会顺很多;下一篇打算具体聊聊 ring 与 tree AllReduce 在 NVLink / IB / RoCE 不同物理层上的带宽差异。