PyTorch DDP分布式训练实战:从进程模型到性能调优的完整排坑指南 单卡训练一切正常loss听话地往下掉显存也够用。你为了赶进度把模型塞到两张甚至四张卡上启动命令从python train.py改成torchrun ...然后噩梦就开始了进程直接卡死、NCCL报错、loss曲线像心跳图一样震荡、GPU利用率只有30%...这套组合拳打下来再佛系的人也会怀疑人生。这篇文章我想把分布式多卡训练DDP这条路上的坑系统性地捋一遍。不是贴官方文档而是把我自己从第一次接触DDP到后来能稳定跑几十张卡的过程中踩过、看过、帮人排查过的那些问题整理成一份排查手册。内容会更贴近实际使用场景不管是刚入门的初学者还是被线上任务折磨的资深工程师应该都能找到自己需要的那块拼图。1. 单卡跑得好好的一上DDP就崩先搞懂DDP到底改了什么很多人的第一反应是DDP把我的代码改坏了其实DDP压根没改你的代码它改的是代码运行的方式。理解这一点后面排查问题会顺畅很多。1.1 DDP和DataParallel的差异不只是名字不同PyTorch里有两套多卡方案torch.nn.DataParallelDP和torch.nn.parallel.DistributedDataParallelDDP。DP是老方案写法简单一行代码就能把模型包起来但它有j几个硬伤多进程虽然都叫多卡DP其实是单进程多线程一个进程的GIL会限制吞吐通信走的是GPU 0这块卡做梯度聚合很容易把GPU 0的显存和通信带宽打满形成单点瓶颈而且DP的负载均衡很差卡多了以后加速比基本是线性衰减的。所以当你用DP跑8卡的时候实际效果可能和4卡差不多甚至更慢。DDP的实现思路完全不同它是真正意义上的多进程每张卡对应一个独立进程每个进程持有完整的模型副本和优化器状态前向和反向都在本地计算然后通过ring-allreduce算法在进程之间同步梯度。环形通信让每张卡只和相邻卡通信通信量均衡分布在所有卡上不存在单点瓶颈。这也是为什么DDP能支撑起几百张卡规模的训练任务。理解这个差异非常关键因为很多从DP迁移到DDP的人会保留DP时代的惯性思维模型包一层就完事了反正都是多卡嘛。实际上DDP对代码结构、数据加载、进程管理都有明确要求任何一环没跟上训练就跑不起来。1.2 分布式训练的根本进程、rank、world_sizeDDP的整套逻辑建立在一组基础概念上我把它们用最直白的方式解释一下process进程每张GPU卡对应一个Python进程这个进程只操作自己那块卡。rank进程编号每个进程有一个全局唯一的编号从0到world_size-1。它决定了这个进程使用哪块GPU、处理哪一部分数据。local_rank本地进程编号在一台机器内部每个进程的编号通常从0到num_gpus_per_node-1。单机多卡时local_rank和rank是同一个值多机多卡时local_rank就是本机内编号。world_size全局进程数所有参与训练的进程总数等于机器数乘以每台机器的卡数。master_addr / master_portrank 0进程的IP地址和端口其他进程通过它来建立初始通信。每次新开一个项目我都会习惯性地花两分钟把这张表写进笔记里。因为后续所有启动参数的设置、所有报错信息的解读都离不开这几个概念。很多人看到world_size报错就懵了其实它就是在告诉你我期望4个进程但只找到了2个。还有一层容易被忽视的基础知识init_method。DDP要求所有进程在训练开始前先完成一组网络握手PyTorch默认用环境变量方式env://来初始化进程组也就是从MASTER_ADDR、MASTER_PORT、WORLD_SIZE、RANK这几个环境变量里读取分布式配置。torchrun这样的启动工具会自动帮我们设置好这些变量但如果你绕开torchrun手动启动进程就必须手动把它们设置正确一个不对就是起不来或者进程组初始化失败。2. 启动阶段连环坑环境变量、进程调度与初始化顺序如果说单卡训练是从代码开始的那么DDP训练是从启动开始的。这一阶段的问题往往最让人抓狂因为代码逻辑完全没跑到报错却在最开始就出现了。2.1 GPU设备分配CUDA_VISIBLE_DEVICES的潜规则这是DDP入门的第一个经典大坑。很多人想用0号卡和1号卡训练启动脚本里写了CUDA_VISIBLE_DEVICES0,2然后在代码里用torch.cuda.set_device(local_rank)来分配设备。看似合理对吧但实际跑起来你大概率会遇到两种问题一是第0个进程被分到了物理GPU 2上二是进程组初始化时NCCL找不到设备。为什么因为CUDA_VISIBLE_DEVICES0,2会把物理GPU 2重映射成逻辑GPU 1所以NCCL看到的设备编号是0和1而不是物理编号0和2。你的代码里如果用了set_device(local_rank)local_rank1时设置的是逻辑设备1也就是物理GPU 2这倒是能对上但如果你的代码里写死了torch.device(cuda: str(args.local_rank))且没有先设置CUDA_VISIBLE_DEVICES那就会把local_rank1的进程硬塞到物理GPU 1上完全不理会你原本的分配计划。正确的做法是如果你用torchrun启动它会自动设置好环境变量你要做的就是在代码最前面设置模型和tensor之前执行import torch import torch.distributed as dist local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank)torchrun已经把CUDA_VISIBLE_DEVICES设置成了只暴露本机卡所以local_rank和CUDA逻辑设备号是对应的。这块我一直严格遵守一个原则进程启动后第一件事就是设置设备绝不在初始化分布式环境之前创建任何CUDA tensor。因为一旦某个进程的CUDA上下文先创建了它可能被绑定到错误设备上后续set_device也无力回天表现就是显存分配到了某张不想要的卡上、NCCL初始化失败。2.2 启动方式torchrun、python -m、手动多进程的区别不同启动方式对新手来说是个隐形陷阱。官网现在推荐的是torchrun但是网上大量老教程还在用python -m torch.distributed.launch。这两个方式在参数上有细微差别torchrun用--nproc_per_node而torch.distributed.launch也支持这个参数但老代码经常写--ngpus_per_node格式完全不同。我自己统一使用torchrun写法如下torchrun --nproc_per_node4 \ --master_port29500 \ train.py --args...这里有一个非常容易忽略的细节--master_port。如果多个人在同一台机器上同时起训练任务或者你自己起了任务没关掉就再起一个默认的29500端口很容易被占用然后报Address already in use。这个报错信息模棱两可经常被误认为网络问题调半天防火墙才发现是端口被前面的任务占了。排查这个问题的快捷命令netstat -tlnp | grep 29500看到进程ID后用kill结束旧任务或者换一个不常用的端口比如29501、32000。我在团队里已经形成了习惯需要长时间跑的任务固定端口段其他临时任务用随机高位端口从源头上避开冲突。torchrun和不用torchrun的另一个核心区别是RANK和LOCAL_RANK这两个环境变量。用torchrun时它帮你设置好一切不用时你需要手动为每个进程设置环境变量一旦漏了RANK初始化进程组会直接报Environment variable RANK is not set这是最常见的启动失败原因之一。2.3 初始化顺序dist.init_process_group放哪个位置很关键进程组初始化的位置不是随便放的它必须在所有使用GPU的操作之前。一个原本能跑的单卡代码如果加了DDP却在init_process_group之前创建了模型并放到CUDA上会直接报初始化失败或者进程卡住。完整的顺序应该是import os import torch import torch.distributed as dist # 步骤1读取环境变量并设置设备 local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) # 步骤2初始化进程组 dist.init_process_group(backendnccl) # 步骤3创建模型并放到设备 model MyModel().cuda() model torch.nn.parallel.DistributedDataParallel(model, device_ids[local_rank]) # 步骤4构建数据加载器 # 步骤5开始训练循环注意一个细节init_process_group里如果没指定init_method它会默认读环境变量。用torchrun启动时这些变量是齐的但如果你是手动起进程就必须自己设好。backendnccl是GPU训练的首选后端CPU训练才用gloo。如果版本比较老还需要在init_process_group里指定一个唯一的rank但现在环境变量有了以后一般不传也不会错。还有一种坑是初始化方式不一致。比如有同事喜欢在代码里写init_methodtcp://...去指定通信地址有时候IP写错了或者端口被占用也会卡住。初期调试如果遇到hang住不出日志最优先检查的就是init_process_group是否能顺利通过。2.4 随机种子少一个种子的罪与罚DDP要求每个进程独立、可复现地运行但进程之间又是相互依赖的生产者/消费者关系。如果不设置随机种子每个进程初始化模型参数的随机权重就不一样训练从第一步就开始发散梯度同步没有任何意义loss曲线出现各种奇怪形态。这是很多人训练正常但结果总不对劲的隐性原因之一。每个进程里都应该在进程组初始化之后、模型初始化之前设置好所有随机源import random import numpy as np import torch def set_seed(seed42): local_rank int(os.environ[LOCAL_RANK]) seed seed local_rank # 每个进程不同的种子 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed)使用seed local_rank而不是完完全全相同的种子是因为有些操作比如BatchNorm的统计值期望不同进程看到不同的随机序列后续结果才不至于所有卡生成一模一样的数据。但这里有个反直觉的细节模型初始化的权重必须所有进程一致数据加载的偏移和增强的随机性必须各进程不同。所以种子策略是模型前固定数据后分开。我在实际项目里的做法是初始化前先torch.manual_seed(seed)让所有进程模型一致然后在构造DataLoader时加上worker_init_fn配合seed rank做差异化。3. 训练运行中的隐蔽坑数据采样、日志、状态同步与恢复前面的坑还算明显启动阶段过了训练开始后又是一堆暗雷。这些坑不会让你一眼崩溃但会让结果离谱、时间白白浪费。3.1 DistributedSampler的乱序问题DDP训练里每个进程负责处理一部分数据靠的是torch.utils.data.distributed.DistributedSampler。很多人踩的第一个坑是用了DataLoader(shuffleTrue)同时结合DistributedSampler结果每个epoch喂给模型的数据顺序是错误的或者某些数据永远没有被采到。DistributedSampler内部自带shuffle逻辑你需要在每个epoch开始时手动调用sampler.set_epoch(epoch)否则每个epoch的shuffle顺序会完全一样。更重要的是如果DataLoader里同时开了shuffleTrue那就和sampler的shuffle冲突了。我自己固定写法是sampler DistributedSampler(dataset, num_replicasworld_size, rankrank, shuffleTrue) dataloader DataLoader(dataset, batch_sizebatch_size, samplersampler, num_workers4, pin_memoryTrue) for epoch in range(epochs): sampler.set_epoch(epoch) for batch in dataloader: ...另外一个很容易被忽视的点是batch_size的含义。在DDP模式下如果你在DataLoader里设置batch_size32那么每个进程都会取32个样本一个step的实际batch size是32 × world_size。这直接影响学习率设置、梯度累积的步数计算和显存预估。不少人一开始没概念直接用单卡batch size跑8卡结果显存爆炸或者学习率没有同步放大导致收敛极慢。合理做法是让每个进程的batch size和单卡时保持一致然后按照world_size倍数放大全局学习率或者在优化器里做线性缩放具体要结合任务调节没有通解但至少要知道全局batch size 单进程batch size × 卡数这件事。3.2 日志和 checkpoint 的进程重复问题DDP下所有进程都执行同样的代码如果打印一行日志你会看到8行完全相同的输出。如果保存模型文件你会看到8个一样的checkpoint。这看起来无伤大雅但日志把你刷到找不到有效信息、checkpoint占满磁盘的时候就很痛苦了。比较规范的做法是只让rank 0进程负责日志打印、指标记录、模型保存这些全局职责if dist.get_rank() 0: print(fEpoch: {epoch}, Loss: {loss.item():.4f})checkpoint保存也只在rank 0上执行。这一条看起来简单但很多人是在整个任务跑完发现最后一轮模型是坏的、或者压根没有checkpoint时才想起来。更隐蔽的问题是rank 0负责保存模型但模型每个进程都有副本保存前要确保当前epoch所有进程的模型状态一致。正常情况下梯度同步保证了参数一致但如果你自己改了某些参数、手动调用了no_sync就可能出现某个进程的模型和其他进程不同步的情况。所以我保存模型时除了常规的model.module.state_dict()之外还会把优化器状态、随机数状态、epoch、scaler用AMP时一起存下来方便断点续训。3.3 从checkpoint恢复时的状态同步断点续训是DDP里的重灾区。很多人保存checkpoint的时候没想太多恢复的时候每个进程都用同样的文件恢复模型模型是一致的但每个进程的随机数状态、DataLoader的采样起始位置并不一致导致恢复后第一个epoch的数据分布和训练曲线出现跳变。通用的恢复流程应该是checkpoint torch.load(path, map_locationcpu) model.module.load_state_dict(checkpoint[model_state]) optimizer.load_state_dict(checkpoint[optimizer_state]) sampler.set_epoch(checkpoint[epoch]) for state in torch.random.get_rng_state_all(): ...更为关键的是如果只在一个进程比如rank 0上恢复了优化器状态其他进程没有恢复那么后续梯度更新时各进程的优化器状态就乱了模型参数开始发散。解决方案是让所有进程都加载同一份checkpoint因为checkpoint是从rank 0保存下来的所有进程都能访问到直接都加载即可。一行dist.barrier()再配合所有进程加载就避免了有的进程读完、有的进程还没读的竞态问题。3.4 数据集划分与缓存坑在数据预处理阶段DDP很容易让人忽略数据预处理部分的并行语义。比如你有预处理逻辑图像解码、数据增强写在Dataset.__getitem__里这个逻辑会在每个进程复制执行。数据量大的时候8个进程一起做重复的预处理CPU直接被打满GPU却在空转等数据。排查这个问题时用nvidia-smi看GPU利用率会发现利用率忽高忽低最常被误判为NCCL通信慢或者训练代码有bug。实际原因是num_workers过低、数据预处理过重、或者数据读取路径里有不可并行的共享锁。我一般会先把num_workers调到4-8然后在Dataset里对核心耗时操作做缓存比如离线把resize做完存成内存中的list训练时只做随机裁剪等轻量增强。还有一类缓存坑某些第三方库会在各进程的临时目录/tmp里缓存特征比如huggingface的datasets库。多个进程同时写同一个缓存目录可能互锁或写坏文件。问题是它报的错五花八门——权限错误、文件不存在、HDF5错误——排查半天都是一头雾水。解决办法是给每个进程设置独立的缓存目录TMPDIR/tmp/rank_${LOCAL_RANK}或者把这些缓存目录指向不同的地方。在NFS或者SSHFS这种网络文件系统上训练时更要当心缓存目录和checkpoint目录的网络IO往往是隐形性能瓶颈。4. 性能问题显存占满但GPU利用率上不去通信成了瓶颈训练能跑通loss也能收敛但训练速度就是上不去8卡加速比只有3倍这就到了性能调优阶段。性能问题最隐蔽因为报错很少只有数据说话。我一般按照下面几条路径排查。4.1 先看GPU利用率和CPU瓶颈拿到一个DDP训练任务第一件事是开一个终端监控工具watch -n 1 nvidia-smi重点看两个指标GPU-Util和Memory-Usage。如果GPU-Util在0%-100%之间剧烈跳动说明GPU在频繁地等数据。这时候大概率数据加载环节出问题了。如果GPU-Util一直很低个位数到30%而CPU的使用率很高top看每个core都打满说明数据预处理或者数据读取代销超过了GPU计算。经典的排查手段是先把num_workers调大再看pin_memoryTrue是否设置。pin_memory本质上是让数据loader分配锁页内存从CPU到GPU的拷贝更快你不用太关心原理照做就行。还有一个常见误区的细节num_workers开大了并不一定代表快比如你在Windows上跑num_workers0反而可能更慢因为进程启动的额外开销比并行收益还大在Linux服务器上就比较靠谱。接下来是NCCL通信时间。DDP做梯度同步时所有进程都要等待最慢的那个如果数据加载不均衡某个进程单步时间比其他进程慢整个训练就被拖慢。这种情况下可以适当增大DataLoader的prefetch_factor默认2让数据加载提前多缓冲几批。4.2 DDP参数调优find_unused_parameters、static_graph、gradient_as_bucket_view这三个参数是DDP性能的隐藏开关很多人一辈子都用默认值遇到性能问题只能干瞪眼。find_unused_parameters默认False。如果你的网络中存在一些没有参与loss计算、没有梯度的参数比如BERT在某次forward中没有使用的某些层、或多任务训练中某些分支没被激活DDP在后向传播时会因为找不到所有参数的梯度而报错。把find_unused_parametersTrue能让它通过但代价是每次迭代都要额外遍历一次参数性能有明显损耗。所以我建议网络结构稳定、没有unused参数时保持False如果确实要使用优先检查是否能通过结构调整消除unused参数而不是长期依赖这个开关。static_graphDDP假设每次迭代的计算图结构可能变化所以它会做一些动态检查。如果你的模型每轮forward的结构完全一样绝大多数训练任务都是如此设置static_graphTrue可以让DDP跳过这些检查减少一部分开销。这个参数比较隐蔽但对某些小模型或者计算很快的模型非常有效。gradient_as_bucket_view默认False。这个参数让DDP把所有参数的梯度打包到同一个连续存储块bucket中通信时直接对这块内存做allreduce而不是逐个tensor通信。显存占用会更友好通信效率更高。我的做法是设置True这个参数在PyTorch 1.7开始支持几乎没有副作用。还有一个常常被忽略的优化点broadcast_buffers。DDP默认会在每次forward前广播buffer比如BatchNorm的running_mean/running_var如果你的模型里BatchNorm很多广播开销不小。当显存和通信都吃紧时分析一下你的模型是否真的需要跨卡同步BN统计值如果场景允许比如小batch足够你不需要同步BN可以设置broadcast_buffersFalse能省下一些通信量。注意这两个是互斥的你想省通信选False你想用同步BN选True没有既省又同步的办法。4.3 梯度累积与no_sync的正确姿势梯度累积gradient accumulation是模拟大batch的经典技巧配合DDP时有个大坑。默认情况下每次loss.backward()都会触发一次跨进程的梯度allreduce通信。如果你一次迭代做4步梯度累积就会通信4次而其中3次完全没有必要。正确做法是在前3步加model.no_sync()上下文管理器在第4步才执行真正的累积同步with model.no_sync(): loss compute_loss(model, batch) loss.backward() # 只会本地累积不触发跨卡同步 # 第4步正常backward触发同步 loss compute_loss(model, batch) loss.backward() optimizer.step() optimizer.zero_grad()这个优化对加速效果非常明显。尤其你的模型单次forward时间很短、通信时间占比高的时候不做这个优化就是白白浪费3/4的通信开销。注意no_sync必须配合优化器的梯度累积逻辑使用即你不能在no_sync块内调用optimizer.step()否则梯度的更新状态就乱了。另一个相关的问题是你开启梯度累积后如果学习率调整策略是按step调整的那你实际上的逻辑步是4次迭代才更新一次学习率调度器的step也要相应每4次迭代才调一次否则你实际上会比预期学习率衰减快很多导致收敛变慢。这个细节不容易看错但改动后收敛行为异常时优先检查。4.4 多机多卡时NCCL通信稳定性问题从单机多卡扩展到多机多卡NCCL通信的坑会指数级增多。最常见的是NCCL timeout报错信息形如RuntimeError: NCCL error in: ProcessGroupNCCL::broadcast: NCCL communicator aborted这类问题出现时先检查以下几点网络连通性ping测试机器之间是否通NCCL走的是RDMA或TCP拥塞或丢包都会导致超时。master_addr设置MASTER_ADDR必须指向rank 0所在机器的IP端口要能在所有机器上访问。当然也不能是防火墙屏蔽的端口。NCCL环境变量设置NCCL_DEBUGINFO可以看到更详细的通信日志定位是哪一步卡住。NCCL_SOCKET_IFNAME需要和实际网卡名对应比如NCCL_SOCKET_IFNAMEeth0。集群环境里IP地址有多个网卡时这个变量尤其重要。带宽和拓扑多机通信最好走高速网络如InfiniBand如果走千兆以太网传输大量梯度时性能大幅下降。至少要知道你的环境是哪种网络做一次简单的带宽测试再决定模型大小和卡数是否匹配。很多人觉得NCCL报错就是代码问题其实很多时候是网络环境问题。我会在项目早期就做一个最小化的NCCL通信测试# test_nccl.py import torch import torch.distributed as dist dist.init_process_group(backendnccl) rank dist.get_rank() tensor torch.ones(1024, 1024).cuda(rank) dist.all_reduce(tensor) print(fRank {rank}: NCCL communication test passed)用torchrun --nproc_per_node... --nnodes... train.py类似的方式起这个测试脚本能快速判断分布式通信环境是否正常。不要等到训练跑到一半才去排查网络问题。5. 从踩坑到稳定我沉淀下来的一套DDP工程规范经历了这么多之后我开始把DDP训练当作一个有固定流程的工程来做而不是每次踩坑再修。这里有几个我一直在用的规范对新人尤其重要。5.1 最小可复现的启动模板我每次开始新项目时都从下面这个模板起步它包含了所有DDP必须的要素已经跑通过无数次能排除90%的启动错误# train.py import os import random import argparse import numpy as np import torch import torch.distributed as dist import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torch.utils.data.distributed import DistributedSampler def setup(): local_rank int(os.environ[LOCAL_RANK]) world_size int(os.environ[WORLD_SIZE]) rank int(os.environ[RANK]) torch.cuda.set_device(local_rank) dist.init_process_group(backendnccl, init_methodenv://) return local_rank, world_size, rank def cleanup(): dist.destroy_process_group() def set_seed(seed, local_rank): random.seed(seed local_rank) np.random.seed(seed local_rank) torch.manual_seed(seed local_rank) torch.cuda.manual_seed(seed local_rank) torch.cuda.manual_seed_all(seed local_rank) class DummyDataset(Dataset): def __init__(self, size1000): self.data torch.randn(size, 64) self.label torch.randint(0, 2, (size,)) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx], self.label[idx] def main(): local_rank, world_size, rank setup() set_seed(42, local_rank) dataset DummyDataset() sampler DistributedSampler(dataset, num_replicasworld_size, rankrank, shuffleTrue) dataloader DataLoader(dataset, batch_size32, samplersampler, pin_memoryTrue) model nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 2)).cuda() model nn.parallel.DistributedDataParallel(model, device_ids[local_rank], output_devicelocal_rank) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01) for epoch in range(5): sampler.set_epoch(epoch) for data, label in dataloader: data, label data.cuda(), label.cuda() logits model(data) loss criterion(logits, label) optimizer.zero_grad() loss.backward() optimizer.step() if rank 0: print(fEpoch {epoch}, Loss: {loss.item():.4f}) cleanup() if __name__ __main__: main()启动命令torchrun --nproc_per_node2 train.py这个模板虽然简单但它正确处理了设备分配、进程组初始化、数据采样、模型封装、日志隔离、进程组销毁这些核心环节。当你的训练出问题时先把任务代码套回这个模板跑一遍能跑通再逐步替换成真实的数据和模型。这种二分排查法能快速缩小问题范围。5.2 每个训练脚本都要有的环境探针深入DDP之后我养成了一个习惯在每个训练脚本的初始化阶段加一段环境打印把关键信息输出到日志里方便后续排查问题。这个环境探针长这样if rank 0: print(f[Env] PyTorch: {torch.__version__}) print(f[Env] CUDA: {torch.version.cuda}) print(f[Env] NGPU: {world_size}, Rank: {rank}) print(f[Env] NCCL: {torch.cuda.nccl.version()}) print(f[Env] GPUs: {torch.cuda.get_device_name(local_rank)})这段信息在排查为什么这个版本能跑那个版本不能跑、为什么换了机器就报NCCL错误这类问题时极其有用。多机训练时尤其要保证所有机器的PyTorch、CUDA、NCCL版本一致版本不匹配可能导致NCCL初始化行为异常。我遇到过的最离谱的一次就是两台机器上的CUDA小版本不一致导致通信协议协商失败报错非常诡秘。5.3 排查DDP问题的固定排查链路踩了这么多坑我最终沉淀出一套自己的排查链路。每当DDP训练出问题我会按下面的顺序一个环节一个环节地检查效率远高于随机搜索启动环境torchrun能不能成功执行是否报端口/文件/权限错误。进程组所有进程是否成功调用了init_process_group是否设置了正确的backend。设备设置每个进程的torch.cuda.set_device是否在CUDA操作之前执行设备编号是否和LOCAL_RANK一致。数据加载DistributedSampler是否正确传给DataLoader是否存在shuffleTrue冲突。模型封装DistributedDataParallel的device_ids和output_device是否正确是否在模型调cuda()之后封装。通信与同步报错是否出现在backward或step阶段如果是要怀疑梯度同步问题检查是否有unused parameters、是否某个进程的forward分支不同导致死锁。性能监控nvidia-smi看GPU利用率和显存是否均衡top看CPU状态NCCL_DEBUGINFO看通信日志。这套链路被我写成了团队内部的文档后来很多新同事照着排查大部分问题都能自己解决找我的次数大大减少。写在最后的一些零碎经验最后分享几个不太好归类、但实际中总会遇到的零碎经验。多进程的错误处理比单卡诡异得多。在单卡上某个进程exception直接抛出来程序退出。但DDP下如果rank 0那个进程抛错退出其他进程还在等待通信就会hang住。所以DDP训练日志里看到某个进程报错时整个任务通常表现为卡死而非退出这个现象本身就是一个信号先去查崩溃日志而不是盯着通信配置发愁。我一般建议在每个训练脚本里加上进程崩溃时的处理逻辑如果任意进程非零退出用torch.distributed.destroy_process_group()加os.kill让所有进程尽快结束避免僵尸进程占着GPU显存不放。关于显存不均衡问题。如果每张卡的batch size一样理论显存应该基本一致。但实际中经常看到某张卡显存明显高于其他卡数据和batch size看起来也都对。排查方向有两个一是某些进程的dataloader采样了不同长度的序列NLP任务存在padding不均问题二是某个进程显存没有释放干净比如多余的tensor没释放。对第一个问题DistributedSampler配合drop_lastTrue可以缓解长度不匹配带来的步数错乱。对第二个问题就要靠显存监控工具比如nvidia-smi dmon查看是谁占着显存。还有一个容易被忽略的经验不要在训练过程中用torch.cuda.empty_cache()。这个函数会清理缓存块看起来像是释放了显存但实际上下一个forward会触发更耗时的重新分配反而让训练变慢。它更适合做显存调试比如临时观察最大可用显存而不是作为常规训练的一部分。如果你正准备把项目从单卡改成DDP我的建议是先跑通最小模板然后用一个简单的模型完整训练几个epoch确认收敛没有问题再把真实模型搬进去。不要一步到位否则出错时单卡模型、DDP环境、数据并行、模型结构这几个变量纠缠在一起排查难度剧增。分布式训练本质上没有多神秘只要理解了进程模型、数据划分和通信同步这三件事剩下的坑基本都可以通过日志和监控一步步定位。