节点从六个减到两个 — 数清优化改动了什么
目标
亲手搭建汇集了优化可以动手的内容的 /root/onnxq-optimize/graph.onnx,并编写工具 /root/onnxq-optimize/optlevel.py,它运行 ORT 的四个优化级别,把计算图导出成文件并数节点。写下每个级别消失了什么、新增了什么,测量答案是否相同,并用域名证明导出的文件为什么不能搬到别的机器上。
为什么重要
onnxruntime 在打开会话时会重写计算图。提前折叠只靠常量就能算出的部分,删除可有可无的节点,把常见的形状合并成一个。哪些内容被改到什么程度,由优化级别决定。
比较量化前后速度时数字对不上,原因常常就在这里。如果两次测量的优化级别不同,那个比较测的就不是量化,而是优化。把基线固定为 ORT_DISABLE_ALL,这种抖动就消失了。
代价更高的事故是把优化后的文件保存下来去部署。那个文件里有非标准域的节点,别的运行时打不开。可是 onnx.checker 却让这个文件通过了——因为检查器会放行不认识的域。所以能否移植的依据不是检查器,而是节点的域列表。
这个实验不测时间。这台机器是模拟环境,同样的事也会抖动到两倍。所以只判定结构发生了怎样的变化和答案是否相同。
评分器不会相信你写下的文字。它会在临时目录里摆出自己亲手搭建的计算图,真正运行你的工具,把结果与评分器自己用四个级别优化同一文件得到的值进行核对。形状、权重和随机种子每次运行都不同。
步骤
- 创建并运行 /root/onnxq-optimize/build_graph.py,生成 /root/onnxq-optimize/graph.onnx。
- 在 /root/onnxq-optimize/optlevel.py 中实现
nodes,读出当前计算图的节点和个数。 - 加入
optimize,按给定的级别优化计算图,并导出成文件。 - 加入
levels,把四个级别全跑一遍,数出节点并排摆放。 - 加入
fusion,写下每个级别消失了什么、新增了什么。 - 加入
equal,把用给定的随机种子生成的同一个输入放进四个级别,测量答案是否相同。 - 加入
portable,判定导出的文件是否只使用标准算子。 - 生成 /root/onnxq-optimize/opt_report.json 和 /root/onnxq-optimize/opt_report.md 作为报告。
参考
- Python 解释器是 /opt/onnx-lab/bin/python。系统自带的
python3中既没有 onnx 也没有 numpy。运行示例:/opt/onnx-lab/bin/python /root/onnxq-optimize/optlevel.py levels /root/onnxq-optimize/graph.onnx - 这个 Pod 没有网络。材料需要自己搭建。
- 计算图约定:输入
x是 FLOAT,轴为 [符号, 6],输出y为 [符号, 5]。节点中必须有 MatMul、Add、Relu、Identity、Mul,并且 至少有一个输入全是 initializer 的节点(那就是会被折叠的常量)。全部开启优化后,节点数必须减少。 - 运行约定:
optlevel.py <명령> ...(占位符为命令名)。答案以一个 JSON 对象输出到标准输出。成功时退出码为 0,未知命令时为 2。 - 级别名称共四个:
ORT_DISABLE_ALL、ORT_ENABLE_BASIC、ORT_ENABLE_EXTENDED、ORT_ENABLE_ALL。 nodes <모델>(占位符为模型文件)的响应:{"nodes": [{"op_type", "domain", "name"}...], "count": {op_type: 개수}, "initializers": [이름 정렬]}。nodes 的顺序就是文件中写的顺序。optimize <모델> <단계> <출력>(占位符依次为模型文件、级别与输出文件)把SessionOptions.graph_optimization_level设为该级别,把optimized_model_filepath设为输出路径后打开会话,再重新读取保存的文件,输出{"level", "out", "nodes": [op_type...], "count", "initializers"}。levels <모델>(占位符为模型文件)的响应:以级别名称为键、装有{"nodes": [op_type...], "count": {...}}的四个对象。fusion <모델>(占位符为模型文件)的响应:每个级别为{"removed": 원본에 있었는데 사라진 op_type 정렬, "added": 새로 생긴 op_type 정렬, "total": 노드 수}。equal <모델> <씨앗> <행수>(占位符依次为模型文件、随机种子与行数)用numpy.random.RandomState(씨앗).standard_normal((행수, 입력너비))(占位符依次为随机种子、行数与输入宽度)生成 float32,放入全部四个级别。响应:{"seed", "rows", "levels": {단계: {"sum", "max", "max_abs_diff"}}, "max_abs_diff"}。sum 和 max 是整个输出的总和与绝对值最大值,max_abs_diff 是与 ORT_DISABLE_ALL 的输出之间的最大绝对差。写到小数点后六位就足够了。portable <모델>(占位符为模型文件)读取用ORT_ENABLE_ALL导出的文件,输出{"nondefault_domains": 노드 도메인 가운데 표준이 아닌 것 정렬, "opset_domains": opset_import 의 도메인 정렬, "checker": "ok"|"error", "portable": 불리언}。标准域是空字符串和ai.onnx。opt_report.json中写入levels(四个级别的nodes)、nondefault_domains、portable、equality(seed、rows、max_abs_diff)。opt_report.md用## 무엇이 줄었나、## 어느 단계에서 무엇이 바뀌나、## 답은 같은가、## 이 파일을 옮겨도 되나四节来写(四个标题为韩文,依次意为“什么减少了”“哪个级别改变了什么”“答案是否相同”“这个文件能搬走吗”),并在正文中写出优化前的节点数和剩余的域名。- ORT 用
ORT_ENABLE_ALL导出时,会向标准错误输出“只能在相同环境中使用”的警告。把SessionOptions.log_severity_level设为 2 就能看到该警告,设为 3 则会安静下来。警告走标准错误,所以标准输出中的 JSON 不会被破坏。 - 浮点数:级别不同,内核和计算顺序就不同。float32 的有效位数是 7 位,最后一位可能抖动,所以判断是否相同时,要设一个 1e-5 左右的相对误差容差,并把依据写下来。
- 不测时间。这台机器是模拟环境,同样的事也会抖动到两倍。
- 官方文档:Graph optimizations · ORT Python API · Gemm · Execution Providers
- 常见错误:直接部署优化后的文件;把检查器通过当作可移植的依据;比较优化级别不同的两次测量;直接把节点数当成速度。
汇集优化可以动手的内容
创建并运行 /root/onnxq-optimize/build_graph.py,生成 /root/onnxq-optimize/graph.onnx。其中必须有一个输入全是 initializer 的节点、一个 Identity,以及 MatMul、Add、Relu、Mul。
想看到常量折叠,放入一个把两个 initializer 相加的节点,并在后面使用它的结果即可。Identity 什么都不做,正好是可有可无的节点的例子。在 MatMul 后面放 Add,再在后面放 Relu,就成了会被融合的形状。保存之前,用 onnx.checker.check_model(model, full_check=True) 过滤一遍。
先把当前的计算图数一遍
在 /root/onnxq-optimize/optlevel.py 中实现 nodes <모델>(占位符为模型文件),输出 {"nodes", "count", "initializers"}。nodes 的每一项中放入 op_type、domain、name。
标准算子的节点 domain 是空字符串。现在应该全是空字符串,但打开优化后的文件,就会出现不是的。要到那时才能比较,所以现在先把当前的数好。顺序保持文件中写的样子。
把优化后的计算图导出成文件
加入 optimize <모델> <단계> <출력>(占位符依次为模型文件、级别与输出文件),把按该级别优化后的计算图保存到输出路径,再重新读取保存的文件,数出节点输出。
给 SessionOptions.optimized_model_filepath 指定路径,打开会话期间重写的计算图就会保存到该路径。不打开会话,文件也不会生成。用 ORT_DISABLE_ALL 导出的是基线,应该得到与原文件相同的节点才正常。
把四个级别并排摆放
加入 levels <모델>(占位符为模型文件),把四个级别全跑一遍,为每个级别输出装有 {"nodes", "count"} 的对象。
在临时目录里每个级别导出一个再读取即可,用完后删掉。把四行并排摆放,每个级别发生了什么一目了然——这比读文档更快。
什么消失了,什么新增了
加入 fusion <모델>(占位符为模型文件),为每个级别输出 {"removed", "added", "total"}。removed 是原文件中有、现在消失了的 op_type,added 是新出现的 op_type。
比较原文件的 op_type 集合和每个级别的 op_type 集合即可。只靠常量计算的节点会消失,结果变成 initializer 留下来。MatMul 后面的 Add 会被合并成一个 Gemm,到下一个级别就会出现连激活也吞进去的名字。也请留意那个名字所在的域是什么。
计算图变了,答案还相同吗
加入 equal <모델> <씨앗> <행수>(占位符依次为模型文件、随机种子与行数),把用该随机种子生成的同一个输入放进全部四个级别,输出 {"seed", "rows", "levels", "max_abs_diff"}。
输入用 numpy.random.RandomState(씨앗).standard_normal((행수, 입력너비))(占位符依次为随机种子、行数与输入宽度)生成 float32。输入宽度从模型声明的第 1 轴读取。为每个级别写下整个输出的总和与绝对值最大值,还要写下与基线的最大绝对差。不要期望完全相同,而要先定好容差——float32 的有效位数只有 7 位。
这个文件可以搬走吗
加入 portable <모델>(占位符为模型文件),读取用 ORT_ENABLE_ALL 导出的文件,输出 {"nondefault_domains", "opset_domains", "checker", "portable"}。
标准域只有空字符串和 ai.onnx。只要有一个节点属于其他域,这个文件就只能用于那个环境。onnx.checker 连这样的文件也会放行,所以要把检查器的结果和域列表一起记下来——二者互相矛盾,正是这一步的关键。把 log_severity_level 设为 2,还能看到 ORT 在保存时发出的警告。
用一页报告四个级别
在 /root/onnxq-optimize/opt_report.json 中写入 levels、nondefault_domains、portable、equality,并把 /root/onnxq-optimize/opt_report.md 写成 ## 무엇이 줄었나、## 어느 단계에서 무엇이 바뀌나、## 답은 같은가、## 이 파일을 옮겨도 되나 四节(四个标题为韩文,依次意为“什么减少了”“哪个级别改变了什么”“答案是否相同”“这个文件能搬走吗”)。
评分器会亲自用四个级别优化同一个计算图并数节点,再用 equality 中写的随机种子和行数重新运行,测量差值。所以要实际测量后再写。报告中要把优化前的节点数和剩余的域名按数字和名称原样写下来,接收方才能据此判断。