Checkpoint 保存与恢复
训练中断会杀死整个 run;checkpoint 让它接着跑。把模型、优化器、调度器、loss 历史、step 计数器和 RNG 状态原子地存下来,这样任意时刻被 kill 都能在磁盘上留下一个合法文件。
类型: Build 语言: Python 前置要求: 第19阶段第42-45课 预计时间: ~90 分钟
学习目标
- 把完整的训练状态打包到一个 payload 中,能在全新进程里加载恢复。
- 用"先写临时文件再 rename"的方式实现原子保存,crash 不会留下写到一半的文件。
- 恢复 Python、NumPy 和 PyTorch 的 RNG 状态,使 resume 后的 loss 与未中断的 baseline 一致。
- 为单文件装不下的大模型构建分片 checkpoint 布局,带 hash 校验的 shard 加上 JSON 索引。
问题背景
你设了一个 18 小时的训练任务。挂钟上限是 4 小时。集群在第 11 小时重启了——因为你上面的人批准了一次内核升级。没有 checkpoint 就从头来过。没有 resume,就连优化器花了前 11 小时才学到的状态也丢了:即使模型权重幸存,AdamW 的矩估计也没了,下一步会朝着训练轨迹早已走过的方向猛冲。
正确的产物是一个包含续跑所需一切的文件:模型参数、优化器状态、调度器状态、画图用的 loss 历史、当前 step/epoch/batch-in-epoch 计数器,以及每种随机源的 RNG 状态。没有 RNG 状态,resume 后的 loss 曲线就是另一条曲线——同一个模型、同一份数据,不同的 shuffle、不同的 dropout mask、仪表盘上不同的数字。
原子保存是合约的另一半。直接写目标文件名意味着写到一半 crash 会留下一个损坏文件;resume 读到的是垃圾。先写同目录下的临时文件再 rename,crash 时之前的好文件依然完好。rename 在 POSIX 文件系统上是原子操作。
核心概念
flowchart TD
ckpt[checkpoint 载荷] --> m[模型 state_dict]
ckpt --> o[优化器 state_dict]
ckpt --> s[调度器 state_dict]
ckpt --> tr[训练状态: step, epoch, batch_in_epoch, losses]
ckpt --> rng[RNG 状态: python, numpy, torch_cpu, torch_cuda]
ckpt --> meta[wall_saved_at, schema]
ckpt --> write[原子写入: 临时文件然后 os.replace]五类状态
| 类别 | 为什么重要 |
|---|---|
| Model | 权重和 buffer;模型"是什么" |
| Optimizer | Momentum 和 adaptive moments;没有这些下一步就是另一个优化问题 |
| Scheduler | 学习率在曲线上的位置;cosine schedule 尤其在意 |
| Train counters | Step、epoch、batch-in-epoch,加上画仪表盘的 loss 历史 |
| RNG state | dropout、数据 shuffle 和模型内部采样的确定性保证 |
原子保存
flowchart LR
payload[载荷] --> tmpf[写入 .ckpt.pt.XXXX.tmp]
tmpf --> rename[os.replace 到 ckpt.pt]
rename --> done[ckpt.pt 有效]
crash1[rename 前崩溃] --> orig[ckpt.pt 不变]
crash2[rename 后崩溃] --> done两条规则。第一,临时文件和目标放在同一个目录下,保证 rename 在同一个文件系统内;跨设备 rename 不是原子的。第二,每次临时文件名唯一,避免两个 writer 互踩。
分片 checkpoint
模型变大后,单文件 payload 加载太慢、检查太难,网络共享传输中断更痛苦。解决办法是把参数状态拆成 shard,再写一个小索引把它们串起来。
flowchart LR
state[state_dict] --> split[按轮询方式拆分 key 到 N 个分片]
split --> s0[model.shard-000.pt]
split --> s1[model.shard-001.pt]
split --> sN[model.shard-NNN.pt]
s0 --> idx[index.json]
s1 --> idx
sN --> idx
meta[meta.pt: 优化器 + 调度器 + 训练状态 + RNG] --> idx索引记录 shard 数量、每个 shard 的 sha256 以及 meta 文件的 sha256。加载器在任何 hash 不匹配时直接报错。shard 可以落在不同物理磁盘上;meta 很小,先读。
Resume 从 epoch 中间继续
如果 resume 只能跳到下一个 epoch 开头,浪费几分钟到一整天不等。解决办法是 (epoch, batch_in_epoch) 加上 RNG 状态。加载后,训练循环把随机数生成器快进到当前 epoch 中已消费的 batch 之后,从 batch_in_epoch 继续。课程代码就是这么做的;断言是 resume 后的 loss 轨迹与未中断 baseline 的差异在 1e-4 以内。
cc-atomic-checkpoint动手构建
code/main.py 提供四个原语和一个 demo 驱动。
第一步:捕获和恢复 RNG 状态
capture_rng_state 返回一个 dict,包含 Python 的 random.getstate、NumPy 的 np.random.get_state,以及 PyTorch CPU 和 CUDA 的 RNG 字节。restore_rng_state 做反向操作。CPU tensor 是一个 uint8 字节 buffer,PyTorch 的 RNG 知道怎么消费它。
第二步:原子保存
atomic_save 把 payload 写到目标目录的临时文件,再用 os.replace 换到最终文件名。atomic_write_json 对分片索引做同样的事。
第三步:完整 checkpoint 往返
save_checkpoint 把 model、optimizer、scheduler、train state 和 RNG 打包成一个 dict。load_checkpoint 做反向操作并返回 TrainState。schema 字段是升级钩子:未来格式变更只需 bump 版本字符串,loader 按版本分发。
第四步:分片变体
save_sharded_checkpoint 用 round-robin 把参数 key 分配到 N 个 shard,对每个 shard 做原子保存,写一个包含 optimizer、scheduler 和 train state 的 meta 文件,再写一个带 shard sha256 的 JSON 索引。load_sharded_checkpoint 在合并前校验每个 shard。
第五步:resume demo
run_resume_demo 训练一个小模型跑 total_steps 步,在 interrupt_at 处保存 checkpoint,然后继续。第二个进程恢复 checkpoint 并跑完剩余步骤。函数返回中断点之后两条 loss 轨迹的 max absolute difference。RNG 恢复后,差值为零或浮点噪音。
运行:
python3 code/main.py单文件和分片 demo 都断言 max-diff < 1e-4。摘要写入 outputs/resume-demo.json。
实际应用
生产训练栈把 checkpointing 作为 trainer 的一部分。形状一样:model + optimizer + scheduler + counters + RNG,原子写入,以 step 命名方便找最新的。分片布局让大模型可以并行读取;index.json 是让这一切运转的关键。
三个需要坚持的模式:
- Schema 是 payload 里的一个字符串。 迁移逻辑按它分发。没有它你就没法演进格式而不破坏旧 run。
- 每个 shard 都做 sha256。 静默截断的下载是最坏的 bug 类型;loader 要么早报错,要么就是太晚才发现。
- 保持 checkpoint 频率诚实。 每 N 步和每隔一定挂钟分钟数保存一次,取较短者。否则那个 crash 的超长 step 会浪费一整个窗口的工作量。
交付产物
outputs/skill-checkpoint-save-resume.md 是任何新训练脚本的 recipe:payload 结构、原子写入、RNG 捕获、分片索引。把 skill 丢进 repo,在周期性保存点接上 save_checkpoint,在启动时接上 load_checkpoint,run 就能扛住 kill。
练习
- 把 round-robin 分片替换为按参数组分片(
.weight结尾的 vs.bias结尾的)。什么时候哪种布局更好? - 扩展保存逻辑,保留最近 K 个 checkpoint 并清理更旧的。磁盘小的时候 K 取多少合适?
- 加一个
--ckpt-every-seconds标志,按挂钟间隔触发保存,而非仅按 step 数。 - 加一条 checksum 校验路径,在启动时扫描目录下的所有 checkpoint,报告哪些已损坏。
- 实现一个
migrate_v1_to_v2函数,给 payload 加一个新字段并 bump schema 字符串。让 load 兼容两个版本。
关键术语
| 术语 | 日常说法 | 实际含义 |
|---|---|---|
| Atomic save | "写完祈祷" | 先写到同目录的临时文件,再用 os.replace 替换到目标文件名 |
| State dict | "权重" | 模型参数和 buffer,以参数名为 key |
| Sharded checkpoint | "大模型文件" | 多个文件,每个 shard 一个,外加一个 meta 文件和一个带 sha256 的 JSON 索引 |
| RNG state | "随机种子" | 捕获的 Python random、NumPy、torch CPU、torch CUDA 的状态;不只是 seed |
| Mid-epoch resume | "重启" | 快进 RNG,从同一个 epoch 的下一个 batch 继续 |
延伸阅读
- POSIX
rename语义,os.replace原子性声明的基础。 - PyTorch
torch.save和torch.load文档,包括跨设备恢复的map_location。 - 第19阶段第46课覆盖了本课的 checkpoint payload 需要跨越的梯度累积。
- 第19阶段第48课覆盖了分布式 wrapper,本方案需要兼容其 state dict 格式。
- Linux 内核
fsync文档,原子 rename 背后的持久性保证。