2. NCCL通信原语:AllReduce、AllGather、ReduceScatter、Broadcast、Send/Recv

好,咱们直接进入正题。NCCL 的通信原语,说白了就是 GPU 之间互相“喊话”的几种固定套路。你搞分布式训练,天天跟它们打交道。我刚开始接触 NCCL 时,总觉得这些原语名字长得像,一不留神就用错。后来踩了几个坑,才真正搞明白它们各自是干什么的。

今天我把五个最核心的原语掰开揉碎讲清楚:AllReduce、AllGather、ReduceScatter、Broadcast、Send/Recv。你想想看,这五个原语基本覆盖了分布式训练中 90% 的通信场景。

2.1 AllReduce:最常用的“求和”操作

AllReduce 是分布式训练里出现频率最高的原语,没有之一。它的作用很简单:每个 GPU 上的数据,经过 AllReduce 之后,所有 GPU 都得到相同的结果。最常见的就是梯度求和。

举个例子,你有 4 张卡,每张卡算出一个梯度值 [1, 2, 3, 4]。AllReduce 之后,每张卡上都得到 [10, 10, 10, 10](1+2+3+4=10)。

核心要点:AllReduce = Reduce(归约)+ Broadcast(广播)。先求和,再分发。

我在项目中遇到过一个问题:用 AllReduce 做梯度同步时,通信量是 O(N) 的,N 是 GPU 数量。8 张卡还好,64 张卡时通信开销就上来了。这时候就要考虑用 Ring AllReduce 来优化。

// NCCL 中调用 AllReduce 的典型代码
ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum, comm, stream);

个人经验:我建议你在小规模集群(≤8卡)时直接用 AllReduce 没问题。但超过 32 卡,就要考虑用 Hierarchical AllReduce 或者和 ReduceScatter 搭配使用。

2.2 AllGather:把碎片拼成完整图

AllGather 做的事情是:每个 GPU 把自己手里的一块数据发给所有人,最后每个 GPU 都拥有完整的数据集。

这有点像拼图游戏——每个人手里有一块拼图,AllGather 之后,每个人手里都有一套完整的拼图。

我举个例子:你有 4 张卡,每张卡上有 2 个元素:

  • GPU0: [a0, a1]
  • GPU1: [b0, b1]
  • GPU2: [c0, c1]
  • GPU3: [d0, d1]

AllGather 之后,每张卡上都得到 [a0, a1, b0, b1, c0, c1, d0, d1]。

应用场景:AllGather 常用于模型并行中的参数同步,或者数据并行中需要完整特征图的情况。

嗯,这里要注意:AllGather 的通信量是 O(N) 的,而且每个 GPU 的接收缓冲区大小是发送缓冲区大小的 N 倍。说白了,就是内存占用会变大。

2.3 ReduceScatter:先归约,再分散

ReduceScatter 是 AllReduce 的“好搭档”。它做的事情是:先对数据进行归约(比如求和),然后把结果分散到各个 GPU 上。

还是用 4 张卡的例子:

  • 每张卡上有 4 个元素
  • ReduceScatter 之后,GPU0 得到所有卡第 0 个元素的和
  • GPU1 得到所有卡第 1 个元素的和
  • GPU2 得到所有卡第 2 个元素的和
  • GPU3 得到所有卡第 3 个元素的和

说白了,ReduceScatter 就是“先合并,再分家”。每个 GPU 只保留一部分结果。

避坑指南:我曾经在 64 卡集群上做 AllReduce,发现通信瓶颈在带宽上。后来改成 ReduceScatter + AllGather 的组合,通信量从 O(N) 降到了 O(log N)。这个优化思路你一定要记住。

// ReduceScatter 调用示例
ncclReduceScatter(sendbuff, recvbuff, recvcount, ncclFloat, ncclSum, comm, stream);

2.4 Broadcast:一对多的“喊话”

Broadcast 是最直观的原语:一个 GPU 把数据发给所有其他 GPU。就像老师在课堂上喊“同学们,看这里”,然后所有学生都收到同样的信息。

典型场景:参数初始化时,root GPU 把初始模型参数广播给所有其他 GPU。

