对应 Maps to:CMU 11-868 / 11-968 Large Language Model Systems(Spring 2026,Lei Li),Lecture 15 · Wed 3/11 · Distributed Model Training II(slides:llmsys-15-ddp.pdf,31 页)。 阅读材料:Li et al. (2020) PyTorch Distributed: Experiences on Accelerating Data Parallel Training(VLDB);PyTorch 文档 “DistributedDataParallel”、“DDP Communication Hooks”;原课的代码示例 llmsys_code_examples/ddp_example。 依据说明:slides 覆盖:回顾(参数服务器、NCCL、环形 all-reduce)、DDP 的含义、论文与设计目标(非侵入、可拦截)、分布式进程的设置(world size、global / local rank、launch.py 与环境变量、init_process_group)、使用示例、朴素实现与”重叠”的想法、梯度分桶(bucket_cap_mb、构造时决定映射、逆序、桶就绪即异步 all-reduce)、Reducer 的代码、可扩展性与重叠的效果。slides 里只有标题、没有展开的部分(图、实验数字),以及未用到的参数、no_sync、buffer、桶的大小实验、扩展到 256 块 GPU、跳过同步的收敛影响,取自论文 §3–§5。本机的 DDP 实验(gloo、CPU 多进程)、通信钩子记录的分桶、时间线模型与桶大小的扫描、no_sync 的正确 / 错误用法、BatchNorm buffer 的检查、扩展与跳过同步的模型、收敛实验,都是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:
code/lecture15/(5 个脚本 +ddp_sim.py;真正跑 DDP 的 3 个脚本各 8–15 秒,其余 2 秒以内) 预计阅读时间:2.5 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 写出一个 DDP 训练脚本的骨架:进程组(world size、rank、local rank、MASTER_ADDR / PORT)、DDP 包装、构造时的参数广播。
- 解释 DDP 的两个核心优化——梯度分桶和通信与反向计算重叠——各解决什么问题,以及 Reducer 怎样用 autograd 钩子实现它们。
- 分析桶的大小这个取舍:太小延迟主导,太大重叠不起来;为什么所有进程必须按相同的顺序发起桶的 all-reduce,桶的顺序为什么用参数的逆序。
- 正确使用
no_sync()做梯度累积,知道它必须包住前向;知道find_unused_parameters什么时候需要、代价是什么;知道 buffer 是怎样同步的。 - 估算 DDP 扩展到几百块卡时的效率,理解”跳过同步”的收益与对学习率的影响。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,后面再展开 Previewed only |
|---|---|
| 数据并行、环形 all-reduce、α-β 模型(第 14 讲) | 流水线并行与张量并行(第 16 讲) |
| 自动求导的计算图与反向传播(第 5 讲) | 把梯度 / 参数 / 优化器状态切开的 ZeRO、FSDP(第 18 讲) |
| CUDA 流与异步执行(第 3 讲) | 梯度压缩(通信钩子的一种用法) |
0.3 本讲地图 Map
§1 DDP 是什么:回顾数据并行;设计目标;进程的设置;最小示例;构造时的广播 ← 代码 01
§2 梯度分桶:为什么不一个梯度一次 all-reduce;桶怎样划分;Reducer 的实现 ← 代码 02
§3 重叠:时间线;桶多大最好;第一个桶;桶的顺序 ← 代码 03
§4 三个细节:no_sync 与梯度累积;没有用到的参数;buffer ← 代码 04
§5 扩展到几百块卡:每次迭代变慢、跳过同步、对收敛的影响 ← 代码 05
§6 小结 §7 常见误区 §8 练习 §9 延伸阅读 §10 下一讲预告
1. DDP 是什么 · What DDP is
1.1 回顾
slides 第 2–6 页:数据并行的四步——切数据、各自算本地梯度、all-reduce 求平均、各自更新(第 14 讲)。**DDP(DistributedDataParallel)**是同一件事的工程实现:多台机器、每台多块 GPU;每个进程有一份模型副本,各自做前向和反向,梯度在所有进程之间求平均,每个进程的优化器在本地做同样的更新。
1.2 设计目标
slides 第 8 页(论文 §3.1):
- 非侵入(non-intrusive):开发者只需对单卡的训练脚本做最小的改动。
- 可拦截(interceptive):API 要让实现能截获各种信号、及时触发合适的算法——尽量多地暴露优化的机会。
于是 DDP 的用法就是把模型包一层(slides 第 12 页):
model = MyModel().cuda(device_ids[0])
ddp_model = DDP(model, device_ids)
loss_fn(ddp_model(inputs), labels).backward() # 梯度同步藏在 backward 里
optimizer.step()
backward() 和 step() 之间没有任何显式的”同步梯度”调用——DDP 通过 autograd 钩子把通信插进了反向传播(§2.3)。
1.3 进程的设置
slides 第 9–11 页:
- world size:进程总数 W;global rank:全局编号;local rank:本机上的编号,用来选 GPU。2 台机器、每台 2 块 GPU 时,global rank 0–3,每台机器上的 local rank 是 0、1。
- 启动器(launch.py / torchrun)通过环境变量给每个进程传
MASTER_ADDR、MASTER_PORT(rank 0 所在机器的地址和端口,大家在那里”集合”)、RANK、WORLD_SIZE,local rank 作为参数或LOCAL_RANK传入。 - 每个进程运行同一份脚本:
dist.init_process_group(backend="nccl")→ 训练 →dist.destroy_process_group()。
01 号脚本在本机用 spawn 起 4 个进程、gloo 后端(没有 GPU),环境变量的设置方式和 torchrun 一样。
1.4 构造时的广播
论文算法 1 的第一步:DDP 构造时,rank 0 把模型的参数和 buffer 广播给所有进程,保证所有副本从同一个起点出发。01 号脚本第 2 部分让每个 rank 用不同的种子初始化:包成 DDP 之前各 rank 的参数最多差 0.49,之后逐位相同。4 个进程各用 1/4 的数据训练 10 步后,各 rank 的参数仍然逐位相同,与单进程整批训练差 2.8e-17(求和顺序不同造成的舍入,第 14 讲 §4.2)。
⏱ 01 号脚本第 3 部分顺带测了 CPU 上的速度:4 个进程各算 1/4 的批,本地计算并没有按 1/4 缩短(这些”设备”共用同一颗 CPU 的矩阵单元和内存带宽),gloo 的通信又多出约 10 ms,所以在这台机器上看不到加速。本讲用真实的 DDP 验证正确性和行为,用模型(§3、§5)讨论速度。
2. 梯度分桶 · Gradient bucketing
2.1 朴素做法的两个问题
slides 第 13–14 页、论文 §3.2.1:最朴素的实现是”反向全部算完、再对每个参数的梯度做一次 all-reduce”。它有两个性能问题:
- 集合通信在小张量上效率很低。论文图 2:两块 GPU 上 all-reduce 6000 万个 float32,切成很多小张量分别通信比切成少数大张量慢得多;NCCL 在每个张量 2000 万个参数时仍没饱和,gloo 在约 50 万个参数时到顶。02 号脚本第 3 部分 ⏱ 在本机复现了缩小版:4M 个 float32 切成 4096 个 1K 元素的张量要 300 多 ms,一整份不到 3 ms,差 100 多倍。
- 计算和通信之间有一道硬墙:反向算完才开始通信,两者没法重叠。
2.2 桶
slides 第 15–18 页:
- 把多个梯度装进一个桶(bucket),一个桶一次 all-reduce。桶的上限由构造参数
bucket_cap_mb控制(默认 25 MB)。 - 参数到桶的映射在构造时确定,依据是桶的上限和各参数的大小。
- 参数大致按
model.parameters()的逆序装桶:DDP 假设层是按前向调用的顺序注册的,那么反向传播产生梯度的顺序大致就是它的逆序。 - 一个桶里的梯度全部就绪时,Reducer 立刻对这个桶发起异步的 all-reduce,反向传播继续计算前面的层——通信和计算重叠。
02 号脚本第 1 部分在本机真正跑 DDP,用通信钩子(register_comm_hook,钩子拿到的 GradBucket 有编号、拼好的梯度缓冲区和参数列表)把每次 all-reduce 的桶记下来(6 层 Linear(512, 512),每层约 1.05 MB):
| bucket_cap_mb | 第 1 次迭代的桶数 | 第 3 次迭代的桶数 | 各桶 MB |
|---|---|---|---|
| 0.5 | 1 | 6 | 1.05 × 6 |
| 2 | 1 | 3 | 2.10 × 3 |
| 25 | 1 | 1 | 6.30 |
从第二次迭代起,桶的划分与”按参数的逆序依次装桶、桶的字节数一到上限就封口”的规则逐个吻合;桶 0 里最先放进去的是最后一层的偏置和权重。本机的 PyTorch 版本在第一次迭代把全部梯度放在一个桶里,之后按实际观察到的梯度就绪顺序重建桶(rebuild buckets)——比”假设逆序”更稳妥。
第 2 部分:ResNet-50 有 161 个参数张量、BERT-large 有 391 个,大半是只有几 KB 的偏置、BN / LayerNorm 参数;默认 25 MB 时分成 4 个和 48 个桶。
2.3 Reducer:用 autograd 钩子实现
slides 第 20 页(论文 §4.2):DDP 构造时,给每个参数的梯度累加器(AccumulateGrad 节点)装一个后置钩子;梯度累加进 .grad 之后钩子被调用:
void Reducer::autograd_hook(size_t index) { mark_variable_ready(index); }
void Reducer::mark_variable_ready(size_t variable_index) {
auto& bucket = buckets_[variable_locators_[variable_index].bucket_index];
if (--bucket.pending == 0) // 这个桶里最后一个梯度也就绪了
mark_bucket_ready(bucket_index);
}
void Reducer::mark_bucket_ready(size_t bucket_index) {
for (; next_bucket_ < buckets_.size() && buckets_[next_bucket_].pending == 0; next_bucket_++)
all_reduce_bucket(buckets_[next_bucket_]); // 按编号顺序依次发起
}
每个桶记着还差几个梯度(pending),每个钩子减一,减到 0 就标记桶就绪;mark_bucket_ready 里的循环保证按编号顺序发起 all-reduce——桶 i + 1 即使先凑满,也要等桶 i 发起之后才发起。all_reduce_bucket 调用通信钩子(默认就是 all-reduce 再除以 world size),返回一个 future;下一次前向开始前,各桶的 pending 计数被重置。为了加速梯度在参数和桶之间的拷贝,桶总是建在参数所在的设备上。
为什么必须按相同的顺序?(论文图 3(a))PyTorch 每次前向都现场构建计算图,不同进程上梯度就绪的顺序可能不同。如果每个进程”哪个桶先凑满就先发哪个”,进程 1 发的是桶 2、进程 2 发的是桶 3,all-reduce 就把不同的数据加在一起——结果错误或程序崩溃。
3. 重叠:桶多大最好 · Overlapping and the bucket size
3.1 时间线
03 号脚本用一个简单的模型:一条计算流按参数的逆序依次产生梯度,一条通信流按桶的编号依次做环形 all-reduce(α-β 模型),两者互不拖慢。ResNet-50(批 32)、16 个进程、快的互连(α = 10 μs、β = 10 GB/s)、25 MB 的桶:
不重叠:一步 53.3 ms
反向 |████████████████████████████████████████ |
通信 | 000000111111222222333333|
重叠(DDP):一步 37.8 ms
反向 |████████████████████████████████████████████████████████ |
通信 | 000000000111111111222222222 33333333|
重叠后快了 41%(论文图 6:ResNet-50 和 BERT 在 32 块 GPU 上用 NCCL 时重叠带来 38.0% 和 35.2% 的提速,用 gloo 时 26.8% 和 21.5%——gloo 的通信本身太慢,成了反向传播里的主导)。露在外面的总是最后一个桶:它装着最前面几层的梯度,要等反向走到最前面才算出来,无论怎样分桶都藏不住。
3.2 桶的上限
论文 §5.2:“没有一个桶大小适合所有应用,要实测”;默认 25 MB 是经验值。03 号脚本第 2 部分(每步时间,ms):
| 模型 | 互连 | 上限 0 | 1 MB | 5 MB | 25 MB | 100 MB | 一个桶 | 最佳 |
|---|---|---|---|---|---|---|---|---|
| ResNet-50 | 快 | 67.5 | 33.3 | 33.4 | 37.8 | 44.3 | 52.4 | 1 MB |
| ResNet-50 | 慢 | 1157.7 | 401.7 | 269.7 | 217.6 | 228.2 | 230.6 | 50 MB |
| BERT-large | 快 | 368.7 | 296.7 | 282.7 | 270.3 | 269.0 | 430.3 | 50 MB |
| BERT-large | 慢 | 4859.6 | 3395.6 | 3097.9 | 2806.1 | 2605.3 | 2698.2 | 100 MB |
两头都不好:上限 0(每个梯度一个桶)时每个梯度都付一次 2(K − 1)α,比不重叠还慢;只有一个桶时完全没有重叠。模型越大、互连越慢,越偏向大桶。和论文的实测对比:BERT 用 NCCL 时 50 MB 最好,对得上;ResNet-50 用 NCCL 时论文是 10–25 MB,模型给出更小的值(真实的每次通信还有内核发射、流同步等固定开销);gloo 时论文是 5 MB,模型却偏好大桶——gloo 在约 2 MB 以上带宽就饱和,更大的桶只会让第一个桶等得更久,α-β 模型描述不了这种饱和。这正说明为什么要实测。
第一个桶小一点(第 3 部分):第一个桶上限 1 MiB(PyTorch 的默认 first_bucket_bytes),第一次通信从 1.88 ms 提前到 0.02 ms,一步从 37.75 降到 35.04 ms。
桶的顺序(第 4 部分,BERT-large):如果层的注册顺序和计算顺序不一致,桶 0 可能最后才就绪,所有通信都被堵到反向之后:按正确的逆序 270.3 ms,层乱序注册 425.7 ms,完全反序 444.7 ms。PyTorch 1.5 用参数的逆序近似反向的顺序;现在的版本在第一次迭代后按实际顺序重建桶。
4. 三个细节 · No-sync, unused parameters, buffers
4.1 no_sync 与梯度累积
论文 §3.2.4:梯度累积时,前几个微批只需要在本地累加梯度,最后一个微批之后再同步。DDP 不知道你打算立刻 step() 还是继续累积,所以提供了 no_sync() 上下文:
with ddp.no_sync():
for inp, exp in zip(inputs, expected_outputs):
loss_fn(ddp(inp), exp).backward() # 不同步,只在本地累积
loss_fn(ddp(another_inp), another_exp).backward() # 这次同步累积的全部梯度
opt.step()
04 号脚本第 1 部分(2 个进程,每步 4 个微批,3 步):
| 写法 | all-reduce 次数 | 与单进程的差 |
|---|---|---|
| 每个微批都同步 | 12 | 2.8e-17 |
| 前 3 个微批:只有 backward 在 no_sync 里 | 12 | 2.8e-17 |
| 前 3 个微批:前向 + 反向都在 no_sync 里 | 3 | 2.8e-17 |
三种写法结果相同(all-reduce 是线性的),但只有第三种省掉了通信:开关是在 DDP 的 forward 里读取的——只把 backward 包进 no_sync 不起作用,这是一个很隐蔽的坑。
4.2 没有用到的参数
论文 §3.2.3:某次迭代只用到模型的一部分(条件分支、MoE 里没被选中的专家)时,没用到的参数不会产生梯度、它们的钩子不会触发,所在的桶永远凑不满,反向可能卡住(论文图 3(b))。04 号脚本第 2 部分:
- 默认(
find_unused_parameters=False):一次迭代没用到分支 b,下一次 forward 时 DDP 发现上一次的归约没有结束,报错 “Expected to have finished reduction in the prior iteration before starting a new one”。 find_unused_parameters=True:forward 结束时 DDP 从输出出发遍历 autograd 图,找出这次没参与的参数,提前标记为就绪。所有 rank 都没用到的参数,梯度保持 None(不改写——有的优化器靠”梯度缺失”决定是否更新动量)。为此 DDP 用一个位图记录本地的使用情况,再额外 all-reduce 一次得到全局情况(位图的元素类型不同,不能并进梯度的 all-reduce)。- 只有一部分 rank 用到 b 时,b 的梯度照样 all-reduce,没用到的 rank 贡献 0:结果是 (g₀ + 0)/2——脚本里 rank 0 上的值正好是它自己梯度的一半。
- 代价:每次迭代都要遍历计算图、多一次 all-reduce(⏱ 即使所有参数都用到,每次迭代也更慢)——只在确实需要时打开。
4.3 buffer
论文 §4.1:BatchNorm 的 running_mean / running_var 这类 buffer,由 rank 0 说了算——每次前向开始之前,DDP 把 rank 0 的 buffer 广播给所有进程(broadcast_buffers=True)。04 号脚本第 3 部分故意让两个 rank 的数据分布不同:训练模式的前向之后两个 rank 的 running_mean 不同(各自用本地数据更新了),下一次前向开始时 rank 1 的值被换成了 rank 0 的。所以”一致”只在每次前向的起点成立,而且用的是 rank 0 那份数据的统计量;需要全局统计量要用 SyncBatchNorm。LLM 用 LayerNorm / RMSNorm,没有这种跨样本的 buffer。
4.4 其他
- 通信钩子:
register_comm_hook可以替换”对一个桶做 all-reduce”这一步,例如先压缩成 fp16 / PowerSGD 再通信。02、04 号脚本用它来记录桶和计数。 - 轮转的进程组(论文 §5.4):建多个进程组、轮流把桶分给它们,绕开单个通信库内部的并发限制。
5. 扩展到几百块卡 · Scalability
论文 §5.3:每卡批大小不变(弱扩展),ResNet-50 用 NCCL 扩展到 256 块 GPU 时,每次迭代比单卡慢一倍;用 gloo 时 ResNet 慢 3 倍、BERT 慢 6 倍。05 号脚本第 1 部分用 §3 的模型(25 MB 的桶、重叠):256 块卡时 ResNet-50 慢 1.20 倍、BERT-large 慢 2.26 倍。环形 all-reduce 的带宽项几乎不随 K 变,但延迟项 2(K − 1)α 线性增长,每个桶都要付;真实集群里还有慢节点、网络拥塞和跨机器的低带宽,所以实测更严重。总吞吐仍随 K 增长。
跳过同步(论文图 10):每 n 次迭代才同步一次,平摊下来接近单卡的速度——256 块 GPU、每 8 次同步一次,ResNet-50 快了 38%(NCCL)/ 57%(gloo)。模型给出 ResNet-50 17%、BERT-large 95%。
对收敛的影响(论文图 11):n 个微批的梯度在本地相加后才更新一次——相当于用 n 倍的学习率走 1/n 的步数。05 号脚本第 3 部分(512 个微批、每个 32 个样本):
| 学习率 | n = 1 | n = 2 | n = 4 | n = 8 | n = 8、学习率 ÷ 8 |
|---|---|---|---|---|---|
| 0.02 | 0.2344 | 0.2344 | 0.2343 | 0.2345 | 0.6163 |
| 0.2 | 0.1412 | 0.1487 | 0.1635 | 0.3782 | 0.2281 |
学习率小的时候几乎没有影响(论文:批大小 8、学习率 0.02 时影响可以忽略);学习率大的时候,n 倍的”隐含学习率”太大了(论文:批大小 256、学习率 0.06 时 no_sync 损害了最终的损失,“隐含地要求更小的学习率”)。把学习率除以 n 就变回”大批量、少步数”的训练——跳过同步的问题,其实就是”用多大的有效批、多大的学习率”这个老问题。
6. 小结 · Summary
- DDP = 数据并行的工程实现:每个进程一份副本;构造时 rank 0 广播参数和 buffer;backward 里通过 autograd 钩子把梯度 all-reduce 成平均值;优化器在本地更新。用户代码只多一行
DDP(model)。 - 梯度分桶:集合通信在大张量上效率高,所以把梯度按参数的逆序装进约 25 MB 的桶;本机版本第一次迭代后按实际就绪顺序重建桶。
- 重叠:一个桶凑满就异步 all-reduce,和剩下的反向计算重叠;露在外面的只有最后一个桶。桶太小延迟主导、太大重叠不起来,最佳值要实测;所有进程必须按相同的顺序发起各桶的 all-reduce。
- no_sync 必须包住前向;find_unused_parameters 解决条件分支导致的”桶凑不满”,代价是每次遍历计算图和一次额外的 all-reduce;buffer 每次前向开始时被 rank 0 覆盖。
- 扩展到几百块卡时,延迟项和慢节点让每次迭代变慢;每 n 次同步一次能大幅提速,但累加的梯度隐含 n 倍的学习率。
7. 常见误区 · Common pitfalls
- “DDP 在 optimizer.step() 里同步梯度。” 同步发生在 backward() 里,step() 之前梯度已经是平均值了。
- “每个参数的梯度一算出来就单独 all-reduce 最快。” 小张量的集合通信效率很低;要分桶。
- “桶越大越好,通信次数最少。” 一个大桶要等反向全部算完,重叠就没了。
- “桶哪个先凑满就先发哪个。” 所有进程必须按相同的顺序发起,否则会把不同的数据加在一起。
- “把 backward 放进 no_sync 就不会同步。” 开关在 forward 里读取;前向也要放进去。
- “find_unused_parameters=True 没有代价,开着保险。” 每次迭代都要遍历计算图、多一次 all-reduce。
- “DDP 保证所有进程的 BatchNorm 统计量一致。” 只在每次前向开始时被 rank 0 覆盖,用的是 rank 0 的局部统计量。
- “每 8 步同步一次和每步同步、学习率不变时等价。” 累加的梯度是 8 倍大,隐含的学习率也是 8 倍。
8. 练习 · Exercises
- 3 台机器、每台 4 块 GPU,每块 GPU 一个进程。global rank 7 的进程在哪台机器上、local rank 是多少?WORLD_SIZE 是多少?
- 为什么 DDP 必须在构造时广播参数,而不能只靠”每个进程用同一个随机种子初始化”?
- 一个模型按顺序有参数 p0(10 MB)、p1(2 MB)、p2(8 MB)、p3(20 MB)、p4(4 MB)。桶上限 12 MB,按 DDP 的规则分桶。
- 第 3 题里,反向依次在 1、2、3、4、5 ms 时算出 p4、p3、p2、p1、p0 的梯度;每个桶的 all-reduce 要 0.5 ms + 0.1 ms/MB。画出时间线,一步(反向 + 通信)多长?不重叠呢?
- 用 α-β 模型说明:为什么桶的上限为 0 时可能比”不重叠、一个桶”还慢?
- 两个进程上梯度就绪的顺序不同:进程 0 先就绪桶 1、进程 1 先就绪桶 0。如果各自”先就绪先发”,会发生什么?
- 写出用 no_sync 做 4 步梯度累积的正确代码。如果把每个微批的损失除以 4,最终的梯度等于什么?
- 一个 MoE 模型每个 token 只经过 8 个专家中的 2 个。用 DDP 训练时要注意什么?
- 为什么 find_unused_parameters 的位图要单独做一次 all-reduce,而不能并进梯度的桶里?
- 每卡批 32、单卡一步 50 ms;256 块卡时一步 100 ms。总吞吐是单卡的多少倍?扩展效率多少?
- 256 块卡、每步同步时 100 ms、不同步时 50 ms,每 4 步同步一次,平摊每步多少?对应的学习率应该怎样调整才和每步同步的有效批一致?
- 用 DDP 训练带 BatchNorm 的 CNN,各卡的数据分布差别很大(例如按类别分片)。会有什么问题?怎么办?
参考答案
- 每台 4 个进程:rank 7 在第 2 台机器(编号 1,rank 4–7),local rank 3,WORLD_SIZE 12。
- 同一个种子也可能因为库版本、设备(CPU / GPU 的随机数生成器不同)、初始化代码里的分支等原因得到不同的参数;而且从检查点恢复时,只有 rank 0 需要读文件。广播一次最简单可靠。
- 逆序:p4(4)→ 4 MB;+ p3(20)→ 24 ≥ 12,桶 0 = {p4, p3};p2(8)→ 8;+ p1(2)→ 10;+ p0(10)→ 20 ≥ 12,桶 1 = {p2, p1, p0}。
- 桶 0 在 2 ms 就绪(p3 算完),通信 0.5 + 2.4 = 2.9 ms → 2–4.9 ms;桶 1 在 5 ms 就绪,通信 0.5 + 2.0 = 2.5 ms → 5–7.5 ms。一步 7.5 ms。不重叠:5 + 2.9 + 2.5 = 10.4 ms(若合成一个桶:5 + 0.5 + 4.4 = 9.9 ms)。
- 每个桶的时间 ≈ 2(K − 1)α + 2(K − 1)/K·n/β:上限为 0 时有多少个参数就付多少次 2(K − 1)α(ResNet-50 是 161 次),延迟的总和可能超过重叠省下的时间;一个桶只付一次。
- 进程 0 发起的是桶 1 的 all-reduce,进程 1 发起的是桶 0 的:集合通信按调用顺序配对,结果把进程 0 的桶 1 和进程 1 的桶 0 加在一起(大小不同时直接出错,大小相同时悄悄算错)。
-
每个微批的损失除以 4,累加的梯度就是 4 个微批合起来的平均梯度(再在进程之间平均),等于有效批为 4 × 每卡批 × 进程数的整批平均梯度。opt.zero_grad() for i, (x, y) in enumerate(micro_batches): ctx = ddp.no_sync() if i < 3 else contextlib.nullcontext() with ctx: (loss_fn(ddp(x), y) / 4).backward() opt.step() - 有的专家在某次迭代的某些 rank 上可能一个 token 也没分到,参数不产生梯度——要么打开 find_unused_parameters,要么保证每次迭代都”触碰”所有专家(例如加 0 乘的辅助项)。更常见的做法是专家并行(第 17 讲),专家不做数据并行的复制。
- 位图的元素类型(整数 / 布尔)和梯度(浮点)不同,一个桶的缓冲区只能有一种类型;而且所有参数共用一个位图,比每个桶一个更省。
- 吞吐 = 256 × 32 / 100 ms,相对单卡 32 / 50 ms 是 128 倍;扩展效率 128 / 256 = 50%。
- (3 × 50 + 100) / 4 = 62.5 ms。每 4 步同步时累加的是 4 个微批的梯度之和,若想保持每步同步时”每次更新的步长”,学习率要除以 4(此时等价于有效批变成 4 倍、更新次数变成 1/4 的大批量训练)。
- 每张卡上的 BatchNorm 用本地批的统计量做归一化,分布差别大时各卡的归一化不一致;running_mean 也只是 rank 0 的局部统计量(DDP 每次前向开始时广播 rank 0 的 buffer),推理时用它会偏。办法:用 SyncBatchNorm(跨卡计算均值和方差),或者打乱数据让每张卡的分布一致,或者换成 LayerNorm / GroupNorm 这类不依赖批统计量的归一化。
9. 延伸阅读 · Further reading
- Li et al. (2020) PyTorch Distributed: Experiences on Accelerating Data Parallel Training(VLDB):本讲的主要来源。
- PyTorch 文档:“Getting Started with Distributed Data Parallel”、“DDP Communication Hooks”、“torchrun (Elastic Launch)”。
- Sergeev & Del Balso (2018) Horovod:Tensor Fusion(和分桶同样的想法)。
- Vogels et al. (2019) PowerSGD: Practical Low-Rank Gradient Compression:可以作为 DDP 的通信钩子。
- Zhao et al. (2023) PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel:DDP 的下一步(第 18 讲)。
10. 下一讲预告 · Next lecture
DDP 让我们能用很多块卡更快地训练——前提是模型能放进一块卡。第 1 讲、第 14 讲算过,405B 的模型连参数带优化器状态要几 TB,DDP 帮不上忙。第 16 讲讲模型并行:按层把模型切成几段放在不同的卡上(流水线并行),GPipe 怎样用微批次把”气泡”从 (p − 1)/p 压到 (p − 1)/(m + p − 1),1F1B 怎样省激活显存;以及按矩阵把每一层切开(张量并行),Megatron-LM 怎样安排列切分和行切分,让一个 MLP 前向只需一次 all-reduce。我们会写出 HW5 的时钟周期调度表,模拟流水线的前向和反向,并核算张量并行的通信量。