第4章:PyTorch DDP实战:DistributedDataParallel使用详解

分布式训练,说白了就是把一块GPU干不完的活,分给多块GPU一起干。PyTorch的DistributedDataParallel(DDP)是目前最主流的方案。我刚开始接触时也踩过不少坑,今天就把这些经验一次性倒出来。

4.1 DDP的核心原理

DDP不是简单的数据复制。它采用了一种叫「All-Reduce」的通信模式。每个GPU独立计算梯度,然后通过NCCL后端把所有梯度加起来求平均,再同步更新参数。

嗯,这里要注意:DDP在每个进程里维护一个完整的模型副本,而不是把模型切分到不同GPU上。这和模型并行有本质区别。

关键点:DDP是数据并行,不是模型并行。每个GPU处理不同的数据批次,但模型参数是完整的。

DDP 训练流程 训练数据 DataLoader DistributedSampler GPU 0 (Rank 0) GPU 1 (Rank 1) GPU 2 (Rank 2) GPU 3 (Rank 3) All-Reduce 梯度同步 (NCCL后端) 参数更新 Optimizer.step()

4.2 启动方式:torchrun vs multiprocessing

PyTorch提供了两种启动DDP的方式。我个人强烈推荐torchrun,它更现代、更稳定。

4.2.1 torchrun(推荐)

# 单机多卡
torchrun --nproc_per_node=4 train.py

# 多机多卡
torchrun --nnodes=2 --nproc_per_node=8 --rdzv_endpoint=master:1234 train.py

torchrun会自动处理环境变量设置。你只需要在代码里读取这些变量:

import torch
import torch.distributed as dist

def setup():
    dist.init_process_group("nccl")
    local_rank = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local_rank)
    return local_rank

小技巧:torchrun会自动设置RANK、LOCAL_RANK、WORLD_SIZE等环境变量。不要手动设置,否则会冲突。

4.2.2 multiprocessing(不推荐)

这是老方法,用torch.multiprocessing.spawn启动。我在项目中遇到过一个问题:子进程的异常很难捕获,调试起来特别痛苦。

# 不推荐,除非你有特殊需求
import torch.multiprocessing as mp

def train(rank, world_size):
    dist.init_process_group("nccl", rank=rank, world_size=world_size)
    # ... 训练逻辑

if __name__ == "__main__":
    mp.spawn(train, args=(4,), nprocs=4)

警告:multiprocessing方式在Python 3.8+上可能有兼容性问题。如果你用PyTorch 1.9以上版本,请直接用torchrun。

4.3 核心代码模板

这是我多年总结出来的DDP训练模板,直接拿去用:

import os
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler

def train():
    # 1. 初始化进程组
    dist.init_process_group("nccl")
    local_rank = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local_rank)
    
    # 2. 创建模型并包装
    model = MyModel().to(local_rank)
    ddp_model = DDP(model, device_ids=[local_rank])
    
    # 3. 数据加载(关键!)
    dataset = MyDataset()
    sampler = DistributedSampler(dataset)
    dataloader = DataLoader(dataset, sampler=sampler, batch_size=32)
    
    # 4. 优化器
    optimizer = torch.optim.Adam(ddp_model.parameters())
    
    # 5. 训练循环
    for epoch in range(10):
        sampler.set_epoch(epoch)  # 重要!保证每个epoch数据打乱
        for batch in dataloader:
            optimizer.zero_grad()
            loss = ddp_model(batch)
            loss.backward()
            optimizer.step()
    
    # 6. 清理
    dist.destroy_process_group()

if __name__ == "__main__":
    train()

4.4 性能调优

DDP的性能瓶颈通常不在计算,而在通信。我调优时主要关注这几个方面:

参数 推荐值 说明
batch_size 每个GPU 32-128 太小会导致通信开销占比过高
gradient_accumulation 2-8步 模拟更大batch,减少通信频率
find_unused_parameters False 设为True会降低性能
bucket_cap_mb 25(默认) 梯度桶大小,影响通信粒度

调优口诀:计算密集时用大batch,通信密集时用小batch。如果GPU利用率低于80%,先检查数据加载是不是瓶颈。

4.5 常见坑与避坑指南

我曾经在项目里被这些坑折磨过,今天全部分享出来:

  1. 数据加载不一致:每个进程必须加载相同的数据集,但用不同的采样器。不要手动切分数据!
  2. 模型保存与加载:只保存rank 0的模型。保存时用model.module.state_dict()而不是model.state_dict()。
  3. BN层问题:DDP默认同步BN,但小batch时效果反而差。可以用SyncBatchNorm.convert_sync_batchnorm()手动控制。
  4. 日志打印:所有进程都会打印日志。我习惯用if rank == 0: print(...)来避免刷屏。
  5. 梯度累积:如果用了梯度累积,记得在optimizer.step()前调用model.require_backward_grad_sync = False来跳过不必要的同步。

曾经踩过的坑:有一次我忘了设置sampler.set_epoch(epoch),结果每个epoch的数据顺序都一样,模型过拟合得一塌糊涂。排查了整整两天才找到原因。

4.6 性能对比

我用ResNet-50在4卡V100上做过测试,结果如下:

配置 吞吐量(images/sec) 加速比
单卡 280 1x
4卡DDP(默认) 980 3.5x
4卡DDP(调优后) 1080 3.86x

你看,调优后接近线性加速。说白了,DDP的通信开销是固定的,只要计算量够大,加速比就接近理想值。

我的建议:刚开始用DDP时,先跑一个小模型验证正确性。确认loss曲线和单卡一致后,再上大模型。否则出了问题,你根本分不清是代码bug还是分布式的问题。

可可精品资料库