校准数据决定精度 — 窄的、准的、宽的,各做一次
目标
亲手实现 CalibrationDataReader,用偏窄、准确、偏宽三份校准数据把同一个模型做静态量化,把计算图中写入的输入缩放因子和自己测得的最大绝对误差并排留下来。并通过测量确认:偏了的校准无法靠增加样本数修复,以及 QDQ 和 QOperator 是同一个计算的不同形态。
为什么重要
静态量化在转换时就把激活的缩放因子定死,而这个缩放因子由校准数据决定。所以校准数据不是配置值,而是输入值。同一个模型、同一条命令,校准数据不同,得到的就是完全不同的模型。可是那份数据既不会留在代码里,也不会留在日志里。 出问题的方向有两个。如果校准所见的范围比实际窄,超出范围的值就会贴在整数类型的端点上,无法通过反量化还原。如果宽,虽然不会被截断,但把刻度分给了根本用不到的范围,真正有值的区间分辨率就下降了。是哪一种,光看一个误差是不知道的,要做三个版本来测才能分辨。 而且有一类问题无法靠样本数修复。如果校准数据的分布本身就偏了,把这份数据增加 512 倍,观察到的范围也只会稍微变宽。必须能分辨问题在分布而不在数量,才不会把时间花在错误的地方。 评分器不会相信你写下的数字。它会亲自驱动你的供给器,看是否遵守契约;把你的工具用在评分器搭建的模型上;并用你的模型文件 重新计算 你写下的误差来核对。
步骤
- 创建并运行 /root/onnxq-calib/gen_model.py,生成 /root/onnxq-calib/fp32.onnx。
- 在 /root/onnxq-calib/reader.py 中实现
make_reader(arrays, input_name)。 - 在 /root/onnxq-calib/calibtool.py 中实现
quantize和scales,生成 /root/onnxq-calib/int8_match.onnx 和 /root/onnxq-calib/match.json。 - 加入
error,用偏窄的校准生成 /root/onnxq-calib/int8_narrow.onnx 和 /root/onnxq-calib/narrow.json。 - 用偏宽的校准生成 /root/onnxq-calib/int8_wide.onnx 和 /root/onnxq-calib/wide.json。
- 把三者汇集在一处,生成 /root/onnxq-calib/calib_report.json。
- 把偏窄的校准增加到 1、8、64、512 批来试,写入 /root/onnxq-calib/samples.json。
- 用同样的校准再生成 QOperator 格式,并生成 /root/onnxq-calib/format.json 和 /root/onnxq-calib/report.md。
参考
- Python 解释器是
/opt/onnx-lab/bin/python。系统自带的python3中既没有 numpy 也没有 onnx。运行示例:/opt/onnx-lab/bin/python /root/onnxq-calib/calibtool.py scales /root/onnxq-calib/int8_match.onnx。 - 运行约定:成功时退出码为 0,并向标准输出打印一个 JSON 对象。参数个数不对时为 2。
quantize <입력.onnx> <출력.onnx> <씨앗> <배율> <묶음수> [형식](占位符依次为输入文件、输出文件、随机种子、倍率、批数与格式)的响应:{"out", "bytes", "batches", "span", "format", "ops", "input_scale", "input_zero_point"}。格式为qdq(默认)或qoperator。激活和权重都是 QInt8。scales <모델.onnx>(占位符为模型文件)的响应:{"input_scale", "input_zero_point", "observed_span", "quantize_nodes", "dequantize_nodes"}。observed_span 是 input_scale 乘以 255 所得的值。error <fp32.onnx> <int8.onnx> <씨앗> <행>(占位符依次为随机种子与行数)的响应:{"rows", "max_abs_error", "mean_abs_error", "max_abs_output"}。- 生成校准数据的规则(评分器会重新生成同样的数据):一批为 16 行。特征数是模型输入最后一个轴的大小。用
rng = numpy.random.default_rng(씨앗)(占位符为随机种子),每批生成(rng.normal(0, 1, (16, 특징)) * 배율).astype(numpy.float32)(占位符依次为特征数与倍率),生成批数那么多批。要在乘完 之后 再降为 float32。 - 生成评估数据的规则:
numpy.random.default_rng(씨앗).normal(0, 1, (행, 특징)).astype(numpy.float32)(占位符依次为随机种子、行数与特征数)。不要乘以倍率——这才是真实分布。 - 这个实验使用的值:校准随机种子 4242,评估随机种子 777,评估行数 256,批数 64。倍率为偏窄 0.2、准确 1.0、偏宽 10.0。
make_reader(arrays, input_name)必须返回继承了onnxruntime.quantization.CalibrationDataReader的对象。get_next()每次返回一个{입력이름: 배열}(占位符依次为输入名称与数组)字典,数据用完后返回None。结束之后再被调用时,也必须一直返回 None——抛出异常的话,转换器会在校准中途崩溃。- 已耗尽的供给器不能重复使用。每次量化都要重新创建。
- 模型中写入的输入缩放因子,可以在计算图中找到以输入张量为第一个输入的
QuantizeLinear(QOperator 格式则是QLinearMatMul),读取它的缩放因子 initializer 得到。 calibtool.py会引用同一目录下的reader.py。请把脚本所在的目录加入sys.path。- 第 7 步生成的模型,保存到 /root/onnxq-calib/samples 下,分别为
narrow_n1.onnx、narrow_n8.onnx、narrow_n64.onnx、narrow_n512.onnx、match_n8.onnx。512 批需要一点时间。 - 调用
quantize_static时会出现建议做预处理的警告。这个实验的模型本来就很简单,不做预处理也能照常运行。 - 官方文档:ONNX Runtime 量化 · QuantizeLinear · DequantizeLinear · ONNX Concepts
- 常见错误:把供给器只创建一次,再用于多次转换;结束后抛出异常;给评估数据乘以倍率;猜测格式不同准确率也会不同。
先把要测的对象拿到手
创建并运行 /root/onnxq-calib/gen_model.py,生成 /root/onnxq-calib/fp32.onnx。批次轴必须是只有名称的动态轴,这是一个含两个矩阵和两个偏置的 MLP。
用 onnx.helper 搭出计算图,用 onnx.checker 确认后再保存。输入形状的第一个轴不要用数字,而用名称("N")。创建后用 onnxruntime 跑一次,就能确定无误。
把数据灌给转换器的契约
在 /root/onnxq-calib/reader.py 中实现 make_reader(arrays, input_name)。返回继承了 CalibrationDataReader 的对象,get_next() 每次给出一个输入字典,数据用完后要一直返回 None。
契约很简短,但结尾的处理是关键。转换器会一直调用到 None 出现为止,所以数据用完后如果抛出异常,校准做到一半就会崩溃。可以持有一个迭代器,用 next(it, None) 给出,也可以从列表的前面依次取出。请记住,已耗尽的对象不能再次使用。
用准确的校准做一次
在 /root/onnxq-calib/calibtool.py 中实现 quantize 和 scales,用随机种子 4242、倍率 1.0、64 批生成 /root/onnxq-calib/int8_match.onnx,然后把 span、batches、input_scale、input_zero_point、observed_span、quantize_nodes、dequantize_nodes 写入 /root/onnxq-calib/match.json。
给 quantize_static 把 quant_format 设为 QDQ,把 activation_type 和 weight_type 设为 QInt8。校准数据的规则已在参考部分写死——评分器要重新生成同样的数据,所以请原样遵守。scales 在计算图中找到以输入张量为第一个输入的 QuantizeLinear,读取它的缩放因子 initializer。确认一下 observed_span 与校准数据的范围宽度大致吻合。
偏窄的校准——被截断
加入 error,用随机种子 4242、倍率 0.2、64 批生成 /root/onnxq-calib/int8_narrow.onnx,然后把 span、batches、input_scale、observed_span、max_abs_error、match_max_abs_error 写入 /root/onnxq-calib/narrow.json。误差用评估随机种子 777、256 行来测。
评估数据不要乘以倍率——那才是真实分布。如果校准所见的范围比实际窄五倍,评估输入中相当一部分会贴在数据类型的端点上。把准确校准的误差也一并写下来,下一步比较就容易了。
偏宽的校准——糊成一团
用随机种子 4242、倍率 10.0、64 批生成 /root/onnxq-calib/int8_wide.onnx,并以与 narrow.json 相同的形式写入 /root/onnxq-calib/wide.json。
这次不会被截断——因为校准看到的范围比实际宽得多。可误差还是比准确的校准大。这是把 256 个刻度分给了根本用不到的范围。把 observed_span 与准确校准的拿来比一比。
把三者放在一处
在 /root/onnxq-calib/calib_report.json 中写入 eval、narrow、match、wide、best、clipping_worse_than_coarse。三个条目中要有 span、input_scale、observed_span、max_abs_error,best 是误差最小的那一项的名称。
clipping_worse_than_coarse 是一个布尔值,表示偏窄一侧的误差是否比偏宽一侧的误差更大。把三个误差并排摆放,就会看到一个 U 形——中间最低,向两边升高。看看哪一侧的壁更陡。
样本数无法修复的问题
把偏窄的校准(倍率 0.2)分别用 1、8、64、512 批量化四次,保存为 /root/onnxq-calib/samples 下的 narrow_n1.onnx、narrow_n8.onnx、narrow_n64.onnx、narrow_n512.onnx,再把准确校准的 8 批保存为 match_n8.onnx,然后把 narrow_runs、match_small、narrow_best、narrow_gain、beats_narrow 写入 /root/onnxq-calib/samples.json。
narrow_best 是四次中最小的误差,narrow_gain 是 1 批的误差除以 narrow_best 所得的值——也就是把样本增加 512 倍所得到的改善。beats_narrow 是一个布尔值,表示准确校准的 8 批是否优于偏窄校准的 512 批。512 批需要一点时间。
同样的计算,不同的形态
用准确的校准(随机种子 4242、倍率 1.0、64 批)生成 QOperator 格式的 /root/onnxq-calib/int8_qop.onnx,把 qdq、qoperator、same_input_scale、max_abs_difference、difference_vs_error 写入 /root/onnxq-calib/format.json,然后把 /root/onnxq-calib/report.md 写成四节。
qdq、qoperator 条目中放入 bytes、ops、max_abs_error。max_abs_difference 是把同一个评估批次放入两种格式的模型所得输出之间的最大绝对差,difference_vs_error 是它除以 QDQ 的误差所得的值。两种格式的误差大小在同一个数量级,但两者之间的差并不是 0——因为 QDQ 把执行方式交给运行时。报告四节的标题,请保持任务句子中的原样。