激活检查点:重算为何要保留随机状态
激活检查点用重算换取较少的激活保存,但重算必须对应原来那次前向计算。带Dropout的片段若抽到另一份掩码,后向可能拿原损失的上游导数,乘上另一条计算路径的局部导数。前向loss看起来完全一样,也不能证明梯度没变。
文章目录
下面用一个标量例子得到正确梯度66、错误重算梯度0,再展示保存与恢复随机状态的两个职责。代码是Python标准库教学模拟,不是一次PyTorch或GPU训练测试。Read in English。
这里的checkpoint,不是保存模型文件
激活检查点关注一次训练步骤内部的中间张量。正常反向传播需要使用前向中的某些中间结果;检查点选择少存一部分,等后向需要时再计算。它与为了断点续训而保存权重、优化器状态的磁盘检查点,不是同一个用途。
PyTorch 2.14参考将其描述为计算与内存的取舍,并提醒后向调用的函数若与前向行为不同,例如依赖变化的全局变量,可能产生错误或静默错误梯度。本文围绕这一等价性要求展开,不承诺具体节省多少显存。
重算也不是简单“再调用一次就行”。输入、参数、随机状态和影响分支的上下文,都可能参与决定实际执行的函数。需要复现的是与原前向相匹配的计算,而不只是相同形状的输出。
一个掩码,就能把梯度从66变成0
设可训练标量w=2,输入x=3,目标t=1。用一个简化的Dropout倍率m表示保留或丢弃:以相同概率取2或0。前向y=w·x·m,损失L=½(y−t)²。固定一次原前向抽到m=2,得到y=12、L=60.5。
从损失传回片段的上游导数是∂L/∂y=y−t=11。原前向对应的局部导数∂y/∂w=x·m=6,所以∂L/∂w=11×6=66。这里对一次固定抽样求导,不是在推导期望损失。
若为了恢复片段中间量而重算时抽到m′=0,局部导数变成0。把它与原上游导数11配起来,得到0。这不是原来m=2那条路径的梯度;重新抽样得到另一条合法路径,也不能替代当前这次反向所需的路径。
保存随机状态,有两个不同目标
第一是局部回放:保存片段原前向前的随机状态s₀,在重算时暂时恢复它,才能重新生成同一抽样。只在训练一开始设置seed,不能保证每次重算都回到正确的随机流位置。
第二是保护外部随机流:原前向已经把状态从s₀推进到s₁。重算完成后,外部流程应继续使用它本来应有的状态,而不是被额外的重算抽样推进。图和代码假设两次之间没有其他外部抽样,因此恢复到s₁;一般情形应保存重算入口处的当前外部状态,重算后恢复它,不能抹掉中途合法推进的随机流。仅恢复s₀并不完整。
Python random文档提供getstate与setstate来获取和恢复生成器内部状态。下面用独立Random对象演示这个原则,避免触碰任何真实训练状态。
九项检查复现局部回放
这段代码用解析链式法则计算梯度,固定本地Python随机种子。bad_rng从原前向之后的状态继续抽样;正确回放从原前向之前的状态开始,再把外部状态恢复到after。
# Scalar chain-rule and Python RNG model; not PyTorch autograd.
from random import Random
def multiplier(rng):
return 2.0 if rng.random() < 0.5 else 0.0
r = Random(1)
before = r.getstate()
m = multiplier(r)
after = r.getstate()
w, x, target = 2.0, 3.0, 1.0
y = w*x*m
loss = 0.5*(y-target)**2
upstream = y-target
grad = upstream*x*m
bad_rng = Random()
bad_rng.setstate(after)
bad_m = multiplier(bad_rng)
bad_grad = upstream*x*bad_m
assert m == 2.0
assert bad_m == 0.0
assert loss == 60.5
assert grad == 66.0
assert bad_grad == 0.0
r.setstate(before)
replay_m = multiplier(r)
assert replay_m == m
assert upstream*x*replay_m == grad
r.setstate(after)
assert r.getstate() == after
reference = Random()
reference.setstate(after)
assert r.random() == reference.random()
print("9 replay checks passed")
九项检查本地通过,覆盖原倍率、另一倍率、损失、正确与错误梯度、回放倍率、回放梯度、状态恢复和后续随机流。没有运行autograd、真实checkpoint、Dropout算子或显存测试。这里的Python随机流也不应被误认为PyTorch会自动为你管理的任意外部随机源。
随机状态保留,也不是全部等价性
PyTorch参考说明默认检查点会处理一定范围的RNG状态,但设备覆盖存在限制;不能据此认为自定义生成器、外部库或函数内部的新设备迁移都自动得到相同保障。使用范围应按具体版本文档核对。
即使掩码一致,其他可变状态仍可能破坏重算。比如片段读取一个会变化的全局倍率、更新计数器后按计数选择分支,或根据外部缓存走不同路径。重放随机状态无法把这些状态也自动倒回去。
因此先把计算片段写得尽量明确:哪些输入参与计算,哪些状态只读,哪些操作有副作用。日志、计数与缓存写入即使不改变张量值,也可能在重算时再次发生;不能用“函数通常只调用一次”的假设设计业务副作用,也不应依赖重算固定完整执行两次。
比较结果时,不要只盯着loss
可以先用同一小输入、同一参数与相同随机初始状态,比较不开检查点和启用检查点的前向输出及参数梯度。仅loss一致不够,本例在错误后向发生前已经固定了相同loss;问题出现在局部导数恢复阶段。
梯度也应逐参数核对,而不是只比较一个总范数。不同向量可以有相同范数;两个错误梯度还可能在汇总时抵消。检查时记录最大误差、容差、参数范围与数据类型,才能解释“接近”的含义。
恢复真实训练条件时,再逐项加入混合精度、分布式封装与实际随机算子。本文没有提供这些组合的实测结论;前一天的AMP梯度裁剪讨论的是梯度单位和顺序,这里讨论的是梯度对应哪一次计算,两者需要分别验证。
省下的内存,要和增加的工作一起量
激活检查点主要减少某些前向中间量的保留,不代表参数、优化器状态、工作区与所有缓存都按同一比例缩小。若峰值来自别处,检查点可能改变不了真正的瓶颈。
评估时应在相同模型、输入形状、批量、数据类型和正确性条件下,分别记录峰值内存与单步耗时。只写“开了能跑”可以说明某个配置被执行,但不能推出通用节省率,也不能把吞吐变化都归因于重算。
检查点边界也是取舍:片段越大,重算与存储的组合就越不同。先定位占用来源,再选择边界,并检查梯度等价性,比把所有层机械包起来更有依据。
从一个可解释的片段开始
最小排查顺序是先固定输入和参数,确认不含随机或副作用的片段可重算;再加入一个随机算子,检查状态回放;最后恢复真实模块和训练封装。若差异只在某一步出现,就能把排查范围缩到那次新增条件。
不要为了让比较通过而把所有随机性永久关闭,也不要把不同实现的容差差异一概视为错误。目标是确认启用检查点后仍计算预期的训练目标与梯度,而非仅让两条日志看起来一致。
若这个梯度反例和状态时间线帮你定位了重算中的差异,欢迎通过文末支持区自愿支持。
依据与补发说明
于2026年10月18日核对PyTorch检查点参考及Python随机状态接口。PyTorch页面标示最近更新为2026年7月27日,这是工程机制解读,不是新发布新闻。两幅图、标量推导与九项检查为原创教学内容。本组保留10月18日09:00计划文章日期,实际于同日补发;未验证特定框架或硬件上的性能与误差界。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


