本文是《通信与互联:从 NCCL 到 RDMA》系列的第 1 篇(共九篇)。下一篇:硬件互联——PCIe、NVLink、NVSwitch 与网络拓扑。

总纲把这个系列要回答的问题定为一句话:一次 all_reduce 从调用到完成,数据在 PCIe、NVLink、InfiniBand 上是怎么流动的,为什么有时候是带宽的问题、有时候是延迟的问题。要回答它,先得有一把尺子。没有尺子,nccl-tests 打出来的 busbw 23.1 GB/s 只是一个数字,profiler 里 ncclDevKernel_AllReduce 的 145 µs 也只是一个数字,你不知道它们是好是坏、离上限多远、差的那部分该去哪一层找。

这一篇造这把尺子。它不碰任何硬件、不读 NCCL 的源码,只做三件事:把集合通信的原语定义清楚,把描述一条链路的 α-β 模型建立起来,再把 ring 和 tree 两个最基本的 all_reduce 算法在这个模型上推导出理论时间。推导的产物是几个公式和一个几十行的 Python 脚本,后面六篇的每一个实测数字都要拿来和它比。

尺子的刻度是两本账。带宽的账问:这次通信一共要在链路上搬多少字节,链路每秒能搬多少;延迟的账问:这次通信要分几步走,每一步有多少与字节数无关的固定开销。同一个 all_reduce,1 GB 的梯度几乎只有第一本账,64 KB 的 decode 张量几乎只有第二本账。分不清这两本账,就会在延迟主导的场景里升级网卡,在带宽主导的场景里合并小消息——两者都不会有任何效果。

本篇的核心问题就是把这两本账各算一遍:

8 张卡做一次 1 GB 的 all_reduce,链路单向 25 GB/s,ring 算法理论上要多久?1 改成 64 KB 呢?2 这两个数字为什么分别对带宽和延迟敏感?3

回答它需要的全部材料是:ring all_reduce 走 \(2(n-1)\) 步、每个 rank 收发 \(\frac{2(n-1)}{n}S\) 字节,以及一个 α 的量级。有了这三样,答案是两行算术;没有它们,答案只能靠测。本篇的目标是让读者在没有机器的情况下也能写出这两行算术,并且知道实测值应该落在哪个范围、超出范围时该怀疑哪一层。

依照系列惯例,本篇的性能数字全部是理论下界或典型量级,不是实测。链路带宽用公开标称值(InfiniBand HDR 单向 25 GB/s、NDR 单向 50 GB/s;NVLink 与 PCIe 的数字下一篇展开),每步延迟 α 用数量级估计(InfiniBand 上一步 5–20 µs、NVLink 上几 µs),文中会反复标注”典型量级、非实测”。第六篇会用 nccl-tests 把这些数字换成你手上机器的真实值。

一、总览:三个变量、两本账、一条曲线

1. 问题的形状

任何一次集合通信都可以用四个量描述:参与者数 \(n\)、数据量 \(S\)(字节)、每步固定开销 \(\alpha\)(秒)、链路带宽 \(\beta\)(字节/秒)。前两个由上层决定——多少张卡、多大的张量;后两个由硬件和软件栈决定——什么链路、什么协议、多少层软件参与一次握手。这一篇的任务是给出从这四个量到时间 \(T\) 的函数:

上层决定                            底层决定
─────────────────────────           ────────────────────────────────
n   参与者数(DP/TP/EP 的度)        α   每一步的固定开销:握手、kernel 启动、proxy 响应
S   字节数(张量大小 × dtype)        β   链路每秒能搬多少字节:NVLink / PCIe / IB 的单向速率
        │                                       │
        └──────────────┬────────────────────────┘
                       ▼
            算法:把 (n, S) 变成"走几步、每步搬多少字节"
                       │
                       ▼
            T = 步数 × α  +  每 rank 搬运字节数 / β
                 ─────────     ───────────────────
                 延迟的账          带宽的账

算法是中间那一层:ring 把 all_reduce 变成 \(2(n-1)\) 步、每步 \(S/n\) 字节;tree 把它变成 \(2\log_2 n\) 步、每步更大的块。同一个 \((n, S, \alpha, \beta)\),不同算法给出不同的 \(T\),而且两本账上的优劣往往相反——这是后面第五、六章的主题。

2. 一条曲线

把 \(T\) 换算成带宽(\(S/T\) 的某个归一化,第七章讲 algbw 与 busbw 的区别)、以 \(S\) 为横轴画出来,就是 nccl-tests 输出的那条曲线,它的形状对整个系列都重要:

busbw
  ▲
  │                                ┌─────────────────── 平台:由 β 与算法带宽效率决定
  │                          ╭─────╯                    (带宽的账)
  │                     ╭────╯
  │                ╭────╯
  │           ╭────╯       ← 拐点附近:S* ≈ 步数 × α × β / 效率
  │      ╭────╯              (两本账相当)
  │ ╭────╯
  │─╯   ← 小消息端:T ≈ 步数 × α,与 S 无关;busbw 随 S 线性上升
  └──────────────────────────────────────────────────────▶ S(对数轴)
   8 KB     128 KB      2 MB       32 MB      512 MB

左端的斜坡是延迟主导:时间几乎不随 \(S\) 变,所以带宽随 \(S\) 线性增长;右端的平台是带宽主导:时间随 \(S\) 线性增长,带宽饱和在链路速率乘以算法效率。拐点的位置由 \(\alpha\)、\(\beta\)、步数共同决定,对 8 卡 IB 上的 ring all_reduce 大约在 MB 量级(第五章算)。第六章会讲这条曲线的每种异常形状对应哪一层的问题,本篇先把它”应该长什么样”算出来。

3. 本文的章节安排

本文的章节安排
章 主题 内容
二 集合通信原语 八个原语的语义、组合与对偶关系、每个原语每 rank 至少搬多少字节
三 训练与推理的通信模式 DP / FSDP / TP / PP / MoE / 推理 TP / PD 分离各用哪个原语、多大、几个参与者
四 α-β 模型 一条消息的 T = α + S/β;拐点 S* = αβ;模型忽略了什么
五 ring all_reduce reduce_scatter + all_gather 的完整推导、4 rank 逐步图、核心问题的两个数字
六 tree all_reduce log n 的延迟、朴素二叉树为什么只有一半带宽、double binary tree、Ring vs Tree 对照表
七 algbw 与 busbw nccl-tests 的两个带宽怎么算、各原语的系数、为什么只有 busbw 能和链路比
八 分层与多级算法 节点内快、节点间慢:两级 all_reduce 的代价、为什么平坦 ring 跨节点吃亏
九 消息大小的谱 几十 KB / 几十 MB / GB 三个量级各在曲线哪一段、各自的对策与检查项
十 小结 要点、公式速查、源码位置、comm-probe 的 cost_model.py

二、集合通信原语:语义、组合与下界

1. 八个原语

集合通信(collective)是一组进程(rank)同时参与、语义上一次完成的数据交换。与点对点 send/recv 的区别在于:每个 rank 调用同一个函数,参数里没有”发给谁”,数据的流向由原语的定义决定。下面用 \(n = 4\)、每个 rank 持有一段数据的方式给出八个原语的语义。记 rank \(i\) 的输入为 \(x_i\),\(\oplus\) 为归约算子(sum、max、min、prod、avg 之一)。

broadcast(root = 0)                 reduce(root = 0)
  r0: x0     →  r0: x0                 r0: x0     →  r0: x0⊕x1⊕x2⊕x3
  r1: -      →  r1: x0                 r1: x1     →  r1: -
  r2: -      →  r2: x0                 r2: x2     →  r2: -
  r3: -      →  r3: x0                 r3: x3     →  r3: -

all_reduce                            all_gather
  r0: x0     →  r0: x0⊕x1⊕x2⊕x3        r0: x0     →  r0: [x0 x1 x2 x3]
  r1: x1     →  r1: x0⊕x1⊕x2⊕x3        r1: x1     →  r1: [x0 x1 x2 x3]
  r2: x2     →  r2: x0⊕x1⊕x2⊕x3        r2: x2     →  r2: [x0 x1 x2 x3]
  r3: x3     →  r3: x0⊕x1⊕x2⊕x3        r3: x3     →  r3: [x0 x1 x2 x3]

reduce_scatter(每个 x_i 切成 4 块 x_i[0..3])
  r0: x0[0] x0[1] x0[2] x0[3]   →  r0: x0[0]⊕x1[0]⊕x2[0]⊕x3[0]
  r1: x1[0] x1[1] x1[2] x1[3]   →  r1: x0[1]⊕x1[1]⊕x2[1]⊕x3[1]
  r2: x2[0] x2[1] x2[2] x2[3]   →  r2: x0[2]⊕x1[2]⊕x2[2]⊕x3[2]
  r3: x3[0] x3[1] x3[2] x3[3]   →  r3: x0[3]⊕x1[3]⊕x2[3]⊕x3[3]

all_to_all(第 i 个 rank 的第 j 块发给第 j 个 rank,等于矩阵转置)
  r0: x0[0] x0[1] x0[2] x0[3]   →  r0: x0[0] x1[0] x2[0] x3[0]
  r1: x1[0] x1[1] x1[2] x1[3]   →  r1: x0[1] x1[1] x2[1] x3[1]
  r2: x2[0] x2[1] x2[2] x2[3]   →  r2: x0[2] x1[2] x2[2] x3[2]
  r3: x3[0] x3[1] x3[2] x3[3]   →  r3: x0[3] x1[3] x2[3] x3[3]

scatter(root = 0)                   gather(root = 0)
  r0: x[0] x[1] x[2] x[3] → r0: x[0]    r0: x0  →  r0: [x0 x1 x2 x3]
                            r1: x[1]    r1: x1  →  r1: -
                            r2: x[2]    r2: x2  →  r2: -
                            r3: x[3]    r3: x3  →  r3: -

send / recv                            点对点:一个 rank 发、一个 rank 收,必须配对

几点约定后面会反复用到。S 指什么:对 broadcast、reduce、all_reduce,\(S\) 是每个 rank 的 buffer 大小,也是最终结果的大小;对 all_gather,\(S\) 指拼接后的总大小(每 rank 输入 \(S/n\));对 reduce_scatter,\(S\) 指输入总大小(每 rank 输出 \(S/n\));对 all_to_all,\(S\) 是每 rank 的输入总量(也等于输出总量)。这与 nccl-tests 打印的 size 列一致,第七章核对源码时会看到它是怎么算的。归约的顺序:\(\oplus\) 在浮点上不满足结合律,不同算法、不同 rank 数会给出比特级不同的结果,这是第六篇”结果不稳定”一类问题的来源,此处只需记住 all_reduce 不保证各 rank 结果的比特一致性——NCCL 实际上保证了(所有 rank 收到同一份归约结果),但不同次运行、不同算法之间不保证。

2. 组合与对偶

八个原语之间有两组关系。第一组是组合:

