原创教学项目 · 核对:2026-10-09。附带 CPU 标准库恢复实验,不是分布式 checkpoint 后端的完整实现。
训练跑到一半中断,幸好权重文件还在。加载后,同一道题的输出没变,下一步训练却和原来对不上。问题可能不在权重:优化器记住的更新方向、当前学习率,以及接下来该读哪条数据,也都影响下一步。
这篇先用 2 步更新看看差别,再讨论该存什么、什么时候存,以及如何检查真的接上了。只想先弄懂原理,可以读到小例子;正在写训练脚本,再往下看恢复测试。
先分清这里的 checkpoint 指什么
| 你可能听到的说法 | 实际做的事 | 能不能接着训练? |
|---|---|---|
| “下载一个模型 checkpoint” | 取得某个版本的模型权重及配套配置 | 可以拿来初始化;不一定包含上次训练的全部状态 |
| “每隔 1000 步存 checkpoint” | 把当时的训练状态写下来 | 状态存全、版本兼容,才有条件继续同一次训练 |
| “打开 activation checkpointing” | 少存一些中间激活,反向时重新计算 | 它解决显存问题,不负责中断恢复 |
拿已有权重开始新实验叫 warm start;继续被打断的那次实验叫 resume。两者都有用,只是比较实验时要分清。比如换了数据、重置优化器后继续训练,可以另记一个 run,不必硬说成无缝恢复。
权重一样,为什么下一步会不同?
先不看大模型。只用 1 个权重,初值为 1;学习率为 0.1,动量系数(momentum)为 0.9。为了只观察动量的影响,假设每步梯度都为 1,没有 weight decay 或其他修正。
每步先算动量 $v_{t+1}=0.9v_t+g_t$,再更新权重 $w_{t+1}=w_t-0.1v_{t+1}$。第一步后,动量是 1,权重是 0.9。此时中断:
- 完整恢复0.9 → 0.71动量也恢复为 1,下一步动量为 0.9 × 1 + 1 = 1.9。
- 只恢复权重0.9 → 0.80新优化器从动量 0 开始,下一步动量只有 1。
def momentum_step(weight, velocity, gradient, rate=0.1, momentum=0.9):
next_velocity = momentum * velocity + gradient
return weight - rate * next_velocity, next_velocity
saved_weight, saved_velocity = momentum_step(1.0, 0.0, 1.0)
resumed_weight, resumed_velocity = momentum_step(saved_weight, saved_velocity, 1.0)
warm_weight, warm_velocity = momentum_step(saved_weight, 0.0, 1.0)
assert abs(resumed_weight - 0.71) < 1e-12
assert abs(warm_weight - 0.80) < 1e-12
这也解释了为什么“加载后的预测一致”还不够:预测主要检查模型状态,接着更新一次才会用到优化器、学习率和下一批数据。Adam 的状态更丰富,但检查思路一样。
两类产物,不要混成一个文件
| 产物 | 应包含什么 | 用在哪里 |
|---|---|---|
| 恢复 checkpoint | 模型或 adapter、优化器、调度器、步数、RNG、数据状态、运行清单;启用 AMP 时还需 GradScaler 等状态 | 中断后继续训练 |
| 推理导出 | 权重或 adapter + 精确基座、tokenizer、聊天模板、配置与生成约定 | 离线评估、推理服务 |
LoRA adapter 很小,也不能独立说明用了哪个基座。记录不可变 revision,不只写模型名。合并、量化或更换推理后端后,用同一组固定输入再测;训练进程里生成正常,不保证导出的产物也一致。
训练恢复包可能保留数据路径和样本状态,不应原样当作公开模型包。只加载可信来源的文件;不要为了解决兼容性随意关闭反序列化安全限制。
第一次实现时,可以按“漏存之后会发生什么”来检查:
| 漏掉什么 | 可能看到什么 | 对照时看哪里 |
|---|---|---|
| Optimizer state | 预测没变,下一步权重却不同 | 上面的 momentum 例子 |
| Scheduler / step | 接上后学习率回到开头或错一拍 | 下一次更新实际使用的学习率 |
| 随机数生成器状态(RNG state) | Dropout 或数据增强换了一次随机结果 | 重启后的随机数和梯度 |
| 数据位置 / packing buffer | 已见样本重跑,或部分样本被跳过 | 下一批样本 ID、token 与 mask |
| 数据或 tokenizer 版本 | 同一个 ID 读出来已不是原来的输入 | 版本、固定样本的编码结果 |
不要因为 loss 有一点波动就断定恢复失败,也不要因为曲线接得平滑就认定成功。先固定环境和输入,核对下一步;跨硬件或版本时再区分数值误差与状态丢失。PyTorch 的保存教程也把用于继续训练的状态与单独模型权重分开。
恢复的是哪一个时刻
最容易解释的保存点是:完成一次 optimizer update 和 scheduler 更新、清空梯度后,开始下一次 accumulation window 之前。
- 01 / 计算完成这一组 microbatch有效 token 分母覆盖整个累积窗口。
- 02 / 更新更新参数与训练计数保持 optimizer、scheduler 和数据位置一致。
- 03 / 保存先写完整快照写入期间不要把半成品标记成最新可恢复版本。
- 04 / 验证重启后跑下一步对照下一个样本、学习率和更新后的状态。
若在累积到一半时保存,还需要未应用梯度、microstep 位置和相应数据状态。项目先避免这条复杂路径。异步保存也要确定快照对应同一时刻,不能让后台读到正在被更新的 tensor。
数据游标尤其容易错:worker 可能已经预取后面的样本,但模型还没消费它们。恢复应对齐已消费数据,而不是文件读取进度。动态 packing 还要保存剩余缓冲,或用确定性的方式重建。只记 epoch=2 不够。
一个能亲手验证的小实验
training_contracts.py训练一个只有 1 个权重的回归玩具:有 momentum、随步数变化的学习率、打乱的数据顺序和随机输入扰动。虽然不是 LLM,但足以暴露“漏存一个状态”的问题。
比较连续跑 12 步,与跑 5 步、保存、创建新对象恢复后再跑 7 步。相同 Python 环境下,完整恢复应得到相同状态;仅恢复权重或故意清空 momentum 则不是同一训练路径。
python3 practice/post-training/code/training_contracts.py
python3 -m unittest discover -s site/tests -p 'test_training_contracts.py'
程序在临时目录里写 JSON,不读外部模型文件。保存时写同目录临时文件,flush / fsync 后用原子替换更新目标。这个教学写法防止读者看到写了一半的目标文件;不承诺机器断电后的持久性,也不是对象存储或多 rank 提交协议。
我们还会测试:少了 RNG 字段会被拒绝,数据版本变化会被拒绝,遗留的临时文件不会覆盖上一份可恢复状态。实际 GPU 训练还需单独保存各 rank 的 CPU / CUDA RNG,并考虑非确定性算子;玩具实验的精确相等不能直接推广到所有硬件。
多卡保存,多了哪些问题
分片(sharding)模型不能假设“rank 0 存一下就全了”。PyTorch Distributed Checkpoint提供多 rank 状态保存与加载,并支持在兼容条件下重新分片。它解决张量布局问题,不自动恢复应用层的数据顺序或业务配置。
| 变化 | 先确认什么 | 不能默认保证什么 |
|---|---|---|
| 同拓扑恢复 | 所有必要分片完整、状态与 manifest 对应同一步 | 文件存在就代表完整可用 |
| 改变 GPU 数量 | 后端能加载该模型 / 优化器布局,重算 DP batch | 样本顺序、dropout、更新轨迹逐 bit 相同 |
| 升级框架 | 状态格式、参数名称、optimizer 映射可兼容 | 任意版本间可直接恢复 |
| 异步保存 | 快照一致性、后台写完成、额外内存 | 调用返回就表示所有文件落盘 |
| 对象存储 | 用唯一版本前缀写入,全部成功后再发布完成清单 | 本地文件 rename 的语义可直接搬过去 |
恢复之前核对文件列表、大小 / 校验和及完成标记;哈希用于发现损坏,不证明来源可信。保存失败时保留旧版本,不先删除唯一可用的 checkpoint。
多久存一次,best 和 latest 怎么留
存得越密,可能损失的训练越少,但停顿与存储越多。假设每次暂停保存 30 秒,每训练 10 分钟保存一次,忽略其他开销,保存占总时间 $30/(600+30)\approx4.76\%$。若故障在两次保存之间均匀发生,平均需重跑约 5 分钟的训练;这只是便于理解的假设,不是通用最优间隔。
latest 用于接着跑,best 用于记录按开发集规则选出的候选(candidate),两者不一定是同一步。事先写清比较指标、保存频率和保留数量;测试集不参与 best 选择。也保留一份已验证可加载的旧版,等新保存完成并通过检查再轮换。
最后把恢复测试放进实验的早期,而不是第一次掉卡以后。下一篇看如何比较与替换模型:能恢复只是必要条件,还不是模型值得上线的理由。
本页术语 2
- 分片 sharding
- 把参数、梯度或优化器状态切开分给多张卡,每张只存其中一份
- 候选 candidate
- 召回阶段捞出来、等待精排挑选的内容