本文摘要:多卡训练卡死却无报错无输出,是分布式排查中最棘手的故障。先注入超时让沉默开口,再逐 rank 打印步数定位差异,依据是梯度同步的阻塞语义。
一、问题与结论
用 torchrun --nproc_per_node=8 train.py 启动 8 卡训练,前 3 个 step 的 loss 正常下降;第 4 个 step 起,nvidia-smi 里 8 张卡的 GPU-Util 同时从 92% 掉到 0%,进程仍存活,终端没有 Python traceback、没有 NCCL warning、也没有新输出,只能 kill -9 清场。
结论先给:这类卡死的根因几乎总是 rank 间集合操作步调不一致------某个 rank 少执行了一次 loss.backward(),其余 rank 就在梯度 all_reduce 上永久等待。all_reduce 是阻塞式集合操作,要求所有 rank 同时进入,缺一 rank 时不抛异常、不打日志,"没有任何报错"本身就是诊断线索。排查的第一步不是读日志(根本没有日志可读),而是制造可观测性:给进程组注入显式超时,再逐 rank 打印步数。
二、排查与选择依据
按顺序做三步,每步只引入一个变量:
- 注入超时 :
dist.init_process_group(..., timeout=timedelta(seconds=60))。卡死会在 60 秒后变成RuntimeError,错误信息附带超时的集合操作名称与 rank。 - 逐 rank 打印 :在训练主循环加
print(f"[Rank {rank}] step {i}", flush=True),比较各 rank 最后一条输出停在哪一步。flush=True不能省,否则缓冲区会吞掉最后几行。 - 查看该 rank 最后一步 :常见三种情况------OOM 被
try/except吞掉、数据加载抛错后被外层捕获、batch 数量比其他 rank 多 1。
若第 2 步仍看不出差异,再加环境变量:NCCL_DEBUG=INFO 打印每次集合通信的调用顺序、参与 rank 与数据量;TORCH_DISTRIBUTED_DEBUG=INFO(PyTorch 1.13+)打印分布式层状态。注意 NCCL_DEBUG 只对 NCCL 后端生效,在 gloo 下需改用 dist.barrier() 逐段确认。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界(何时不用) |
|---|---|---|---|
显式 timeout + 逐 rank 打印(本文路径) |
卡死原因未知、集合通信类 | 需重启训练,打印对吞吐有轻微影响 | 报错已有完整 Python 栈时,直接按栈定位即可 |
NCCL_DEBUG=INFO 日志 |
已怀疑某 rank 未进入集合操作 | 日志量大,高级别会拖慢训练 | 仅 NCCL 后端生效,CPU / gloo 调试无效 |
DistributedSampler(drop_last=True) |
根因确认为数据分片不一致 | 每 epoch 需 set_epoch(),padding 改变样本数 |
Dataset 有顺序依赖时可能引入新问题 |
gloo 后端 CPU 复现 |
需在无 GPU 环境反复验证 | 不走 NCCL 路径 | CUDA stream 依赖、显存映射类故障无法复现 |
本文路径的代价是排查期要改代码并重启训练;如果故障只出现一次、后续不可复现,改用被动日志更合适。单进程训练不存在集合通信,也无需这套探针。
三、关键原理
DDP 在每次 loss.backward() 内部自动执行梯度的跨 rank all_reduce 同步,这是阻塞式集合操作:所有参与进程必须同时进入调用才能返回。当某个 rank 因异常退出、提前 break 或进入不同分支,其余 rank 会无限等待------关键在于卡死点在"还在等"的 rank 上,而不是出错的 rank 上,只看存活进程的日志会误判方向。

init_process_group 的 timeout 参数(类型 datetime.timedelta)把这种等待变成可捕获的超时异常。版本差异需要注意:PyTorch 1.x 下 gloo 后端不传 timeout 时默认 1800 秒,NCCL 后端的默认行为依赖 NCCL 版本;PyTorch 2.x 引入更细粒度的超时参数,torch.distributed.launch 也已被 torchrun 取代。另外 PyTorch 的 timeout 与 NCCL 内部的 NCCL_TIMEOUT 是两层机制,可能先后触发、报错叠加,排查时应把 PyTorch 侧超时设得较小,信息最干净。
四、可运行示例
环境:单机 ≥2 CPU 核、PyTorch 2.x(具体小版本未验证)、无 GPU,使用 gloo 后端。输入为随机张量,无需外部数据。将代码保存为 ddp_hang_demo.py:
python
import os
from datetime import timedelta
import torch
import torch.nn as nn
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
def main():
rank = int(os.environ["RANK"])
world_size = int(os.environ["WORLD_SIZE"])
# 关键:显式超时,把无限等待变成可观测的 RuntimeError
dist.init_process_group(
backend="gloo",
rank=rank,
world_size=world_size,
timeout=timedelta(seconds=15),
)
model = DDP(nn.Linear(10, 1))
# BUG:两个 rank 的 batch 数不一致
# 现实中来自数据集不整除、DataLoader 配置差异或某 rank 提前 break
n_batches = 4 if rank == 0 else 5
for i in range(n_batches):
out = model(torch.randn(2, 10))
loss = out.sum()
loss.backward()
model.zero_grad()
print(f"[Rank {rank}] batch {i} done", flush=True)
print(f"[Rank {rank}] loop complete", flush=True)
dist.barrier()
dist.destroy_process_group()
if __name__ == "__main__":
main()
操作步骤:
bash
torchrun --nproc_per_node=2 ddp_hang_demo.py
预期输出(两个 rank 的打印会交错,关键差异在最后一段):
text
[Rank 0] batch 3 done
[Rank 0] loop complete
[Rank 1] batch 3 done
# 此后两个 rank 分别停在各自的集合调用上
# 约 15 秒后,先后抛出 RuntimeError,指向超时的集合操作
RuntimeError: [gloo]: Timeout after 15 seconds while waiting for collective operation...
实际输出 :未验证------本次写作未在多进程环境实际执行,上述内容为按 gloo 超时机制推演的结果,运行后请以真实回显为准。
常见失败及其修复:不传 timeout 时脚本会无限挂起且无任何报错,原因是 gloo 与 NCCL 在缺省配置下可能等待 1800 秒甚至更久;修复方式是显式传入 timeout=timedelta(seconds=15),把沉默卡死变成带操作名的 RuntimeError。
五、验证结果与边界
验证方法:若 Rank 0 打印了 loop complete,而 Rank 1 的最后一条输出停在 batch 3 done,且约 15 秒后抛出指向集合操作的 RuntimeError,即可确认卡死点是 backward() 内的 all_reduce,而非数据加载或前向计算。
适用边界:该路径只覆盖集合通信类卡死。NCCL 特有的 CUDA stream 依赖或显存映射故障需要 NCCL_DEBUG=INFO 直接观察通信层;已有完整 Python 栈的报错不需要超时探针;超时异常只能指出"哪个集合操作超时",仍需逐 rank 打印才能定位根因。排查结束后应及时移除 TORCH_DISTRIBUTED_DEBUG=DETAIL 等开关,大模型训练下它会显著增加日志量并拖慢训练。
防回归:在 CI 中断言各 rank 的 len(dataloader) 一致,或直接采用 DistributedSampler(drop_last=True),并确认每个 epoch 调用了 set_epoch()。