自己造一把尺 — 缩放、零点与往返误差
目标
编写 qmath.py,按 QuantizeLinear 和 DequantizeLinear 的定义计算缩放因子和零点,实现量化与反量化,确认往返误差在缩放因子的一半以内,并在同一份数据上比较对称与非对称、按通道的缩放因子与整个张量的缩放因子。
为什么重要
转换工具给出的 int8 模型准确率下降时,要选对修复的地方,就得知道那个工具在里面做的算术。缩放因子是观察范围除以刻度数所得的值,零点是实数 0.0 落下的整数位置。如果范围里没有 0,填充和 ReLU 产生的 0 就会变成不是 0 的值。
往返误差有上限。只要值在范围之内,误差就不会超过缩放因子的一半——因为量化就是把值移到缩放因子间隔的网格上。所以如果误差远远超过这一半,问题不在舍入,而在范围。这一把尺子能让原因判断变快。
数据的形状也决定方式。ReLU 之后的激活全部大于等于 0,对它用对称 int8,负数一侧的刻度就整个闲置,缩放因子变粗两倍。权重矩阵里只要有一个离群值,整个张量的缩放因子就会被这个值拖着走,其余的全被糊掉——拆分通道,损害就被困在那个通道里。
评分器不会相信你写下的数字。它每次都会用不同的随机种子生成数组,真正运行你的 qmath.py,并把同样的输入原样交给 ONNX 的 QuantizeLinear、DequantizeLinear 算子去运行,再与得到的结果核对。
步骤
- 创建并运行 /root/onnxq-scale/gen_data.py,在 /root/onnxq-scale/data 下生成三份数组,并在 /root/onnxq-scale/qmath.py 中实现
params。 - 加入
q,把实数数组降成整数数组。 - 加入
dq,把整数数组还原成实数。 - 加入
roundtrip,同时给出往返误差及其上限。 - 故意把范围收窄制造饱和,把结果写入 /root/onnxq-scale/saturate.json。
- 对全部大于等于 0 的数据分别用对称和非对称,写入 /root/onnxq-scale/symmetry.json。
- 加入
channel,比较按通道的缩放因子和整个张量的缩放因子,写入 /root/onnxq-scale/channel.json。 - 用 /root/onnxq-scale/report.json 和 /root/onnxq-scale/report.md 汇总成一页。
参考
- Python 解释器是
/opt/onnx-lab/bin/python。系统自带的python3中没有 numpy。运行示例:/opt/onnx-lab/bin/python /root/onnxq-scale/qmath.py params -3.2 5.1 asym-u8。 - 运行约定:成功时退出码为 0,并向标准输出打印一个 JSON 对象。参数个数不对时为 2。
params <lo> <hi> <mode>的响应:{"scale": 실수, "zero_point": 정수, "qmin": 정수, "qmax": 정수, "mode": 문자열}。q <입력.npy> <출력.npy> <mode> [lo hi](占位符依次为输入文件、输出文件与模式)的响应:{"scale": 실수, "zero_point": 정수, "clipped": 정수, "dtype": 문자열, "count": 정수}。不给 lo、hi 时,使用输入数组的最小值和最大值。clipped 是被截断的元素个数。dq <양자.npy> <출력.npy> <scale> <zero_point> <mode>(占位符依次为量化数组文件与输出文件)的响应:{"min": 실수, "max": 실수, "count": 정수}。roundtrip <입력.npy> <mode> [lo hi](占位符为输入文件)的响应:{"mode", "scale", "zero_point", "lo", "hi", "clipped", "max_abs_error", "bound", "within_bound"}。bound 是缩放因子的一半,within_bound 是布尔值。channel <입력.npy> <axis>(占位符为输入文件)的响应:{"axis", "channels", "per_tensor_scale", "per_tensor_max_error", "per_channel_scales", "per_channel_max_error", "worst_channel", "channels_improved"}。按对称 int8 计算。- mode 有两种。
asym-u8是 uint8、qmin 为 0、qmax 为 255 的非对称方式,sym-i8是 int8、qmin 为 -128、qmax 为 127、零点为 0 的对称方式。对称方式中,把一侧的宽度除以 127(不是 128)。 - 范围中必须包含 0。lo 大于 0 时降到 0,hi 小于 0 时升到 0。
- 舍入是 向偶数靠拢的舍入。numpy 的
np.rint就是这样工作的。如果用内置的round()函数或int(x + 0.5),答案就会不同。 - 生成缩放因子时用
np.float32(...)包起来,除法也用 float32 来做。如果用 Python 的 float 来除,处于边界的元素会与运行时相差一个刻度。 - 材料数组在 /root/onnxq-scale/data 下,共三个:spread.npy、positive.npy、weights.npy。它们由 gen_data.py 生成,评分器会读取这些文件。
- 官方文档:QuantizeLinear · DequantizeLinear · ONNX Concepts · ONNX Runtime 量化
- 常见错误:范围里不放 0;对称方式中除以 128;用向下取整的舍入代替
np.rint;发生饱和时仍然写成没有超过上限。
把观察范围变成缩放因子和零点
创建并运行 /root/onnxq-scale/gen_data.py,在 /root/onnxq-scale/data 下生成三个数组,并在 /root/onnxq-scale/qmath.py 中实现 params <lo> <hi> <mode>。范围中没有 0 时必须补上。
非对称是把 (hi - lo) 除以 255,零点是实数 0.0 落下的整数位置——用 qmin 减去 lo/scale 后再舍入。对称是把左右宽度中较大的一侧除以 127,零点为 0。先处理 lo 大于 0 或 hi 小于 0 的情况。
把实数降成整数刻度
加入 q <입력.npy> <출력.npy> <mode> [lo hi](占位符依次为输入文件、输出文件与模式),把实数数组降成整数数组,并把结果保存为 .npy。响应中要有 scale、zero_point、clipped、dtype、count。
定义是 saturate(round(x / scale) + zero_point)。先除、再舍入、再加零点,最后截断到数据类型的两端。顺序换了,答案就不同——不能先加零点再舍入。数出被截断的元素个数,作为 clipped 输出。
把整数还原成实数
加入 dq <양자.npy> <출력.npy> <scale> <zero_point> <mode>(占位符依次为量化数组文件与输出文件),把整数数组还原成 float32 实数数组。响应中要有 min、max、count。
定义是 (q - zero_point) * scale。这个方向上既没有舍入,也没有饱和——前面被截掉的值不会在这里恢复。先把整数数组升成 float32 再做减法,才能避免 uint8 出现负数回绕。
往返误差及其上限
加入 roundtrip <입력.npy> <mode> [lo hi](占位符为输入文件与模式),先量化再立刻反量化,同时输出最大绝对误差和上限。bound 是缩放因子的一半,within_bound 表示误差是否在其范围内。
量化就是把值移到缩放因子间隔网格上最近的点。网格间隔就是缩放因子,所以最远也只有一半。由于浮点数的缘故,可能会略微超出一点,所以比较时要留一点余地。这个上限只在值处于范围之内时才成立。
把范围收窄会坏掉什么
把 data/spread.npy 分别按观察范围原样往返一次,以及收窄到 -1.0 1.0 往返一次,并把两个结果以 full、narrow、error_ratio 写入 /root/onnxq-scale/saturate.json。
范围收窄会让缩放因子变得更细——这本身是好事。问题在于跑到范围之外的值会被贴到墙上。误差若远远超过上限,原因就不在舍入,而在范围。error_ratio 是收窄一侧的误差除以原来的误差所得的值。
对只有 0 以上的数据用对称方式会怎样
对 data/positive.npy 分别使用 asym-u8 和 sym-i8,把 asym、sym、scale_ratio 写入 /root/onnxq-scale/symmetry.json。每一项都要有 scale、zero_point、max_abs_error、usable_levels。
usable_levels 是落在范围 [lo, hi] 内的刻度个数——把范围宽度除以缩放因子,加上 1 后舍入即可得到。对于全部大于等于 0 的数据,非对称应该得到 256,对称应该得到 128。确认一下缩放因子之比是否就是误差之比。
把一个离群值困住
加入 channel <입력.npy> <axis>(占位符为输入文件与轴),用对称 int8 比较按通道的缩放因子和整个张量的缩放因子,并把 data/weights.npy 按 axis 0 运行的结果写入 /root/onnxq-scale/channel.json。
把一个轴看作通道,为每个通道分别求出 min 和 max,生成缩放因子。按通道的缩放因子是一维数组,为了广播,要调整形状,只让对应的轴有长度。channels_improved 是按通道一侧的误差小于整个张量一侧的通道数。关键在于,含有离群值的那个通道不会变好。
把测得的汇总成一页
在 /root/onnxq-scale/report.json 中写入 bound_rule、spread、positive_asym_over_sym、channel_gain、saturation_ratio,并在 /root/onnxq-scale/report.md 中用 ## 스케일과 영점은 어디서 나오나、## 왕복 오차의 상한、## 대칭과 비대칭、## 이상값 하나가 하는 일 四节来写(四个标题为韩文,依次意为“缩放因子和零点从哪里来”“往返误差的上限”“对称与非对称”“一个离群值做了什么”)。
channel_gain 不是把整个张量的误差除以按通道的误差,而是以 改善最多的通道 为基准更诚实。含有离群值的通道保持不变,所以整体的最大误差几乎相同。报告中请在数字旁逐行写下这个数字指的是什么。