Skip to content

10.2 数据并行

数据并行(Data Parallelism)是分布式训练中最基础、最广泛使用的并行策略。它的核心思想可以用六个字概括:模型复制,数据分片——在每个 GPU 上维护一份完整的模型副本,将全局训练数据切分成若干互不重叠的子集分配给各设备,各设备独立完成前向与反向传播后同步梯度,最终以统一的梯度更新各自的模型副本。

这一策略之所以成为"默认起点",是因为它与模型架构完全解耦:无需修改模型结构,只需一个简单的包装器即可将任意单卡训练脚本扩展到多卡。然而,朴素实现中"先算完所有梯度、再统一通信"的串行模式会严重限制扩展效率。本节将从 DDP(Distributed Data Parallel)的原理与实现出发,逐步引入梯度累积、桶式梯度聚合与通信-计算重叠等关键优化技术。


10.2.1 DDP 原理与从零实现

核心工作流程

假设集群中有 N 个 GPU,全局批次大小为 B。DDP 的单次训练迭代包含以下步骤:

  1. 数据分发:全局批次被均匀切分为 N 个微批次(micro-batch),每个 GPU 获得 B/N 个样本。实践中,通过 DistributedSampler 保证各进程获取的数据互不重叠。
  2. 前向传播:每个 GPU 使用本地模型副本对自己的微批次进行前向计算,得到本地损失 i
  3. 反向传播:每个 GPU 对本地损失执行反向传播,计算出本地梯度 gi。此时各 GPU 的梯度不同,因为处理的数据不同。
  4. 梯度同步(All-Reduce):所有 GPU 执行一次 All-Reduce 操作,计算梯度的均值 g¯=1Ni=1Ngi。操作完成后,每个 GPU 都持有相同的平均梯度。
  5. 参数更新:每个 GPU 使用同步后的梯度 g¯ 独立调用优化器更新参数。由于起点一致、梯度一致,更新后的模型参数仍然严格一致。

这一流程的数学等价性很清晰:对全局批次 B 计算的梯度,等价于对 N 个微批次分别计算梯度后取均值。因此 DDP 产出的梯度与单卡在整个全局批次上计算的梯度完全一致,不存在近似误差。

与 DP 的关键区别

PyTorch 中的 DataParallel(DP)基于单进程多线程实现,存在两个致命缺陷:一是受限于 Python 全局解释器锁(GIL),多线程无法真正并行执行 Python 代码;二是每次前向传播都需要主 GPU 广播模型并收集输出,导致主 GPU 成为通信和内存瓶颈。DDP 采用多进程架构(每张 GPU 绑定一个独立进程),各进程维护独立的模型和优化器,仅在梯度同步时通过高效的 All-Reduce 进行通信,彻底消除了上述瓶颈。现代大模型训练一律使用 DDP 而非 DP。

PyTorch 从零实现

下面给出一个完整的 DDP 训练脚本骨架,展示从进程初始化到梯度同步的全流程:

python
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)来启动多进程,它会自动设置 RANKWORLD_SIZE 等环境变量。

10.2.2 梯度累积与有效批量大小

为什么需要梯度累积

在大模型训练中,全局批次大小(Global Batch Size)是影响收敛速度和最终性能的关键超参数。研究表明,对于给定的模型和数据集,存在一个临界批次大小(critical batch size):在该值以下,增大批次可以近似线性地减少所需训练步数;超过该值后收益递减。因此,训练通常需要使用较大的全局批次。

然而,单张 GPU 的显存容量对每卡批次大小施加了硬约束。当模型参数、激活值和优化器状态占满显存后,每卡能容纳的样本数可能非常有限。梯度累积(Gradient Accumulation)正是为解决这一矛盾而设计的技术:将一个大的逻辑批次拆分为若干更小的微批次,依次前向-反向传播并累加梯度,最后统一执行一次参数更新

有效批量大小公式

设数据并行度为 N(GPU 数量),每卡每次前向传播的微批次大小为 b(即单卡物理批次大小),梯度累积步数为 A,则有效批量大小(Effective Batch Size)为:

Beff=N×b×A

例如:8 张 GPU,每卡微批次大小为 4,梯度累积 8 步,则有效批量大小为 8×4×8=256

这一公式的直觉含义是:每次真正的参数更新所依据的梯度,等价于在 Beff 个样本上计算的梯度均值。梯度累积不改变数学等价性——它只是在时间维度上将一次大的前向-反向传播拆分成了多次小的前向-反向传播。

实现要点

梯度累积的实现需要注意两个关键问题:

1. 损失缩放:当使用 loss.mean() 作为损失时,每个微批次的梯度已经除以了微批次内的样本数。但累积 A 步的梯度总和还需要再除以 A,才能得到与全局批次等价的梯度。通常的做法是在计算损失时直接除以 A

python
# 教学示例:展示核心逻辑,省略了部分 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() 上下文管理器来跳过中间步骤的同步:

python
# 教学示例:展示核心逻辑,省略了部分 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() 可以将通信次数从每个微批次一次降低到每 A 个微批次一次,显著减少通信开销。


10.2.3 桶式梯度聚合与通信-计算重叠

朴素 DDP 的梯度同步策略是"等反向传播全部结束后,再启动一次 All-Reduce"。这意味着在整个反向传播阶段,网络带宽完全空闲;在整个 All-Reduce 阶段,GPU 计算单元完全空闲。两者串行执行,总时间为二者之和。PyTorch DDP 通过**桶式梯度聚合(Gradient Bucketing)通信-计算重叠(Communication-Computation Overlap)**两项技术协同工作来消除这一瓶颈。

