3. 通信原语:点对点通信、集合通信

通信原语,说白了就是分布式训练里节点之间「怎么说话」的基本方式。我刚开始接触分布式时,总觉得这玩意儿离算法很远,直到有一次在 64 卡集群上跑模型,发现通信开销占了训练时间的一半以上——嗯,从那以后我再也不敢小看通信原语了。

今天咱们就聊聊两类最核心的通信方式:点对点通信集合通信。后者里又包含 AllReduce、AllGather、ReduceScatter、Broadcast 这几个高频选手。

3.1 点对点通信:最基础的「一对一」

点对点通信,就是两个进程之间直接发消息。一个发(Send),一个收(Recv)。

我在项目中遇到过一种情况:某个节点算完了一部分梯度,需要单独传给另一个节点做聚合。这时候用点对点通信最直接,没必要惊动所有人。

核心特点:

  • 只有两个参与者:发送方和接收方
  • 通信模式灵活,可以自定义消息内容
  • 常用于控制流、小批量数据传输

举个例子,MPI 里的 MPI_SendMPI_Recv 就是典型的点对点通信。代码大概长这样:

// 进程 0 发送数据
if (rank == 0) {
    float data[4] = {1.0, 2.0, 3.0, 4.0};
    MPI_Send(data, 4, MPI_FLOAT, 1, 0, MPI_COMM_WORLD);
}

// 进程 1 接收数据
if (rank == 1) {
    float buffer[4];
    MPI_Recv(buffer, 4, MPI_FLOAT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
}

你想想看,如果每个节点都只跟邻居说话,那点对点通信就够了。但分布式训练里,我们经常需要「所有人跟所有人说话」——这时候就得请出集合通信了。

3.2 集合通信:多对多的「群聊」

集合通信,就是一组进程同时参与通信。每个进程都贡献数据,最终得到聚合结果。

我个人习惯把集合通信分成两类:数据分发类数据聚合类。前者是把一份数据发给所有人,后者是把所有人的数据合并成一份。

3.2.1 Broadcast:一传多,简单粗暴

Broadcast 就是把一个进程的数据广播给所有其他进程。比如参数初始化后,需要让所有 worker 都知道初始权重是多少。

避坑指南:我曾经在广播大张量时忘了考虑带宽限制,结果广播操作成了瓶颈。后来我改用分块广播,配合流水线,速度提升了不少。

Broadcast 的通信量是 O(N),其中 N 是数据大小。所有进程最终拿到相同的数据。

3.2.2 AllReduce:最常用的「求和」操作

AllReduce 是分布式训练里出现频率最高的通信原语。它的作用是把所有进程的数据做某种规约操作(比如求和、求最大值),然后把结果返回给每个进程。

为什么常用?因为梯度同步就是 AllReduce 的典型场景。每个 GPU 算完自己的梯度后,需要把所有梯度加起来,然后每个 GPU 都拿到这个总和,才能更新参数。

AllReduce 的两种常见实现:

  • Ring AllReduce: 把进程排成一个环,数据分块传递。通信量是 2(N-1)/N * N ≈ 2N,效率高。
  • Tree AllReduce: 用树形结构做规约和广播。通信量是 2N logP,适合小数据量。

我个人更推荐 Ring AllReduce,尤其是在大模型训练中。它把通信负载均匀分摊到每个节点上,不会出现单点瓶颈。

3.2.3 ReduceScatter:先规约,再分散

ReduceScatter 是 AllReduce 的「前半段」。它把数据分散到各个进程上做规约,每个进程只拿到一部分结果。

举个例子:假设有 4 个进程,每个进程有 4 个数据。ReduceScatter 会让每个进程最终只拿到 1 个规约后的数据。这正好是 Ring AllReduce 的第一步。

我在项目中遇到过一种情况:我们想计算所有节点的梯度总和,但每个节点只需要更新自己负责的那部分参数。这时候用 ReduceScatter 就比 AllReduce 更省带宽。

3.2.4 AllGather:收集所有人的数据

AllGather 是 ReduceScatter 的「反向操作」。每个进程把自己的数据发给所有人,最终每个进程都拥有完整的数据集。

通信量是 O(NP),其中 P 是进程数。说白了,数据量会随着进程数线性增长。所以 AllGather 不适合大数据量场景。

注意:AllGather 和 AllReduce 的区别在于:AllGather 不做规约,只是收集;AllReduce 做规约,然后返回结果。别搞混了。

3.3 一张图看懂集合通信

下面我用 SVG 画了一张图,展示四种集合通信的核心逻辑。每个图都假设有 4 个进程,每个进程有 4 个数据块。

集合通信原语对比图 Broadcast 进程0: [A B C D] 进程1: [A B C D] 进程2: [A B C D] 进程3: [A B C D] 进程0的数据广播给所有人 AllReduce 进程0: [A+B+C+D] 进程1: [A+B+C+D] 进程2: [A+B+C+D] 进程3: [A+B+C+D] 所有数据求和后返回给每个人 ReduceScatter 进程0: [A0+A1+A2+A3] 进程1: [B0+B1+B2+B3] 进程2: [C0+C1+C2+C3] 进程3: [D0+D1+D2+D3] 每个进程只拿一部分规约结果 AllGather 进程0: [A0 B0 C0 D0] 进程1: [A1 B1 C1 D1] 进程2: [A2 B2 C2 D2] 进程3: [A3 B3 C3 D3] 收集所有人的数据,不做规约 通信原语选择建议 • 参数广播 → Broadcast • 梯度同步 → AllReduce(推荐 Ring 实现) • 分参数更新 → ReduceScatter + AllGather 组合 • 模型并行中收集激活值 → AllGather

3.4 实际项目中的选择

说了这么多理论,咱们聊聊实际怎么选。

我记得有一次做千卡规模的 BERT 训练,一开始用的 AllReduce 实现是 Tree 模式,结果通信效率很低。后来换成 Ring AllReduce,吞吐量直接提升了 30%。

为什么会这样?因为 Ring 模式把通信量均匀分摊了,而 Tree 模式在树根节点会有瓶颈。你想想看,如果根节点要处理所有数据,那它肯定最先扛不住。

我的建议:

  • 小规模集群(8 卡以内):用 Broadcast + AllReduce 就够了
  • 大规模集群(64 卡以上):优先考虑 Ring AllReduce 或分层 AllReduce
  • 混合并行场景:把 ReduceScatter 和 AllGather 组合使用,能省不少带宽

嗯,通信原语这块就聊到这儿。记住一句话:选对通信原语,比调优超参数还重要。毕竟通信开销摆在那儿,选错了再怎么调也救不回来。


公众号:蓝海资料掘金营,微信deep3321