不用校准数据 — 实测动态量化
目标
编写 dyntool.py,对自己的模型做动态量化,并全部用数字留下:计算图是怎么变化的,文件在哪里缩小、哪里保持原样,为什么输入分布变了它也撑得住,以及这种方式不适用的情况是什么样子。
为什么重要
静态量化必须先收集代表性输入才能开始,而找这份数据是现场最花时间的事。动态量化不预先确定激活的范围,而是在每次推理时看着那个张量来确定,所以只需要一个模型文件。因此它是在决定做什么之前先试一下的基线。 不过,得知道得到什么、失去什么,才能做出选择。得到的是分布波动时也撑得住的特性——每个批次都重新做一把尺子,就不会超出校准时见过的范围。失去的是可复现性。缩放因子由该批次整体的最大值决定,所以一行离群值会把同一批次里其他行也拖下来。单独放入时好好的输入,会因为邻居而变差。 文件大小也要测了才知道。变小的只有矩阵,偏置仍是 float32,缩小后的矩阵旁边还跟着缩放因子和零点。在权重很小的模型里,新增的节点比缩减的部分还大,文件反而变大。 评分器不会相信你写下的数字。它每次都用不同的随机种子和形状重新搭建模型,真正运行你的工具,并与 DynamicQuantizeLinear 算子的实际结果,以及评分器亲自运行的推理结果进行核对。
步骤
- 创建并运行 /root/onnxq-dyn/gen_model.py,生成 /root/onnxq-dyn/fp32.onnx。
- 在 /root/onnxq-dyn/dyntool.py 中实现
quantize,生成 /root/onnxq-dyn/dyn.onnx。 - 加入
sizes,数出每个 initializer 的字节数,写入 /root/onnxq-dyn/size.json。 - 加入
dqparams,亲手实现 DynamicQuantizeLinear 在每次推理时做的计算。 - 加入
compare,在改变输入倍率的同时测量误差,写入 /root/onnxq-dyn/batches.json。 - 加入
outlier,测量一行离群值是否会扰动批次,写入 /root/onnxq-dyn/outlier.json。 - 用 /root/onnxq-dyn/gen_small.py 生成一个极小的模型并量化,写入 /root/onnxq-dyn/unfit.json。
- 用 /root/onnxq-dyn/report.json 和 /root/onnxq-dyn/report.md 汇总成一页。
参考
- Python 解释器是
/opt/onnx-lab/bin/python。系统自带的python3中既没有 numpy 也没有 onnx。运行示例:/opt/onnx-lab/bin/python /root/onnxq-dyn/dyntool.py sizes /root/onnxq-dyn/fp32.onnx。 - 运行约定:成功时退出码为 0,并向标准输出打印一个 JSON 对象。参数个数不对时为 2。
quantize <입력.onnx> <출력.onnx>(占位符依次为输入文件与输出文件)的响应:{"out": 경로, "bytes": 정수, "ops": {연산자: 개수}, "removed": [사라진 연산자], "added": [새로 든 연산자]}。权重为 QInt8。sizes <모델.onnx>(占位符为模型文件)的响应:{"file_bytes": 정수, "initializers": {이름: {"dtype": 문자열, "elements": 정수, "bytes": 정수}}, "initializer_bytes": 정수}。dqparams <입력.npy> <출력.npy>(占位符依次为输入文件与输出文件)的响应:{"scale": 실수, "zero_point": 정수, "min": 실수, "max": 실수, "count": 정수}。把量化后的 uint8 数组保存到输出路径。compare <fp32.onnx> <int8.onnx> <씨앗> <행> <배율>(占位符依次为随机种子、行数与倍率)的响应:{"rows", "span", "max_abs_output", "max_abs_error", "relative"}。relative 是误差除以 fp32 输出的最大绝对值所得的值。outlier <fp32.onnx> <int8.onnx> <씨앗> <행> <튀는값>(占位符依次为随机种子、行数与离群值)的响应:{"rows", "spike", "clean", "with_spike", "ratio"}。- 生成批次的规则(评分器会重新生成同样的批次):特征数是模型输入最后一个轴的大小。用
rng = numpy.random.default_rng(씨앗)(占位符为随机种子),即(rng.normal(0, 1, (행, 특징)) * 배율).astype(numpy.float32)(占位符依次为行数、特征数与倍率)。要在乘完 之后 再降为 float32。 outlier的规则:用上面的规则,倍率取 1.0 生成批次,clean 是只放入该批次时的最大绝对误差。with_spike 是在 下方接上 一行numpy.full((1, 특징), 튀는값, dtype=numpy.float32)(占位符依次为特征数与离群值)后放入,在 原批次的那些行 上测得的最大绝对误差。ratio 是 with_spike 除以 clean 所得的值。- DynamicQuantizeLinear 的定义:范围是
[min(x, 0), max(x, 0)],缩放因子是该宽度除以 255 所得的值,零点是实数 0.0 所在的整数位置,并截断到 0 到 255 之间。舍入与 numpy 的np.rint相同,即向偶数靠拢。 - 缩放因子要用
np.float32(...)生成,除法也要用 float32 来做。如果用 Python 的 float 来除,处于边界的元素会与运行时相差一个刻度。 fp32.onnx的批次轴必须是只有名称的动态轴(["N", 20])。如果固定成数字,评分器就无法放入其他行数。- 第 5 步用随机种子 20260917、行数 32,测量倍率 0.05、1.0、50.0。第 6 步是随机种子 20260917、行数 12、离群值 60.0。
- 官方文档:ONNX Runtime 量化 · DynamicQuantizeLinear · MatMulInteger · ONNX Concepts
- 调用
quantize_dynamic时会出现建议做预处理的警告。这个实验的模型本来就很简单,不做预处理也能照常运行。 - 常见错误:凭猜测认为文件变小了而不去测量;把偏置也写成了 int8;在生成批次的规则里调换降为 float32 的顺序;在离群值实验中把离群的那一行也算进误差。
先把要测的对象拿到手
创建并运行 /root/onnxq-dyn/gen_model.py,生成 /root/onnxq-dyn/fp32.onnx。批次轴必须是只有名称的动态轴,这是一个含两个矩阵和两个偏置的 MLP。
用 onnx.helper 搭出计算图,用 onnx.checker 确认后再保存。输入形状的第一个轴不要用数字,而用名称("N")。initializer 用 numpy_helper.from_array 创建。创建后用 onnxruntime 跑一次,就能确定无误。
一条命令,只固定权重
在 /root/onnxq-dyn/dyntool.py 中实现 quantize <입력.onnx> <출력.onnx>(占位符依次为输入文件与输出文件),生成 /root/onnxq-dyn/dyn.onnx。响应中要有变换后的算子数量、消失的算子和新增的算子。
对 onnxruntime.quantization 的 quantize_dynamic 把 weight_type 设为 QInt8 即可。不要放校准数据——这里也没有地方放。变换后的计算图用 onnx.load 打开,数 node 的 op_type 就能看到。确认一下 MatMul 去哪里了。
用名称说清楚哪里变小了
加入 sizes <모델.onnx>(占位符为模型文件),为每个 initializer 数出数据类型、元素数和字节数,并把两个模型比较的结果以 fp32_bytes、dyn_bytes、shrunk、kept_float、weight_bytes_before、weight_bytes_after 写入 /root/onnxq-dyn/size.json。
遍历 onnx.load 得到的 graph.initializer,用 numpy_helper.to_array 转换,就能得到 dtype 和 nbytes。shrunk 是 fp32 中有、而动态模型中名称已消失的 initializer,kept_float 是仍以 float32 保留下来的 initializer。看看偏置在哪一边。
每次推理都重新做尺子的计算
加入 dqparams <입력.npy> <출력.npy>(占位符依次为输入文件与输出文件),亲手实现 DynamicQuantizeLinear 在每次推理时做的计算。输出缩放因子、零点和范围,并保存量化后的 uint8 数组。
范围中必须包含 0——最小值为正时降到 0,最大值为负时升到 0。uint8 有 255 个刻度,零点是实数 0.0 所在的整数位置。分别放入全为正数的数组和全为负数的数组,就能看到零点跑到两端。
把输入放大 1000 倍试试
加入 compare <fp32.onnx> <int8.onnx> <씨앗> <행> <배율>(占位符依次为随机种子、行数与倍率),用随机种子 20260917、行数 32 测量倍率 0.05、1.0、50.0,并以 runs 和 relative_spread 写入 /root/onnxq-dyn/batches.json。
生成批次的规则已在参考部分写死——评分器要重新生成同样的批次,所以请原样遵守。relative_spread 是三个 relative 值的最大值除以最小值所得的值。看看绝对误差会随输入大小增大,而相对误差怎么样。
一行邻居就能扰动批次
加入 outlier <fp32.onnx> <int8.onnx> <씨앗> <행> <튀는값>(占位符依次为随机种子、行数与离群值),用随机种子 20260917、行数 12、离群值 60.0 测量,并写入 /root/onnxq-dyn/outlier.json。
把同样的 12 行放入两次——一次原样,一次在下方接上离群的那一行。误差 只在前面的 12 行上 测量。离群行自身的误差不计入。这样“因为邻居而变差”这句话才成立。
在小模型上反而变大
用 /root/onnxq-dyn/gen_small.py 生成一个极小的模型 /root/onnxq-dyn/small.onnx,量化后得到 /root/onnxq-dyn/small_dyn.onnx,然后把结果以 small_fp32_bytes、small_dyn_bytes、grew、growth_bytes、dynamic_quantize_nodes、kept_float_ops 写入 /root/onnxq-dyn/unfit.json。
权重只有几百字节时,新增的节点和缩放因子、零点 initializer 比缩减的部分还大。kept_float_ops 是 fp32 模型和动态模型 两边都有 的算子——也就是没有改变而保留下来的那些。grew 是布尔值,不是字符串。
把得到的和失去的汇总成一页
在 /root/onnxq-dyn/report.json 中写入 graph_change、file、relative_error_spread、outlier_ratio、small_model_grew,并在 /root/onnxq-dyn/report.md 中用 ## 그래프가 어떻게 바뀌나、## 어디가 줄고 어디가 안 줄었나、## 보정 자료가 필요 없는 이유、## 동적 양자화가 맞지 않는 경우 四节来写(四个标题为韩文,依次意为“计算图是怎么变化的”“哪里缩小了、哪里没缩小”“为什么不需要校准数据”“动态量化不适用的情况”)。
读取前面步骤生成的各个 JSON 来拼装即可。file 中的 ratio 是动态模型的字节数除以 fp32 的字节数所得的值。报告中请在数字旁逐行写下这个数字指的是什么——读的人没有看到你的实验。