TT Lab
开始
学习 学习路径 课程

AI瘦身失败事件

校准数据决定了那把尺 — 太窄会被截断,太宽会被磨平

在 TT Lab 中继续学习

一句话总结

静态量化中,缩放因子由校准数据决定。如果校准所见的范围比实际分布窄,数值就会被截断;如果宽,刻度就会变稀疏——无论哪种情况,准确率都是校准数据的函数。

为什么总在这里出问题

静态量化在转换时就把激活的缩放因子定死。要定死,就得知道激活分布在什么范围,而告诉你这一点的就是校准数据。转换器把这份数据灌进去,观察每一层的最小值和最大值,再用这个范围生成缩放因子和零点。

所以校准数据 不是配置值,而是输入值。同一个模型、同一条转换命令,只要换了校准数据,出来的就是完全不同的模型。可是这份数据既不会留在代码里,也不会留在日志里。几个月后再问“这个模型的准确率为什么是这样”,手边只有模型文件和转换脚本,而真正决定答案的,是那天用过的那些数组。

出问题的方向有两个。范围窄,就会被截断。比校准时见过的最大值更大的值进来,就会贴在整数类型的端点上,无法通过反量化还原。误差会变成往返上限的几十倍。范围宽,就会糊成一团。 256 个刻度被分配到实际并不使用的宽范围上,数据真正所在的区间里只落几个刻度。不会被截断,但失去了分辨率。

在同一个激活分布上,把校准范围分别取窄、取准、取宽的三种情形。取窄时两端被截断,范围外的值贴在整数的端点上;取准时刻度均匀落在数据上;取宽时数据所在的区间里只落几个刻度,分辨率丧失

工作原理

ONNX Runtime 的量化文档要求给静态量化传入 CalibrationDataReader。契约很简短。

class 내_공급기(CalibrationDataReader):
    def get_next(self):
        더 줄 것이 있으면 -> {"입력이름": 배열}  사전 하나
        더 없으면        -> None

转换器会一直调用 get_next(),直到返回 None。所以有两种常见的错误。一种是结束之后抛出异常而不是返回 None(校准做到一半就崩了),另一种是重复使用已经耗尽的供给器(第二次转换拿不到任何校准数据)。

校准留下的痕迹就在模型里。打开计算图,找到紧跟在输入后面的 QuantizeLinear,读它的缩放因子 initializer,缩放因子乘以刻度数,就是校准时观察到的范围的宽度。凭这一个数字,就能事后确认“校准数据看到了多大的范围”。

格式也有两种。QDQ 保持原有算子不变,在前后插入 QuantizeLinear 和 DequantizeLinear。计算图里仍能看到 MatMul,整数化由运行时在执行时合并。QOperator 则直接换成 QLinearMatMul 这样的整数算子。节点数少,文件也小,但计算图读起来很困难。

这里要点出一个常见的误解。从同一份校准得到的两种格式,使用相同的缩放因子和零点,误差的大小也在同一个数量级,但并不是逐元素完全相同。因为 QDQ 把原有算子留在计算图里,由运行时决定执行方式。运行时可以把 Q/DQ 折叠起来用整数内核运行,也可以直接用反量化后的值做实数运算。所以“换了格式,输出有点不一样”是正常的,如果差了一个数量级,那就该怀疑校准。

在现场相遇的样子

第一,有一类问题增加样本也解决不了。如果校准数据的分布本身就偏了,把这份数据增加几百倍,观察到的范围也只会稍微变宽。把偏窄的校准增加 512 倍得到的改善,还不如用正确的分布只给几批。必须通过测量来区分,问题有时不在数量,而在分布。

第二,静态并不总是比动态更准确。“静态更好”这句话,说的是校准做对了的情形。校准偏了,得到的模型会比动态量化差得多。而且这一点在评估之前,哪里都不会显现。

第三,截断和糊成一团的症状不同。截断只在大输入上错得很厉害,小输入没有问题。糊成一团则是所有输入都均匀地错一点。把误差按输入大小分开看,就能分辨是哪一种。

第四,把校准数据留成记录。用了哪些样本、用了多少个、这些数据的范围是多少,都写在模型旁边。必须能与模型里的缩放因子对照,以后才能找到原因。

实际工作中真正重要的事

下一项实验要做什么

亲手实现 CalibrationDataReader,用偏窄、准确、偏宽三份校准数据把同一个模型量化三次,把计算图中写入的输入缩放因子和自己测得的最大绝对误差并排写下来。接着把偏窄的校准从 1 批增加到 512 批,测出这样也无法修复;最后用同一份校准生成 QDQ 和 QOperator 两种格式,同时测出两种格式的误差大小以及两者之间的差异。评分器会亲自驱动你的供给器,并用你的模型文件重新计算你写下的误差来核对。