1. 从单卡到集群:分布式训练代码的实战演进
最近在社区里看到不少朋友在讨论如何把自己的模型训练从单张显卡扩展到多张,甚至多台机器。这确实是个挺实际的问题,尤其是当你的模型越来越大,数据越来越多,单张卡动辄训练几天甚至几周的时候,那种等待的煎熬感,相信搞过深度学习的朋友都懂。我自己也是从单卡一路踩坑过来的,从最开始用torch.cuda.empty_cache()手动清理显存,到后来折腾单机多卡,再到配置多机多卡集群,中间遇到了各种稀奇古怪的问题。今天我就结合PyTorch这个框架,把“单机单卡”、“单机多卡”、“多机多卡”这三种模式的训练代码,从最基础的写法到生产级的实践,系统地拆解一遍。无论你是刚入门想了解并行训练的基本概念,还是已经有一定基础想优化自己的训练流程,这篇文章里应该都能找到你需要的“干货”。
简单来说,这三种模式代表了计算资源利用的三个层次。单机单卡是起点,所有计算和参数更新都在一张GPU上完成,逻辑最简单,是调试和验证想法的基石。单机多卡是性价比最高的升级路径,在一台拥有多张GPU的服务器上,通过数据或模型并行来加速训练,是绝大多数实验室和中小型公司的首选。而多机多卡则是面向超大规模模型和海量数据的终极方案,它突破了单台机器的物理限制,将计算任务分布到多台机器的多个GPU上协同完成,技术复杂度最高,但也是训练百亿、千亿参数模型的必经之路。接下来,我们就从最简单的开始,一步步深入。
2. 单机单卡:一切复杂度的起点与基石
在谈论任何分布式训练之前,我们必须先把单机单卡的训练流程搞得清清楚楚、明明白白。这不仅是因为它是所有复杂训练的起点,更因为一个高效、健壮的单卡训练脚本,是后续进行多卡并行改造的最佳模板。很多在多卡环境下遇到的诡异问题,其根源往往能在单卡训练脚本中找到。
2.1 核心代码结构与最佳实践
一个标准的单卡训练循环,其骨架大致如下。我强烈建议你即使已经会了,也对照检查一下自己的代码是否包含了这些关键部分和最佳实践。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, Dataset import os # 1. 设备定义 - 这是单卡训练的“心脏” device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu') print(f'Using device: {device}') # 注意:这里明确指定了`cuda:0`。即使只有一张卡,养成显式指定的习惯, # 能避免后续扩展时因默认设备问题导致的错误。 # 2. 模型、数据、优化器定义 model = YourModel().to(device) # 模型必须移动到设备上 train_dataset = YourDataset(...) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() # 3. 训练循环核心 for epoch in range(num_epochs): model.train() running_loss = 0.0 for batch_idx, (data, target) in enumerate(train_loader): # 数据也必须移动到设备上 data, target = data.to(device, non_blocking=True), target.to(device, non_blocking=True) # 前向传播 optimizer.zero_grad() output = model(data) loss = criterion(output, target) # 反向传播与优化 loss.backward() optimizer.step() running_loss += loss.item() # 可选的:定期记录或验证 if batch_idx % 100 == 0: print(f'Epoch [{epoch}/{num_epochs}], Step [{batch_idx}/{len(train_loader)}], Loss: {loss.item():.4f}') # 这里可以添加模型验证、TensorBoard日志记录等 avg_loss = running_loss / len(train_loader) print(f'Epoch {epoch} finished. Average Loss: {avg_loss:.4f}') # 可选的:学习率调度、模型保存等 # scheduler.step() # torch.save(model.state_dict(), f'checkpoint_epoch_{epoch}.pth')这段代码看起来简单,但里面有几个至关重要的细节,直接决定了你训练的效率上限和代码的可扩展性。
关键细节1:pin_memory与non_blocking在DataLoader中设置pin_memory=True,并在.to(device)时设置non_blocking=True,这对GPU训练至关重要。CPU内存分为“可分页内存”和“锁页内存”。普通数据在“可分页内存”中,GPU通过DMA(直接内存访问)拉取数据时,需要先由操作系统确保该内存页面被锁定(即“pin”住),这个过程有开销。设置pin_memory=True后,DataLoader会预先将数据加载到“锁页内存”中,GPU可以直接访问,省去了锁页的开销。non_blocking=True则使得数据从CPU到GPU的传输变为异步操作,CPU在发起传输后就可以立刻执行后续指令,而不是干等着传输完成,从而实现了CPU和GPU工作的重叠,提升了整体吞吐量。对于小模型或简单任务,这个优化可能不明显,但对于数据加载成为瓶颈的大规模训练,这是必选项。
关键细节2:梯度管理optimizer.zero_grad()的位置很重要。必须在每次计算新梯度之前清零,否则梯度会累积。有些教程会在loss.backward()之前调用,有些会在optimizer.step()之后立即调用下一轮的清零。从逻辑上讲,两种都可以,但更常见的、不易出错的做法是在loss.backward()之前调用,这样代码意图更清晰:为本次反向传播准备一个干净的梯度缓冲区。
关键细节3:损失计算与记录loss.item()用于从单元素的张量中提取标量值用于打印或记录。直接对loss张量进行累加会导致计算图不断膨胀,最终可能耗尽内存。running_loss是一个Python浮点数,不参与计算图构建。
2.2 显存管理与性能调优实战
单卡训练最大的限制就是显存。模型参数、梯度、优化器状态、以及每一层的激活值(前向传播的中间结果,反向传播时需要)都会占用显存。一个常见的误区是只关注模型参数大小。实际上,对于使用Adam优化器的float32训练,每个参数需要存储:参数本身(4字节)、梯度(4字节)、Adam优化器的动量(momentum,4字节)和方差(variance,4字节)。这意味着每个参数需要至少16字节的显存。一个1亿参数的模型,仅优化器状态就需要约1.6GB显存,再加上激活值,显存需求很容易翻倍。
实操技巧:监控与清理养成监控显存使用情况的习惯。可以用torch.cuda.memory_allocated()和torch.cuda.memory_reserved()来查看当前已分配和预留的显存。
print(f'Allocated: {torch.cuda.memory_allocated(device)/1024**3:.2f} GB') print(f'Reserved: {torch.cuda.memory_reserved(device)/1024**3:.2f} GB')torch.cuda.empty_cache()是大家熟悉的方法,它会释放PyTorch的CUDA缓存中所有未占用的缓存内存。但要注意,它不会释放正在被张量占用的显存。它的主要作用是在你删除了一些中间张量(del some_tensor)后,或者在不同任务间切换时,主动让PyTorch将空闲缓存归还给系统。在训练循环中频繁调用它通常是不必要的,甚至可能有害,因为PyTorch的缓存机制本身就是为了加速后续的显存分配。
更有效的显存节省策略:
梯度累积(Gradient Accumulation):当你的批大小(batch size)受限于显存时,这是一个神器。其原理是,连续进行多次前向和反向传播,但不立即更新参数(
optimizer.step()),而是让梯度在.grad属性中累积。累积到一定步数后,再执行一次参数更新,并将梯度清零。这相当于用多个小批(micro-batch)模拟了一个大批(macro-batch)的效果。accumulation_steps = 4 # 累积4步 for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) output = model(data) loss = criterion(output, target) loss = loss / accumulation_steps # 损失按累积步数缩放,保证梯度大小一致 loss.backward() # 梯度累积到 .grad 中 if (batch_idx + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这样做,等效批大小变成了
batch_size * accumulation_steps,但显存占用仅与单步的batch_size相关。混合精度训练(AMP, Automatic Mixed Precision):这是现代GPU训练(尤其是NVIDIA Volta架构及之后的GPU)的标配。其核心思想是,在保证训练精度基本不变的前提下,让模型的部分计算(如前向传播和梯度计算)使用
float16(半精度),从而显著减少显存占用(约一半)并利用Tensor Core加速计算。而模型权重、优化器状态等仍以float32(单精度)维护,以保证数值稳定性。from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() # 梯度缩放器,防止float16下梯度下溢 for data, target in train_loader: data, target = data.to(device), target.to(device) optimizer.zero_grad() with autocast(): # 在这个上下文管理器内,PyTorch会自动选择float16或float32进行计算 output = model(data) loss = criterion(output, target) scaler.scale(loss).backward() # 缩放损失,再反向传播 scaler.step(optimizer) # 缩放器先反缩放梯度,再执行优化器step scaler.update() # 根据梯度情况动态调整缩放因子混合精度训练通常能带来1.5到3倍的训练速度提升,并大幅节省显存。这是单卡训练迈向高效训练必须掌握的技术。
3. 单机多卡:数据并行的黄金标准与实战
当你拥有一台配备多张GPU的服务器时,单机多卡数据并行是提升训练速度最直接、最有效的方式。其核心思想非常简单:将同一个批次(batch)的数据平均分割成若干份(称为minibatch),每张GPU拿到一份,独立进行前向传播和反向传播,计算各自的梯度。然后,所有GPU的梯度被收集起来,求平均,再用这个平均梯度去更新所有GPU上相同的模型参数。这样,理论上N张GPU就能获得接近N倍的训练加速。
PyTorch提供了两种主流实现方式:nn.DataParallel(DP) 和DistributedDataParallel(DDP)。我强烈建议,在任何新的项目中,直接使用DDP,并忘记DP。尽管DP代码改动量极小(只需一行),但它存在严重的性能瓶颈和限制。
3.1 为什么弃用DataParallel,拥抱DistributedDataParallel?
DataParallel的设计是单进程、多线程的。它在一个进程内创建多个线程,每个线程控制一张GPU。其工作流程是:
- 将模型复制到主GPU(默认
cuda:0)。 - 每次迭代,将输入数据在主GPU上分割,然后散射(scatter)到其他GPU。
- 各GPU并行完成前向计算。
- 将输出聚集(gather)回主GPU计算损失。
- 将损失散射到各GPU进行反向传播计算梯度。
- 将各GPU的梯度聚集回主GPU并求平均。
- 在主GPU上更新模型参数。
- 将更新后的模型参数广播到其他GPU。
问题就出在第2、4、6步的“聚集”操作。所有数据都需要流经主GPU,这使得主GPU的显存和带宽成为整个系统的瓶颈(即“主GPU瓶颈”)。随着GPU数量增加,通信开销急剧增大,加速比远达不到线性。此外,DP对模型类型有限制(例如,包含LSTM或pack_padded_sequence的模型可能出错),并且与nn.Module的一些钩子(hook)配合使用时行为可能不符合预期。
DistributedDataParallel则采用了多进程架构,彻底解决了这个问题。每个GPU对应一个独立的进程,每个进程拥有自己独立的Python解释器和数据加载器。模型在每个进程(即每张GPU)上独立初始化一份副本。DDP的核心是一个高效的All-Reduce通信原语。在反向传播结束后,各进程计算出的梯度不是发送到主进程,而是所有进程通过高速互联(如NVLink、PCIe)相互通信,共同合作对所有梯度进行求和或平均,并将结果同步到每一个进程。这样,通信是点对点的,没有中心节点瓶颈,通信效率远高于DP。
3.2 DDP训练代码完整实现与逐行解析
下面是一个完整的、可用于生产的单机多卡DDP训练脚本模板。我会逐部分解释其作用和关键参数。
# train_ddp.py import torch import torch.nn as nn import torch.optim as optim import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DataLoader, DistributedSampler from torch.utils.data.distributed import Dataset import os import argparse def main(): # 1. 解析命令行参数,获取当前进程的排名(rank)和总进程数(world_size) parser = argparse.ArgumentParser() parser.add_argument('--local_rank', type=int, default=-1, help='local rank for distributed training') args = parser.parse_args() # 2. 初始化进程组(Process Group)—— DDP的核心 # backend: 通信后端。单机多卡用'ncc'(NVIDIA Collective Communications Library)最快。 # init_method: 初始化方式。'env://'表示从环境变量中读取master地址和端口。 dist.init_process_group(backend='nccl', init_method='env://') # 获取全局信息 world_size = dist.get_world_size() # 总进程数 = GPU数量 rank = dist.get_rank() # 当前进程的全局编号,0为主进程 local_rank = args.local_rank # 当前进程在本机上的编号,通常等于使用的GPU编号 torch.cuda.set_device(local_rank) # 设置当前进程使用的GPU print(f'[Rank {rank}] Initialized. World size: {world_size}, Local rank: {local_rank}') # 3. 准备模型、数据、优化器(每个进程独立进行) model = YourModel().cuda() # 模型放到当前GPU上 ddp_model = DDP(model, device_ids=[local_rank], output_device=local_rank) # DDP包装模型。device_ids指定本进程使用的GPU,output_device指定输出设备。 # 数据集和采样器 train_dataset = YourDataset(...) # DistributedSampler是关键!它会确保每个进程在每个epoch中读取数据的不同部分,避免数据重复。 train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) train_loader = DataLoader( train_dataset, batch_size=64, # 这是**每个GPU**上的批大小 sampler=train_sampler, # 使用分布式采样器,不要再用shuffle=True num_workers=4, pin_memory=True, drop_last=True # 建议设置为True,丢弃最后不完整的batch,保证All-Reduce顺利进行 ) optimizer = optim.Adam(ddp_model.parameters(), lr=1e-3) # 优化器作用于ddp_model criterion = nn.CrossEntropyLoss() # 4. 训练循环 for epoch in range(num_epochs): # 在每个epoch开始前,调用sampler的set_epoch方法,确保不同epoch的数据shuffle不同 train_sampler.set_epoch(epoch) ddp_model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.cuda(non_blocking=True), target.cuda(non_blocking=True) optimizer.zero_grad() output = ddp_model(data) # 前向传播 loss = criterion(output, target) loss.backward() # 反向传播,DDP会自动在背后同步梯度 optimizer.step() # 打印日志:通常只在主进程(rank 0)进行,避免输出混乱 if rank == 0 and batch_idx % 100 == 0: print(f'Epoch [{epoch}/{num_epochs}], Step [{batch_idx}/{len(train_loader)}], Loss: {loss.item():.4f}') # 可选的:只在主进程保存模型检查点 if rank == 0: torch.save(ddp_model.module.state_dict(), f'checkpoint_epoch_{epoch}.pth') # 注意:保存的是`ddp_model.module`,即原始的、未包装的模型。 # 5. 清理进程组 dist.destroy_process_group() if __name__ == '__main__': main()关键点解析与避坑指南:
local_rank参数:这个参数通常由启动脚本(如torch.distributed.launch或torchrun)自动传入,用于标识当前进程使用的是本机的第几张GPU。你必须通过torch.cuda.set_device(local_rank)来正确设置设备。DistributedSampler:这是实现数据并行的关键组件。假设总数据有1000条,world_size=4。DistributedSampler会确保:- Rank 0 拿到第 0, 4, 8, ... 条数据。
- Rank 1 拿到第 1, 5, 9, ... 条数据。
- 以此类推。 这样,每个epoch所有进程加起来刚好把全部数据遍历一遍,且不重复。务必在
DataLoader中用sampler参数替代shuffle参数,并在每个epoch开始时调用sampler.set_epoch(epoch),这样才能保证每个epoch的数据顺序是不同的。
批大小(
batch_size)的理解:在DDP中,DataLoader的batch_size指的是每个GPU每次处理的数据量。全局批大小(Global Batch Size) =batch_size*world_size。当你调整学习率时,通常需要根据全局批大小进行缩放(如线性缩放规则:lr_new = lr_base * (global_batch_size / base_batch_size))。模型保存与加载:保存时,保存的是
ddp_model.module.state_dict()。ddp_model是DDP包装后的对象,其.module属性才是你原始的模型。加载时,如果是单卡推理,直接加载到单卡模型即可。如果是恢复多卡训练,则需要先在各卡上初始化原始模型,然后用DDP包装,再加载状态字典。启动命令:上述脚本不能直接用
python train_ddp.py运行。需要使用PyTorch的分布式启动工具。# 旧版方式 (仍可用) python -m torch.distributed.launch --nproc_per_node=4 train_ddp.py # --nproc_per_node=4 表示在本机启动4个进程(对应4张GPU) # 新版推荐方式 (PyTorch 1.9+) torchrun --nproc_per_node=4 train_ddp.py启动器会自动设置必要的环境变量(如
MASTER_ADDR,MASTER_PORT,WORLD_SIZE,RANK,LOCAL_RANK)并启动多个进程。
3.3 混合精度训练(AMP)与DDP的结合
将AMP与DDP结合能获得最大的性能收益。结合时需要注意,梯度缩放器(GradScaler)是每个进程独立的。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for data, target in train_loader: data, target = data.cuda(), target.cuda() optimizer.zero_grad() with autocast(): output = ddp_model(data) loss = criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()代码几乎和单卡时一样,因为DDP的梯度同步是透明的,发生在loss.backward()内部,而scaler.scale(loss).backward()调用的是包装后的backward,所以梯度同步依然正常工作。
4. 多机多卡:跨机器协同训练的工程挑战
当模型或数据规模大到单台服务器的GPU和内存都无法容纳时,就需要将训练任务分布到多台机器上,这就是多机多卡训练。它在单机多卡DDP的基础上,引入了跨机器的网络通信,复杂度和挑战性都上了一个台阶。
4.1 核心概念与架构
多机多卡训练通常也采用数据并行模式,其架构可以理解为将单机DDP的进程组扩展到了多台机器。假设你有2台机器(Node),每台机器有4张GPU。
- World Size: 总进程数 = 2 nodes * 4 GPUs/node = 8。
- Rank: 每个进程的全局唯一ID,从0到7。
- Local Rank: 进程在其所在机器内的本地ID,每台机器上都是从0到3。
- Node Rank或主节点: 通常将Rank 0所在的机器设为主节点(Master Node),负责协调初始化等工作。
通信模式依然依赖于高效的集合通信库(如NCCL)。NCCL能够优化多机多卡间的通信,自动选择最快的路径(如机内通过NVLink,机间通过InfiniBand或高速以太网)。
4.2 多机DDP训练代码实战
代码层面,多机与单机DDP的差异主要在于进程组的初始化和启动方式。
代码修改点:在dist.init_process_group中,需要指定所有进程都能访问到的主节点地址和端口。
import os def main(): # 通常从环境变量获取关键信息,而非单纯依赖命令行参数 world_size = int(os.environ['WORLD_SIZE']) rank = int(os.environ['RANK']) local_rank = int(os.environ['LOCAL_RANK']) master_addr = os.environ['MASTER_ADDR'] master_port = os.environ['MASTER_PORT'] # 初始化进程组 dist.init_process_group( backend='nccl', init_method=f'tcp://{master_addr}:{master_port}', world_size=world_size, rank=rank ) torch.cuda.set_device(local_rank) # ... 后续模型、数据加载等代码与单机DDP完全一致 ...可以看到,模型定义、DDP包装、数据加载、训练循环的代码与单机DDP完全一致。这就是DDP API设计的优秀之处,它屏蔽了底层的分布式细节。
真正的挑战在于环境配置与启动:
网络环境:所有训练节点必须在同一个网络内,能够通过IP地址相互访问。防火墙需要开放指定的端口(如
29500,这是PyTorch常用的默认端口)。使用高速网络(如InfiniBand)能极大提升跨机通信效率,避免通信成为瓶颈。共享文件系统:如果训练脚本需要加载预训练模型、保存检查点或读取共享数据,所有节点必须能访问到这些文件。通常需要配置网络文件系统(NFS)、对象存储(如S3)或集群存储。
启动协调:你需要一个工具在所有机器上同时启动训练进程。常见方式有:
- SSH集群手动启动:在主节点上通过SSH到各个工作节点执行命令。适用于节点数少的情况。
- 集群作业调度系统:如Slurm、PBS。这是HPC和大型企业集群的标准方式,可以方便地申请资源、管理任务。
- PyTorch Elastic Launch (torchrun):PyTorch内置的工具,也支持多机启动。
使用torchrun进行多机启动示例:
假设有两台机器,主机名(或IP)分别为node1(主) 和node2。 在node1上执行:
torchrun \ --nnodes=2 \ # 总节点数 --nproc_per_node=4 \ # 每个节点的进程数(GPU数) --rdzv_id=12345 \ # 一个唯一的任务ID --rdzv_backend=c10d \ # rendezvous 后端 --rdzv_endpoint=node1:29500 \ # 主节点地址和端口 train_multi_node.py在node2上执行同样的命令。torchrun会通过rdzv_endpoint协调所有节点,自动设置WORLD_SIZE,RANK,LOCAL_RANK,MASTER_ADDR,MASTER_PORT等环境变量。
4.3 多机训练的性能调优与故障排查
多机训练的性能瓶颈往往从计算转移到了通信。你需要关注通信与计算的重叠,以及通信量本身。
关键优化技术:
梯度压缩(Gradient Compression):在同步梯度前,对梯度进行压缩(如量化、稀疏化),减少通信数据量。PyTorch自身对此支持有限,但一些第三方库(如DeepSpeed)提供了高级功能。
重叠计算与通信:DDP在
loss.backward()时进行梯度同步。更激进的优化是,在反向传播计算某一层梯度时,就同步上一层的梯度(如果已经计算完)。这需要模型具有特定的结构或框架支持。调整
find_unused_parameters参数:在初始化DDP时,如果模型的前向传播路径是动态的(某些参数可能不被使用),需要设置find_unused_parameters=True。但这会引入额外的开销,如果确定所有参数都被使用,务必设为False以提升性能。
常见问题与排查:
- 连接失败:检查
MASTER_ADDR和MASTER_PORT是否正确,防火墙是否开放,网络是否通畅。使用ping和telnet命令测试。 - 挂起或超时:可能是某个节点进程卡住或崩溃。检查日志,确保所有节点都成功执行到
dist.init_process_group。可以适当增加初始化超时时间(dist.init_process_group的timeout参数)。 - 精度差异或训练不稳定:多机训练由于引入了更多的随机性(不同节点的数据加载顺序、随机数种子等),可能导致与单机结果有细微差异。确保在所有进程上设置了相同的随机种子(
torch.manual_seed,np.random.seed),并使用DistributedSampler保证数据划分的一致性。 - OOM(内存不足):检查每台机器的每张卡是否都有足够的显存。注意,多机训练时,由于通信缓冲区等开销,每卡显存占用可能略高于单机同等配置。
5. 从YOLO训练看分布式实战:以YOLOv5为例
很多朋友搜索“yolo训练代码 多机多卡”,说明目标检测这类视觉任务对分布式训练的需求很迫切。这里我以业界流行的YOLOv5为例,看看一个成熟的CV项目是如何集成分布式训练的。这能给我们编写自己的训练代码带来很多启发。
YOLOv5的分布式训练支持做得非常完善,它主要使用了torch.nn.parallel.DistributedDataParallel。我们不需要修改模型代码,只需要关注其训练脚本train.py中的相关逻辑。
核心分布式逻辑分析:
参数解析与初始化:
# 在 train.py 中 parser.add_argument('--device', default='', help='cuda device, i.e. 0 or 0,1,2,3 or cpu') parser.add_argument('--local_rank', type=int, default=-1, help='DDP local rank')它使用
--local_rank来自动识别DDP环境。设备设置与DDP初始化:
# 判断是否处于DDP模式 if RANK != -1: # RANK 从环境变量获取,非-1表示分布式训练 assert torch.cuda.device_count() > LOCAL_RANK, 'insufficient CUDA devices for DDP command' torch.cuda.set_device(LOCAL_RANK) device = torch.device('cuda', LOCAL_RANK) dist.init_process_group(backend='nccl' if dist.is_nccl_available() else 'gloo', timeout=timedelta(seconds=10800))YOLOv5会检查NCCL是否可用,优先使用NCCL后端。
模型包装:
if cuda and RANK != -1: model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[LOCAL_RANK], output_device=LOCAL_RANK)数据集与采样器:
# 创建数据集 dataset = LoadImagesAndLabels(...) # 创建分布式采样器 sampler = torch.utils.data.distributed.DistributedSampler(dataset) if RANK != -1 else None # 创建DataLoader dataloader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, sampler=sampler, ...)这里清晰地展示了单卡与多卡模式下采样器的区别。
训练循环中的同步:
- 指标同步:在验证或计算mAP时,需要将所有进程的预测结果收集起来进行统一评估。YOLOv5使用了
torch.distributed.all_gather等操作来同步张量。 - 日志打印:只在主进程(
RANK in [-1, 0])进行日志打印和进度条显示。 - 模型保存:同样只在主进程保存检查点。
- 指标同步:在验证或计算mAP时,需要将所有进程的预测结果收集起来进行统一评估。YOLOv5使用了
启动脚本: YOLOv5推荐使用
torch.distributed.launch(或torchrun)来启动。# 单机4卡训练 python -m torch.distributed.launch --nproc_per_node 4 train.py --img 640 --batch 256 --data coco.yaml --weights yolov5s.pt注意这里的
--batch 256是全局批大小。YOLOv5的训练脚本会自动将其除以world_size,得到每个GPU的批大小。
从YOLOv5中学到的经验:
- 封装良好:用户几乎无需关心分布式细节,只需按标准方式准备数据和模型,然后通过启动命令指定GPU数量即可。
- 健壮性处理:代码中充满了对
RANK和LOCAL_RANK的判断,确保了代码在单卡和多卡环境下都能正确运行。 - 实用功能集成:自动批大小调整、指标同步、日志处理等,这些都是生产级分布式训练代码必备的要素。
当你自己编写训练代码时,可以参考这种模式:将分布式相关的初始化、采样、包装、同步逻辑抽象成独立的函数或模块,并通过环境变量或参数灵活控制,使代码清晰且易于维护。
6. 分布式训练中的常见“坑”与调试技巧
无论单机多卡还是多机多卡,从单卡代码迁移过来总会遇到一些意想不到的问题。下面是我总结的一些常见“坑”及其解决方法。
问题1:程序挂起,没有任何输出或错误。
- 可能原因1:进程组初始化失败。最常见的是主节点地址或端口错误,或者防火墙阻止了通信。
- 排查:确保所有节点能互相ping通,并且主节点的指定端口是开放的。使用
netstat或ss命令检查端口监听状态。可以尝试使用更简单的backend='gloo'(对CPU训练或网络环境复杂时更稳定)来测试是否是NCCL问题。
- 排查:确保所有节点能互相ping通,并且主节点的指定端口是开放的。使用
- 可能原因2:某个进程提前崩溃。如果8个进程中有一个因为OOM或其他错误崩溃,其他进程会在集合通信操作(如
all_reduce)处一直等待,导致挂起。- 排查:仔细检查每个节点的日志输出。确保所有进程都成功执行到
dist.init_process_group之后。可以在每个rank打印一条带rank信息的信息来确认。 - 技巧:在
dist.init_process_group中设置timeout参数(如timeout=datetime.timedelta(seconds=30)),避免无限期等待。
- 排查:仔细检查每个节点的日志输出。确保所有进程都成功执行到
问题2:训练速度没有提升,甚至变慢。
- 可能原因1:通信瓶颈。特别是多机场景下,网络带宽不足或延迟过高。
- 排查:使用
nvprof或PyTorch Profiler分析训练过程,查看all_reduce等通信操作占用的时间比例。 - 优化:尝试减小模型(特别是梯度大小),使用梯度压缩,或升级网络硬件(如使用InfiniBand)。
- 排查:使用
- 可能原因2:数据加载瓶颈。当计算被GPU加速后,数据预处理和加载可能成为新的瓶颈。
- 排查:观察GPU利用率。如果GPU利用率经常掉到很低,可能是CPU数据加载跟不上。
- 优化:增加
DataLoader的num_workers(通常设为CPU核心数),使用更快的存储(如NVMe SSD),或将数据预处理移到GPU上进行(如使用DALI库)。
问题3:多卡训练结果与单卡不一致(精度损失或发散)。
- 可能原因1:批大小(Batch Size)变化未调整学习率。从单卡到多卡,全局批大小增大了N倍。学习率需要相应调整(通常线性增大)。
- 解决:应用学习率缩放规则。例如,单卡批大小为64,学习率1e-3。4卡训练时,全局批大小为256,学习率可尝试调整为4e-3。但这只是一个经验法则,最好进行小规模实验确定。
- 可能原因2:Batch Normalization层的行为差异。BN层在训练时使用当前小批次的统计量(均值和方差)。在数据并行中,每个GPU上的批次是独立的,BN层是在每个GPU的局部批次上计算统计量,这与在等效的全局大批次上计算统计量在理论上有细微差别。
- 解决:对于大模型和大批量,这种影响通常很小。如果担心,可以使用
SyncBatchNorm(torch.nn.SyncBatchNorm)替换普通BN层。它会跨所有GPU同步计算均值和方差,但会引入额外的通信开销。
- 解决:对于大模型和大批量,这种影响通常很小。如果担心,可以使用
- 可能原因3:随机性。数据加载的随机shuffle(即使使用
DistributedSampler,不同进程顺序也不同)、dropout、初始化等都会引入随机性。- 解决:为了可复现性,固定所有随机种子(
torch.manual_seed,np.random.seed,random.seed),并在DistributedSampler上设置相同的seed。但要注意,追求完全一致可能会牺牲一些性能。
- 解决:为了可复现性,固定所有随机种子(
问题4:GPU显存占用异常高。
- 可能原因:DDP的通信缓冲区。DDP会为模型参数的梯度预留通信缓冲区。对于超大模型,这部分开销不可忽视。
- 排查:使用
torch.cuda.memory_summary()查看详细的内存分配。 - 优化:在初始化DDP时,可以尝试设置
broadcast_buffers=False(如果模型的buffer在训练中不变化)。对于非常大的模型,可以考虑使用更高级的分布式策略,如Zero Redundancy Optimizer (ZeRO),它通过分区优化器状态、梯度和参数来显著减少每张卡的显存占用。这需要借助DeepSpeed或FairScale等库实现。
- 排查:使用
一个实用的调试流程:
- 从小开始:先用1个节点、1张卡(模拟单卡)运行,确保代码逻辑正确。
- 单机多卡:在单机上用2张卡运行,验证数据并行是否正确,速度是否有提升。
- 模拟多机:可以在同一台机器上用不同的端口启动多个进程组,来模拟多机环境,测试通信逻辑。
- 真实多机:最后再上真实的多机环境,并从小规模任务开始测试。
分布式训练调试确实比单卡复杂,但一旦打通,对于大规模模型训练来说是必不可少的技能。耐心和系统性的排查是关键。