🏠 总目录📚 本教程 数据并行
📑 本页目录(点开跳转)

10 · 数据并行与 AllReduce

26 分钟 | ⭐ 最简单也最常用的并行


🎯 一句话

每张卡放一份完整的模型,各自吃不同的数据,算完梯度后求个平均。 它是唯一一种不改变模型结构的并行方式 —— 所以永远先试它。


🔄 一、它怎么工作

数据并行(DP):每张卡一份完整模型副本 每张卡吃不同的数据分片,算出各自的梯度 数据分片0 GPU0 模型副本 梯度0 数据分片1 GPU1 模型副本 梯度1 数据分片2 GPU2 模型副本 梯度2 数据分片3 GPU3 模型副本 梯度3 AllReduce 求平均 所有卡拿到【相同的平均梯度】→ 各自 step() → 参数保持一致 ⭐
四条支路收束到 AllReduce 那一格 —— 它左边每张卡的梯度都不一样,它右边全部相同。参数之所以不会漂开,靠的就是这一个同步点。

🔑 为什么求平均而不是求和: 因为等效 batch 变成了 N 倍,要保持梯度的尺度不变。 求和的话相当于学习率被放大了 N 倍。

# PyTorch DDP:标准写法
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP

dist.init_process_group("nccl")
model = DDP(model.to(local_rank), device_ids=[local_rank])

sampler = torch.utils.data.distributed.DistributedSampler(dataset)
loader = DataLoader(dataset, sampler=sampler, batch_size=micro_bs)

for epoch in range(E):
    sampler.set_epoch(epoch)      # ⭐ 必须!否则每轮的数据划分完全相同
    for batch in loader:
        loss = model(batch)
        loss.backward()           # ⭐ AllReduce 在这里自动发生
        opt.step(); opt.zero_grad(set_to_none=True)

⚠️ sampler.set_epoch(epoch) 是最常被漏掉的一行: 不加的话每个 epoch 的数据顺序和划分完全一样,等于没有 shuffle


⚙️ 二、DDP 的两个关键优化

① 梯度桶(bucketing)+ 通信计算重叠 ⭐

   ❌ 朴素做法:等所有梯度算完,一次性 AllReduce
      → 反向传播时 GPU 在算,网络完全闲置
      → AllReduce 时网络在传,GPU 完全闲置

   ✅ DDP 的做法:
      反向传播是【从后往前】的
      → 最后一层的梯度【最先】算出来
      → 攒够一个"桶"(默认 25MB)就【立刻】开始 AllReduce
      → 通信和后面层的计算【重叠】⭐
   时间轴:
   计算  ████████████████████████
   通信      ▓▓▓▓  ▓▓▓▓  ▓▓▓▓  ▓▓▓▓     ← 藏在计算后面
             ↑ 桶1  ↑桶2  ↑桶3  ↑桶4

💡 这就是为什么 DDP 比手写 AllReduce 快很多。 桶大小可调:DDP(model, bucket_cap_mb=25) —— 太小则通信次数多、开销大;太大则重叠机会少。

② 梯度累积时要关掉同步

# ❌ 每个 micro batch 都 AllReduce → 通信量翻 N 倍
for i, batch in enumerate(loader):
    loss = model(batch) / accum
    loss.backward()

# ✅ 只在最后一步同步
for i, batch in enumerate(loader):
    is_last = (i + 1) % accum == 0
    ctx = nullcontext() if is_last else model.no_sync()    # ⭐
    with ctx:
        (model(batch) / accum).backward()
    if is_last:
        opt.step(); opt.zero_grad(set_to_none=True)

⚠️ 忘了 no_sync() 是一个隐蔽的性能杀手: 累积 8 步就意味着通信量是必要值的 8 倍,而代码看起来完全正常。


🔁 三、Ring AllReduce(第 5 章的展开)

   N 张卡排成环,梯度切成 N 份

   阶段① ReduceScatter(转 N-1 步):
     每一步,每张卡把手里的一份发给下家、从上家收一份并累加
     → 结束时,第 i 张卡持有【第 i 份的完整求和】

   阶段② AllGather(再转 N-1 步):
     把各自那份完整结果传遍全环
     → 结束时,所有卡都有完整的求和结果
   ⭐ 每张卡收发的数据量 = 2 × (N-1)/N × 数据量 ≈ 2 × 数据量
      【和卡数几乎无关】

   对比朴素的"都发给 0 号卡":0 号要收发 N-1 倍 💀
