数据并行每步都要把 N 份梯度变成同一份。Ring all-reduce 用 reduce-scatter + all-gather 把通信摊到每张卡,每卡只搬 2(N−1)/N × S,代价是 2(N−1) 步。
DDP 再把梯度切成默认 25 MiB 的 bucket,让 AllReduce 在反向还没结束时就开始。
论文在 32 卡上测到 overlap 的 38.0%(ResNet50/NCCL)与 35.2%(BERT/NCCL)收益;按 Figure 6,这是每轮延迟相对下降(论文称 speedup),38.0% 约合 1.61× 吞吐。但 256 卡(ResNet50/NCCL,共享资源池)时每轮仍比单卡慢一倍。NCCL 官方文档说明它会按拓扑自动选算法,不保证走 ring。
数据并行不是把模型切开,而是把 batch 切开。每张卡保留完整副本,反向后用 AllReduce 把 N 份梯度求平均;DDP 的注释明确说训练循环中参数不在进程间广播,广播的是 buffer,梯度才是每步同步的对象(torch/nn/parallel/distributed.py:556-561);构造时 init_sync=True 会同步一次参数/buffer,见第 05 节。
Ring 把一次全量 AllReduce 拆成两个阶段。reduce-scatter 与 all-gather 各 N−1 步;每卡发送量(接收量相同)2(N−1)/N × S。N=8 时是 1.75S,N=64 时是 1.96875S,通信量几乎不随 N 增长,但步数从 14 涨到 126。
DDP 的工程重点是 bucket + overlap。默认常规 bucket 上限 25 MiB(首个 bucket 默认 1 MiB),按参数逆序装桶,桶内梯度到齐就异步 AllReduce,和更早层的反向重叠。25 MiB 是默认值,不是最优值;论文 §5.2 明确说没有单一 bucket 大小适合所有模型。
边界同样重要。小梯度时 2(N−1) 步的固定延迟占主导;NCCL 官方文档说明它会按拓扑自动选算法,多机下不保证走 ring;模型单卡放不下时 DDP 的完整副本不省显存,要换 FSDP/ZeRO 的分片。
读完这节你应该知道:DDP 每步真正同步的是什么,为什么这个同步点难以藏起来。
数据并行(data parallel)把全局 batch 切成 N 份,每张卡(rank)保留一份完整的模型副本,吃其中一份数据。设第 i 张卡上的局部梯度为 gi,要让所有副本在每一步之后仍然完全相同,优化器就必须看到同一份全局梯度:
torch/nn/parallel/distributed.py:545-554)没有这一步,各卡从第一次更新开始就会分叉:每张卡只见过自己那份数据,梯度不同,参数更新也不同。DDP 的选择是训练循环中只同步梯度,不同步参数。源码注释写得很直白:参数不在进程间广播;每个 iteration 广播的是 buffer(例如 BatchNorm 统计量),由 rank 0 发给其他副本(torch/nn/parallel/distributed.py:556-561)。初始化时 init_sync=True 仍会同步一次参数/buffer,见第 05 节。
这个同步是一个集合通信。论文 §2.3 把 AllReduce 定义为:每个参与的进程提供等长 tensor,集合应用 sum 等操作,然后把相同结果返回给每个参与者;在所有进程到齐之前,AllReduce 不能开始。这跟参数服务器的 P2P 通信是两种模型。也正因为要等所有 rank,AllReduce 天然是一个同步点。
同步点之所以难藏,是因为它落在反向传播的末端。论文 §5.1 的测量显示:反向传播本来就是一轮里最耗时的阶段,而其中的通信步骤占总延迟一半以上;模型越大越明显。更麻烦的是梯度不是一个大 tensor,而是许多层的小 tensor——论文 §3.2.2 指出,集合通信在大 tensor 上效率更高,逐个梯度立刻发 AllReduce 会拉高总通信时间。
于是问题拆成两半:拓扑上,怎样避免所有流量挤到一个根节点;时间上,怎样让通信在反向还没结束时就开工。前者是 ring all-reduce,后者是 DDP 的 bucket 与 overlap。
读完这节你应该知道:两种同步拓扑的成本差在哪,为什么根节点会先成为瓶颈。
最直觉的同步方式是参数服务器(parameter server):每个 worker 把完整梯度 S 发给一个中心节点,中心节点求和(或平均),再把结果 S 发回每个 worker。每个 worker 的收发量是 2S,看起来不大;但中心节点的入口要收 N × S、出口要发 N × S。中心节点的网卡带宽是固定的,加 worker 只会让排队更长,不会让根节点的带宽变多。Baidu 的 ring all-reduce 文章在 §The Communication Problem 里正是用这个瓶颈来引出 ring。
每 worker 收发 2S;根节点单向 N × S。N 越大,根节点的入口/出口越先饱和。
卡在哪:同步 PS 的聚合时间随 N 增长;异步 PS 可以绕开等待,但会改变梯度语义,本页只比较同步聚合与同步 AllReduce。
每张卡只和左右邻居说话;两个阶段各 N−1 步,每卡发送量(接收量相同)2(N−1)/N × S。
代价:步数随 N 线性增长;所有 rank 必须同步进入集合通信,最慢的链路决定每一步的时间。
把两种拓扑的账并列写出来:
论文 §2.3 还提到另一类集合算法:tree-based AllReduce。它和 ring 是两种不同的集合算法,具体选谁取决于实现与拓扑。换句话说,ring 不是 AllReduce 的唯一实现,甚至不是 NCCL 的唯一选择——这一点在第 07 节会展开。
Baidu 博客的推导有一个明确前提:ring 的带宽最优性是假设每步延迟相对带宽可忽略。这个前提在梯度很大、N 不太大时成立;小梯度或超大 N 时,2(N−1) 步的固定延迟会把账翻过来。这也是为什么 DDP 不满足于「一次 AllReduce」,而要做 bucket 和 overlap。
读完这节你应该知道:reduce-scatter 与 all-gather 各做什么,bucket 和 overlap 如何把通信藏进计算。
把要同步的梯度看成一块大小 S 的连续数据,切成 N 个 chunk,每块 S/N。所有 rank 在逻辑上排成一个环,每张卡只向右邻发送、从左邻接收。
N−1 步。每步每张卡把一个 chunk 发给右邻,同时从左邻收到一个 chunk,并把收到的值累加到本地对应位置。走完 N−1 步后,每张卡持有一块已经被所有 rank 规约过的 chunk。N−1 步。每步把手上已有的、已规约的 chunk 发给右邻,同时从左邻收到另一个已规约 chunk;这次不再相加,只覆盖。走完 N−1 步后,每张卡都拿到全部 N 块规约结果。具体哪一块最终落在哪张卡,取决于环的起点和 chunk 编号约定;所有约定只差一个整体平移,通信量不变。下面按「每步发一块、收一块」画出示意。
DDP 不会对每个梯度 tensor 立刻发一次 AllReduce。论文 §3.2.2 的测量说明,集合通信在大 tensor 上效率更高:Gloo 在约 500K 参数/次时达到平台,NCCL 在 NVLink 上到 20M 参数/次都没有明显饱和。所以 DDP 把多个梯度拼进一个 bucket,等桶里的梯度都到齐,再对整个桶做一次 AllReduce;但也不能把所有梯度塞进一个桶,否则反向结束前通信没法开始。
源码里的默认值很明确:_DEFAULT_BUCKET_CAP_MB = 25(torch/nn/parallel/distributed.py:31),单位换算 _MB_TO_BYTES = 1024 * 1024(同文件第 33 行)。构造参数 bucket_cap_mb=None(第 832 行)时,_BucketCapacityConfig.create 令常规桶取这个 25 MiB 默认值(第 86-89 行),再乘 1024×1024 得到字节数(第 104 行);但首个 bucket 走 dist._DEFAULT_FIRST_BUCKET_BYTES(第 106-110 行),即 kDefaultFirstBucketBytes = 1024 * 1024(torch/csrc/distributed/c10d/reducer.hpp:29),默认只有 1 MiB。论文 §4.2 与 §5.2 把它写作 25MB;源码按 MiB 计算,本页以源码的 25 MiB 为准。
桶的大小是纯粹的取舍:桶越大,单次集合通信越大、系统开销越省,但第一个桶要等更久才能发;桶越小,通信能更早开始,但 AllReduce 次数变多。论文 §5.2 的结论是没有单一最优值:16 卡两机上,ResNet50 在 NCCL 下 10–25MB 最快,BERT 在 NCCL 下 50MB 最好,而 Gloo 后端 5MB 反而最好。25MB 是论文作者按经验给的默认值,不是普适最优。
首个 bucket 默认只有 1 MiB:它装的是反向最后才就绪的那批参数,小桶可以避免它们被并进大桶后拖到最后,让 AllReduce 更早开始(torch/nn/parallel/distributed.py:1456-1460)。
DDP 为每个梯度累加器注册一个 autograd hook;hook 触发时检查所属 bucket,如果桶内所有梯度都齐了,最后一个 hook 就发起该桶的异步 AllReduce。这样通信可以和更早层的反向计算重叠。论文 §3.2.3 还提醒了一个约束:所有进程必须用同样的装桶顺序,且不能先发 bucket i+1 再发 bucket i;如果 bucket 0 恰好最后才就绪,那就没有任何重叠空间。
PyTorch v1.5 的做法是按 model.parameters() 的逆序装桶,假设参数注册顺序大致等于前向调用顺序,逆序就近似反向计算顺序。源码注释也写明 AllReduce 按注册参数的逆序进行(torch/nn/parallel/distributed.py:634)。这是一个工程近似,不是严格保证;论文自己也说它「not a perfect solution」。
另一个默认值容易忽略:gradient_as_bucket_view=False(torch/nn/parallel/distributed.py:835)。默认情况下,梯度要先复制进 bucket,规约结果再复制回各参数的 .grad;把它设为 True 后,.grad 直接是 bucket 的视图,文档说可省下与梯度总量相当的峰值显存,并避免梯度与 bucket 之间的复制,但代价是不能对梯度调用 detach_()(同文件第 730-741 行)。
做梯度累积时,用 ddp.no_sync() 包住前几个 micro-batch,让这些步只累加、不同步;退出上下文后的第一次 forward-backward 再统一同步(torch/nn/parallel/distributed.py:1696-1722)。这不是绕过同步,而是把多个 micro-batch 的同步合并成一次(每个 bucket 仍各做一次 AllReduce)。
读完这节你应该知道:ring 的带宽账怎么算,为什么 N 从 8 到 64 通信量几乎不变,而延迟不是。
推导只需要数每一步搬了多少。梯度总量为 S,切成 N 块,每块 S/N。一个阶段有 N−1 步,每步每卡发送一块、接收一块,所以一个阶段的发送量是 (N−1) × S/N。两个阶段(reduce-scatter + all-gather)加起来:
这个式子有两个关键性质。第一,N 越大,(N−1)/N 越接近 1,每卡通信量趋近 2S,而不是随 N 爆炸;这正是 ring 相对参数服务器的优势。第二,步数是 2(N−1),随 N 线性增长;每步都有固定的启动/同步延迟,所以「带宽最优」不等于「小消息最快」。
Baidu 博客给了一个可对账的算例:300M 参数的模型,梯度约 1.2 GB;博客给出每卡约 2.4 GB(单向发送量;接收量与之相同)、6 GB/s 下约 400 ms(§Applying the Allreduce to Deep Learning)。2.4 GB 等于 2 × 1.2 GB;严格按 2(N−1)/N × S,有限 N 的每卡量会小于 2S,博客该处没有重述 N。
拖动世界规模 N、梯度大小 S、每卡带宽 B,下面的读数按同一组公式实时重算。默认 N=8、S=1 GiB、B=25 GiB/s,对应手算组 A。
口径:V = 2 × (N − 1) / N × S;steps = 2 × (N − 1);T = V / B。
假设每个 bucket 是一个连续张量、所有 rank 同步进入集合通信、每步只传 S/N、收发全双工且各方向带宽为 B。
忽略每步固定延迟、拓扑差异、拥塞、协议头与 straggler;V 只计单向发送量(接收量与之相同);T = V/B 假设收发全双工重叠;若收发共享同一份带宽,时间应按 2V/B 计,不代表端到端时间。
本交互只描述 ring 这一支,不模拟 NCCL 的 tree/collnet/NVLS 路径。
组 A:N=8,S=1.00 GiB,B=25 GiB/s。
组 B:N=64,S=1.00 GiB,B=25 GiB/s。
JS 用 toFixed(5) 显示同样的 5 位小数:组 A 是 1.75000 GiB / 14 / 70.00000 ms,组 B 是 1.96875 GiB / 126 / 78.75000 ms;把滑块拉到对应参数应与上面逐位一致。
这组数说明什么:N 从 8 到 64,每卡通信量只增加 12.5%(相对 N=8),步数却从 14 增到 126(9 倍)。带宽账几乎不随 N 变,延迟账随 N 线性变——这就是 ring 在大消息上强、在小消息/大世界规模上需要别的算法或更大 bucket 的原因。
注意 12.5% 是相对 N=8 的通信量增幅,不是时间增幅;9 倍是步数比,不是端到端延迟比。时间还取决于每步延迟、链路是否全双工、是否拥塞,以及反向计算能不能真的和通信重叠。
读完这节你应该知道:DDP 在什么时候同步、什么时候不同步,以及 no_sync 改变了什么。
init_sync=True,第 830 行;关掉则要自己保证);如果启用了 buffer 同步,rank 0 会在 forward 前把 buffer(如 BatchNorm 统计量)广播给其他副本(torch/nn/parallel/distributed.py:556-561)。训练循环中 DDP 不广播参数。model.parameters() 的逆序排列(torch/nn/parallel/distributed.py:634),目的是让反向早期就绪的层先发。divide_by_initial_world_size 与 joined 进程数)。no_sync() 里的 micro-batch 只累加不同步,退出后的第一次 forward-backward 再统一同步(第 1696-1722 行)。默认的 gradient_as_bucket_view=False(第 835 行)决定了第 2 步里有一次「梯度 → bucket」复制,以及第 3 步后有一次「bucket → .grad」复制。把开关设为 True 可以让 .grad 直接指向 bucket 的偏移,省掉梯度与 bucket 之间的复制和一份梯度大小的峰值显存;代价是不能再对梯度调用 detach_()(第 730-741 行)。
还有一个会影响流程的开关:find_unused_parameters=False 是默认值(第 833 行)。设为 True 后,DDP 会在 forward 后遍历 autograd graph,把没有参与 loss 的参数提前标记为 ready,避免它们所在的 bucket 永远等不到最后一个 hook(第 718-728 行)。这会改变哪些梯度被规约;不需要时不要打开。
读完这节你应该知道:这些数字是在什么集群、什么模型、什么后端上测的,不能直接搬到别的机器。
论文的独占集群是 4 台服务器、每台 8 张 NVIDIA Tesla V100,服务器之间用 Mellanox MT27700 ConnectX-4 100GB/s NIC,4 台机器在同一个机架内(§5)。32 卡实验全部在这个集群上;256 卡实验跑在共享资源池里,任务分到的硬件并不相同(§5.3)。模型是 ResNet50 与 BERT,输入和标签多为随机生成,目的是比较每轮延迟而不是精度(§5)。
先看 overlap 的收益。论文把不开 overlap 的总延迟归一化为 1,再比较开 overlap 后的每轮延迟;Figure 6 的读数是:
| 模型 | 后端 | overlap 延迟降幅 |
|---|---|---|
| ResNet50 | NCCL | 38.0% |
| BERT | NCCL | 35.2% |
| ResNet50 | Gloo | 26.8% |
| BERT | Gloo | 21.5% |
NCCL 的加速比 Gloo 高,论文的解释是 Gloo 的通信本身更慢,反向传播里通信占比更大,能重叠的空间反而被通信主导。论文还指出,在反向传播内部,通信步骤占总延迟一半以上,模型越大越明显(§5.1)。这解释了为什么 38.0% / 35.2% 的延迟降幅不是「AllReduce 免费」,而是「把原本暴露的通信藏进计算」。
Bucket 大小没有单一最优。论文 §5.2 在 16 卡两机上扫了 0MB 到更大桶:ResNet50 在 NCCL 下 10–25MB 最快,BERT 在 NCCL 下 50MB 最好(BERT 的参数量约为 ResNet50 的 15 倍);Gloo 后端则是 5MB 最好。到 32 卡时,只要桶大于 5MB,从 16 卡扩到 32 卡就没有明显回退;而 0MB(每个梯度单独发)在 32 卡上明显更慢。默认 25MB 是作者按经验给的起点,不是所有模型的终点。
再看规模。Figure 9 的 ResNet50/NCCL 在 256 卡上每轮比单卡慢 100%,论文据此换算「有效扩展因子 256 × 50% = 128」;Gloo 后端 256 卡时 ResNet 慢约 3 倍、BERT 慢约 6 倍,论文把原因归到网络瓶颈(§5.3)。同一篇论文的摘要写「attains near-linear scalability using 256 GPUs」。两处口径并列:摘要写 near-linear,§5.3 的 Figure 9 写 100% slowdown 与 128 卡有效扩展;页面不替原文统一,也不把 128 卡的有效扩展称为 near-linear。
这些是 PyTorch Distributed 论文在 2020 年、特定 V100 集群上的实测,不是 NCCL 或 DDP 的通用性能承诺。源码核对的是 commit 2ee7a63 的实现;论文写 25MB,源码按 25 MiB 计算,单位换算见第 03 节。
读完这节你应该知道:哪些条件下本页的公式会高估或低估,以及 DDP 和 FSDP/ZeRO 的分界线。
Ring 的带宽最优性有一个明确前提:每步延迟相对带宽可忽略(Baidu 博客 §Allreduce Communication Cost)。当梯度很小、或者 bucket 很小时,每步搬的数据少,固定延迟占比上升,2(N−1) 步的延迟会盖过带宽收益。论文 §3.2.2 的测量也说明小 tensor 的集合通信效率更低:Gloo 在约 500K 参数/次时饱和,NCCL 在 NVLink 上到 20M 参数/次都没有明显饱和。DDP 的对策是 bucket,但桶太大又会让第一个桶等太久;§5.2 因此说 bucket size 必须按模型和机器实测。
如果模型里有很多不参与 loss 的参数,find_unused_parameters=True 会遍历 autograd graph 来提前标记它们(第 718-728 行)。这是处理 unused 参数所需的额外工作,不是免费的;默认 False(第 833 行),只有在确实有 unused 参数时才打开。
本页的 2(N−1)/N × S 假设每张卡到邻居的带宽相同。单机内可能是 NVLink/NVSwitch,跨机是 InfiniBand 或 TCP/IP,带宽和延迟都不同;NCCL 支持单机或多机、PCIe/NVLink/NVSwitch、InfiniBand Verbs 或 TCP/IP(NCCL README)。一旦跨机,环里最慢的那条链路决定每一步,不能把单机 ring 的均匀链路假设直接外推。
论文的多机数据也说明这一点:32 卡独占集群是 4 台机器、100GB/s NIC;256 卡共享资源池里,NCCL 的 ResNet50 每轮慢 100%,Gloo 的 ResNet/BERT 慢约 3 倍/6 倍,论文把 Gloo 的退化归因于网络瓶颈(§5.3)。这些都是 DDP 论文的实测,不是 NCCL 算法本身的结论。
NCCL 官方文档的 NCCL_ALGO 一节写得很清楚:默认未设置时,NCCL 会根据节点拓扑与架构自动选择可用算法;可选值包括 Ring、Tree、Collnet、CollnetChain/CollnetDirect、NVLS、NVLSTree、PAT(版本不同,支持项不同)。所以「DDP 用 ring all-reduce」是一个常见但过窄的说法:DDP 调用的是 AllReduce,具体走 ring 还是 tree/collnet/NVLS 由 NCCL 和拓扑决定。
NCCL 还曾提供 NCCL_TREE_THRESHOLD(2.4 引入、2.5 移除):它设置一个字节阈值,小于该阈值的消息用 tree 而不是 ring。这个历史细节说明算法选择是版本相关的;本页的 2(N−1)/N × S 只描述 ring 这一支,不能当成 NCCL 的通用成本模型。需要强制或排除算法时,用 NCCL_ALGO;不要把它和 DDP 的 bucket 参数混为一谈。
每卡保留完整参数、梯度、优化器状态;每步通信量约为 2S(ring 下是 2(N−1)/N × S)。模型能放进单卡时,在数据并行方案里这通常是最简单、通信最少的方案。
它也可以和 ZeroRedundancyOptimizer 配合,只把优化器状态分片,降低每卡优化器显存(torch/nn/parallel/distributed.py:524-528);这只切优化器状态,不切参数和梯度。
FSDP 的 FULL_SHARD 把参数、梯度、优化器状态都分片:forward 前 all-gather 参数,forward 后 reshard;backward 前再次 all-gather,backward 后 reduce-scatter 梯度(torch/distributed/fsdp/api.py:37-42)。
FSDP 文档说它受 ZeRO Stage 3 启发(torch/distributed/fsdp/fully_sharded_data_parallel.py:122)。代价是每步通信操作更多、实现更复杂;是否更快取决于模型、网络和 overlap,页面不给统一倍数。
分界线可以写得很实际:模型 + 梯度 + 优化器状态能放进单卡,优先 DDP;放不下才考虑 FSDP/ZeRO 的分片。FSDP 的 NO_SHARD 是 DDP 式的完全复制,已被标记 deprecated,官方建议直接用 DDP(torch/distributed/fsdp/api.py:50-55)。另外,DDP 不支持用 torch.autograd.grad 算梯度,梯度必须累加到参数的 .grad(torch/nn/parallel/distributed.py:647-650);包装后不能再替换模型参数(同文件第 660-668 行),也不应在之后增删参数(第 627-629 行)。
本页交互的覆盖范围:单桶、连续张量、同构链路、所有 rank 同步进入集合通信、只计算 ring 的单向发送量(接收量相同)。未覆盖:NCCL 的 tree/collnet/NVLS 分支;多机跨 IB 的带宽差异;每步固定延迟、协议头、拥塞与 straggler;混合精度与梯度压缩 hook;多个 bucket 与反向计算的实际重叠率;FSDP/ZeRO 的分片通信。它是一张手算账,不是 NCCL 模拟器。