4. 通信原语与库:NCCL基础、All-Reduce、All-Gather、Reduce-Scatter、Broadcast

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

你想想看,单卡训练模型,数据量大了显存放不下,计算时间也长。多卡并行是出路,但卡与卡之间怎么交换数据?这就得靠通信原语和库了。

我个人习惯把通信原语比作“集体舞的固定动作”。每个GPU都知道自己该在什么时候、跟谁、交换什么数据。而NCCL,就是NVIDIA官方编排这些动作的“舞蹈教练”。

4.1 NCCL:多卡通信的基石

NCCL全称是NVIDIA Collective Communications Library。嗯,名字很长,但核心就一句话:它帮你把多卡通信的脏活累活全干了。

我在项目中遇到过最头疼的事,就是自己手写多卡同步逻辑。用MPI?太重了。用Socket?你得处理丢包、重传、拓扑感知……后来换成NCCL,世界清净了。

NCCL的优势在于:

  • 硬件感知:它知道GPU之间是走NVLink、PCIe还是InfiniBand,自动选最优路径。
  • 极致优化:内部用了很多底层trick,比如RDMA、GPUDirect,延迟极低。
  • 易用性:你只需要调用几个API,剩下的交给它。
小提示:NCCL的版本很重要。我建议你尽量用最新稳定版,老版本在跨节点通信时可能有bug。

4.2 All-Reduce:最常用的集体舞

All-Reduce是分布式训练里出现频率最高的操作。没有之一。

它的目标很简单:每个GPU上的数据,经过All-Reduce后,所有GPU都得到相同的结果(通常是求和或求平均)。

举个例子:你有4张卡,每张卡上有一个数字[1, 2, 3, 4]。做All-Reduce求和后,每张卡都得到[10, 10, 10, 10]。

为什么会这样?因为每个位置上的数字都加了一遍。

在NCCL里,调用All-Reduce就一行代码:

ncclAllReduce(sendbuff, recvbuff, count, ncclFloat, ncclSum, comm, stream);

参数含义很直观:发送缓冲区、接收缓冲区、元素个数、数据类型、操作类型(求和)、通信器、CUDA流。

避坑指南:我曾经在All-Reduce里传了错误的数据类型。比如模型参数是float16,我传了ncclFloat32。结果算出来的梯度全是错的,模型直接不收敛。排查了两天才发现。所以,类型一定要匹配!

4.3 All-Gather:收集所有人的数据

All-Gather和All-Reduce有点像,但结果不同。

All-Gather不求和,它只是把每个GPU上的数据“拼”在一起,然后分发给所有人。

假设你有4张卡,每张卡上有一个向量:卡0有[A],卡1有[B],卡2有[C],卡3有[D]。All-Gather之后,每张卡都得到[A, B, C, D]。

这个操作在什么场景下用?

  • Batch Normalization的同步:需要收集所有卡的均值和方差。
  • 模型并行中的参数同步:某些层需要全局信息。

NCCL调用方式:

ncclAllGather(sendbuff, recvbuff, sendcount, ncclFloat, comm, stream);

注意:recvbuff的大小必须是sendbuff的N倍(N是GPU数量)。

警告:All-Gather的显存开销很大。如果你有8张卡,每张卡要收集的数据是1GB,那每张卡就需要8GB的接收缓冲区。显存不够的话,程序直接OOM。

4.4 Reduce-Scatter:先求和,再分发

Reduce-Scatter是All-Reduce的“前身”。很多高性能的All-Reduce实现,底层就是先做Reduce-Scatter,再做All-Gather。

它的逻辑是:

  1. 把数据分成N块(N是GPU数量)。
  2. 每个GPU负责对其中一块做Reduce(比如求和)。
  3. 最后每个GPU只保留自己负责的那一块结果。

举个例子:4张卡,每张卡有数据[1,2,3,4]。Reduce-Scatter后:

  • 卡0得到[1+1+1+1] = [4]
  • 卡1得到[2+2+2+2] = [8]
  • 卡2得到[3+3+3+3] = [12]
  • 卡3得到[4+4+4+4] = [16]

你看,每个GPU只保留了部分结果。这为后续的All-Gather做好了准备。

NCCL调用:

ncclReduceScatter(sendbuff, recvbuff, recvcount, ncclFloat, ncclSum, comm, stream);
个人经验:如果你自己实现分布式训练框架,建议直接用NCCL的All-Reduce,别自己拆成Reduce-Scatter+All-Gather。NCCL内部已经做了最优调度,你手动拆反而可能降低性能。

4.5 Broadcast:一对多广播

Broadcast是最简单的通信原语。一个GPU(称为root)把自己的数据发给所有其他GPU。

场景很明确:

  • 初始化参数:把模型参数从卡0广播到所有卡。
  • 同步随机种子:确保所有卡用相同的随机数生成器初始状态。

NCCL调用:

ncclBroadcast(buff, buff, count, ncclFloat, root, comm, stream);

注意:sendbuff和recvbuff可以是同一个缓冲区。因为root既发送也接收,其他卡只接收。

关键点:Broadcast是同步操作。所有卡必须都到达这个调用点,才能继续往下走。如果某张卡卡住了,整个集群都会等你。

4.6 如何选择?一张表说清楚

原语 输入 输出 典型场景
All-Reduce 每卡一份数据 每卡得到全局Reduce结果 梯度同步
All-Gather 每卡一份数据 每卡得到所有数据拼接 同步BN统计量
Reduce-Scatter 每卡一份数据 每卡得到部分Reduce结果 All-Reduce的中间步骤
Broadcast root有一份数据 所有卡得到相同数据 参数初始化

嗯,到这里你应该对这四个原语有了清晰的认识。我个人觉得,All-Reduce是重中之重,你至少得把它用熟。其他几个,遇到具体场景再查文档也来得及。

最后提醒一句:NCCL的通信是异步的,记得用cudaStreamSynchronize或者ncclGroupEnd来确保操作完成。我曾经因为忘了同步,读到了未就绪的数据,调试了一整个下午。