PyTorch 派生缓存的精度转换与失效处理 ​

模型参数已经转换成 FP16,推理却仍然报 Half 与 Float 不匹配。检查参数列表时看不出异常,因为留下 FP32 数据的对象,是评估模式下生成的一份派生缓存。

这个问题最早表现为缓存没有跟随模型迁移设备,后续精度检查又遇到了相同类型的故障:先在 FP32 下调用 eval(),再调用 .half(),缓存保留原来的 dtype。对该实现,先转换精度、再调用 eval() 重建缓存,可以恢复正常执行。问题涉及模块状态的管理,与模型计算公式无关。

一、参数之外还有哪些张量 ​

PyTorch 模块中的 Tensor 不一定都是参数。对精度和设备转换而言,三类对象的行为不同:

对象是否随模块执行精度和设备转换是否进入 state_dict
Parameter是,按转换接口规则处理是
注册的 buffer是,浮点 buffer 可转换精度默认进入,可设为非持久
普通属性中的 Tensor不由模块转换接口自动遍历处理不自动进入

因此,model.parameters() 中所有参数都正确,仍不足以证明下一次前向使用的张量都正确。模块可能从参数展开索引表、生成位置偏置、构造掩码或缓存其他中间结果,再直接挂到普通属性上。

这些对象参与后续计算,却没有进入参数或 buffer 的管理范围。检查它们时,需要沿前向实际读取的属性回溯,而不是只遍历权重。

注册为 buffer 也不意味着所有类型都应转成浮点。索引通常需要保持整数,布尔掩码需要保持布尔类型;模块的浮点转换会按类型规则处理它们。将所有属性统一执行 .half(),可能修复一处计算冲突,却破坏另一处索引语义。对象是否属于模型状态,与它应该使用哪种 dtype,需要分别确定。

二、调用顺序为什么改变结果 ​

下面用一个与具体模型无关的例子说明缓存类型的变化,不包含实际网络实现:

python
import torch
from torch import nn

class CachedScale(nn.Module):
    def __init__(self):
        super().__init__()
        self.scale = nn.Parameter(torch.ones(1))

    def train(self, mode=True):
        super().train(mode)
        if mode:
            if hasattr(self, "cached_scale"):
                del self.cached_scale
        else:
            with torch.no_grad():
                self.cached_scale = self.scale * 2
        return self

m = CachedScale().eval()  # 此时生成 FP32 缓存
m.half()                 # 参数转换,普通 Tensor 属性保留
assert m.scale.dtype == torch.float16
assert m.cached_scale.dtype == torch.float32

m.eval()                 # 该实现会重新生成缓存
assert m.cached_scale.dtype == torch.float16

eval() 等价于调用 train(False)。普通模块只需要切换训练标志,但自定义模块可以在重写的 train() 中做更多事情。这里的缓存刷新正是这种附加行为,所以再次调用 eval() 才有作用。

实际故障中,FP16 运算结果与 FP32 缓存相加后可能发生类型提升,直到后面的矩阵乘法才报错。报错算子暴露了冲突,缓存生成的位置才解释了冲突从哪里来。直接在最后一个矩阵乘法前强制转换,容易掩盖更早的状态问题。

对应验证流程调整为先完成设备和精度转换,再进入评估模式。这个顺序适用于会在 eval() 中重建缓存的实现;若模块没有刷新逻辑,重复调用 eval() 并不会自动转换普通属性。

还要区分 eval() 与 no_grad()。前者改变模块模式,后者控制自动求导记录;eval() 本身不会关闭梯度。把由可训练参数派生的缓存放在评估分支,是因为该分支通常假定参数在一段推理期间不再变化。若随后又需要训练,缓存就必须清除或重新计算,否则可能继续使用旧值,甚至绕开当前参数的梯度路径。这也是示例在 train(True) 时删除缓存的原因。

三、缓存随模型迁移还不够 ​

对于无需训练、可以重建的张量,非持久 buffer 是一种常见选择:

python
self.register_buffer("cached_value", None, persistent=False)

在缓存被赋予 Tensor 后,它能够参与模块的设备与精度转换,同时不写入 state_dict。persistent=False 控制的是序列化范围,不是是否迁移。

但注册 buffer 不能自动解决数据过期。例如,缓存由某个参数计算而来,之后加载了新权重,旧缓存仍可能保留;设备和 dtype 都正确,数值却已经不再对应当前参数。

缓存需要同时考虑两种有效性:一是能否与当前输入执行计算,涉及 device、dtype 和 shape;二是内容是否仍对应当前参数和模式。前一种失效常表现为异常,后一种可能安静地产生错误结果。

失效处理应由模块明确负责:加载权重后清除派生缓存,进入需要缓存的模式时重建,或在前向中检查必要条件后惰性生成。若缓存还依赖输入尺寸,尺寸也应纳入检查。具体选择取决于重建成本和调用方式,不能只靠应用层记住某个调用顺序。

可以把派生缓存理解成一个有前提的计算结果:当参数、尺寸、模式和执行精度满足生成时的条件时,复用它才成立。device、dtype 和 shape 比较容易检查,参数内容是否变化则更适合通过明确的加载、更新和模式切换事件来使缓存失效。只用参数对象的身份作为依据也不够,因为原地更新不会更换对象。

PyTorch 的 load_state_dict 文档与加载后 hook 提供了一个有用的设计出发点:权重恢复是明确的状态变化事件。可以在这个事件之后清理派生状态,让拥有缓存的模块负责恢复一致性。是否采用 hook,取决于模块如何封装加载和初始化过程。

另一个容易忽略的问题是转换顺序的数值影响。若缓存为 C=f(θ),先用 FP32 生成再转换到 FP16,与先把参数转换到 FP16 再计算 f,不保证得到相同结果。两条路径的中间舍入不同;如果 f 包含归约或非线性运算,差异可能进一步传播。因此,将缓存注册为 buffer 与每次重建缓存,虽然都能解决 dtype 对齐,却未必具有相同数值行为。

这意味着最终应选择并验证一条明确的推理准备流程。缓存的主要价值是减少重复计算,其正确性仍取决于它是否代表当前模型应执行的那次计算。

四、验证应覆盖状态变化 ​

一次冷启动前向通过,覆盖不了已生成缓存之后的迁移。更有效的检查是先触发缓存,再转换精度或设备,确认前向读取的对象与当前模型一致。

同样需要检查训练与评估往返,以及已有缓存时加载新权重。加载后的输出应与同权重的全新模型比较;只检查缓存存在、shape 正确、参数加载成功,无法发现数值过期。

一个较有辨别力的对照是比较两条历史:全新模块加载目标权重后直接准备推理;另一个模块先生成旧缓存,再迁移精度、加载相同目标权重并准备推理。最终参数、输入和执行设置应相同。若输出仍取决于模块此前经历过什么,就需要继续检查未被刷新或未被序列化的状态。这种对照比重复运行同一份已缓存模型更容易暴露问题。

验证时还应记录缓存的实际重建次数。在第一次推理生成一次缓存是合理行为;若每次前向都重新生成,正确性可能通过,但复用带来的性能收益已经消失。反过来,缓存永远不重建也可能掩盖加载新权重后的过期问题。正确性与缓存命中行为应一起核对。

这些检查可以放在隔离副本中完成。测试的是模块的状态转换契约,不需要修改训练策略。若准备继续导出模型,还应先固定评估状态与缓存内容,再按ONNX 导出中的 Python 形状计算与静态适配检查图捕获过程。

五、参考资料 ​