训练 Infra 07:稳定性工程——失败模式、监控、checkpoint 与脏环境治理
把训练故障分成五类并给出各自的探测手段,用 Young/Daly 公式算出这套集群的最优 save_steps 约等于 77,并复盘"被 kill 的进程显存未释放导致新任务启动即 OOM"的脏启动竞态与根治方案。
训练 Infra 07:稳定性工程
本节属于大模型训练 Infra 实战课。前六节讲怎么让训练跑得对、跑得快。这一节讲怎么让它别停。
一个残酷的经验数字:大规模训练任务里,用于排查故障和重跑的时间,经常超过纯计算时间。这一节讲的东西不提高 MFU 一个百分点,但它决定你的 40 张卡一个月里有多少天在真正训练。
1. 失败模式分类
先把故障分类,因为不同类别的探测手段完全不同。混在一起想,就会出现"加了一堆监控但真出事时还是不知道看哪"。
| 类别 | 典型现象 | 探测手段 | 平均定位难度 |
|---|---|---|---|
| OOM | 某 rank 崩溃,常在 step 1 之后 | 每 rank 显存日志 + dmesg | 低(如果日志到位) |
| 死锁 | 无进展,GPU 100% 或 0% | 心跳 + py-spy + flight recorder | 中(第 4 节的流程) |
| 慢节点(straggler) | 步耗时突增且不恢复 | 每 rank 分段计时 | 中高 |
| 硬件故障 | ECC 错误、掉卡、NVLink 降级 | nvidia-smi -q、dmesg、xid |
中 |
| 数据问题 | loss 突刺/NaN、某步特别慢 | 数据 checksum + 样本长度分布 | 高 |
各类的关键要点
OOM:第 2 节讲透了。核心是显存日志必须记 step 1 之后的 max_memory_allocated,且每个 rank 都要记。被系统 OOM killer 杀掉的进程连 Python 异常都不会有,只能从 dmesg 里找。
死锁:第 4 节的完整流程。核心是不要相信 GPU 利用率。
慢节点:这是最容易被忽略的一类。集体通信让整组的速度等于最慢的那个,所以一张卡降频/一条链路降级,会让 40 张卡一起变慢,而所有卡看起来都很"忙"。探测方法是记录每 rank 的分段耗时:
每步记录: 前向耗时 / 反向耗时 / 通信等待耗时 / 优化器耗时
关键是**“通信等待耗时”**这一项。慢节点自己的通信等待很短(大家都在等它),而其他 39 个 rank 的通信等待会异常长。通信等待时间的分布,就是慢节点的指纹。
常见成因:GPU 温度墙降频、单条 NVLink 降级到低带宽、某网卡跑在错误的速率、CPU 被别的进程抢占、NUMA 绑核不对、这台机器上的 NFS 挂载不健康。
硬件故障:定期采集 nvidia-smi -q 里的 ECC 计数、时钟节流原因(Clocks Throttle Reasons)、NVLink 状态。Xid 错误要从 dmesg 抓。ECC 可纠正错误的计数持续上升是换卡的先兆。
数据问题:多模态 + 变长序列的组合下这一类会变多。一个超长样本、一张损坏的图、一段异常的 token 分布都可能引发 OOM 或 loss 突刺。建议记录每步的样本 id 列表——出问题时能立刻定位到具体样本,这个日志的价值远超它的成本。
2. 监控设计
三层结构
第 1 层 · 进度与健康(必须有,秒级)
每 rank 每步: step号, 时间戳, loss, grad_norm, lr,
max_memory_allocated, 分段耗时
→ 落到文件 + 汇总到一处
第 2 层 · 趋势(tensorboard / 类似工具)
loss, grad_norm, 吞吐 tok/s, MFU, 学习率
MoE 专有: 专家负载分布, drop rate, router 熵
→ 用于看训练质量,不用于故障探测
第 3 层 · 看门狗(外部进程,独立于训练)
读第 1 层的心跳: "最新心跳距今 > 阈值" → 报警/自动处置
→ 必须是独立进程,训练挂了它还活着
第 3 层为什么必须独立
第 4 节讲过 NCCL watchdog 的局限:它只能发现卡在 collective 里的情况,管不了数据加载卡住、NFS 挂死、Python 死锁。
而一个在训练进程之外运行的心跳监控,能发现所有"没进展"的情况,不管原因是什么。它的逻辑简单到可以用几十行 shell 写:
每 60 秒:
读所有 rank 的心跳文件,取最新时间戳
if now - max(timestamps) > 3 × 单步耗时:
触发诊断包采集(py-spy 全集群扫栈 + nvidia-smi + dmesg 尾部)
报警
注意"触发诊断包采集"这一步——报警的同时自动把现场抓下来。因为等人赶到时,进程可能已经被别人重启了,现场就没了。第 4 节那套 py-spy 流程应该是自动的,不是手动的。
MoE 专有指标
除了通用指标,MoE 一定要看这三个:
| 指标 | 健康表现 | 异常含义 |
|---|---|---|
| 专家负载分布 | 相对均匀 | 塌缩到少数专家 → router collapse |
| drop rate | 低且稳定 | 突增 → 路由不均或容量因子太小 |
| router 熵 | 稳定,不持续下降 | 持续下降 → 专家多样性在丢失 |
这三个指标比 loss 更早报警。 router collapse 时 loss 可能还在缓慢下降,但模型实际上已经退化成了一个稠密小模型——你付着 35B 的显存和通信成本,拿到 3B 的效果。
3. checkpoint 策略
写入成本(回到第 1 节那笔账)
唯一数据量:
bf16 权重 35e9 × 2 = 70 GB
优化器状态 35e9 × 12 = 420 GB
--------------------------------
合计 = 490 GB
NFS 写带宽约 1 GB/s → 一次 ≈ 490 秒 ≈ 8.2 分钟 ≈ 4 个训练步
去重的价值
每 rank 各写本地全量态: 约 972 GB (非专家在 40 张卡上重复,专家分片在 5 张卡上重复)
只写唯一分片(mcore 的 distributed checkpoint): 490 GB
差 2 倍。 这就是为什么要用 Megatron 的 distributed checkpoint 而不是"每个 rank 各存一份 state_dict"。
最优 save_steps:Young/Daly 公式
checkpoint 频率是一个可以算的最优化问题。经典结论:
最优间隔 = sqrt(2 × C × MTBF)
C = 写一次 checkpoint 的耗时
MTBF = 平均无故障时间
直觉:存太密,开销大;存太疏,崩一次损失大。最优点在两者平衡处。
代入 C = 490 秒、单步 120 秒:
| MTBF | 最优间隔 | ≈ save_steps | checkpoint 开销 | 崩溃期望损失 |
|---|---|---|---|---|
| 4 小时 | 1.04 h | 31 | 11.5% | 0.52 h |
| 8 小时 | 1.48 h | 44 | 8.4% | 0.74 h |
| 24 小时 | 2.56 h | 77 | 5.1% | 1.28 h |
| 72 小时 | 4.43 h | 133 | 3.0% | 2.21 h |
对照几个常见的拍脑袋取值:
| save_steps | 间隔 | 开销 | 崩溃期望损失 |
|---|---|---|---|
| 50 | 1.67 h | 7.6% | 0.83 h |
| 100 | 3.33 h | 3.9% | 1.67 h |
| 200 | 6.67 h | 2.0% | 3.33 h |
| 500 | 16.67 h | 0.8% | 8.33 h |
所以关键问题是:你们的 MTBF 是多少?
- 如果一天崩一次(MTBF 24h),
save_steps ≈ 77,开销 5% - 如果还在调试期、几小时就出事(MTBF 4-8h),
save_steps ≈ 30-45,开销 8-12%——这个开销是值得付的 save_steps=500只有在 MTBF 超过一周时才合理
建议:先记录实际的 MTBF(每次任务中断都记时间和原因),再代公式。 调试期用 30-50,稳定后放到 77-100。
异步 checkpoint
如果不想付这 5-10%,标准做法是异步:
1. 训练进程把状态拷到本地 NVMe(快,几十秒)
2. 立刻恢复训练
3. 后台进程慢慢把本地盘的内容搬到 NFS
代价是需要本地盘空间(每节点约 100GB 量级),以及要处理"上一次还没搬完,下一次又来了"的情况。收益是 checkpoint 的阻塞时间从 8 分钟降到几十秒。
safetensors 与格式
训练态(mcore 分片 + 优化器) → 用于断点续训,格式绑定 mcore 版本
交付态(safetensors,只有权重) → 用于评测、RL rollout、对外交付
两者用途不同,都要存。safetensors 的价值在于它是框架中立的交换格式——第 5 节讲过,这正是 SFT 的产出能直接喂给 verl 的原因。
断点续训的三个必查项
续训最怕的是"跑起来了,但其实状态不对"。至少要验证:
- 优化器状态真的恢复了:检查 Adam 的 m/v 不是全零。少这一步,续训相当于重新 warmup,loss 会有个明显的坑。
- 数据顺序恢复了:dataloader 的采样器状态、epoch 内的位置、随机种子。否则会重复训练同一批数据,或者跳过一部分。
- 学习率调度器的 step 数恢复了:否则 lr 会跳回到 warmup 阶段。
验证方法:续训后头几步的 loss 应该和中断前的最后几步平滑衔接。 如果有明显跳变,上面三项一定有一项错了。这个检查花一分钟,能省掉几天的困惑。
4. 事故复盘 ③:脏启动竞态
现象
上一个任务被 kill 掉。立刻启动新任务,新任务启动即 OOM。
根因
被 kill 的进程,显存不一定立刻释放。
原因有好几种,都很常见:
- 进程还在退出过程中。
kill发的是 SIGTERM,进程要走信号处理、析构、CUDA context 销毁。显存在 CUDA context 真正销毁时才归还,这可能要几秒到几十秒。 - 进程变成僵尸/D 状态。如果它卡在不可中断的内核调用里(比如 NFS IO、或某些 GPU 驱动调用),
kill根本杀不掉它,显存永久占着,直到那个调用返回或机器重启。 - 子进程泄漏。torchrun 会 fork 出很多进程(dataloader worker 等),杀主进程时子进程可能变成孤儿继续持有显存。
- NCCL/CUDA 的共享内存和 IPC 句柄残留在
/dev/shm。
于是就出现了竞态:新任务在旧任务的显存还没归还时启动,看到的可用显存不够,直接 OOM。 而且因为第 2 节讲的原因(惰性初始化 + 非均匀分片),这个 OOM 可能只发生在个别 rank 上,然后又变成一次静默死锁——两个事故叠加。
根治:启动前排空
在训练脚本最前面加一道"排空闸门",不通过就拒绝启动:
#!/usr/bin/env bash
# drain.sh —— 每个节点在启动训练前必须先通过这一关
set -euo pipefail
PATTERN='pretrain_gpt|megatron|swift|torchrun|verl'
NEED_FREE_MB=${NEED_FREE_MB:-90000} # 96GB 卡,要求至少 90GB 可用
TIMEOUT=${TIMEOUT:-180}
# 1. 先礼后兵地清掉残留进程
pids=$(pgrep -f "$PATTERN" || true)
if [[ -n "$pids" ]]; then
echo "[drain] 发现残留进程: $pids,发送 SIGTERM"
kill -TERM $pids 2>/dev/null || true
sleep 15
pids=$(pgrep -f "$PATTERN" || true)
if [[ -n "$pids" ]]; then
echo "[drain] 仍存活,发送 SIGKILL: $pids"
kill -KILL $pids 2>/dev/null || true
fi
fi
# 2. 清 IPC/共享内存残留
rm -rf /dev/shm/nccl-* /dev/shm/torch_* 2>/dev/null || true
# 3. 轮询等显存真正归还 —— 这是最关键的一步
deadline=$((SECONDS + TIMEOUT))
while true; do
# 取所有卡里"可用显存最小"的那一张
min_free=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits \
| sort -n | head -1)
if (( min_free >= NEED_FREE_MB )); then
echo "[drain] OK: 最小可用显存 ${min_free}MB >= ${NEED_FREE_MB}MB"
break
fi
if (( SECONDS >= deadline )); then
echo "[drain] 失败: 等了 ${TIMEOUT}s,最小可用仍只有 ${min_free}MB"
echo "[drain] 占用者:"
nvidia-smi --query-compute-apps=pid,used_memory,process_name \
--format=csv
# D 状态进程无法杀掉,只能人工介入
ps -eo pid,stat,wchan:32,cmd | awk '$2 ~ /D/' || true
exit 1 # ← 关键:拒绝启动,而不是硬着头皮上
fi
sleep 5
done
三个设计要点
- 看"最小可用显存",不是平均。一张卡不干净就会让整个任务死锁,木桶效应。
- 超时就退出(
exit 1),不要继续。硬着头皮启动的后果是 5 小时静默死锁,代价远大于"晚 3 分钟启动"。 - 失败时打印占用者和 D 状态进程。D 状态(不可中断睡眠)的进程杀不掉,这种情况需要人工介入甚至重启节点——脚本要能明确告诉你"这不是我能解决的"。
这道闸门要在每个节点上跑,且必须全部通过才启动训练。 建议做成 launcher 的一部分,而不是靠人记得执行。
5. 脏环境治理
三种"脏"
| 脏源 | 表现 | 治理 |
|---|---|---|
| 环境变量污染 | 集体通信死锁,无报错(事故 ⑥) | 统一注入 + 启动时 diff |
| 显存/进程残留 | 启动即 OOM(事故 ③) | 排空闸门 |
| Python 环境漂移 | 三个月后同样代码跑不出同样结果 | 锁文件 + 镜像 |
环境变量:启动时 diff
第 4 节给了规则,这里给实现思路。启动后每个 rank 做一件事:
import os, json, socket, hashlib
import torch.distributed as dist
KEYS = ('NCCL_', 'TORCH_', 'CUDA_', 'OMP_', 'GLOO_')
def assert_env_consistent():
env = {k: v for k, v in os.environ.items() if k.startswith(KEYS)}
# 排除天然应当不同的
for k in ('CUDA_VISIBLE_DEVICES', 'NCCL_COMM_ID'):
env.pop(k, None)
blob = json.dumps(env, sort_keys=True)
digest = hashlib.sha256(blob.encode()).hexdigest()[:16]
gathered = [None] * dist.get_world_size()
dist.all_gather_object(gathered, (digest, socket.gethostname(), blob))
if dist.get_rank() == 0:
groups = {}
for d, host, b in gathered:
groups.setdefault(d, []).append(host)
if len(groups) > 1:
print("[env] 环境变量不一致! 分组情况:")
for d, hosts in groups.items():
print(f" {d}: {sorted(set(hosts))}")
# 打印差异明细,然后终止
raise RuntimeError("环境变量跨节点不一致,拒绝继续")
print(f"[env] OK,全集群一致: {digest}")
十几行代码,挡掉一整类最难查的故障。 注意它必须在 init_process_group 之后、真正训练之前跑——而且它自己就是一次 all_gather,如果连这一步都挂住,那本身就是最早的信号。
容器化的真正价值
容器化经常被当成"部署方便",但在训练集群上它的价值是别的:
没有容器: "环境" = 宿主机上所有人历史操作的叠加
不可版本化、不可回滚、不可复现、不可审计
有容器: "环境" = 一个 image tag
可版本化、可回滚、可复现、可 diff
具体解决什么:
- bashrc 污染消失:容器里的环境由 Dockerfile 定义,别人往宿主机 bashrc 里写什么都不影响你
- 依赖锁定:第 5 节那条 cu128 约束链被固化在镜像里,不会有人"顺手升级一下"
- 可复现:三个月后用同一个 tag,跑出同样的结果
- 多任务隔离:不同实验用不同镜像,互不干扰
代价:需要处理 GPU 直通(--gpus all)、IPC(--ipc=host 或加大 shm)、网络(RDMA 设备直通、--net=host)、NFS 挂载。这些都是一次性的工程投入。
建议:即使短期不上容器,也至少要做到"依赖有锁文件 + 环境变量由启动脚本统一注入 + 启动前排空 + 启动时 diff"。这四条能覆盖容器化八成的收益,而工作量小一个数量级。
6. 一份启动前检查清单
把本课程前七节的教训压成一张可执行的表:
[ ] 1. 排空:所有节点最小可用显存 >= 90GB,无残留进程 (第 7 节)
[ ] 2. 环境:全集群 NCCL_*/TORCH_*/CUDA_* 变量 diff 一致 (第 4/7 节)
[ ] 3. 网卡:NCCL_SOCKET_IFNAME 和 NCCL_IB_HCA 显式指定 (第 1 节)
[ ] 4. 显存预算:算过峰值,含优化器状态,留 >=8% 余量 (第 2 节)
[ ] 5. 预热:启动即做一次 dry-run step,提前 alloc 优化器状态 (第 2 节)
[ ] 6. 整除:TP×PP×CP×DP = world 且 EP | DP (第 3 节)
[ ] 7. 超时:ddp_timeout 设 10-15 分钟,不是 1 小时 (第 4 节)
[ ] 8. 记录:flight recorder 打开,dump 到共享目录 (第 4 节)
[ ] 9. 异常:except 里先打印落盘再清理,用 os._exit (第 4 节)
[ ] 10. 日志:每 rank 每步的 loss/显存/分段耗时,带 rank 和主机名 (第 7 节)
[ ] 11. 心跳:独立看门狗进程,超时自动抓诊断包 (第 7 节)
[ ] 12. checkpoint:save_steps 按 MTBF 算过,不是拍的 (第 7 节)
[ ] 13. 续训:验证过 loss 平滑衔接 (第 7 节)
[ ] 14. MoE:专家负载/drop rate/router 熵 三个指标在看 (第 7 节)
7. 常见坑
- 监控只有 loss 曲线。loss 正常但 router 已经塌缩、或某节点已降频,你看不出来。
- 看门狗跑在训练进程里。训练挂了它也挂了。
- save_steps 拍脑袋。500 在 MTBF 8 小时的集群上意味着期望损失 8 小时。
- 只存训练态不存 safetensors,或反过来。两者用途不同。
- 续训不验证衔接。优化器状态没恢复的续训,看起来在跑,实际上在浪费。
- kill 完立刻重启。事故 ③ 的完整复现路径。
- 把"环境"当成给定条件。它是你需要主动管理的对象。
- 报警了但没抓现场。人赶到时进程已被重启,只能等下次。
思考题
1. 你们记录了一个月,发现任务中断 6 次:3 次 OOM(都在调整配置后的第一步)、2 次单节点掉卡、1 次 NFS 挂死。
- (a) MTBF 大约是多少?按 Young/Daly,
save_steps该设多少? - (b) 那 3 次 OOM 用本节清单的哪几条可以完全避免?
- © NFS 挂死那次,NCCL watchdog 能发现吗?什么能发现?
2. 有人提议把 checkpoint 改成异步(先落本地 NVMe,后台搬 NFS)。
- (a) 阻塞时间从 8.2 分钟降到多少?(假设本地 NVMe 写 2 GB/s)
- (b) 这时候 Young/Daly 里的 C 变成多少?最优 save_steps 变成多少(按 MTBF 24h)?
- © 新引入了什么故障模式?(提示:本地盘满了会怎样、搬运没完成就崩了会怎样)
最后一节讲从 SFT infra 到 RL infra 的演进:rollout 引擎、reward 服务、权重同步这三块新增部件,以及"一套 infra 两个入口"的设计思想——前七节学的东西,在 RL 阶段哪些直接复用、哪些要重新算。