缩放与零点 — 量化就是挑一把尺
一句话总结
量化就是在实数轴上确定 刻度间隔 scale 与 0 所在的位置 zero_point,这两者一旦确定,剩下的只是除法和舍入。
为什么需要它
调用转换工具,就会得到 int8 模型。准确率下降了,该改什么?多给一些校准数据?按通道拆分?只把激活保留为 float?这些选项全都是关于 缩放因子如何确定 的选择。不知道里面跑的是什么算术,就选不出来,只剩下反复试错。
而且确实有出错的地方。最常见的是 0 没有还原回 0 的情形。如果观察范围是 [0.4, 7.0],就照这个范围直接定缩放因子,实数 0.0 就落在可表示的刻度之外。填充和 ReLU 产生的 0,会变成不是 0 的值,这个误差还会沿着各层放大。所以遵循规范的实现,一定会把 0 包含进范围。
工作原理
ONNX 用两个算子来做这件事。QuantizeLinear 把实数降成整数,DequantizeLinear 把整数升成实数。定义很简短。
QuantizeLinear y = saturate(round(x / y_scale) + y_zero_point)
DequantizeLinear x = (q - x_zero_point) * x_scale
round 是 向偶数靠拢的舍入。0.5 变成 0,1.5 变成 2,2.5 也变成 2。saturate 是截断到数据类型的两端,uint8 是 0 和 255,int8 是 -128 和 127。
缩放因子和零点由观察范围 [lo, hi] 得出。有两支。
- uint8 非对称方式:
scale = (hi - lo) / 255,zero_point = round(0 - lo / scale)。把 256 个刻度整个分配到范围上。零点会是一个不为 0 的值,那个位置就是实数 0.0 的整数坐标。 - int8 对称方式:
reach = max(|lo|, |hi|),scale = reach / 127,zero_point = 0。把 0 钉在正中间,向两边给出相同的宽度。零点为 0,整数内核里就少了一次减法——把权重按对称处理,原因就在这里。
由此引出一个重要的性质。往返误差不会超过缩放因子的一半。量化就是把值移到缩放因子整数倍网格上最近的点,网格间隔就是缩放因子,所以最远也只有一半。不过这只在 值处于范围之内 时才成立。跑到范围之外的值会被贴到墙上,误差可以任意大。如果误差远远超过缩放因子的一半,那就不是舍入的问题,而是 范围的问题。
在现场相遇的样子
第一,用对称方式处理激活,会浪费一半。ReLU 之后的激活全部大于等于 0。对它用对称 int8,负数一侧的 127 个刻度就一个值也接收不到。剩下的刻度只有一半,缩放因子变粗两倍,误差也恰好变成两倍。所以 权重用对称、激活用非对称 往往是默认设置。
第二,一个离群值会毁掉整个张量。权重矩阵里只要有一处值很大,整个张量的缩放因子就会跟着这个值变大,其余位置全都挤在寥寥几个刻度上。这时答案是 按通道的缩放因子(per-channel)。给每个输出通道单独一个缩放因子,损害就被困在含有离群值的那个通道里。QuantizeLinear 的 axis 属性和一维的缩放因子张量,表达的就是这个。
第三,用 float64 计算缩放因子,会和运行时对不上。运行时是用 float32 做除法的。如果用 Python 的 float 做除法,处于边界的值舍入会偏向另一边,大约 1% 的元素会差一个刻度。这不是什么大问题,但会让人陷入“我的计算和模型输出为什么不一样”而浪费时间。
第四,测误差得有基准。“准确率下降了 1%”是部署判断,“往返误差超过了缩放因子的一半”是原因判断。后者只看一层就能知道,并且直接指出要修的地方。
实际工作中真正重要的事
- 把 0 放进范围。即使观察范围偏向一侧,也要往 0 的方向延伸。
- 误差超过缩放因子的一半,就怀疑饱和。问题不在舍入,而在范围。
- 按数据的形状选择方式。对只有 0 以上的数据,不要用对称。
- 有离群值就拆分通道。整个张量的缩放因子会被最大的那一个值挟持。
下一项实验要做什么
一步步扩展 qmath.py,亲手实现缩放因子与零点的计算、量化、反量化、往返误差和按通道的缩放因子。评分器不会相信你写下的数字——它每次都会用不同的随机种子生成数组,真正运行你的工具,并把同样的值 原样交给 ONNX 的 QuantizeLinear、DequantizeLinear 算子 去运行,再与得到的结果核对。