all_reduce      = reduce_scatter + all_gather        ← ring all_reduce 的基础,第五章
all_reduce      = reduce + broadcast                 ← tree all_reduce 的基础,第六章
all_gather      = n 次 broadcast(每次换一个 root)
reduce_scatter  = reduce + scatter
all_to_all      = n 次 scatter(每次换一个 root)

第一条是本篇最重要的等式。reduce_scatter 之后每个 rank 持有结果的 \(1/n\)(且已经归约完),再 all_gather 把这 \(n\) 段拼起来,每个 rank 就有了完整结果。它的重要性在于两个子操作都是带宽最优的:每个 rank 只需收发 \(\frac{n-1}{n}S\) 字节,而不需要任何 rank 接收全部 \(n\) 份数据。

第二条给出另一种分解:先把所有数据归约到一个 root,再从 root 广播出去。它的步数可以做到 \(O(\log n)\),但 root 要接收 \(n\) 份数据,除非用树形流水化,否则带宽差。

第二组关系是对偶:把一个原语的数据流反过来、把”复制”换成”归约”,就得到另一个:

broadcast   ⟷  reduce            一对多复制  ⟷  多对一归约
scatter     ⟷  gather            一对多分发  ⟷  多对一收集
all_gather  ⟷  reduce_scatter    多对多复制  ⟷  多对多归约

对偶的意义是实现上的:一个 all_gather 算法把箭头反过来、在接收端加一个 \(\oplus\) 就是 reduce_scatter 算法,两者的步数和字节数完全一样。所以后面分析 ring 的 reduce_scatter 阶段时,all_gather 阶段的代价可以直接复用。

3. 每个原语至少要搬多少字节

在任何算法之前,先问一个与算法无关的问题:完成这个原语,每个 rank 至少要接收多少字节?这给出带宽账的下界,任何算法都不可能低于它。

  • broadcast:每个非 root 的 rank 必须收到全部 \(S\),下界 \(S\);
  • reduce:root 必须收到其他 \(n-1\) 个 rank 的贡献。朴素做法 root 接收 \((n-1)S\);用树或链把归约分摊出去,每个 rank 接收 \(S\) 即可(每个中间节点接收、归约、上传),下界 \(S\);
  • all_gather:每个 rank 已有 \(S/n\),还缺 \(\frac{n-1}{n}S\),下界 \(\frac{n-1}{n}S\);
  • reduce_scatter:每个 rank 最终持有 \(S/n\) 的归约结果,这一段需要其他 \(n-1\) 个 rank 各贡献 \(S/n\),共 \(\frac{n-1}{n}S\),且归约可以边收边做,下界 \(\frac{n-1}{n}S\);
  • all_reduce:可以证明(Patarasuk 与 Yuan 2009 年的结果)任何 all_reduce 算法每个 rank 至少收发 \(\frac{2(n-1)}{n}S\)——直觉上就是 reduce_scatter 与 all_gather 的下界相加,两者缺一不可;
  • all_to_all:每个 rank 要发出 \(\frac{n-1}{n}S\)(留一块给自己)、接收同样多,下界 \(\frac{n-1}{n}S\)。
原语              每 rank 接收字节数下界      备注
──────────────    ───────────────────────    ───────────────────────────────
broadcast         S                          非 root 都要拿到全部
reduce            S                          流水化后中间节点各收 S
all_reduce        2(n-1)/n · S               → 2S(n 大时),reduce_scatter + all_gather
all_gather        (n-1)/n · S                → S
reduce_scatter    (n-1)/n · S                → S
all_to_all        (n-1)/n · S                → S;但是 n-1 个不同的目标

这张表就是第七章 busbw 系数的来源:nccl-tests 把测得的 \(S/T\) 乘上这一列的系数,得到”链路实际承载的流量速率”,它才能和链路带宽比。

注意 all_to_all 的一行:字节数下界与 all_gather 相同,但流量模式完全不同——all_gather 的每一块要发给所有人,可以走环流水化;all_to_all 的每一块只发给一个特定的人,\(n\) 个 rank 之间是 \(n(n-1)\) 条不同的流。这意味着它无法从”绕环一圈”里得到好处,而在跨节点时会同时压满所有链路,是 MoE 训练最难对付的通信模式。

三、训练与推理需要哪些原语

1. 训练:五种并行的通信模式

并行策略本身不在本系列范围内,这里只把每一种策略产生的通信当作输入记下来:用哪个原语、消息多大、几个参与者、在不在关键路径上。以一个 7B 参数、hidden 4096、32 层、bf16 的 dense 模型和一个 MoE 模型为例,数字取量级。

策略      原语                     每次消息大小(量级)                   参与者 n           关键路径
──────    ────────────────────     ──────────────────────────────────    ─────────────    ──────────────
DP        all_reduce(梯度)         DDP 按 bucket 发,默认 25 MiB 一桶      DP 度:8 ~ 数千    反向中可重叠
                                   (kDefaultBucketBytesCap)
                                   整体梯度 7B × 2 B = 14 GB(fp32 28 GB)
FSDP      all_gather(参数)         一层参数:~200 M × 2 B ≈ 400 MB         分片组:8 ~ 数百    前向/反向都在,可预取重叠
          reduce_scatter(梯度)     同上                                                    反向中重叠
TP        all_reduce(激活)         tokens × hidden × 2 B:                TP 度:2 ~ 8       每层 2 次,几乎无法重叠
                                   4096 tokens × 4096 × 2 B = 32 MB
PP        send / recv(激活)        micro-batch × seq × hidden × 2 B:      2(点对点)        阶段之间,靠 1F1B 调度隐藏
                                   1 × 4096 × 4096 × 2 B = 32 MB
MoE / EP  all_to_all(token)        tokens × top-k × hidden × 2 B 分散到    EP 度:8 ~ 64      每层 2 次(分发 + 收回)
                                   n 个目标

几个值得注意的量。DP 的 all_reduce 是带宽账的典型:25 MiB 的桶、总量十几 GB,反向传播一边算一边发,通信时间能否被计算掩盖取决于带宽而不是延迟。kDefaultBucketBytesCap = 25 * 1024 * 1024 定义在 PyTorch 的 torch/csrc/distributed/c10d/reducer.hpp(以 PyTorch 2.12 为准),第一个桶更小(kDefaultFirstBucketBytes 为 1 MiB),目的是让反向刚开始就有东西可发。

TP 的 all_reduce 在关键路径上。Megatron 风格的张量并行每一层有两次 all_reduce(attention 输出和 MLP 输出),下一步计算依赖它的结果,没有东西可以和它重叠。所以 TP 一般不出节点:8 卡 NVLink 上一次 32 MB 的 all_reduce 是几百 µs 量级,跨 IB 就是几 ms,乘以每层两次、几十层,差距直接体现在迭代时间上。

FSDP 把 all_reduce 拆成了 reduce_scatter + all_gather——正好是第二章的第一条组合等式。它的通信总量与 DP 相同(每个参数被 all_gather 一次、梯度被 reduce_scatter 一次,合起来还是 \(\frac{2(n-1)}{n}S\)),只是把两半分开放在了前向和反向。

MoE 的 all_to_all 消息大小取决于路由结果,每次不一样,且 \(n(n-1)\) 条流同时打满链路;它是目前训练里最考验网络的模式,后面几篇多处会回到它。

2. 推理:两种几乎相反的模式

推理侧的通信模式只有两种,但两者对通信层的要求几乎相反。

TP all_reduce。prefill 阶段与训练的 TP 一样是几十 MB 的激活;decode 阶段每一步只处理每个序列一个 token,消息是 batch × hidden × 2 B。batch 8、hidden 8192 是 128 KB;batch 32 是 512 KB。每层两次,一个 80 层的模型每生成一个 token 要做 160 次这样的 all_reduce。这是延迟账的典型:按第五章的模型,8 卡 NVLink 上 128 KB 的 ring all_reduce 时间 97% 是 α,带宽多少无关紧要。第七篇的 custom all-reduce 就是为这个区间设计的。

PD 分离的 KV 传输。prefill 实例算完一个请求的 KV cache 后要把它搬到 decode 实例。每个 token 每层的 KV 大小是 \(2 \times \text{kv\_heads} \times \text{head\_dim} \times \text{bytes}\),乘以层数再乘以序列长度。以 80 层、8 个 KV head(GQA)、head_dim 128、bf16 为例:每 token \(2 \times 8 \times 128 \times 2 \times 80 = 327{,}680\) 字节,即 320 KB;一个 4096 token 的 prompt 是 1.28 GB。它是点对点的、目标动态、不需要归约、要与 decode 的计算完全解耦——这些特征让它更适合单边 RDMA WRITE 而不是集合通信(第七篇)。它是带宽账的典型:1.28 GB 在 50 GB/s 的 NDR 上是 26 ms,α 几乎可以忽略。

3. 消息大小的谱

把上面所有消息放到一条对数轴上:

  64 KB     128 KB    512 KB      2 MB        25 MiB     32 MB     400 MB      1.3 GB     14 GB
   │          │         │          │            │          │          │           │          │
   │  decode TP all_reduce(batch 8 ~ 32)       │  DDP bucket │  TP/PP 激活(prefill/训练)  │
   │◄─────────────────────────►│                │◄──────────►│◄────────►│ FSDP 一层 │ KV cache │ 全部梯度
   │                           │                                                    (P2P)    (分桶发)
   │◄──── 延迟主导 ──────────►│◄── 拐点区 ──►│◄──────────────── 带宽主导 ─────────────────────►
   (8 卡 IB ring 的拐点约 2 MB,第五章算;NVLink 上拐点向右移,见第九章)

三个量级、三种账。第九章会把每一段的对策列出来:延迟主导的段合并消息或换算法与协议,带宽主导的段看链路与算法效率,拐点区两本账都要算。

四、α-β 模型

1. 一条消息:T = α + S/β

Hockney 在 1994 年给出的模型把一条点对点消息的传输时间写成两项之和:

\[T(S) = \alpha + \frac{S}{\beta}\]

\(\alpha\)(秒)是与消息大小无关的固定开销,\(\beta\)(字节/秒)是稳态下每秒能搬多少字节。这两个参数的物理含义在 GPU 通信里各自摊开是这样的:

α 里面有什么                                    β 里面有什么
──────────────────────────────────────         ──────────────────────────────────────
kernel 启动、参数下发                             链路的物理速率(NVLink 一条链路、IB 一个端口)
两端的握手:接收方准备好了没有(flag / credit)      链路的协议效率(PCIe 包头、IB 报文头、NCCL 协议的 flag 开销)
线缆与交换机上的传播时延(每跳几百 ns ~ µs)          实际能同时用上几条链路(channel 数)
跨机时 CPU proxy 线程发现请求、提交 RDMA 的响应时间   端到端最慢的一段(PCIe → NIC → 网络 → NIC → PCIe 的最小值)
接收方轮询到完成标志的时间

