训练异常排查:保存配置、日志和随机种子
报错后的下一步应当可选择
不是每个异常都要立刻重启训练。显存溢出可以先检查批大小和样本形状,数据读取失败则先核对分片和权限。日志若能给出异常类别和当前阶段,处理者就能选择合适动作,避免用一次全量重跑掩盖真正的问题。
这份记录也让后来的人知道,哪些现象已经验证过,哪些仍只是待查的猜测。
排查结束后,把这次异常对应的提交、修复配置和复跑结果连在同一条记录里。下次类似告警出现时,先比较这些稳定字段,效率比凭印象回忆训练过程高得多。
一次分布式训练挂起:主进程只留下 NCCL timeout
本文围绕“PyTorch 训练流程优化与分布式训练实践:排障时怎样留下有效证据”整理一个可复查的技术检查点。文中的容量、时延和故障情形只用于说明验证方法;实际判断应以锁定的代码版本、脱敏样本、运行环境与评测脚本复测为准。
在一个合成的挂起场景中,进程仍出现在nvidia-smi列表中,但计算利用率归零。若只保留主进程的超时信息,就无法区分 OOM、数据加载阻塞与通信异常,也难以复查触发链路。
在多卡集群训练中,死锁和静默挂起是定位成本最高的故障类型。如果排障体系没有在崩溃瞬时捕捉并 Dump 出 GPU 物理内存快照和 Python 调用栈,排查过程就会沦为无休止的猜测与盲目重启。
+-----------------------------------------------------------------------+ | 常规低效的排障体验 (No Trace Evidence) | | - 只有主 Rank 抛出掩盖真相的 NCCL Timeout | | - GPU 显存全满,但无从得知哪个 Tensor 引发了最后的 OOM | | - 无法定位是硬件 Drop Packet 还是 Worker CPU 卡死在 Data Loading | +-----------------------------------------------------------------------+ | 注入崩溃拦截与黑匣子 Dump v +-----------------------------------------------------------------------+ | 黑匣子故障证据捕捉 (Fault Memory Snapshot) | | - 信号拦截器 (SIGUSR1 / SIGSEGV) 自动导出 Python 堆栈 (traceback) | | - PyTorch CUDA Memory Dump Snapshot: 保存物理 Memory Segment 视图 | | - 广播崩溃事件 (Rank Breakdown Alert) 阻断静默挂起 | +-----------------------------------------------------------------------+排障必须保留的三大现场:GPU 显存快照、分布式 Rank 通信拓扑、系统 Syscall Trace
为了缩短定位范围,训练 Harness 应在运行时持久化留存三类关键现场证据:
第一,GPU 物理显存分配快照(CUDA Memory Dump Snapshot)。通过 PyTorch 内置的torch.cuda.memory._record_memory_history(),可以记录显存中每一个 Block 的分配、释放与 Python 源代码行号的映射。在抛出 OOM 时将其 Dump 成二进制文件,能通过 Visualizer 直观看到是哪一个巨大的 Attention 临时矩阵吃光了最后几百兆显存。
第二,分布式 Rank 心跳与通信状态拓扑。当出现 NCCL 阻塞时,通过向所有进程发送SIGUSR1信号,触发各卡独立的 Thread Dump,迅速识别出究竟是 Rank 12 在等待数据加载,还是 Rank 0 停在了磁盘 Checkpoint 保存上。
第三,CPU Data Feeding 异步队列监控。监控 PyTorchDataLoader的 Prefetch 队列深度,区分是 GPU 计算崩溃还是 CPU 读盘卡死。
PyTorch 分布式故障现场捕捉与黑匣子记录架构
黑匣子记录架构展示了当训练发生 OOM 或卡死信号时,分布式拦截器如何抢救现场数据。
PyTorch 自动故障现场抓取拦截器与 Dump 引擎
下面是一段生产级可用的 PyTorch 崩溃拦截与 GPU 显存快照 Dump 引擎代码。它支持信号触发式 Stack Dump 和 OOM 瞬时的显存历史导出。
import os import sys import time import signal import traceback import logging import torch # 配置日志 logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") class DistributedFaultInterceptor: def __init__(self, dump_dir: str = "./dump_evidence"): self.dump_dir = dump_dir self.rank = int(os.environ.get("RANK", 0)) self.local_rank = int(os.environ.get("LOCAL_RANK", 0)) os.makedirs(self.dump_dir, exist_ok=True) # 1. 初始化显存分配历史记录 (需 PyTorch 2.1+ 支持) if torch.cuda.is_available(): try: # 记录最多 100,000 次显存分配事件 torch.cuda.memory._record_memory_history( enabled="all", context="alloc", stacks="python" ) logging.info(f"[Rank {self.rank}] 成功开启 CUDA 物理显存分配历史追踪器") except Exception as e: logging.warning(f"无法开启显存追踪器: {e}") # 2. 注册系统信号拦截器 (例如通过 kill -3 <pid> 触发 SIGUSR1 排查死锁) signal.signal(signal.SIGUSR1, self._signal_stack_dump_handler) def _signal_stack_dump_handler(self, signum, frame): """当收到 SIGUSR1 信号时,强制导出当前进程的调用堆栈""" dump_file = os.path.join(self.dump_dir, f"stack_rank_{self.rank}_pid_{os.getpid()}.txt") logging.warning(f"收到信号 {signum},正在导出调用堆栈至 {dump_file}...") with open(dump_file, "w") as f: f.write(f"=== Rank {self.rank} (Local Rank {self.local_rank}) Stack Trace ===\n") traceback.print_stack(frame, file=f) logging.warning(f"堆栈已保存: {dump_file}") def dump_cuda_memory_snapshot(self, tag: str = "oom"): """当发生 OOM 时导出显存快照二进制文件,可导入 https://pytorch.org/memory_viz 查看可视化图表""" if not torch.cuda.is_available(): return snapshot_file = os.path.join(self.dump_dir, f"cuda_memory_rank_{self.rank}_{tag}.pickle") try: logging.error(f"[Rank {self.rank}] 正在导出 CUDA 显存 Snapshot 至 {snapshot_file}...") torch.cuda.memory._dump_snapshot(snapshot_file) logging.error(f"✅ 显存 Snapshot 导出成功!可使用 PyTorch Memory Visualizer 校验剖析。") except Exception as e: logging.error(f"导出显存 Snapshot 失败: {e}") def handle_exception_and_dump(self, exc_type, exc_val, exc_tb): """全局未捕获异常句柄""" logging.critical(f"💥 [Rank {self.rank}] 捕获到全局致命异常: {exc_val}") # 保存异常堆栈文件 error_file = os.path.join(self.dump_dir, f"error_rank_{self.rank}.log") with open(error_file, "w") as f: f.write(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f"Rank: {self.rank} | Local Rank: {self.local_rank}\n") f.write(f"Exception Type: {exc_type.__name__}\n") f.write(f"Exception Value: {str(exc_val)}\n\n") traceback.print_exception(exc_type, exc_val, exc_tb, file=f) # 如果是 CUDA OOM,自动 Dump 显存快照 if issubclass(exc_type, RuntimeError) and "out of memory" in str(exc_val).lower(): self.dump_cuda_memory_snapshot(tag="oom_critical") # 调用默认句柄打印到 stderr sys.__excepthook__(exc_type, exc_val, exc_tb) # 使用示例 if __name__ == "__main__": interceptor = DistributedFaultInterceptor(dump_dir="./dump_evidence") # 将拦截器绑定到全局未捕获异常 sys.excepthook = interceptor.handle_exception_and_dump print(f"当前进程 PID: {os.getpid()} | 可通过以下命令测试堆栈 Dump: kill -3 {os.getpid()}") # 模拟触发一次 CUDA OOM 异常以验证拦截引擎 if torch.cuda.is_available(): print("正在模拟分配超大 Tensor 以触发 OOM 测试...") try: # 申请一个超出普通显存极限的超大 Tensor huge_tensor = torch.empty((100000, 100000, 100), dtype=torch.float32, device="cuda") except Exception as e: # 手动触发全局异常句柄演示 interceptor.handle_exception_and_dump(type(e), e, e.__traceback__) # 清理生成的临时 dump 目录 if os.path.exists("./dump_evidence"): import shutil shutil.rmtree("./dump_evidence")这段拦截引擎能够保证在发生 OOM 的瞬间,自动将 PyTorch 物理 Memory Snapshot 保存为 Pickle 文件,并且支持使用kill -3信号主动导出进程的当前 Python 堆栈。
排障日志公约:如何让日志打印从“废话连篇”变成“定位利器”
除了崩溃 Snapshot 的硬核证据,日常训练日志的格式也需要遵守排障公约。
很多项目的日志文件动辄几个 G,打开全是Epoch 1, Step 100, Loss: 2.34的重复刷新,关键的节点 IP、GPU 温度、主机名却一概没有。
高效的训练日志公约应当包含以下固定元数据头:
结语:排障记录应让后来的人能重建现场,而不是只看到最终修复。
日志要说明当时的上下文
训练停止的那一刻,最有价值的是配置、数据版本、最近的指标和异常栈之间的对应关系。只截取最后一行报错通常不够。记录这些信息不等于保存原始训练数据,敏感字段仍应在落盘前去掉。