10.2 数据并行
数据并行(Data Parallelism)是分布式训练中最基础、最广泛使用的并行策略。它的核心思想可以用六个字概括:模型复制,数据分片——在每个 GPU 上维护一份完整的模型副本,将全局训练数据切分成若干互不重叠的子集分配给各设备,各设备独立完成前向与反向传播后同步梯度,最终以统一的梯度更新各自的模型副本。
这一策略之所以成为"默认起点",是因为它与模型架构完全解耦:无需修改模型结构,只需一个简单的包装器即可将任意单卡训练脚本扩展到多卡。然而,朴素实现中"先算完所有梯度、再统一通信"的串行模式会严重限制扩展效率。本节将从 DDP(Distributed Data Parallel)的原理与实现出发,逐步引入梯度累积、桶式梯度聚合与通信-计算重叠等关键优化技术。
10.2.1 DDP 原理与从零实现
核心工作流程
假设集群中有
- 数据分发:全局批次被均匀切分为
个微批次(micro-batch),每个 GPU 获得 个样本。实践中,通过 DistributedSampler保证各进程获取的数据互不重叠。 - 前向传播:每个 GPU 使用本地模型副本对自己的微批次进行前向计算,得到本地损失
。 - 反向传播:每个 GPU 对本地损失执行反向传播,计算出本地梯度
。此时各 GPU 的梯度不同,因为处理的数据不同。 - 梯度同步(All-Reduce):所有 GPU 执行一次 All-Reduce 操作,计算梯度的均值
。操作完成后,每个 GPU 都持有相同的平均梯度。 - 参数更新:每个 GPU 使用同步后的梯度
独立调用优化器更新参数。由于起点一致、梯度一致,更新后的模型参数仍然严格一致。
这一流程的数学等价性很清晰:对全局批次
与 DP 的关键区别
PyTorch 中的 DataParallel(DP)基于单进程多线程实现,存在两个致命缺陷:一是受限于 Python 全局解释器锁(GIL),多线程无法真正并行执行 Python 代码;二是每次前向传播都需要主 GPU 广播模型并收集输出,导致主 GPU 成为通信和内存瓶颈。DDP 采用多进程架构(每张 GPU 绑定一个独立进程),各进程维护独立的模型和优化器,仅在梯度同步时通过高效的 All-Reduce 进行通信,彻底消除了上述瓶颈。现代大模型训练一律使用 DDP 而非 DP。
PyTorch 从零实现
下面给出一个完整的 DDP 训练脚本骨架,展示从进程初始化到梯度同步的全流程:
import os
import torch
import torch.nn as nn
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler
def setup(rank, world_size):
"""初始化分布式进程组"""
os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "12355"
dist.init_process_group("nccl", rank=rank, world_size=world_size)
torch.cuda.set_device(rank)
def cleanup():
dist.destroy_process_group()
def train(rank, world_size, dataset, epochs=10):
setup(rank, world_size)
# 1. 模型创建并包装为 DDP
model = nn.Linear(1024, 512).cuda(rank)
model = DDP(model, device_ids=[rank])
# 2. 分布式采样器确保数据不重叠
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.MSELoss()
for epoch in range(epochs):
sampler.set_epoch(epoch) # 每轮 shuffle 保证随机性
for x, y in dataloader:
x, y = x.cuda(rank), y.cuda(rank)
# 3. 前向传播
pred = model(x)
loss = loss_fn(pred, y)
# 4. 反向传播——DDP 自动在此过程中插入 All-Reduce
optimizer.zero_grad()
loss.backward()
# 5. 参数更新
optimizer.step()
cleanup()
# 启动方式:torchrun --nproc_per_node=4 script.py几个实现细节值得注意:
sampler.set_epoch(epoch):DistributedSampler内部使用 epoch 作为随机种子的一部分。如果不调用此方法,每轮的数据分配顺序固定不变,会损害训练效果。DDP包装器:在loss.backward()执行过程中,DDP 通过注册在 Autograd 引擎上的钩子(hook)自动触发梯度的 All-Reduce。用户代码不需要显式调用任何通信 API。- 启动方式:推荐使用
torchrun(或torch.distributed.launch)来启动多进程,它会自动设置RANK、WORLD_SIZE等环境变量。
10.2.2 梯度累积与有效批量大小
为什么需要梯度累积
在大模型训练中,全局批次大小(Global Batch Size)是影响收敛速度和最终性能的关键超参数。研究表明,对于给定的模型和数据集,存在一个临界批次大小(critical batch size):在该值以下,增大批次可以近似线性地减少所需训练步数;超过该值后收益递减。因此,训练通常需要使用较大的全局批次。
然而,单张 GPU 的显存容量对每卡批次大小施加了硬约束。当模型参数、激活值和优化器状态占满显存后,每卡能容纳的样本数可能非常有限。梯度累积(Gradient Accumulation)正是为解决这一矛盾而设计的技术:将一个大的逻辑批次拆分为若干更小的微批次,依次前向-反向传播并累加梯度,最后统一执行一次参数更新。
有效批量大小公式
设数据并行度为
例如:8 张 GPU,每卡微批次大小为 4,梯度累积 8 步,则有效批量大小为
这一公式的直觉含义是:每次真正的参数更新所依据的梯度,等价于在
实现要点
梯度累积的实现需要注意两个关键问题:
1. 损失缩放:当使用 loss.mean() 作为损失时,每个微批次的梯度已经除以了微批次内的样本数。但累积
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
accumulation_steps = 8
for i, (x, y) in enumerate(dataloader):
pred = model(x)
loss = loss_fn(pred, y) / accumulation_steps # 提前缩放
loss.backward() # 梯度累加到 .grad 中
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()2. 与 DDP 的配合:在梯度累积过程中,中间步骤(非最后一步)的 All-Reduce 是浪费的——这些中间梯度还没累积完毕,同步它们没有意义。PyTorch 提供了 model.no_sync() 上下文管理器来跳过中间步骤的同步:
# 教学示例:展示核心逻辑,省略了部分 import 和辅助函数定义
for i, (x, y) in enumerate(dataloader):
# 中间步骤:跳过梯度同步
context = model.no_sync() if (i + 1) % accumulation_steps != 0 else nullcontext()
with context:
pred = model(x)
loss = loss_fn(pred, y) / accumulation_steps
loss.backward()
# 累积完毕:同步梯度并更新
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()使用 no_sync() 可以将通信次数从每个微批次一次降低到每
10.2.3 桶式梯度聚合与通信-计算重叠
朴素 DDP 的梯度同步策略是"等反向传播全部结束后,再启动一次 All-Reduce"。这意味着在整个反向传播阶段,网络带宽完全空闲;在整个 All-Reduce 阶段,GPU 计算单元完全空闲。两者串行执行,总时间为二者之和。PyTorch DDP 通过**桶式梯度聚合(Gradient Bucketing)和通信-计算重叠(Communication-Computation Overlap)**两项技术协同工作来消除这一瓶颈。
梯度分桶
一个典型的深度学习模型包含数百甚至数千个参数张量,逐个对它们发起 All-Reduce 会引入大量的通信启动开销(latency overhead)——每次通信都有固定的启动延迟,小消息的实际带宽利用率很低。DDP 的解决方案是将参数梯度打包进**桶(Bucket)**中:
- 逆序分桶:按照参数在反向传播中产生梯度的顺序(即模型层的逆序——从输出层到输入层),将梯度张量依次装入固定容量的桶中。默认桶大小为 25 MB。
- 展平与合并:同一个桶内的多个梯度张量被展平(flatten)为一段连续内存,形成一个大的一维张量。
- 统一通信:对整个桶执行一次 All-Reduce,而非对每个参数单独通信。
分桶带来了两个好处:一是将多次小消息合并为少数几次大消息,大幅提高了网络带宽利用率;二是为通信-计算重叠创造了前提条件——只有当梯度被组织成独立的桶时,才能在部分桶就绪后立即启动通信。
通信与计算重叠
现代 GPU 拥有独立的计算单元(CUDA 核心/张量核心)和数据传输单元(Copy Engine)。这意味着 GPU 可以在执行矩阵运算的同时,在后台通过 NVLink 或 InfiniBand 发送或接收数据。DDP 利用这一硬件能力实现了反向传播与梯度同步的流水线式重叠:
- 反向传播从输出层向输入层逐层进行。当某一层的梯度计算完成后,DDP 通过 Autograd 钩子检查该梯度所属的桶是否已被填满。
- 一旦某个桶内所有参数的梯度都已就绪,DDP 立即异步地对该桶发起 All-Reduce,而不等待更浅层的梯度计算完毕。
- 在 All-Reduce 在网络上传输的同时,GPU 的计算单元继续执行更浅层的反向传播。
- 理想情况下,当反向传播全部结束时,所有桶的 All-Reduce 也已完成或即将完成,通信延迟被大幅"隐藏"在计算时间之后。
用时间线来理解这一过程:
无重叠:
[========= 反向传播 =========][==== All-Reduce ====]
总时间 = T_compute + T_comm
有重叠(桶式):
[========= 反向传播 =========]
[桶3 AR][桶2 AR][桶1 AR] ← 通信与计算并行
总时间 ≈ max(T_compute, T_comm)当
桶大小的调优
桶大小是一个需要权衡的超参数:
- 桶太小:桶的数量增多,每个桶的通信启动开销占比增大,带宽利用率下降。
- 桶太大:桶需要等待更多参数的梯度就绪后才能启动通信,重叠机会减少。极端情况下,如果整个模型只有一个桶,则退化为"算完再通信"的串行模式。
PyTorch DDP 默认的 25 MB 桶大小是一个经验性的良好起点。在实际部署中,可以通过 bucket_cap_mb 参数进行调优:
model = DDP(model, device_ids=[rank], bucket_cap_mb=25)对于参数量较大的模型,适当增大桶容量有利于减少通信次数;对于参数量较小但层数很深的模型,适当减小桶容量有利于增加重叠机会。
10.2.4 DDP 的局限性
DDP 虽然简洁高效,但存在一个根本性瓶颈:内存冗余。每个 GPU 都必须存储完整的模型参数、梯度和优化器状态。以混合精度训练下的 Adam 优化器为例,对于一个参数量为
| 组件 | 精度 | 每参数字节数 |
|---|---|---|
| 模型参数(FP16) | FP16 | 2 |
| 梯度(FP16) | FP16 | 2 |
| 主权重(FP32) | FP32 | 4 |
| Adam 一阶矩(FP32) | FP32 | 4 |
| Adam 二阶矩(FP32) | FP32 | 4 |
| 合计 | 16 |
一个 7B 参数的模型,每张 GPU 需要约
要突破这一瓶颈,需要将模型状态本身也进行分片,这就是 ZeRO 和 FSDP 的核心思路——它们将在后续章节中详细讨论。
10.2.5 小结
本节系统介绍了数据并行的完整技术栈:
- DDP 原理:多进程架构下的"前向-反向-All-Reduce-更新"四步流程,数学上等价于单卡大批次训练。
- 梯度累积:通过
在显存受限时实现大有效批量,配合 no_sync()减少冗余通信。 - 桶式梯度聚合:将零散梯度打包为固定大小的桶,减少通信启动开销,提高带宽利用率。
- 通信-计算重叠:借助异步 All-Reduce 与 Autograd 钩子,将梯度同步隐藏在反向传播的计算时间内,使总时间从
降低到 。
DDP 是分布式训练的基石。理解它的工作原理和优化技术,是进一步掌握 ZeRO、FSDP 等高级显存优化策略的必要前提。