中断续训也与一次跑完相同 — 续训文件里放什么
目标
让一次性训练 60 步的结果,与在 30 步处用原子方式保存恢复文件(权重、优化器状态、步数、批次随机数状态)后接着训练的结果逐比特一致。测量漏掉优化器状态或数据位置会相差多少,以及半精度保存改变了什么。
为什么重要
只保存权重、再接着训练,结果会在没有报错的情况下变掉。AdamW 为每个参数记着两个矩,丢了这份记忆,接上之后的几步走的步幅完全不同。如果在轮次中途把批次顺序倒回去,前面的部分会看两遍,后面的部分却看不到。这类差别几乎不会在损失曲线上显现,很容易在不知不觉中溜过去。
所以 MiniMind 的 lm_checkpoint 把权重和恢复文件分开放,并且都先写进临时文件再改名。这个实验把这个设计究竟守住了什么,与一次性训练的结果做比较来确认。
步骤
- 编写 /root/mm/ckpt/run.py,一次性训练 60 步(随机种子 7、批次 8、长度 128、lr 3e-3),留下 /root/mm/ckpt/straight.pth 和 /root/mm/ckpt/straight_log.csv(
step,train_loss)。 - 进行同样的训练,在 30 步处停下,把恢复文件 /root/mm/ckpt/resume.pth 先写进
.tmp,再用os.replace换名保存(model、optimizer、step、gen_state)。 - 从恢复文件接着训练第 31 步到第 60 步,留下 /root/mm/ckpt/resumed.pth 和 /root/mm/ckpt/resumed_log.csv。必须与第 1 步相同。
- 只丢掉优化器状态接着训练,留下 /root/mm/ckpt/noopt.pth。
- 只丢掉批次随机数状态(用最初的随机种子重来)接着训练,留下 /root/mm/ckpt/noskip.pth。
- 把
straight.pth以半精度保存为 /root/mm/ckpt/straight_fp16.pth,把两个文件的大小和 logits 差异写入 /root/mm/ckpt/fp16.json。 - 在恢复文件中数出权重和优化器状态的元素数,写入 /root/mm/ckpt/optim.json。
- 在 /root/mm/ckpt/report.md 中写
## 무엇을 저장하나、## 빠뜨리면、## 반정밀도三节(三个标题为韩文,依次意为“保存什么”“漏掉会怎样”“半精度”),并放入第 7 步的倍率和第 6 步的 fp16 文件大小(字节)。
参考
- 60 步在节点上大约 5 秒。如果在一个脚本里设置
--stop-at、--resume、--no-optim、--no-skip,第 3–5 步各只要一行。 - 如果用
torch.randint(..., generator=g)抽取批次,就可以用g.get_state()、g.set_state()保存、恢复“下一个要抽的批次”。MiniMind 的SkipBatchSampler所做的事,由这一行完成。 - 恢复文件请用
torch.load(경로, weights_only=True)(占位符为文件路径)读取。如果直接读取别人给的检查点,里面的 Python 代码可能被执行。 - 常见错误:在创建模型 之前 没有固定随机种子,导致恢复的一方初始化不同(因为马上会被覆盖,看上去没问题,但生成器状态会错位);学习率安排按接续的步数从 0 重新数起。
- 原文:trainer_utils.py — lm_checkpoint·SkipBatchSampler · PyTorch — Saving and Loading · torch.load weights_only · os.replace
一次性训练 60 步——基准
编写 /root/mm/ckpt/run.py,以随机种子 7、批次 8、长度 128、lr 3e-3(MiniMind 余弦,以总共 60 步为基准)一次性训练 60 步,留下 /root/mm/ckpt/straight.pth(state_dict)和 /root/mm/ckpt/straight_log.csv(step,train_loss,第 1–60 步)。
与上一个模块的训练循环相同。这次请把损失写到小数点后第六位——要在第 3 步与接续的记录逐位比较。
在第 30 步原子性地保存
进行同样的训练,在第 30 步停下,把 {"model": state_dict, "optimizer": optimizer.state_dict(), "step": 30, "gen_state": 배치 생성기.get_state()}(占位符为批次生成器)写入 /root/mm/ckpt/resume.pth.tmp,再用 os.replace 改名为 /root/mm/ckpt/resume.pth。学习率仍以总共 60 步为基准来计算。
全部写进临时文件之后再改名,就算保存过程中挂了,也会留下一个完好的文件。这就是 MiniMind 的 lm_checkpoint 用 ckp_tmp → os.replace 所做的事。如果 .tmp 还留着,就是漏掉了改名。
接着训练 60 步——必须与基准相同
在新进程中读取 resume.pth,恢复权重、优化器状态和批次生成器状态,从第 31 步训练到第 60 步,留下 /root/mm/ckpt/resumed.pth 和 /root/mm/ckpt/resumed_log.csv(第 31–60 步)。
模型和优化器要与第 1 步完全一样地创建,再用 load_state_dict 覆盖,并用 g.set_state(…) 把生成器倒回去。评分器会比较两个检查点的所有张量,看最大差是否在 1e-6 以下——在同一个节点上运行的话,就是 0。
丢掉优化器状态会怎样
从恢复文件中只恢复权重和批次生成器状态,优化器重新创建,接着训练第 31–60 步,留下 /root/mm/ckpt/noopt.pth。
AdamW 的步长与梯度除以其滑动平均的平方根之后的值成正比。如果记忆从 0 重新开始,接上之后的几步走的步幅完全不同。评分器会确认这个结果是否与基准明显不同。
把批次顺序倒回去会怎样
从恢复文件中只恢复权重和优化器状态,批次生成器用最初的随机种子(7)重新创建,接着训练第 31–60 步,留下 /root/mm/ckpt/noskip.pth。
如果把生成器放回最初的状态,第 31 步会重新看到第 1 步的批次。这就是在轮次中途恢复时,MiniMind 用 SkipBatchSampler 跳过已经看过的批次的原因。
以半精度保存会怎样
把 straight.pth 的所有张量用 .half() 转换,保存为 /root/mm/ckpt/straight_fp16.pth,并把两个文件的大小(字节)和对 /opt/mm/ref/val.npy 最前面 128 个令牌的 logits 的最大绝对差,以 fp32_bytes、fp16_bytes、max_logit_diff 写入 /root/mm/ckpt/fp16.json。
mmkit.load_model 会把 fp16 权重也展开成 fp32 再放进去。文件缩小将近一半(张量之外还有名称之类的东西,所以不是恰好一半),而 logits 会在小数点后第三位左右出现差异。
恢复文件为什么大
把 resume.pth 中权重的元素数(lm_head.weight 与嵌入共享,所以不算)、优化器状态张量(维度在 1 以上的)的元素数及其倍率,以 model_numel、optimizer_numel、ratio 写入 /root/mm/ckpt/optim.json。
optimizer.state_dict()['state'] 为每个参数装着 step(0 维)、exp_avg、exp_avg_sq。倍率为 2,就说明恢复文件大约是权重文件的三倍——这就是大模型的检查点很沉重的原因。
留下保存什么、为什么保存
在 /root/mm/ckpt/report.md 中写 ## 무엇을 저장하나、## 빠뜨리면、## 반정밀도 三节(三个标题为韩文,依次意为“保存什么”“漏掉会怎样”“半精度”),并以数字放入第 7 步的 ratio 和第 6 步的 fp16_bytes。
把第 4、5 步里拉开的幅度一并写上会更好。半精度一节里,请用一行写出“推理用权重”和“恢复用权重”打算怎么区分。