模型说的是:一条链路在 \(S \to 0\) 时时间趋于 \(\alpha\),在 \(S \to \infty\) 时带宽趋于 \(\beta\)。它对单条消息是一个相当好的近似,实测的 \(T(S)\) 曲线通常就是一条直线加一个截距。

2. 拐点 S* = αβ

两项相等的消息大小是这个模型最有用的一个数:

\[\alpha = \frac{S^*}{\beta} \quad\Longrightarrow\quad S^* = \alpha \beta\]

小于 \(S^*\) 的消息,时间的一半以上是 α;大于 \(S^*\) 的消息,时间的一半以上是 \(S/\beta\)。这个数只依赖链路,与算法无关,是判断”这条链路上多大的消息才算大”的标尺。几种链路的典型量级(数量级估计,非实测;α 取的是一次 NCCL 步骤级的端到端固定开销,不是裸链路的传播时延):

链路                     β(单向)        α(每步,典型量级)    S* = αβ
──────────────────       ────────────    ───────────────────    ────────────
NVLink(节点内,NVSwitch)  ~200 GB/s      ~3 µs                  ~600 KB
                          (8 卡 ring 每 rank 通常能用到的量级;标称 H100 单向 450 GB/s)
PCIe 4.0 x16              32 GB/s         ~5 µs                  ~160 KB
InfiniBand HDR            25 GB/s         5 ~ 20 µs               125 KB ~ 500 KB
InfiniBand NDR            50 GB/s         5 ~ 20 µs               250 KB ~ 1 MB

有一个反直觉的地方值得先说:链路越快,拐点越大。NDR 的 α 与 HDR 差不多(延迟主要来自软件与握手,不是线速),但 β 翻倍,所以同样一条 256 KB 的消息在 HDR 上是带宽主导、在 NDR 上接近拐点。换更快的网卡对小消息帮助不大,这就是原因。

3. 模型忽略了什么

α-β 模型足够简单,也因此漏掉了几件后面几篇要补的事:

  • 多条链路并行。一张 GPU 有 18 条 NVLink、一台机器有 8 张网卡;NCCL 用多个 channel 同时走多条路,等效 β 是它们之和。模型里的 β 应理解为”这次通信实际用上的总带宽”,而不是单条链路。
  • 双向。NVLink、PCIe、IB 都是全双工,一个 rank 同时发和收互不抢带宽。本篇所有 β 都指单向,ring 的每个 rank 同时在发和在收,所以只算一个方向即可。
  • 拷贝与归约。数据到达接收端后还要做 \(\oplus\)、可能要在 buffer 之间拷贝;这些消耗 GPU 的访存带宽而不是链路带宽,在 NVLink 这种链路速度接近显存速度一个数量级以内的场合不能完全忽略。
  • 拥塞与竞争。多个流共享一条链路或一台交换机时 β 会下降、α 会抖动;all_to_all 和多任务共享网络时尤其明显。
  • 同步。集合通信要所有 rank 都到了才能开始,最慢的 rank 决定开始时间;模型里的 \(T\) 是从”所有人都到了”起算的。straggler 问题不在模型里,但在 profiler 里非常常见。

这些都是模型与实测之间的差距来源,也就是”比一比”要解释的东西。本篇先把无差距的理论值算出来。

4. 集合通信在模型里

把 α-β 模型用到集合通信上只需要一步:算法决定这次通信分几步、每一步每个 rank 搬多少字节;每一步付一次 α,字节数除以 β 累加。写成通式:

\[T_{\text{coll}} = (\text{步数}) \cdot \alpha + \frac{\text{每 rank 收发的字节数}}{\beta}\]

这里隐含了一个假设:每一步所有 rank 同时在收发,链路是全双工的,所以时间由单个 rank 单方向的字节数决定。步数是延迟账,字节数是带宽账。接下来两章分别对 ring 和 tree 数这两个量。

五、ring all_reduce 的推导

1. 朴素做法为什么不行

最直接的 all_reduce:所有 rank 把 \(S\) 发给 rank 0,rank 0 归约后广播回去。带宽账:rank 0 要接收 \((n-1)S\)、再发出 \((n-1)S\),8 卡 1 GB 就是 7 GB 进、7 GB 出,全部压在一张卡的一条链路上,其他 7 张卡的链路几乎闲着。时间 \(\approx 2(n-1)S/\beta\),随 \(n\) 线性变差。延迟账倒是不错——2 步。

ring 的思想是把 rank 0 的活分给所有人:数据切成 \(n\) 块,每个 rank 负责归约其中一块,负责的那块归约完了再传给所有人。这样每一步所有 rank 的链路都在满负荷工作,没有人闲着。

2. reduce_scatter 阶段:4 个 rank 逐步看

把 rank 排成环,rank \(r\) 只向 rank \((r+1) \bmod n\) 发送、只从 rank \((r-1) \bmod n\) 接收。每个 rank 的数据切成 \(n\) 块,记 rank \(r\) 的第 \(k\) 块为 \(c_k^{(r)}\)。以 \(n = 4\) 为例,初始状态:

          块 0        块 1        块 2        块 3
rank 0    c0(0)       c1(0)       c2(0)       c3(0)
rank 1    c0(1)       c1(1)       c2(1)       c3(1)
rank 2    c0(2)       c1(2)       c2(2)       c3(2)
rank 3    c0(3)       c1(3)       c2(3)       c3(3)

规则:第 \(s\) 步(\(s = 1, \dots, n-1\))rank \(r\) 把自己手上第 \((r - s + 1) \bmod n\) 块的当前部分和发给下游,同时从上游收到第 \((r - s) \bmod n\) 块的部分和,加到自己那块上。用 {0,1} 表示”已累加了 rank 0 和 rank 1 的贡献”:

第 1 步   r0 → r1 发块 0    r1 → r2 发块 1    r2 → r3 发块 2    r3 → r0 发块 3
          rank 0:  块 3 = {3,0}
          rank 1:  块 0 = {0,1}
          rank 2:  块 1 = {1,2}
          rank 3:  块 2 = {2,3}

第 2 步   r0 → r1 发块 3{3,0}   r1 → r2 发块 0{0,1}   r2 → r3 发块 1{1,2}   r3 → r0 发块 2{2,3}
          rank 0:  块 2 = {2,3,0}
          rank 1:  块 3 = {3,0,1}
          rank 2:  块 0 = {0,1,2}
          rank 3:  块 1 = {1,2,3}

第 3 步   r0 → r1 发块 2{2,3,0}   r1 → r2 发块 3{3,0,1}   r2 → r3 发块 0{0,1,2}   r3 → r0 发块 1{1,2,3}
          rank 0:  块 1 = {1,2,3,0}   ← 完整
          rank 1:  块 2 = {2,3,0,1}   ← 完整
          rank 2:  块 3 = {3,0,1,2}   ← 完整
          rank 3:  块 0 = {0,1,2,3}   ← 完整

上面每步只列了发生变化的那一块。把第 3 步结束时 4 个 rank 手上全部 16 块的状态摊开看,能看出 ring 的流水线结构——每一列(同一块)在 4 个 rank 上恰好是 1、2、3、4 份贡献的”阶梯”,完整的那份落在对角线上:

第 3 步结束(reduce_scatter 完成)时的完整状态;✓ = 已含全部 4 个 rank 的贡献

          块 0           块 1           块 2           块 3
rank 0    {0}            {1,2,3,0} ✓    {2,3,0}        {3,0}
rank 1    {0,1}          {1}            {2,3,0,1} ✓    {3,0,1}
rank 2    {0,1,2}        {1,2}          {2}            {3,0,1,2} ✓
rank 3    {0,1,2,3} ✓    {1,2,3}        {2,3}          {3}

每列:4 份部分和分别累加了 1/2/3/4 个 rank 的贡献
每行:只有对角线上的 ✓ 块进入 all_gather 阶段,其余 3 块是早已发出的旧部分和

\(n - 1 = 3\) 步之后,每个 rank 手上恰好有一块是完整归约结果:rank \(r\) 持有第 \((r+1) \bmod n\) 块。这就是 reduce_scatter 的语义(块的归属是轮转的,不影响后续)。每一步每个 rank 发出一块 \(S/n\) 字节、收进一块 \(S/n\) 字节,两个方向同时进行。代价:

\[T_{\text{RS}} = (n-1)\,\alpha + (n-1)\cdot\frac{S/n}{\beta} = (n-1)\,\alpha + \frac{n-1}{n}\cdot\frac{S}{\beta}\]

3. all_gather 阶段

接下来的 \(n-1\) 步,每个 rank 把手上刚完成的那块沿环传下去,收到的完整块直接覆盖本地对应位置(不再归约),再把它继续传给下游:

第 4 步   r0 → r1 发块 1    r1 → r2 发块 2    r2 → r3 发块 3    r3 → r0 发块 0
          rank 0:  块 0、块 1 完整
          rank 1:  块 1、块 2 完整
          rank 2:  块 2、块 3 完整
          rank 3:  块 3、块 0 完整

第 5 步   r0 → r1 发块 0    r1 → r2 发块 1    r2 → r3 发块 2    r3 → r0 发块 3
          每个 rank 有 3 块完整

第 6 步   r0 → r1 发块 3    r1 → r2 发块 0    r2 → r3 发块 1    r3 → r0 发块 2
          每个 rank 有 4 块完整   ← all_reduce 完成

