数一数 float 孤岛 — 算子支持与替换
目标
把同一个模型导出成五种版本之后,编写分析器 graphscan.py,数出计算图中被 int8 包裹的算子和仍为 float 的孤岛。用数字确认两种收窄范围的选项的效果,并把模型改成 Gemm 来减少 Q/DQ 往返。
为什么重要
量化工具只会把自己认识的算子改成 int8 的样子。在 QDQ 格式中,算子名称原样保留,只是在每个张量上插入 QuantizeLinear 和 DequantizeLinear。所以“量化了”和“以 int8 运行”是两回事,要区分它们,就得数计算图。 这个实验把判定规则固定如下:所有输入都来自 DequantizeLinear、所有输出都只流向 QuantizeLinear 的算子,视为被 int8 包裹。其余的仍为 float,相连的合在一起就成了 float 孤岛。 孤岛的问题在边界而不在个数。每个孤岛都会附带流入的 DQ 和流出的 Q,每个边界上都要多一次值的转装和舍入。减少孤岛有两条路:收窄范围,直接去掉不合格的节点;或者改模型,让它变成可量化的样子。 评分器不会相信你写下的数字。它每次都会用不同的层数、形状和排除节点,在临时目录里构建自己的模型,真正运行你的分析器,并与评分器用同样规则数出的值核对。第 6、7 步里,评分器会亲自读取你生成的模型文件重新计数。
步骤
- 创建并运行 /root/ops/gen_models.py,在
/root/ops下生成五个模型。 - 在 /root/ops/graphscan.py 中实现
nodes,统计算子 census 和初始值的数据类型。 - 加入
qdq,统计 QuantizeLinear、DequantizeLinear 和 Q/DQ 往返。 - 加入
islands,区分被 int8 包裹的算子和仍为 float 的孤岛。 - 加入
boundary,统计每个孤岛进出的边界。 - 加入
diff,给出两个模型的差异,并把收窄范围的结果写入 /root/ops/scope.json。 - 生成把 MatMul 和 Add 合并成 Gemm 的 /root/ops/gemm.onnx,以及把它量化后的 /root/ops/gemm_full.onnx,写入 /root/ops/fuse.json。
- 把 /root/ops/ops_report.md 写成四节。
参考
- Python 解释器是
/opt/onnx-lab/bin/python。系统自带的python3中没有 onnx。 - 运行约定:
/opt/onnx-lab/bin/python /root/ops/graphscan.py <명령> <모델...>(占位符依次为命令名与模型文件)。成功时退出码为 0,文件不存在时为 3,用法错误时为 2。答案以一个 JSON 对象输出到标准输出。 - 第 1 步生成的五个文件:
fp32.onnx(原始模型)、full.onnx(静态 QDQ,没有收窄范围的版本)、matmul_only.onnx(op_types_to_quantize=["MatMul"])、excluded.onnx(用nodes_to_exclude去掉一个 MatMul 的版本)、dynamic.onnx(quantize_dynamic)。原始模型用 MatMul、Add、Relu 搭出三层以上,并给每个节点起名字。输入名称是x。 nodes的响应:{"model", "nodes", "op_types", "initializers"}。op_types 是以算子名称为键的个数,initializers 是以初始值的数据类型名称(onnx.TensorProto.DataType.Name给出的 FLOAT、INT8 这类名称)为键的个数。qdq的响应:{"model", "quantize", "dequantize", "round_trips", "weight_dequantize"}。round_trips 是输出直接流入 DequantizeLinear 的 QuantizeLinear 的个数,weight_dequantize 是第一个输入为初始值的 DequantizeLinear 的个数。islands的响应:{"model", "compute", "wrapped", "float", "island_count", "islands"}。compute 是去掉 QuantizeLinear 和 DequantizeLinear 之后的算子个数。islands 的每一项是{"nodes", "size", "op_types"},nodes 和 op_types 要排序。孤岛列表按第一个节点的名称排序。名称为空的节点称为연산자이름_자리번호(韩文占位符,依次意为算子名称与位置编号)。- 包裹的判定:节点的所有输入都是 DequantizeLinear 的输出,并且所有输出都只被 QuantizeLinear 消费,就是被包裹的。只要有一个输出没有任何消费者,就不算被包裹。
boundary的响应:{"model", "island_count", "crossings", "per_island"}。per_island 的每一项是{"nodes", "in_dequantize", "out_quantize"},同一个节点不能数多次。crossings 是全部相加的值。diff <a> <b>的响应:{"a", "b", "op_delta", "quantize_delta", "dequantize_delta", "round_trip_delta", "island_delta", "float_delta"}。各个 delta 是 b 减去 a 所得的值,op_delta 只放入不为 0 的项。scope.json的形式:{"excluded_node": 이름, "models": {"full": {...}, "matmul_only": {...}, "excluded": {...}}, "diff_matmul_only": {...}, "diff_excluded": {...}}。models 的每一项是{"quantize","dequantize","round_trips","island_count","float","crossings"},两个 diff 就是把 a 设为 full.onnx 得到的diff响应原样写入。fuse.json的形式:{"before": {...}, "after": {...}, "removed_quantize": 정수, "removed_round_trips": 정수, "max_abs_error": 실수}。before 是针对 full.onnx 的、after 是针对 gemm_full.onnx 的{"op_types","quantize","dequantize","round_trips"}。max_abs_error 是用同样的输入运行 fp32.onnx 和 gemm.onnx 时的最大绝对误差。- 合并成 Gemm:只有当
MatMul的输出只被那个Add使用、并且Add的另一个输入是初始值时才合并。Gemm(A, B, C)在默认值 alpha=1.0、beta=1.0、transA=0、transB=0 下就是A @ B + C。合并之后必须通过onnx.checker.check_model,并且给出与原始模型相同的答案。 - 动态量化版本的格式不同。会出现
MatMulInteger和DynamicQuantizeLinear,没有 QuantizeLinear、DequantizeLinear。测量孤岛和边界的尺子是为 QDQ 版本准备的,所以对动态版本只用nodes才是诚实的做法。 - 官方文档:量化 · 图优化 · QuantizeLinear · DequantizeLinear · MatMulInteger · Gemm
- 常见错误:只看文件大小就说量化完成;在 QDQ 版本里看到 MatMul 就说没量化;把同一个 DequantizeLinear 数多次;用 QDQ 的尺子去量动态版本。
- 这个实验不测时间和吞吐量,只处理数数。
把同一个模型导出成五种版本
创建并运行 /root/ops/gen_models.py,在 /root/ops 下生成 fp32.onnx、full.onnx、matmul_only.onnx、excluded.onnx、dynamic.onnx。
用 onnx.helper 把 MatMul、Add、Relu 连起来,并给每个节点起名字。有名字,后面才能用 nodes_to_exclude 去掉其中一个。静态量化需要 CalibrationDataReader,同一份校准数据要多次使用时,读取器每次都要重新创建——读完一遍的读取器不会再给你数据。
先数数里面有什么
在 /root/ops/graphscan.py 中实现 nodes <model>(占位符为模型文件),输出算子 census 和初始值数据类型 census。
初始值的数据类型,用 onnx.TensorProto.DataType.Name(init.data_type) 得到名称。把静态 QDQ 版本和动态版本并排数一遍,两种格式差别有多大就一目了然——动态版本里根本没有 MatMul。
数出 Q/DQ 往返
加入 qdq <model>(占位符为模型文件),统计 quantize、dequantize、round_trips、weight_dequantize。
挂在权重上的 DequantizeLinear 没有与之配对的 QuantizeLinear。因为权重已经以 int8 存在文件里了。所以要从 DequantizeLinear 的个数里减去这部分,才能与激活值的往返数对上。
找出仍为 float 的孤岛
加入 islands <model>(占位符为模型文件),区分被 int8 包裹的算子和仍为 float 的算子,并把相连的合在一起,作为孤岛输出。
先建好生产者、消费者的表,后面就容易了。包裹的判定原样使用参考部分的规则。合并孤岛时,只沿着 float 节点之间的连接走——经过 Q 或 DQ 就是另一个孤岛。
数出每个孤岛的边界
加入 boundary <model>(占位符为模型文件),统计每个孤岛流入的 DequantizeLinear 个数和流出的 QuantizeLinear 个数,并输出整体的边界数。
同一个 DequantizeLinear 可能流入一个孤岛的两个节点。不要按节点数,要用集合汇总后再数。如果孤岛的输出直接作为计算图输出,流出的 QuantizeLinear 可能为 0。
收窄范围会带来什么变化
加入 diff <a> <b>(占位符为两个模型文件),输出两个模型的差异,并把三个版本的数值和两个 diff 按参考部分的形式写入 /root/ops/scope.json。
不要以为给了选项就照办了。在按节点名称去掉的版本里,该节点的权重仍是 float 初始值;在按算子类型收窄的版本里,该类型之外的算子整个成了孤岛。两种收窄方式在计算图里留下的痕迹不同。
改模型来减少往返
生成把 MatMul 和 Add 合并成一个 Gemm 的 /root/ops/gemm.onnx,再用相同的设置把它量化成 /root/ops/gemm_full.onnx,然后把两个版本的数值和最大绝对误差写入 /root/ops/fuse.json。
要遵守合并的条件——只有 MatMul 的输出只被那个 Add 使用、并且 Add 的另一个输入是初始值时才合并。合并之后要通过 onnx.checker.check_model,并且一定要用与原始模型相同的输入来运行,确认答案相同。如果值变了,就是合并错了。
写到能用计算图作答
把 /root/ops/ops_report.md 写成 ## 무엇이 int8 로 갔나、## float 로 남은 섬、## 범위를 좁히면 무엇이 달라지나、## 모델을 고쳐 얻은 것 四节(四个标题为韩文,依次意为“什么走了 int8”“仍为 float 的孤岛”“收窄范围会带来什么变化”“改模型得到了什么”)。孤岛的个数和消失的 Q/DQ 个数要以数字写进去。
这份报告回答的是“为什么没有缩小到预期”这个问题。把数过的五个数字(算子 census、Q/DQ 往返、孤岛个数、孤岛大小、边界数)原样写上,就是答案。也要写下没有测时间这件事。