📐 为什么是 2×(N−1)/N(想看再点)

梯度总量 $D$,切成 $N$ 份,每份 $D/N$。

合计 $2(N-1)D/N \approx 2D$($N$ 大时)。

💡 关键点:随着 N 增大,每卡的通信量趋于常数 2D,不随 N 增长。步数是 $2(N-1)$,所以延迟随 N 线性增长 —— 这是 Ring 的软肋。

⭐ 分层 AllReduce:大规模的标准做法

   问题:跨机带宽只有机内 NVLink 的 1/15(第 5 章)
        纯 Ring 会让慢链路成为瓶颈

   ✅ 分层:
   ① 机内 8 卡先做一次 ReduceScatter/AllReduce(NVLink,快)
   ② 各机的代表跨机做一次 AllReduce(慢链路,但数据量只有 1/8)⭐
   ③ 机内 Broadcast/AllGather 回去

   → 跨机通信量降到 1/8

💡 NCCL 会自动根据拓扑选择算法 —— 这也是为什么 第 5 章说要先确认拓扑被正确识别(NCCL_DEBUG=INFO)。


📉 四、扩展效率:为什么卡越多效率越低

   每步时间 = 计算时间 + max(0, 通信时间 − 可重叠部分)

   ⭐ 卡数增加时:
   · 计算时间 ↓(每卡分到的数据变少)
   · 通信量   ≈ 不变(Ring 的性质)
   → 通信占比【上升】→ 效率下降
   一个具体的例子(7B 模型,BF16 梯度 14GB):

   通信量 = 2 × 14 GB = 28 GB/步

   机内 NVLink 900GB/s   → 0.03 秒
   跨机 IB 25GB/s        → 1.1 秒   ⭐ 差 35 倍

   如果单步计算是 0.5 秒:
   · 机内:通信可完全重叠 → 扩展效率 ~95%
   · 跨机:通信 1.1 秒 > 计算 0.5 秒 → 【通信成为瓶颈】💀

🔑 这就是为什么大规模训练必须做三件事: ① 梯度压缩/量化(FP16 甚至 FP8 通信) ② 分层 AllReduce换策略 —— 数据并行到了瓶颈就该上 ZeRO模型并行


⚠️ 五、五个真实的坑

症状 解法
忘了 set_epoch 每轮数据顺序完全一样 加上它 ⭐
忘了 no_sync() 梯度累积时通信量翻 N 倍 见上面代码
各卡 batch 不等长 AllReduce 挂死(有的卡先退出循环) 补齐或用 join() 上下文 ⭐
BatchNorm 各算各的 小 batch 时统计不准,精度掉 SyncBatchNorm 或用 LayerNorm
随机性不一致 各卡 dropout mask 不同导致结果不可复现 统一 seed;数据增强的种子要各卡不同

💥 "AllReduce 挂死"是最难排查的一个: 症状是训练突然卡住不动、没有报错、GPU 利用率 100%(在空转等待)。 根因常常是某张卡的数据比别人少一个 batch,提前退出了循环, 其他卡在等一个永远不会来的 AllReduce。

排查export TORCH_NCCL_BLOCKING_WAIT=1 会让它超时报错而不是永久挂起。


🔗 和站内其他章的关系

相关的地方 这里的位置
第 5 章 Ring AllReduce 本章是它的展开
第 5 章 AllReduce=RS+AG 分层和 ZeRO 都基于它
第 3 章 显存四大块 数据并行一块都没省
第 11 章 ZeRO 数据并行的省显存版
第 9 章 梯度累积 no_sync() 配合
《机器学习与深度学习基础》09 优化器与学习率 warmup 与学习率量级 数据并行把全局 batch 放大了 N 倍,学习率必须跟着改 —— 不然扩到几十卡就发散 ⭐
《Kaggle竞赛方法论》02 训练策略与正则化 梯度累加 那里它是"显存不够时模拟大 batch";在 DDP 里不配 no_sync(),每个累积步都会白同步一次 ⚠️

