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

AI瘦身失败事件

缩放与零点 — 量化就是挑一把尺

在 TT Lab 中继续学习

一句话总结

量化就是在实数轴上确定 刻度间隔 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 把观察范围从负 0.5 到 2.0 分配给编码 0 到 255,实数 0 落下的位置成为零点 51。对称 int8 把 0 钉在正中间,零点为 0,如果数据只有 0 以上,左边一半的 127 格就闲置了

由此引出一个重要的性质。往返误差不会超过缩放因子的一半。量化就是把值移到缩放因子整数倍网格上最近的点,网格间隔就是缩放因子,所以最远也只有一半。不过这只在 值处于范围之内 时才成立。跑到范围之外的值会被贴到墙上,误差可以任意大。如果误差远远超过缩放因子的一半,那就不是舍入的问题,而是 范围的问题。

在现场相遇的样子

第一,用对称方式处理激活,会浪费一半。ReLU 之后的激活全部大于等于 0。对它用对称 int8,负数一侧的 127 个刻度就一个值也接收不到。剩下的刻度只有一半,缩放因子变粗两倍,误差也恰好变成两倍。所以 权重用对称、激活用非对称 往往是默认设置。

第二,一个离群值会毁掉整个张量。权重矩阵里只要有一处值很大,整个张量的缩放因子就会跟着这个值变大,其余位置全都挤在寥寥几个刻度上。这时答案是 按通道的缩放因子(per-channel)。给每个输出通道单独一个缩放因子,损害就被困在含有离群值的那个通道里。QuantizeLinear 的 axis 属性和一维的缩放因子张量,表达的就是这个。

第三,用 float64 计算缩放因子,会和运行时对不上。运行时是用 float32 做除法的。如果用 Python 的 float 做除法,处于边界的值舍入会偏向另一边,大约 1% 的元素会差一个刻度。这不是什么大问题,但会让人陷入“我的计算和模型输出为什么不一样”而浪费时间。

第四,测误差得有基准。“准确率下降了 1%”是部署判断,“往返误差超过了缩放因子的一半”是原因判断。后者只看一层就能知道,并且直接指出要修的地方。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 qmath.py,亲手实现缩放因子与零点的计算、量化、反量化、往返误差和按通道的缩放因子。评分器不会相信你写下的数字——它每次都会用不同的随机种子生成数组,真正运行你的工具,并把同样的值 原样交给 ONNX 的 QuantizeLinear、DequantizeLinear 算子 去运行,再与得到的结果核对。