用 rank × 块 的网格看这三步,完整块(#)从 reduce_scatter 留下的对角线开始,每步沿环向下游多铺一格,直到铺满:

                第 3 步后      第 4 步后      第 5 步后      第 6 步后
      块         0 1 2 3        0 1 2 3        0 1 2 3        0 1 2 3
rank 0           . # . .        # # . .        # # . #        # # # #
rank 1           . . # .        . # # .        # # # .        # # # #
rank 2           . . . #        . . # #        . # # #        # # # #
rank 3           # . . .        # . . #        # . # #        # # # #

#  = 已持有完整归约结果的块      每步每 rank 新增 1 块,共 n-1 = 3 步

步数与字节数与 reduce_scatter 阶段完全相同(第二章说的对偶关系):

\[T_{\text{AG}} = (n-1)\,\alpha + \frac{n-1}{n}\cdot\frac{S}{\beta}\]

4. 总代价与它的两个极限

两段相加:

\[T_{\text{ring}} = 2(n-1)\,\alpha + \frac{2(n-1)}{n}\cdot\frac{S}{\beta}\]

每个 rank 收发的字节数是 \(\frac{2(n-1)}{n}S\)——正是第二章给出的 all_reduce 下界,所以 ring 是带宽最优的。看两个极限:

  • \(n \to \infty\) 时带宽项 \(\to 2S/\beta\),与参与者数无关。1000 张卡和 8 张卡做同样大小的 all_reduce,带宽项几乎一样(\(\frac{2 \cdot 999}{1000} = 1.998\) 对 \(\frac{2 \cdot 7}{8} = 1.75\))。这是 ring 在大消息上无可替代的原因:加机器不会让每张卡的通信量变多。
  • 延迟项 \(2(n-1)\alpha\) 随 \(n\) 线性增长。8 张卡 14 步,1024 张卡 2046 步;α 取 10 µs 时分别是 140 µs 和 20 ms。这是 ring 在小消息、大规模上失败的原因:一个 64 KB 的消息在 1024 卡的环上要走 20 ms,比数据本身的传输时间大四个量级。

\(\frac{2(n-1)}{n}\) 这个系数后面会反复出现:它是 busbw 的校正因子(第七章),也是 NCCL 调优模型里 ring 的步数系数(下一节)。

5. 核心问题的两个数字

现在回答总纲的核心问题。取 \(n = 8\),\(\beta = 25\) GB/s(HDR 单向标称,\(25 \times 10^9\) B/s),\(\alpha = 10\) µs(IB 上一步的典型量级,非实测)。

S = 1 GB(\(10^9\) 字节):

\[\begin{aligned} \text{延迟项} &= 2 \times 7 \times 10\ \mu\text{s} = 140\ \mu\text{s} \\ \text{带宽项} &= \frac{14}{8} \times \frac{10^9}{25 \times 10^9}\ \text{s} = 1.75 \times 40\ \text{ms} = 70\ \text{ms} \\ T_{\text{ring}} &\approx 70.14\ \text{ms} \end{aligned}\]

延迟项占 0.2%。α 就算估错 5 倍(50 µs)也只让总时间变化 1%。这个数字只对 β 敏感:换 NDR(50 GB/s)时间减半;链路只跑出 80% 带宽,时间长 25%。

S = 64 KB(65,536 字节):

\[\begin{aligned} \text{延迟项} &= 140\ \mu\text{s} \\ \text{带宽项} &= 1.75 \times \frac{65536}{25 \times 10^9}\ \text{s} = 1.75 \times 2.62\ \mu\text{s} = 4.6\ \mu\text{s} \\ T_{\text{ring}} &\approx 145\ \mu\text{s} \end{aligned}\]

带宽项占 3%。这个数字只对 α 和步数敏感:换 NDR 几乎没有变化(少 2.3 µs);把 α 从 10 µs 降到 5 µs 时间几乎减半;把 8 卡改成 4 卡(6 步)时间从 145 µs 降到 63 µs。

两个数字对不同变量敏感的原因,就是 \(T_{\text{ring}}\) 两项在 \(S\) 上的不同阶:第一项是常数,第二项与 \(S\) 成正比。\(S\) 差 15,000 倍,两项的比例从 3% : 97% 翻到 97% : 3%。

顺便算出 algbw 与 busbw(定义见第七章):1 GB 时 algbw \(= 10^9 / 0.07014 \approx 14.3\) GB/s,busbw \(= 14.3 \times 1.75 \approx 24.9\) GB/s,几乎就是链路的 25 GB/s;64 KB 时 algbw \(\approx 0.45\) GB/s,busbw \(\approx 0.79\) GB/s,只有链路的 3%。这两个 busbw 数字就是第六章 nccl-tests 曲线两端的理论值。

6. ring 的集体拐点:S* = nαβ

第四章的拐点 \(S^* = \alpha\beta\) 是单条消息的。对整个 ring all_reduce,令两项相等:

\[2(n-1)\,\alpha = \frac{2(n-1)}{n}\cdot\frac{S^*_{\text{ring}}}{\beta} \quad\Longrightarrow\quad S^*_{\text{ring}} = n\,\alpha\,\beta\]

拐点随 \(n\) 线性右移。8 卡 IB(α = 10 µs,β = 25 GB/s)的拐点是 \(8 \times 250\ \text{KB} = 2\) MB;64 卡是 16 MB;1024 卡是 256 MB。也就是说在 1024 卡的平坦 ring 上,一个 25 MiB 的 DDP bucket 仍然是延迟主导的——这就是为什么大规模训练必须用 tree 或分层算法,第六、八章讲。

把 \(T_{\text{ring}}(S)\) 画在双对数坐标上,两项各自是一条直线(延迟项水平、带宽项斜率 1),曲线就是两条渐近线的”圆角拼接”,拐点是它们的交点:

ring all_reduce 的 T(S) 双对数曲线:延迟项水平渐近线与带宽项斜线在 S* = nαβ 处相交;n 变大整条曲线左端抬高、拐点右移,链路变快只压低右半段

三条曲线的差别正是两本账的差别:n 从 8 到 64,左端平台抬高 9 倍(步数 ×9)、拐点从 2 MB 右移到 16 MB,而右端几乎不动(\(\frac{2(n-1)}{n}\) 只从 1.75 到 1.97);换成 NVLink,右半段整体下压 8 倍(β ×8),左端平台只降到 42 µs(α 从 10 µs 到 3 µs)——所以 64 KB 的消息在两种链路上都在平台段,链路带宽对它无关紧要。

反过来,拐点也是一个实用的诊断量:从 nccl-tests 曲线上读出拐点位置,除以 \(n\beta\),就反推出这台机器上一步的 α。

六、tree all_reduce 与 double binary tree

1. 二叉树的延迟:2 log₂ n

ring 的延迟随 \(n\) 线性增长,因为环上每一步只能把信息往前传一格。要在 \(O(\log n)\) 步内让每个 rank 拿到所有人的贡献,需要每一步让”知道的人”翻倍——这就是树。用第二章的第二条组合等式:all_reduce = reduce + broadcast。reduce 阶段数据沿二叉树向根归约,broadcast 阶段结果从根向叶广播。以 \(n = 8\)、一棵深度为 3 的二叉树为例:

              r0                 reduce:叶 → 根,3 步;每个内部节点收两个孩子的数据、加上自己、发给父亲
              │                  broadcast:根 → 叶,3 步;每个内部节点收父亲的结果、转发给两个孩子
              r4
           ┌──┴──┐               (这是 NCCL 实际使用的形状:rank 0 为根、只有一个孩子,
          r2     r6                其余节点按编号二进制里最低位 1 的位置决定层级;奇数 rank 全是叶子)
        ┌─┴─┐  ┌─┴─┐
       r1   r3 r5   r7

步数 \(2\lceil\log_2 n\rceil\):8 卡 6 步(对 14 步),1024 卡 20 步(对 2046 步)。1024 卡、α = 10 µs 时延迟项从 20 ms 降到 200 µs,差 100 倍。这是 tree 存在的全部理由。

2. 朴素二叉树的带宽账:为什么只有一半

延迟账赢了,看带宽账。把数据切成小块流水化(NCCL 的做法),reduce 阶段一个内部节点每处理一块要从两个孩子各收一块、发一块给父亲;整个阶段它的入向链路要承载 \(2S\),出向 \(S\)。broadcast 阶段反过来:入向 \(S\),出向 \(2S\)。叶子节点在 reduce 阶段只发不收(入向 0,出向 \(S\)),broadcast 阶段只收不发。

时间由最忙的链路决定。reduce 阶段瓶颈是内部节点的入向 \(2S\),broadcast 阶段是内部节点的出向 \(2S\),两阶段相加:

\[T_{\text{tree,naive}} \approx 2\lceil\log_2 n\rceil\,\alpha + \frac{4S}{\beta}\]

对比 ring 的带宽项 \(\frac{2(n-1)}{n}\frac{S}{\beta} \approx 2S/\beta\),朴素二叉树只有一半的带宽。原因很直观:树上一半的 rank 是叶子,它们在每个阶段只用了链路的一个方向;另一半是内部节点,它们的一个方向要扛两倍流量。链路是全双工的,但树的结构让每个方向都只有一半的节点在用。

3. double binary tree:两棵互补的树

补救的办法是 2019 年 NCCL 2.4 引入的 double binary tree:建两棵树,第二棵的结构使得第一棵里的叶子在第二棵里是内部节点、反之亦然;数据切成两半,各走一棵树。

树 A(处理前半数据 S/2)                树 B(处理后半数据 S/2;编号平移一位)
        r0                                      r1
        │                                       │
        r4                                      r5
     ┌──┴──┐                                 ┌──┴──┐
    r2     r6            ← 内部节点          r3     r7           ← 内部节点
  ┌─┴─┐  ┌─┴─┐                             ┌─┴─┐  ┌─┴─┐
  r1  r3 r5  r7          ← 叶子             r2  r4 r6  r0         ← 叶子

  A 的内部节点 {0,2,4,6} 在 B 里全是叶子;A 的叶子 {1,3,5,7} 在 B 里全是内部节点

(\(n\) 为偶数时 NCCL 的第二棵树就是第一棵树把 rank 编号平移一位得到的,奇数时用镜像;见 NCCL 2.28.9 src/graph/trees.cc 里的 ncclGetBtree 与 ncclGetDtree,第四篇展开。)

现在算每个 rank 的链路负载。对树 A 来说 rank 3 是叶子,reduce 阶段入向 0、出向 \(S/2\);对树 B 它是内部节点,入向 \(2 \times S/2 = S\)、出向 \(S/2\)。两棵树同时进行,rank 3 在 reduce 阶段的入向总量是 \(S\)、出向 \(S\)。反过来 rank 2 在 A 里是内部节点、在 B 里是叶子,账目一样。broadcast 阶段对称,也是入向 \(S\)、出向 \(S\)。每个 rank 都如此(两个根只有一个孩子,负载更轻,不构成瓶颈)。把单棵树和两棵树的每个方向的负载并排列出来,就能看到”补回一半”是怎么发生的:

单棵树与双树下各 rank 的链路负载
阶段 方向 单棵树:叶子 单棵树:内部节点 双树:任一 rank(一棵里是叶、另一棵里是内部)
reduce 入向 0 2S ← 瓶颈 0 + 2·(S/2) = S
reduce 出向 S S S/2 + S/2 = S
broadcast 入向 S S S/2 + S/2 = S
broadcast 出向 0 2S ← 瓶颈 0 + 2·(S/2) = S
合计(瓶颈方向)     2S + 2S = 4S S + S = 2S

单棵树里每个阶段都有一半 rank 的一个方向空着(叶子的入向、内部节点的出向),另一半 rank 的对应方向扛 \(2S\);双树让每个 rank 在两棵树里各扮一个角色,每个方向的负载都被拉平到 \(S\)。于是:

\[T_{\text{tree,double}} \approx 2\lceil\log_2 n\rceil\,\alpha + \frac{2S}{\beta}\]

带宽项回到了 \(2S/\beta\),与 ring 的极限相同。两棵互补的树把每个 rank 两个方向的链路都用满了,代价是每个 rank 要同时维护两组连接、两条流水线。

4. Ring vs Tree:两本账对照

                     Ring                                  Double Binary Tree
──────────────────   ─────────────────────────────────    ─────────────────────────────────
步数(延迟账)         2(n-1)                                2 ⌈log₂ n⌉
每 rank 字节数         2(n-1)/n · S                          ≈ 2S(两棵树各 S/2,进出各 S)
(带宽账)             n=8: 1.75 S    n=1024: 1.998 S         与 n 无关
带宽效率              最优(等于下界)                         接近最优,比 ring 差 n/(n-1)
                                                            NCCL 调优模型另乘 0.92 经验系数
实现复杂度            每 rank 一进一出、一条流水线              每 rank 两棵树、多组连接
适合的区间            大消息、小规模;节点内                    小消息、大规模;跨节点

数字(α = 10 µs,β = 25 GB/s;非实测)
  n=8,    S=1 GB     140 µs + 70 ms   ≈ 70.1 ms             60 µs + 80 ms   ≈ 80.1 ms      ring 胜
  n=8,    S=64 KB    140 µs + 4.6 µs  ≈ 145 µs              60 µs + 5.2 µs  ≈ 65 µs        tree 胜
  n=1024, S=1 GB     20.5 ms + 80 ms  ≈ 100 ms              200 µs + 80 ms  ≈ 80.2 ms      tree 胜
  n=1024, S=64 KB    20.5 ms + 5 µs   ≈ 20.5 ms             200 µs + 5 µs   ≈ 205 µs       tree 胜 100 倍

结论:8 卡以内、消息大,ring 赢一点;规模一大或消息一小,tree 赢很多。ring 的唯一优势是带宽账上那个 \(\frac{n-1}{n}\),\(n = 8\) 时值 12.5%,\(n = 64\) 时只剩 1.6%。所以实际系统里 tree 是跨节点的默认,ring 是节点内的默认——NCCL 的选择大致就是这样,只是它还有 NVLS、CollNet 等更多选项,第四篇讲。

5. 看一看:NCCL 的调优模型里就是这几个公式

本篇不读 NCCL 内部,但有一处值得先指一下,因为它证明上面推的东西不是纸面练习。NCCL 2.28.9 的 src/graph/tuning.cc 里 ncclTopoTuneModel 为每种(原语 × 算法 × 协议)估算时间时,步数正是这样定义的:

// src/graph/tuning.cc, ncclTopoTuneModel (NCCL 2.28.9)
int nsteps = coll == ncclFuncAllReduce ? 2*(nRanks-1) :
  coll == ncclFuncReduceScatter || coll == ncclFuncAllGather ? nRanks-1 :
  nRanks;
// ...
if (a == NCCL_ALGO_TREE && coll == ncclFuncAllReduce) busBw = std::min(busBw*.92, ...);
// ...
} else if (a == NCCL_ALGO_TREE) {
  if (coll == ncclFuncAllReduce) {
    comm->latencies[coll][a][p] +=
      2 * ((nRanks/nNodes-1) * intraLat + log2i(nNodes) * interLat);
  }

ring all_reduce 走 \(2(n-1)\) 步、reduce_scatter 与 all_gather 走 \(n-1\) 步,与第五章一致;tree 的带宽乘 0.92 的经验系数,延迟按”节点内链 + 节点间树”算——后一条是第八章分层算法的内容。NCCL 用这套 α-β 模型给每个候选算法打分、选最快的那个,NCCL_ALGO / NCCL_PROTO 环境变量(在 tuning.cc 与 src/enqueue.cc 里以 ncclGetEnv("NCCL_ALGO") 读取)可以覆盖这个选择。第四篇会把这张调优表整个读一遍。

七、algbw 与 busbw

1. 两个定义与 nccl-tests 的源码

nccl-tests 对每一个消息大小打印两个带宽。algbw(algorithm bandwidth)是最直接的定义:

\[\text{algbw} = \frac{S}{T}\]

“用户看到的带宽”——传了 \(S\) 字节的数据、花了 \(T\) 秒。busbw(bus bandwidth)把 algbw 乘上第二章那张表里的系数,换算成”每个 rank 的链路上实际流过的字节速率”。以 nccl-tests 2.18.3 的 src/all_reduce.cu 的 AllReduceGetBw 为准:

// nccl-tests src/all_reduce.cu
void AllReduceGetBw(size_t count, size_t typesize, double sec, double* algBw, double* busBw, int nranks) {
  double baseBw = (double)(count * typesize) / 1.0E9 / sec;

  *algBw = baseBw;
  double factor = ((double)(2*(nranks - 1)))/((double)nranks);
  *busBw = baseBw * factor;
}

/ 1.0E9 说明 nccl-tests 的 GB/s 是 \(10^9\) 字节每秒,与网卡的 Gb/s 换算一致(HDR 200 Gb/s = 25 GB/s),与 GiB 无关;factor 就是 \(\frac{2(n-1)}{n}\)。其他几个原语(各在同名 .cu 文件的 *GetBw 函数):

// src/all_gather.cu  AllGatherGetBw
double baseBw = (double)(count * typesize * nranks) / 1.0E9 / sec;   // S 是 n 份拼接后的总量
double factor = ((double)(nranks - 1))/((double)nranks);

// src/reduce_scatter.cu  ReduceScatterGetBw
double baseBw = (double)(count * typesize * nranks) / 1.0E9 / sec;   // S 是输入总量
double factor = ((double)(nranks - 1))/((double)nranks);

// src/alltoall.cu  AlltoAllGetBw
double baseBw = (double)(count * nranks * typesize) / 1.0E9 / sec;
double factor = ((double)(nranks-1))/((double)(nranks));

// src/broadcast.cu  BroadcastGetBw
double factor = 1;

// src/reduce.cu  ReduceGetBw
*busBw = baseBw;                                                      // factor 1

注意 all_gather、reduce_scatter、all_to_all 三个的 baseBw 里多乘了 nranks:这里的 count 是每个 rank 的那一份(由各文件的 *GetCollByteCount 算出,例如 AllGatherGetCollByteCount 把 sendcount 设为 count/nranks),乘回 nranks 后 \(S\) 是总量,与第二章的约定一致。common.cu 的 BenchTime 在计时后调用 args->collTest->getBw(...),传入的 nranks 是 nProcs * nThreads * nGpus,即 communicator 的总 rank 数。

2. 各原语的系数表

原语              nccl-tests 里的 S                busbw / algbw       n=8 时     n→∞
──────────────    ─────────────────────────────    ─────────────────   ────────   ─────
all_reduce        每 rank 的 buffer                2(n-1)/n            1.75       2
all_gather        n 份拼接后的总量                  (n-1)/n             0.875      1
reduce_scatter    输入总量(n × 每 rank 输出)        (n-1)/n             0.875      1
all_to_all        每 rank 输入总量                  (n-1)/n             0.875      1
broadcast         buffer 大小                      1                   1          1
reduce            buffer 大小                      1                   1          1

系数就是第二章”每 rank 至少接收多少字节”那一列除以 \(S\)。它假设的是带宽最优算法下每个 rank 的链路流量;用其他算法(比如朴素树)实际流量会更多,但 busbw 仍按这个系数算——所以 busbw 是”等效 ring 流量”的口径,不是链路的实测流量。

3. 为什么只有 busbw 能与链路带宽比较

拿第五章的数字看。8 卡 1 GB 的 ring all_reduce 理论 70.14 ms,algbw = 14.3 GB/s。这个数字与 25 GB/s 的链路没有直接关系:把 8 卡换成 2 卡,同样的链路、同样跑满,\(T = 2 \times 1 \times 10\ \mu\text{s} + 1.0 \times 40\ \text{ms} \approx 40\) ms,algbw 变成 25 GB/s;换成 64 卡,\(T = 126 \times 10\ \mu\text{s} + 1.97 \times 40\ \text{ms} \approx 80\) ms,algbw 变成 12.5 GB/s。链路一直是那条链路、一直是满的,algbw 却随 \(n\) 变。

busbw 把 \(\frac{2(n-1)}{n}\) 乘回去:2 卡 25.0 GB/s,8 卡 24.9 GB/s,64 卡 24.6 GB/s——只要链路跑满、延迟项可忽略,busbw 就等于链路单向带宽,与 n 无关。把这几组数并排(S = 1 GB,β = 25 GB/s,α = 10 µs,ring,非实测):

不同卡数下 all_reduce 的 algbw 与 busbw
n 步数 × α 每 rank 字节数 / β T algbw = S/T 系数 2(n-1)/n busbw
2 20 µs 1.00 × 40 ms 40.0 ms 25.0 GB/s 1.00 25.0 GB/s
8 140 µs 1.75 × 40 ms 70.1 ms 14.3 GB/s 1.75 24.9 GB/s
64 1.26 ms 1.97 × 40 ms 80.0 ms 12.5 GB/s 1.97 24.6 GB/s
1024 20.5 ms 1.998 × 40 ms 100.4 ms 10.0 GB/s 1.998 19.9 GB/s

algbw 一列随 \(n\) 从 25 掉到 10,busbw 一列在 25 附近不动。(1024 卡的 ring 会降到 19.9 GB/s,但那 20% 是 2046 步的 α 账,不是链路的账;换 tree 就回到 24.9。)这就是它能与硬件标称值直接比的原因:nccl-tests 的 busbw 平台是 23 GB/s、链路是 25 GB/s,你立刻知道链路效率 92%;如果平台是 12 GB/s,你知道差了一倍,该去查路径、GDR、channel 数(第六篇)。

两个数字各有用处。algbw 回答用户的问题:”我的 1 GB 梯度多久能同步完”——它就是 \(S/T\),与你关心的张量直接相关。busbw 回答工程师的问题:”链路跑满了没有”。看曲线时看 busbw,算迭代时间时用 algbw。

一个补充:在节点内用 NVSwitch 的 NVLS 或跨节点用 SHARP 时,归约在交换机里完成,每个 rank 实际只需发一份、收一份(\(\approx S\) 而不是 \(2S\)),此时按 \(\frac{2(n-1)}{n}\) 算出来的 busbw 会超过链路单向带宽。这不是测错,是 busbw 的 ring 等效口径在非 ring 算法下的表现。第四篇讲 NVLS 时会回到这一点;第八章的两级算法算例里也会看到同样的现象。

4. 测一测、比一比:从曲线上能读出什么

把本篇的模型和 nccl-tests 的曲线对起来,有四个可以直接读出的量。第六篇会展开工具用法,这里给出读法:

  • 右端平台高度 vs β:平台是 busbw 的饱和值,理论上等于链路单向带宽乘以协议效率。8 卡 HDR 上应在 20–23 GB/s(”通常能达到”,非实测);平台远低于此,是路径或配置问题,不是算法问题。
  • 左端平台 vs 步数 × α:最小消息的时间 \(T_{\min} \approx (\text{步数}) \times \alpha\)。8 卡 ring 在 IB 上若 \(T_{\min} = 140\) µs,则 α ≈ 10 µs;若是 400 µs,α ≈ 30 µs,该查跨 NUMA、proxy 线程、协议选择。
  • 拐点位置 vs nαβ:曲线达到平台一半高度的 \(S\) 大约是 \(n\alpha\beta\)。已知 \(n\) 和 β,反推 α;与左端平台反推的 α 对不上,说明中间段有额外开销(比如算法切换点选得不好)。
  • 不同 n 的曲线平台是否重合:按 busbw 画,2 卡与 8 卡的平台应该基本重合;不重合说明拓扑上某些 rank 之间的路径比其他 rank 差(比如跨了 PCIe switch 或 NUMA)。

这四条是本篇给第六篇”比一比”的检查项,它们不需要机器,只需要模型。

八、分层与多级算法

1. 两个 β、两个 α

到目前为止的模型假设所有链路一样。真实的集群不是:节点内 8 张卡通过 NVSwitch 互联,每张卡单向几百 GB/s、一步几 µs;节点之间每张卡一张 400 Gb/s 网卡,单向 50 GB/s、一步十几 µs。两者带宽差 4–8 倍,延迟差 3–5 倍。

一个跨 4 个节点、32 张卡的平坦 ring 会怎样?环上 32 条边中有 4 条跨节点(每个节点进一条、出一条),28 条在节点内。ring 的每一步所有 rank 同时收发,一步的时间由最慢的那条边决定;带宽项是 \(\frac{2(n-1)}{n}\frac{S}{\beta_{\min}}\),\(\beta_{\min}\) 是节点间链路。更糟的是每个节点只有一条出边跨节点,8 张网卡只用了 1 张——除非 NCCL 建多条环让不同的环从不同网卡出去(它确实这么做,这就是 channel,第四篇)。

分层的思路是让数据在快的链路上多走、在慢的链路上少走。

2. 两级 all_reduce 的代价

\(N\) 个节点、每节点 \(p\) 张卡(\(n = Np\))。节点内参数 \((\alpha_i, \beta_i)\),节点间 \((\alpha_e, \beta_e)\)。三步:

第 1 步   节点内 reduce_scatter      p 个 rank 的 ring,S 字节      → 每张卡持有 S/p 的节点内归约结果
第 2 步   节点间 all_reduce          每张卡与其他节点上同一位置的卡    → p 个并行的 N-rank ring,各 S/p 字节
                                    组成一个 N-rank 的环,走自己的网卡
第 3 步   节点内 all_gather          p 个 rank 的 ring,S 字节      → 每张卡拿到完整结果

把 \(N = 4\) 个节点、每节点 \(p = 8\) 张卡排成 4 × 8 的网格(行 = 节点,列 = 卡在节点内的位置),三步分别是”横着走”和”竖着走”:

            卡0    卡1    卡2    卡3    卡4    卡5    卡6    卡7
          ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
节点 0    │◀─── 第 1 步 / 第 3 步:节点内 8-rank ring,NVLink ───▶│
          ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
节点 1    │◀──────────────── 节点内 ring,S 字节 ────────────────▶│
          ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
节点 2    │◀──────────────── 节点内 ring,S 字节 ────────────────▶│
          ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
节点 3    │◀──────────────── 节点内 ring,S 字节 ────────────────▶│
          └──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┘
             ▲      ▲      ▲      ▲      ▲      ▲      ▲      ▲
             ▼      ▼      ▼      ▼      ▼      ▼      ▼      ▼
          第 2 步:每一列 4 张卡组成一个 4-rank ring,各搬 S/8,
                   走该列各卡自己的网卡(IB);8 条 ring 同时进行

第 1 步之后第 \(j\) 列的卡持有的是本节点第 \(j\) 块(\(S/p\))的节点内归约结果,所以第 2 步只需要同一列的 4 张卡之间做 all_reduce;4 个节点各 8 张网卡,每张网卡恰好服务一列。

代价:

\[T_{\text{2-level}} = \underbrace{2\left[(p-1)\,\alpha_i + \frac{p-1}{p}\frac{S}{\beta_i}\right]}_{\text{节点内两段}} + \underbrace{2(N-1)\,\alpha_e + \frac{2(N-1)}{N}\cdot\frac{S/p}{\beta_e}}_{\text{节点间}}\]

关键在节点间那一项的分子:\(S/p\) 而不是 \(S\)。节点间要搬的总字节数没有少(\(p\) 张卡各搬 \(S/p\)),但被摊到了 \(p\) 张网卡上,每张网卡只需承载 \(1/p\)。

用数字比一比。\(N = 4\),\(p = 8\),\(S = 1\) GB,\(\beta_e = 25\) GB/s,\(\alpha_e = 10\) µs,\(\beta_i = 200\) GB/s(8 卡 ring 每 rank 在 NVSwitch 上通常能用到的量级,非实测),\(\alpha_i = 3\) µs:

两级
  节点内 reduce_scatter   7 × 3 µs + 7/8 × 1 GB / 200 GB/s   =  21 µs + 4.4 ms
  节点间 all_reduce       6 × 10 µs + 6/4 × 125 MB / 25 GB/s  =  60 µs + 7.5 ms
  节点内 all_gather       7 × 3 µs + 7/8 × 1 GB / 200 GB/s   =  21 µs + 4.4 ms
  合计                                                        ≈ 16.4 ms

平坦 ring(32 rank,每步受节点间链路限制,每节点一张网卡承载全部跨节点流量)
  62 × 10 µs + 62/32 × 1 GB / 25 GB/s                        = 0.6 ms + 77.5 ms ≈ 78 ms

差 4.7 倍。差距的来源就是 \(S/p\):节点间那一段从 77.5 ms 降到 7.5 ms,代价是节点内多走两段共 8.8 ms。当 \(\beta_i \gg \beta_e\) 时这笔交换永远划算。

第七章说过 busbw 在非 ring 算法下会超过链路带宽,这里就是一个例子:两级算法的 algbw \(= 1\ \text{GB} / 16.4\ \text{ms} \approx 61\) GB/s,busbw \(= 61 \times \frac{62}{32} \approx 118\) GB/s,远超网卡的 25 GB/s——因为大部分字节走的是 NVLink。

3. 延迟账上的分层

分层对延迟账同样有效,且更明显。上面的两级算法步数是 \(2(p-1) + 2(N-1) = 14 + 6 = 20\),平坦 ring 是 62;如果节点间用 tree,步数是 \(2(p-1) + 2\log_2 N = 14 + 4 = 18\)。更重要的是哪些步付的是小 α、哪些步付的是大 α:两级算法里 14 步付 \(\alpha_i = 3\) µs、6 步付 \(\alpha_e = 10\) µs,合计 102 µs;平坦 ring 的 62 步里虽然只有 8 步真正跨节点,但每一步所有 rank 要同步,实际每步都按最慢的算,合计 620 µs。

第六章第 5 节引用的 NCCL tree 延迟公式 \(2\,[(p-1)\,\alpha_i + \log_2 N \cdot \alpha_e]\) 正是这个结构:节点内一条链(\(p - 1\) 步)、节点间一棵树(\(\log_2 N\) 步),reduce 与 broadcast 各一遍。NCCL 的 tree 算法本身就是分层的。

4. 分层的代价与前提

分层不是免费的。它假设”同一位置的卡”之间有独立的网卡(每 GPU 一张 NIC,这是第二篇讲的 rail-optimized 设计的原因),假设节点内的链路确实比节点间快得多(PCIe 机器上 \(\beta_i\) 可能只有 \(\beta_e\) 的一两倍,分层的收益就小),也假设 \(p\) 张网卡能同时跑满(NIC 与 GPU 的 PCIe 亲和不对时不能,第二、三篇)。另外,节点内两段各自要付 \((p-1)\alpha_i\),小消息时这一项不可忽略:64 KB 的 all_reduce 在两级算法下是 \(14 \times 3 + 6 \times 10 = 102\) µs,仍是纯延迟主导,分层帮不了它——它需要的是更少的步数(tree)、更低的 α(协议、custom kernel)或者干脆不做这么小的通信(合并)。

九、把消息大小的谱放到曲线上

1. 三个量级、三种账

回到第三章那条对数轴,现在可以给每一段填上两本账的比例。以 8 卡为单位,两种链路(IB HDR:α = 10 µs、β = 25 GB/s;NVLink:α = 3 µs、β = 200 GB/s;均为典型量级、非实测),ring all_reduce:

消息            场景                        IB HDR 8 卡                    NVLink 8 卡
                                           T          延迟占比            T          延迟占比
─────────────   ───────────────────────    ─────────  ────────           ─────────  ────────
64 KB           decode TP,batch 4         145 µs     97%                43 µs      98%
128 KB          decode TP,batch 8         149 µs     94%                43 µs      97%
512 KB          decode TP,batch 32        176 µs     80%                47 µs      90%
2 MB            ring 拐点(IB)             287 µs     49%                60 µs      70%
25 MiB          DDP 一个 bucket             1.98 ms    7%                 271 µs     15%
32 MB           prefill / 训练 TP 激活      2.5 ms     6%                 336 µs     13%
400 MB          FSDP 一层 all_gather        (用 (n-1)/n 系数)14 ms      1.8 ms
1 GB            大梯度 / KV cache 量级      70 ms      0.2%               8.8 ms     0.5%

三个区间的性质:

  • 几十到几百 KB(decode TP):两种链路上都是 90% 以上的延迟。换更快的链路几乎无效;有效的是减少步数(tree、one-shot 的 custom all-reduce)、降低每步的 α(LL 协议、绕开 kernel 启动与 proxy)、或减少通信次数。第七篇的 custom all-reduce 把 8 卡 128 KB 从 NCCL 的几十 µs 降到十几 µs,省的全是 α。
  • 几 MB 到几十 MB(DDP bucket、TP/PP 激活):IB 上处于拐点附近到带宽主导之间,两本账都要看;NVLink 上已经是带宽主导。DDP 把桶设成 25 MiB 而不是 1 MB,就是为了把梯度同步从拐点左边推到右边——8 卡 IB 上 25 个 1 MiB 的消息要 \(25 \times (140 + 73)\ \mu\text{s} \approx 5.3\) ms,一个 25 MiB 的消息只要 1.98 ms。
  • 几百 MB 到 GB(FSDP 大层、KV cache、整体梯度):纯带宽主导。看的是链路速率、算法效率、能否用上所有网卡、GDR 是否生效、能否与计算重叠。α 估错一个量级对结果没有影响。

2. 一次训练迭代的通信账

把模型用到一个具体任务上。7B dense 模型、bf16 梯度 14 GB、64 卡 DDP(8 节点 × 8 卡)、每卡一张 NDR 网卡(50 GB/s 单向)、两级 ring:

节点间:每张卡承载 14 GB / 8 = 1.75 GB,N = 8 的 ring,带宽项 2×7/8 × 1.75 GB / 50 GB/s = 61 ms
节点内:两段,各 7/8 × 14 GB / 200 GB/s = 61 ms,合计 122 ms      ← 与节点间同量级!
延迟:  14 × 3 µs + 14 × 10 µs ≈ 0.2 ms,忽略
合计:  ≈ 183 ms

这个算例暴露了分层的一个盲点:节点内的两段各要在 NVLink 上搬 \(\frac{7}{8} \times 14\) GB,与节点间的时间相当。真实的 NCCL 用多 channel 让节点内多条 ring 并行,实际 \(\beta_i\) 能用到更高(H100 NVSwitch 上 all_reduce 的 busbw 通常能到 300–400 GB/s 量级,非实测),节点内的份额会缩小;但”节点内不是免费的”这一点在 FSDP 这种通信量与 DP 相同、却不能完全重叠的场合会体现出来。反向传播如果是 500 ms,183 ms 的通信能否藏进去,取决于第五篇讲的重叠机制。

3. 比一比:从一个实测数字反推该查哪一层

本篇没有实测,但可以给出拿到实测数字后的判断步骤,这是模型作为尺子的用法:

  1. 算理论值。用 cost_model.py(下一章)输入 \(n\)、\(S\)、链路标称 β、量级 α,得到 \(T_{\text{model}}\) 与 busbw。
  2. 判断区间。看延迟占比:> 80% 是延迟主导,< 20% 是带宽主导,中间是拐点区。
  3. 带宽主导时,比 busbw:实测 / 标称 ≥ 80% 正常;50–80% 查协议效率与 channel 数;< 50% 查路径(是否走了 PCIe 而非 NVLink、是否走了 Socket 而非 IB、GDR 是否生效)——第二、三、六篇。
  4. 延迟主导时,比 \(T / \text{步数}\) 反推 α:IB 上一步 5–20 µs、NVLink 上 2–5 µs 正常;显著高于此查协议选择、跨 NUMA、proxy 线程、框架侧是否有额外同步——第四、五、六篇。
  5. 拐点区,两个都算;先确认 β 正常再看 α,因为 β 的问题更容易定位。
  6. 模型不适用的情形:all_to_all(流量模式不同)、有 straggler(所有 rank 等最慢的)、通信与计算重叠时被抢了 SM——这些在 profiler 里表现为通信时间远大于模型值且方差大。

前两步不需要机器,是本篇能给的;后四步每一步都指向后面某一篇。

十、本文小结

1. 要点回顾

  • 集合通信有八个原语;all_reduce = reduce_scatter + all_gather 是 ring 的基础,all_reduce = reduce + broadcast 是 tree 的基础;broadcast/reduce、scatter/gather、all_gather/reduce_scatter 互为对偶,实现上可以互相翻转。
  • 每个原语每 rank 至少接收的字节数:all_reduce \(\frac{2(n-1)}{n}S\),all_gather / reduce_scatter / all_to_all \(\frac{n-1}{n}S\),broadcast / reduce \(S\)。这一列就是 busbw 的系数。
  • 训练:DP 的梯度 all_reduce(25 MiB 桶,带宽账)、FSDP 的 all_gather / reduce_scatter(几百 MB 一层)、TP 的 all_reduce(几十 MB、关键路径、不出节点)、PP 的 send/recv、MoE 的 all_to_all(\(n(n-1)\) 条流)。推理:decode TP 的 all_reduce(几十到几百 KB,延迟账)、PD 分离的 KV 传输(GB 级点对点,带宽账)。
  • α-β 模型:\(T = \alpha + S/\beta\);单消息拐点 \(S^* = \alpha\beta\);链路越快拐点越大,换快网卡对小消息无效。
  • ring all_reduce:\(T = 2(n-1)\alpha + \frac{2(n-1)}{n}\frac{S}{\beta}\)。带宽项 \(\to 2S/\beta\) 与 \(n\) 无关(大消息最优),延迟项随 \(n\) 线性增长(小消息、大规模失败)。集体拐点 \(S^* = n\alpha\beta\),8 卡 IB 约 2 MB。
  • 核心问题:8 卡、25 GB/s、α = 10 µs,1 GB 约 70 ms(延迟占 0.2%,只对 β 敏感),64 KB 约 145 µs(带宽占 3%,只对 α 与步数敏感)。
  • tree:延迟 \(2\lceil\log_2 n\rceil\alpha\);朴素二叉树带宽只有 ring 一半(叶子只用链路一个方向、内部节点一个方向扛两倍);double binary tree 用两棵互补的树各走一半数据,把带宽补回 \(2S/\beta\)。
  • algbw = \(S/T\) 随 \(n\) 变,回答”我的张量多久同步完”;busbw = algbw × 系数,链路跑满时等于链路单向带宽、与 \(n\) 无关,是唯一能与硬件标称值直接比的数字。非 ring 算法(NVLS、两级)下 busbw 可超过链路带宽。
  • 分层:节点内 reduce_scatter → 节点间 all_reduce(\(S/p\),走 \(p\) 张网卡)→ 节点内 all_gather;节点间流量每网卡减到 \(1/p\),4 节点 32 卡的算例快 4.7 倍。NCCL 的 tree 本身就是”节点内链 + 节点间树”的分层结构。
  • 消息大小的谱:几十 KB 是纯延迟(减步数、降 α、合并),几 MB 到几十 MB 是拐点区(两本账都算),几百 MB 以上是纯带宽(链路、效率、多网卡、重叠)。

2. 公式速查

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
α-β 模型
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
单条消息            T = α + S/β                          拐点 S* = αβ
集合通信通式         T = 步数 × α + 每 rank 字节数 / β      β 为单向;全双工下收发不互抢

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
算法                 步数(延迟账)          每 rank 字节数(带宽账)         拐点
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ring all_reduce      2(n-1)                 2(n-1)/n · S  → 2S             S* = nαβ
ring all_gather      n-1                    (n-1)/n · S   → S              S* = nαβ
ring reduce_scatter  n-1                    (n-1)/n · S   → S              S* = nαβ
朴素二叉树 all_reduce 2⌈log₂n⌉               4S(瓶颈链路)
double binary tree   2⌈log₂n⌉               2S
两级 ring(N 节点×p)  2(p-1) 内 + 2(N-1) 外   2(p-1)/p · S / β_i + 2(N-1)/N · (S/p) / β_e
朴素 reduce+bcast    2                      (n-1)S 进 + (n-1)S 出(root)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
nccl-tests 的两个带宽(GB/s = 10^9 B/s)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
algbw = S / T
busbw = algbw × 系数     all_reduce 2(n-1)/n    all_gather / reduce_scatter / all_to_all (n-1)/n
                         broadcast / reduce 1
S 的口径                 all_gather 为拼接后总量;reduce_scatter 为输入总量;其余为每 rank buffer

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
典型量级(非实测;单向)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
β     IB HDR 25 GB/s · IB NDR 50 GB/s · PCIe 4.0 x16 32 GB/s · NVLink 8 卡 ring 每 rank ~200 GB/s
α     IB 上一步 5–20 µs · NVLink 上一步 2–5 µs
8 卡  IB ring 拐点 ~2 MB · NVLink ring 拐点 ~5 MB
从曲线反推   α ≈ T_min / 步数 ;  β ≈ busbw 平台 / 协议效率 ;  α ≈ 拐点 S / (nβ)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
核心问题(n=8, β=25 GB/s, α=10 µs)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 GB     140 µs + 70 ms  ≈ 70.1 ms     algbw 14.3 · busbw 24.9 GB/s     带宽主导,对 β 敏感
64 KB    140 µs + 4.6 µs ≈ 145 µs      algbw 0.45 · busbw 0.79 GB/s     延迟主导,对 α 与步数敏感

3. 本篇涉及的源码与工具位置

本篇涉及的源码与工具位置
路径 内容
nccl-tests src/all_reduce.cu AllReduceGetBw:algbw = S/T,busbw 系数 \(2(n-1)/n\);AllReduceGetCollByteCount
nccl-tests src/all_gather.cu、src/reduce_scatter.cu、src/alltoall.cu AllGatherGetBw、ReduceScatterGetBw、AlltoAllGetBw:baseBw 乘 nranks 得总量,系数 \((n-1)/n\)
nccl-tests src/broadcast.cu、src/reduce.cu BroadcastGetBw、ReduceGetBw:系数 1
nccl-tests src/common.cu BenchTime 计时后调用 collTest->getBw(...);nranks = nProcs * nThreads * nGpus
NCCL 2.28.9 src/graph/tuning.cc ncclTopoTuneModel:ring 步数 \(2(n-1)\) / \(n-1\),tree 带宽 ×0.92,tree 延迟 \(2[(p-1)\alpha_i + \log_2 N\,\alpha_e]\);读取 NCCL_ALGO / NCCL_PROTO
NCCL 2.28.9 src/graph/trees.cc ncclGetBtree、ncclGetDtree:单棵与 double binary tree 的构造
PyTorch torch/csrc/distributed/c10d/reducer.hpp kDefaultBucketBytesCap = 25 MiB、kDefaultFirstBucketBytes = 1 MiB(DDP bucket 默认值)
comm-probe cost_model.py 本篇增量,见下

4. comm-probe 本篇增量:cost_model.py

comm-probe 的第一个工具是本篇公式的可执行版本。输入 \(n\)、\(S\)、α、β 与算法,输出理论时间、延迟占比、algbw 与 busbw。它不联网、不需要 GPU,后面六篇的每一个实测数字都拿它做参照。

#!/usr/bin/env python3
"""comm-probe / cost_model.py -- alpha-beta cost model for collectives.

T = steps * alpha + bytes_per_rank / beta
algbw = S / T ; busbw = algbw * factor(coll, n)   (factor follows nccl-tests src/*.cu)
"""
import argparse, math

UNITS = {"k": 1 << 10, "m": 1 << 20, "g": 1 << 30}

def parse_size(s):
    s = s.strip().lower().rstrip("b")
    for u, mult in UNITS.items():
        if s.endswith(u):
            return int(float(s[:-1]) * mult)
    return int(float(s))

def busbw_factor(coll, n):
    if coll == "all_reduce":
        return 2 * (n - 1) / n
    if coll in ("all_gather", "reduce_scatter", "all_to_all"):
        return (n - 1) / n
    return 1.0                                  # broadcast / reduce

def ring(coll, n, S, alpha, beta):
    """returns (latency_part, bandwidth_part) for a ring on one link of bandwidth beta."""
    if coll == "all_reduce":
        steps, nbytes = 2 * (n - 1), 2 * (n - 1) / n * S
    elif coll in ("all_gather", "reduce_scatter", "all_to_all"):
        steps, nbytes = n - 1, (n - 1) / n * S
    else:                                       # broadcast / reduce: pipelined chain
        steps, nbytes = n - 1, S
    return steps * alpha, nbytes / beta

def tree(coll, n, S, alpha, beta):
    """double binary tree all_reduce: 2*ceil(log2 n) steps, 2S per rank."""
    if coll != "all_reduce":
        raise SystemExit("tree model implemented for all_reduce only")
    return 2 * math.ceil(math.log2(n)) * alpha, 2 * S / beta

def ring_allreduce_time(n, S, alpha_us, beta_gbps):
    """importable helper for later tools (nccl_log_reader.py, plot_sweep.py): returns T in us."""
    lat, bw = ring("all_reduce", n, S, alpha_us * 1e-6, beta_gbps * 1e9)
    return (lat + bw) * 1e6

def two_level(coll, n, S, alpha, beta, ppn, alpha_i, beta_i):
    """intra reduce_scatter -> inter ring all_reduce on S/ppn -> intra all_gather."""
    if coll != "all_reduce":
        raise SystemExit("2level model implemented for all_reduce only")
    l1, b1 = ring("reduce_scatter", ppn, S, alpha_i, beta_i)       # == all_gather cost
    l2, b2 = ring("all_reduce", n // ppn, S / ppn, alpha, beta)
    return 2 * l1 + l2, 2 * b1 + b2

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--n", type=int, required=True, help="number of ranks")
    p.add_argument("--size", required=True, help="S, e.g. 64K, 25M, 1G (total bytes)")
    p.add_argument("--alpha", type=float, default=10.0, help="per-step latency, us")
    p.add_argument("--beta", type=float, default=25.0, help="link bandwidth, GB/s (1e9 B/s)")
    p.add_argument("--algo", choices=["ring", "tree", "2level"], default="ring")
    p.add_argument("--coll", default="all_reduce",
                   choices=["all_reduce", "all_gather", "reduce_scatter",
                            "all_to_all", "broadcast", "reduce"])
    p.add_argument("--ppn", type=int, default=8, help="ranks per node (2level)")
    p.add_argument("--alpha-intra", type=float, default=3.0, help="intra-node alpha, us")
    p.add_argument("--beta-intra", type=float, default=200.0, help="intra-node beta, GB/s")
    a = p.parse_args()

    S, alpha, beta = parse_size(a.size), a.alpha * 1e-6, a.beta * 1e9
    if a.algo == "ring":
        lat, bw = ring(a.coll, a.n, S, alpha, beta)
    elif a.algo == "tree":
        lat, bw = tree(a.coll, a.n, S, alpha, beta)
    else:
        lat, bw = two_level(a.coll, a.n, S, alpha, beta, a.ppn,
                            a.alpha_intra * 1e-6, a.beta_intra * 1e9)
    T = lat + bw
    algbw = S / T / 1e9
    busbw = algbw * busbw_factor(a.coll, a.n)
    print(f"{a.coll:14s} {a.algo:6s} n={a.n:<4d} S={a.size:>5s}  T={T*1e6:11.1f} us  "
          f"lat={100*lat/T:5.1f}%  algbw={algbw:7.2f}  busbw={busbw:7.2f} GB/s")

if __name__ == "__main__":
    main()

几点实现说明。parse_size 里 1G 是 \(2^{30}\) 字节,与 nccl-tests -b/-e 参数的口径一致,所以下面表里 1G 的时间是 75.3 ms 而不是正文用 \(10^9\) 算的 70 ms;β 的单位是 \(10^9\) B/s,与 nccl-tests 和网卡的口径一致。ring 函数对 broadcast / reduce 用的是流水化链(\(n-1\) 步、每 rank \(S\) 字节),与 NCCL 的 ring broadcast 一致。tree 只实现了 double binary tree 的 all_reduce;two_level 假设每张卡有自己的节点间链路。

一组示例运行(默认 α = 10 µs、β = 25 GB/s;2level 默认节点内 α = 3 µs、β = 200 GB/s):

$ for s in 64K 2M 25M 1G; do ./cost_model.py --n 8 --size $s; done
all_reduce     ring   n=8    S=  64K  T=      144.6 us  lat= 96.8%  algbw=   0.45  busbw=   0.79 GB/s
all_reduce     ring   n=8    S=   2M  T=      286.8 us  lat= 48.8%  algbw=   7.31  busbw=  12.80 GB/s
all_reduce     ring   n=8    S=  25M  T=     1975.0 us  lat=  7.1%  algbw=  13.27  busbw=  23.23 GB/s
all_reduce     ring   n=8    S=   1G  T=    75301.9 us  lat=  0.2%  algbw=  14.26  busbw=  24.95 GB/s

$ ./cost_model.py --n 8 --size 64K --algo tree
all_reduce     tree   n=8    S=  64K  T=       65.2 us  lat= 92.0%  algbw=   1.00  busbw=   1.76 GB/s
$ ./cost_model.py --n 1024 --size 64K;  ./cost_model.py --n 1024 --size 64K --algo tree
all_reduce     ring   n=1024 S=  64K  T=    20465.2 us  lat=100.0%  algbw=   0.00  busbw=   0.01 GB/s
all_reduce     tree   n=1024 S=  64K  T=      205.2 us  lat= 97.4%  algbw=   0.32  busbw=   0.64 GB/s

$ ./cost_model.py --n 32 --size 1G;  ./cost_model.py --n 32 --size 1G --algo 2level
all_reduce     ring   n=32   S=   1G  T=    83835.0 us  lat=  0.7%  algbw=  12.81  busbw=  24.82 GB/s
all_reduce     2level n=32   S=   1G  T=    17550.3 us  lat=  0.6%  algbw=  61.18  busbw= 118.54 GB/s

$ ./cost_model.py --n 8 --size 128K --alpha 3 --beta 200          # NVLink 上的 decode TP all_reduce
all_reduce     ring   n=8    S= 128K  T=       43.1 us  lat= 97.3%  algbw=   3.04  busbw=   5.32 GB/s
$ ./cost_model.py --n 8 --size 1G --coll all_gather
all_gather     ring   n=8    S=   1G  T=    37651.0 us  lat=  0.2%  algbw=  28.52  busbw=  24.95 GB/s

每一行都能在正文里找到对应:第一组是第五章的核心问题与第九章的谱(2 MB 处延迟占比 48.8%,正是拐点);第二组是第六章 Ring vs Tree 表里的 1024 卡;第三组是第八章两级算法的 4.7 倍与 busbw 超过链路的现象;最后两行分别是第七篇要处理的 decode 场景,以及 all_gather 的 busbw 与 all_reduce 一样落在链路带宽上、algbw 却不同。

后面几篇对这个脚本的使用方式是:第二篇用 nvbandwidth 与 ib_write_bw 测出你机器上真实的 β,第六篇用 nccl-tests 最小消息的时间反推 α,把默认值换掉;然后每一条实测曲线都与它输出的理论值比,差距就是那一篇要解释的东西。

模型里的 α 和 β 目前都是量级估计。下一篇给它们填上真实的数字:一台 8 卡服务器内部有哪些链路、每条多快、GPU 到 GPU 和 GPU 到网卡的路径怎么选,以及为什么两张看起来一样的卡之间的带宽可以差一个量级。

nvidia-smi topo -m 里 GPU0 到 GPU1 是 NV12、到 NIC0 是 PIX、到 NIC4 是 SYS。这三个词各自意味着什么带宽和什么路径?为什么 NCCL 会为 GPU0 选 NIC0 而不是 NIC4?

十一、自测

  1. all_reduce、all_gather、reduce_scatter、broadcast 每个 rank 至少要接收多少字节(总数据 \(S\)、\(n\) 个 rank)?这一列在 nccl-tests 里对应什么?

    答案

    all_reduce \(\frac{2(n-1)}{n}S\);all_gather 与 reduce_scatter \(\frac{n-1}{n}S\);broadcast \(S\)。就是 busbw = algbw × 系数里的系数——让不同原语、不同 \(n\) 的结果都能与链路单向带宽直接比。

  2. 单条链路 \(\alpha = 5\) µs、\(\beta = 50\) GB/s,一条消息多大时延迟与带宽各占一半?换成 \(\beta = 100\) GB/s 呢?这说明什么?

    答案

    \(S^* = \alpha\beta = 250\) KB;换快链路后 500 KB——链路越快拐点越大,更多消息落到延迟主导区,换快网卡对小消息无效。

  3. ring 与 double binary tree 在 8 卡上各走几步?带宽各是多少?32 节点呢?

    答案

    ring \(2(n-1) = 14\) 步、带宽 \(2S/\beta\);tree \(2\lceil\log_2 8\rceil = 6\) 步,朴素二叉树带宽只有 ring 一半,double binary tree 两棵互补的树各走一半数据把带宽补回 \(2S/\beta\)。32 节点(256 卡)ring 510 步 vs tree 约 16 步——延迟差一个数量级。

  4. 4 节点各 8 卡、每节点 8 张网卡做 all_reduce,分层算法怎么走?节点间流量每张网卡是平坦 ring 的几分之一?

    答案

    节点内 reduce_scatter(NVLink)→ 每张卡拿自己那 1/8 与其他节点同号卡做节点间 all_reduce(\(S/8\),8 张网卡并行)→ 节点内 all_gather。节点间每网卡只扛 \(S/8\),平坦 ring 是每步 \(S/n\) 但要走满全部 \(n\) 步——算例快 4.7 倍。

  5. algbw 与 busbw 各回答什么问题?为什么只有 busbw 能与硬件标称值比?NVLS 下 busbw 超过链路带宽是错的吗?

    答案

    algbw = \(S/T\),随 \(n\) 变,回答“我的张量多久同步完”;busbw = algbw × 系数,链路跑满时等于链路单向带宽、与 \(n\) 无关。NVLS 让交换机做归约、每卡只发送 \(S/n\),实际链路流量低于 ring 的假设,按 ring 系数折算出的 busbw 自然超过链路带宽——不是错,是系数不适用。

下一篇

硬件互联:PCIe、NVLink、NVSwitch 与网络拓扑

  1. 用 α-β 模型:ring all_reduce 的时间 \(T = 2(n-1)\alpha + \frac{2(n-1)}{n}\frac{S}{\beta}\)。\(n = 8\)、\(\beta = 25\) GB/s、取 \(\alpha = 10\) µs:带宽项 \(\frac{14}{8} \times 1\,\text{GB} / 25\,\text{GB/s} = 70\) ms,延迟项 \(14 \times 10\) µs = 0.14 ms,合计约 70 ms,延迟只占 0.2%。详见第四章、第五章。 ↩

  2. 带宽项 \(\frac{14}{8} \times 64\,\text{KB} / 25\,\text{GB/s} = 4.5\) µs,延迟项 140 µs,合计约 145 µs,带宽只占 3%。详见第五章。 ↩

  3. ring 的带宽项 \(\to 2S/\beta\) 与 \(n\) 无关,是大消息的最优;延迟项 \(2(n-1)\alpha\) 随 \(n\) 线性增长,小消息、大规模时它主导。两者相等的拐点 \(S^* = n\alpha\beta\),8 卡 IB 约 2 MB——一个消息在拐点哪一侧,决定它算哪本账:1 GB 在带宽侧,换更快的链路、多网卡才有用;64 KB 在延迟侧,换快网卡无效,只有减少步数(tree 的 \(2\lceil\log_2 n\rceil\) 步)、降低 \(\alpha\)(NVLink、LL 协议)、把多次小通信合并成一次才有用。训练的梯度桶(25 MiB)在带宽侧,decode TP 的 all_reduce(几十到几百 KB)在延迟侧,这就是两类系统通信优化方向完全不同的原因。详见第五章、第六章、第九章。 ↩

本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/collective-communication-primitives-and-cost-model.html)的前提下,欢迎各种形式的转载、翻译或商业引用。


COMMENTS

评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。

×