✅ 检查点

  1. 数据并行怎么工作?为什么梯度要求平均而不是求和?
  2. sampler.set_epoch() 不加会怎样?
  3. DDP 的梯度桶做了什么?为什么反向传播的顺序让它成为可能?
  4. 梯度累积时忘了 no_sync() 会怎样?
  5. Ring AllReduce 每卡的通信量是多少?它的软肋是什么?
  6. 分层 AllReduce 为什么能降到 1/8?
  7. 为什么卡越多扩展效率越低?
  8. 数据并行省显存吗?
  9. "训练突然卡死、无报错、GPU 100%"最可能是什么原因?
👀 答案
  1. 每张卡放完整模型副本,吃不同数据分片,算完梯度后 AllReduce 求平均,所有卡拿到相同梯度各自 step,参数保持一致。求平均是因为等效 batch 变成 N 倍,求和相当于学习率被放大 N 倍
  2. 每个 epoch 的数据顺序和划分完全一样,等于没有 shuffle
  3. 攒够一个桶(默认 25MB)就立刻开始 AllReduce,让通信和后面层的计算重叠。可能是因为反向传播是从后往前的,最后一层的梯度最先算出来,不用等全部算完。
  4. 通信量翻 N 倍(累积 8 步就是 8 倍必要通信量),而代码看起来完全正常——隐蔽的性能杀手。
  5. ≈ 2 × 数据量,和卡数几乎无关。软肋是步数为 2(N−1),延迟随卡数线性增长
  6. 先在机内用 NVLink 做一次归约,只让各机的代表跨机通信(数据量变成 1/8),最后机内广播回去。
  7. 因为卡数增加时计算时间下降(每卡数据变少)但通信量基本不变,所以通信占比上升
  8. 一块都没省——每张卡都有完整的参数、梯度、优化器状态。省显存要用 ZeRO。
  9. 某张卡的数据比别人少一个 batch 提前退出循环,其他卡在等一个永远不来的 AllReduce。排查:TORCH_NCCL_BLOCKING_WAIT=1 让它超时报错而不是永久挂起。

🛑 可以停在这里

走神救援

数据并行 = 每卡一份完整模型、吃不同数据、AllReduce 求平均求平均不求和,否则等于学习率放大 N 倍);它是唯一不改变模型结构的并行,所以永远先试它。⚠️sampler.set_epoch(epoch) 最常被漏——不加等于没 shuffleDDP 的两个关键优化:①⭐梯度桶 + 通信计算重叠反向是从后往前的,最后一层梯度最先出来,攒够 25MB 桶就立刻 AllReduce,藏在后面层的计算后面)②⚠️梯度累积时必须 model.no_sync()——忘了则通信量翻 N 倍而代码看起来完全正常。Ring AllReduce = ReduceScatter(N-1步) + AllGather(N-1步),⭐每卡通信量 ≈ 2×数据量、和卡数无关,软肋是步数 2(N−1) 导致延迟随卡数线性增长。⭐分层 AllReduce:机内 NVLink 先归约 → 只让各机代表跨机通信(数据量降到 1/8) → 机内广播。扩展效率下降的原因:卡多了计算时间下降但通信量不变 → 通信占比上升;具体数字:7B 模型通信 28GB/步,机内 NVLink 0.03 秒 vs 跨机 IB 1.1 秒(差 35 倍),计算 0.5 秒时机内可完全重叠而跨机通信成瓶颈 → 该上 ZeRO 或模型并行。⚠️五个坑:忘 set_epoch、忘 no_sync、各卡 batch 不等长导致 AllReduce 挂死、BatchNorm 各算各的、随机性不一致。💥⭐"训练突然卡死、无报错、GPU 100%"= 某卡数据少一个 batch 提前退出,其他卡在等永不到来的 AllReduceTORCH_NCCL_BLOCKING_WAIT=1 让它超时报错。⭐数据并行一块显存都没省

下一节 👉 11-ZeRO与FSDP.md ⭐⭐

打卡记录保存在你的浏览器里,首页能看到总进度