梯度分桶

一个典型的深度学习模型包含数百甚至数千个参数张量,逐个对它们发起 All-Reduce 会引入大量的通信启动开销(latency overhead)——每次通信都有固定的启动延迟,小消息的实际带宽利用率很低。DDP 的解决方案是将参数梯度打包进**桶(Bucket)**中:

  1. 逆序分桶:按照参数在反向传播中产生梯度的顺序(即模型层的逆序——从输出层到输入层),将梯度张量依次装入固定容量的桶中。默认桶大小为 25 MB。
  2. 展平与合并:同一个桶内的多个梯度张量被展平(flatten)为一段连续内存,形成一个大的一维张量。
  3. 统一通信:对整个桶执行一次 All-Reduce,而非对每个参数单独通信。

分桶带来了两个好处:一是将多次小消息合并为少数几次大消息,大幅提高了网络带宽利用率;二是为通信-计算重叠创造了前提条件——只有当梯度被组织成独立的桶时,才能在部分桶就绪后立即启动通信。

通信与计算重叠

现代 GPU 拥有独立的计算单元(CUDA 核心/张量核心)和数据传输单元(Copy Engine)。这意味着 GPU 可以在执行矩阵运算的同时,在后台通过 NVLink 或 InfiniBand 发送或接收数据。DDP 利用这一硬件能力实现了反向传播与梯度同步的流水线式重叠

  1. 反向传播从输出层向输入层逐层进行。当某一层的梯度计算完成后,DDP 通过 Autograd 钩子检查该梯度所属的桶是否已被填满。
  2. 一旦某个桶内所有参数的梯度都已就绪,DDP 立即异步地对该桶发起 All-Reduce,而不等待更浅层的梯度计算完毕。
  3. 在 All-Reduce 在网络上传输的同时,GPU 的计算单元继续执行更浅层的反向传播。
  4. 理想情况下,当反向传播全部结束时,所有桶的 All-Reduce 也已完成或即将完成,通信延迟被大幅"隐藏"在计算时间之后。

用时间线来理解这一过程:

无重叠:
  [========= 反向传播 =========][==== All-Reduce ====]
  总时间 = T_compute + T_comm

有重叠(桶式):
  [========= 反向传播 =========]
       [桶3 AR][桶2 AR][桶1 AR]   ← 通信与计算并行
  总时间 ≈ max(T_compute, T_comm)

TcomputeTcomm 时(即计算时间大于通信时间,这在模型足够大、网络带宽足够高时成立),通信开销几乎被完全隐藏。这也是 DDP 在实际训练中能够实现接近线性扩展的关键原因。

桶大小的调优

桶大小是一个需要权衡的超参数:

  • 桶太小:桶的数量增多,每个桶的通信启动开销占比增大,带宽利用率下降。
  • 桶太大:桶需要等待更多参数的梯度就绪后才能启动通信,重叠机会减少。极端情况下,如果整个模型只有一个桶,则退化为"算完再通信"的串行模式。

PyTorch DDP 默认的 25 MB 桶大小是一个经验性的良好起点。在实际部署中,可以通过 bucket_cap_mb 参数进行调优:

python
model = DDP(model, device_ids=[rank], bucket_cap_mb=25)

对于参数量较大的模型,适当增大桶容量有利于减少通信次数;对于参数量较小但层数很深的模型,适当减小桶容量有利于增加重叠机会。


10.2.4 DDP 的局限性

DDP 虽然简洁高效,但存在一个根本性瓶颈:内存冗余。每个 GPU 都必须存储完整的模型参数、梯度和优化器状态。以混合精度训练下的 Adam 优化器为例,对于一个参数量为 Φ 的模型,每个 GPU 需要存储:

组件精度每参数字节数
模型参数(FP16)FP162
梯度(FP16)FP162
主权重(FP32)FP324
Adam 一阶矩(FP32)FP324
Adam 二阶矩(FP32)FP324
合计16

一个 7B 参数的模型,每张 GPU 需要约 7×109×16=112 GB 的显存——远超单张 A100(80 GB)的容量。更关键的是,增加 GPU 数量只能摊分数据,不能减少每张卡的模型状态占用。这就是为什么仅靠 DDP 无法训练真正的大模型。

要突破这一瓶颈,需要将模型状态本身也进行分片,这就是 ZeRO 和 FSDP 的核心思路——它们将在后续章节中详细讨论。


10.2.5 小结

本节系统介绍了数据并行的完整技术栈:

  1. DDP 原理:多进程架构下的"前向-反向-All-Reduce-更新"四步流程,数学上等价于单卡大批次训练。
  2. 梯度累积:通过 Beff=N×b×A 在显存受限时实现大有效批量,配合 no_sync() 减少冗余通信。
  3. 桶式梯度聚合:将零散梯度打包为固定大小的桶,减少通信启动开销,提高带宽利用率。
  4. 通信-计算重叠:借助异步 All-Reduce 与 Autograd 钩子,将梯度同步隐藏在反向传播的计算时间内,使总时间从 Tcompute+Tcomm 降低到 max(Tcompute,Tcomm)

DDP 是分布式训练的基石。理解它的工作原理和优化技术,是进一步掌握 ZeRO、FSDP 等高级显存优化策略的必要前提。