关键点:Broadcast 是“一对多”,不是“多对多”。只有一个发送者,多个接收者。

我刚开始做分布式训练时,经常把 Broadcast 和 AllGather 搞混。后来我总结了一个记忆方法:Broadcast 是“一个人说话,所有人听”;AllGather 是“每个人都说一句,最后所有人都知道全部内容”。

// Broadcast 调用示例,root=0 表示 GPU0 是发送者
ncclBroadcast(buff, buff, count, ncclFloat, 0, comm, stream);

注意:Broadcast 的通信量是 O(N) 的,但只有 root 节点发送数据。如果 root 节点带宽不够,会成为瓶颈。我建议在千卡集群中,用分层 Broadcast 来缓解这个问题。

2.5 Send/Recv:最灵活的“点对点”通信

Send/Recv 是 NCCL 里最灵活的原语,也是其他所有原语的基础。它做的事情很简单:一个 GPU 发送数据,另一个 GPU 接收数据。

其他原语(AllReduce、AllGather 等)本质上都是 Send/Recv 的组合。比如 Ring AllReduce,就是通过一圈 Send/Recv 来实现的。

核心思想:Send/Recv 是“点对点”,其他原语是“集合通信”。集合通信 = 多个点对点通信的组合。

我举个例子:你想实现一个自定义的通信模式,比如“GPU0 发给 GPU2,GPU1 发给 GPU3”,这时候用 AllReduce 就不合适了,得用 Send/Recv。

// Send/Recv 示例:GPU0 发送,GPU1 接收
if (rank == 0) {
    ncclSend(sendbuff, count, ncclFloat, 1, comm, stream);
} else if (rank == 1) {
    ncclRecv(recvbuff, count, ncclFloat, 0, comm, stream);
}

个人经验:我建议你在实现自定义通信拓扑时,优先考虑 Send/Recv。但要注意,Send/Recv 需要你手动管理通信顺序,否则容易死锁。我曾经在调试一个 128 卡的通信模式时,就因为 Send/Recv 顺序没配对,卡了整整两天。

2.6 原语对比与选择

为了让你一目了然,我把五个原语的核心特性整理成了一张表:

原语 通信模式 通信量 典型场景
AllReduce 多对多 O(N) 梯度同步
AllGather 多对多 O(N) 参数同步、特征拼接
ReduceScatter 多对多 O(N) 梯度分片、AllReduce 优化
Broadcast 一对多 O(N) 参数初始化、模型分发
Send/Recv 点对点 O(1) 自定义通信拓扑

你想想看,这张表其实揭示了一个规律:除了 Send/Recv 是点对点,其他四个都是集合通信。而集合通信的通信量基本都是 O(N),N 是 GPU 数量。这就是为什么集群规模越大,通信开销越高的根本原因。

2.7 知识体系总览

下面这张 SVG 图,把五个原语的关系和核心逻辑串起来了。我建议你多看几遍,理解它们之间的区别和联系。

NCCL 通信原语知识体系 NCCL 通信原语 集合通信 点对点通信 AllReduce AllGather ReduceScatter Broadcast Send / Recv 核心逻辑 • AllReduce = Reduce(归约)+ Broadcast(广播) • AllGather:每个 GPU 收集所有 GPU 的数据碎片,拼成完整数据 • ReduceScatter:先归约求和,再分散到各个 GPU • Broadcast:一个 GPU 发送,所有 GPU 接收(一对多) • Send/Recv:最基础的点对点通信,其他原语的基础

这张图把五个原语分成了两大类:集合通信和点对点通信。集合通信里的四个原语,说白了就是 Send/Recv 的“高级封装”。你理解了 Send/Recv,其他原语的底层逻辑也就通了。

最后提醒一句:别小看 Send/Recv。我见过不少工程师只会用 AllReduce,遇到自定义通信需求就抓瞎。掌握 Send/Recv,你才能真正灵活地设计通信模式。

好了,这五个原语就讲到这里。记住它们各自的特点和适用场景,后面讲通信瓶颈定位时,你会经常用到这些知识。

想要更多资料,联系可可