数清你真正拿到的 — 执行提供程序与误差累积
目标
编写工具 eprun.py,测出请求的执行提供程序和会话实际拿到的提供程序,数出计算图会按所支持的算子列表被切成几段,并把量化误差在每个层边界如何增长做成表。
为什么重要
执行提供程序是用来请求的,而不是有保障的。在这个环境里亲自测一下,就会发现即使把不存在的提供程序放进列表,也不会抛异常,而是直接落回 CPU 运行。所以“正在用加速器”的错觉,会在日志里不留任何痕迹地持续很久。确认的办法只有一个——去问会话。 提供程序不是承担整个模型,而是只拿走每个节点中自己能承担的部分。所以如果支持列表在计算图的顺序上被截断,模型就会被切成多段,每段之间都要传递值。片段切分方式不同,计算顺序就不同,同一个模型放入同样的输入,不同的机器也可能得出不同的数。 对误差也需要同样的态度。量化误差在每一层都会新产生,前一层的误差会进入下一层。不过它不是每一层均匀增长的,所以不要用说法带过,要在每个层边界测量并做成表,才能看出哪一层有问题。 这个 Pod 里只有两个提供程序(AzureExecutionProvider、CPUExecutionProvider)。不把不存在的加速器当作存在,只处理在这里能观察到的东西。评分器每次都会用不同的模型、随机种子和支持列表,真正运行你的工具,并把同样的计算重做一遍来核对。
步骤
- 创建并运行 /root/ep/gen_ep.py,生成 /root/ep/fp32.onnx 和 /root/ep/int8.onnx。层数在五层以上。
- 在 /root/ep/eprun.py 中实现
providers,并把各个请求列表的结果写入 /root/ep/providers.json。 - 加入
partition,数出在给定支持的算子列表时,计算图会被切成几段。 - 加入
expose,生成把层边界张量取成计算图输出的版本,并保存 /root/ep/fp32_exposed.onnx 和 /root/ep/int8_exposed.onnx。 - 加入
layers,给出每个层边界的最大绝对误差、相对误差和最小余弦。 - 加入
growth,给出逐层叠加时误差增长的比值,并把你这个模型的结果写入 /root/ep/growth.json。 - 加入
settings,给出同一个模型、同一个输入只改变设置运行的结果,并写入 /root/ep/settings.json。 - 把 /root/ep/ep_report.md 写成四节。
参考
- Python 解释器是
/opt/onnx-lab/bin/python。系统自带的python3中没有 onnxruntime。 - 运行约定:
/opt/onnx-lab/bin/python /root/ep/eprun.py <명령> <인자...>(占位符依次为命令名与参数)。成功时退出码为 0,文件不存在时为 3,用法错误时为 2。答案以一个 JSON 对象输出到标准输出。EP 列表和所支持的算子列表用逗号连成一个参数给出。 - 模型的输入名称为
x,输入形状为[None, 특징수](占位符为特征数),用 MatMul、Add、Relu 搭出五层以上。int8.onnx用quantize_static(quant_format=QDQ)生成。 providers <model> <목록> [...](占位符依次为模型文件与 EP 列表)的响应:{"model", "available", "cases"}。available 原样取自onnxruntime.get_available_providers()。cases 的每一项是{"requested", "actual", "missing", "fell_back", "error"},actual 是会话的get_providers(),missing 是请求了但不在 available 中的名称,fell_back 表示 actual 是否与 requested 不同,error 是无法创建会话时的异常名称(否则为 null)。- 请求不存在的提供程序时,onnxruntime 会把回退提示打印到 标准输出。不处理的话就会混进作为答案的 JSON,所以在创建会话期间要把标准输出转到标准错误,或者至少让 JSON 出现在最后一行。
providers.json是在上述响应上再加一行note。既要包含只写了存在的提供程序的请求,也要包含混入了不存在的提供程序的请求。partition <model> <지원연산자목록>(占位符依次为模型文件与所支持的算子列表)的响应:{"model","supported","nodes","claimed","partition_count","handoffs","partitions"}。按计算图中写的节点顺序遍历,属于支持列表的算子标为ep,否则标为cpu,把持有者相同且连续的区间合成一段。partitions 的每一项是{"owner","nodes","size"},handoffs 是片段数减 1 的值(没有片段时为 0)。这是模拟,不是实际的分配。expose <model> <out.onnx>的响应:{"model","out","boundaries","outputs"}。层边界是把 MatMul、Gemm 节点接收输入的第一个输入张量按计算图顺序汇集起来,并在最后加上计算图的第一个输出。已经是输出的名称不再重复加入。在整数版本里,如果想把整数张量以 float 取出,会话就打不开,所以请遵守把接收输入的那一侧张量定为边界的规则。layers <fp32> <int8> <seed>的响应:{"seed","rows","layer_count","layers"}。输入是numpy.random.default_rng(seed).standard_normal((64, 특징수)).astype(numpy.float32)(占位符为特征数),rows 为 64。会话用providers=["CPUExecutionProvider"]创建,其他设置保持默认值。layers 的每一项是{"index","fp32","int8","max_abs_error","scale","rel_error","cos_min"},scale 是 FP32 一侧张量的最大绝对值,rel_error 是最大绝对误差除以 scale 所得的值(scale 为 0 时为 0.0),cos_min 是按行求出的余弦的最小值(分母为 0 时该行取 1.0)。两个计算图的边界按位置顺序配对。growth <fp32> <int8> <seed>的响应:{"seed","layer_count","rel_error","ratios","monotone","total_growth","max_ratio"}。ratios 的第 k 个是第 k 层的相对误差除以前一层的相对误差所得的值,前一层为 0 时为 null。monotone 表示相对误差是否一次也没有下降,total_growth 是最后一个除以第一个所得的值(第一个为 0 时为 null),max_ratio 是最大比值的{"index","ratio"},并列时取靠前的位置。settings <model> <seed>的响应:{"model","seed","baseline","cases","all_equal"}。基准是ORT_DISABLE_ALL,intra/inter 线程为 1,cases 按这个顺序放入 (ORT_ENABLE_ALL, 1)、(ORT_DISABLE_ALL, 2)、(ORT_ENABLE_ALL, 2) 三种。每一项是{"level","intra_op","max_abs_diff","bitwise_equal"},baseline 是{"level","intra_op","max_abs_output","mean_output"}。growth.json和settings.json就是针对你这个模型的上述响应。随机种子由你来选,这个值会写进文件。ep_report.md是## 무엇을 요청했고 무엇을 쥐었나、## 그래프가 쪼개지는 자리、## 층을 거듭하며 자란 오차、## 이 기계에서만 참인 것四节(四个标题为韩文,依次意为“请求了什么,拿到了什么”“计算图被切开的位置”“逐层叠加后增长的误差”“只在这台机器上成立的事”)。- 官方文档:Execution Providers · Python API · 图优化 · 线程管理 · ONNX Concepts
- 常见错误:直接把请求列表写进日志;以为放入不存在的 EP 会抛异常;想把整数版本的中间张量以 float 取出,结果会话打不开;假定误差逐层单调增大。
- 这个实验不测时间和吞吐量。这台 Mac 是模拟环境,延迟会抖动到两倍。
做出叠了多层的模型及其整数版本
创建并运行 /root/ep/gen_ep.py,生成 /root/ep/fp32.onnx 和 /root/ep/int8.onnx。线性算子必须有五个以上。
如果只有两层或三层,就看不出误差增长的样子。请叠五层以上。静态量化需要 CalibrationDataReader,校准数据要从与测试输入相同的分布中抽取,但不必是同一批样本。
把请求的和拿到的并排摆放
在 /root/ep/eprun.py 中实现 providers <model> <목록> [...](占位符依次为模型文件与 EP 列表),并把只写了存在的提供程序的请求,和混入了不存在的提供程序的请求的结果,写入 /root/ep/providers.json。
放入不存在的提供程序,似乎会抛异常,但在这个环境里并不会。亲自放进去试试,测一测会发生什么。session.get_providers() 是答案,请求列表不是答案。也许有的版本会抛异常,所以把异常名称也记下来。另外,回退提示会输出到标准输出,要防止它混进答案——contextlib.redirect_stdout 很好用。
数出计算图会被切成几段
加入 partition <model> <지원연산자목록>(占位符依次为模型文件与所支持的算子列表),模拟如果有一个只支持这些算子的提供程序,计算图会被怎样切开。同时输出片段数和传递次数。
这不是模仿实际的分配,而是数出支持列表在计算图的顺序上被截断的位置。持有者相同且连续的区间就是一段。如果假定整数版本只支持 MatMul,由于 Q/DQ 的存在,片段会增加多少,一眼就能看到。
把层边界张量取出来
加入 expose <model> <out.onnx>,保存把层边界张量追加为计算图输出的版本,并生成 /root/ep/fp32_exposed.onnx 和 /root/ep/int8_exposed.onnx。
在整数版本里,QuantizeLinear 的输出是 int8。如果写成要把它以 float 取出,会话就打不开。线性算子接收输入的那一侧张量,在两个计算图里都是 float,所以是安全的。把保存的版本实际打开并运行一下,再往下走。
在每个层边界测误差
加入 layers <fp32> <int8> <seed>,把用随机种子生成的同一个输入放进两个版本,输出每个层边界的最大绝对误差、大小、相对误差和最小余弦。
只看绝对误差,靠后的层总是显得更差。因为值本身变大了。所以要同时给出除以该层张量最大绝对值得到的相对误差。两个计算图的边界名称不同,请按位置顺序配对。
把增长的比值做成表
加入 growth <fp32> <int8> <seed>,输出各层的相对误差及其比值、是否单调、整体增长倍数、以及跳得最高的层,并把你这个模型的结果写入 /root/ep/growth.json。
测一下就会发现,也会出现比值小于 1 的区间。因为激活函数会截掉一部分,每一层的缩放因子也是重新确定的。不单调本身就是结果,请原样写下。前一层的相对误差为 0 时无法相除,记为 null。
只改变设置,用同一个输入再运行
加入 settings <model> <seed>,把同一个模型、同一个输入只改变优化级别和线程数来运行,输出与基准的差异。把你这个模型的结果写入 /root/ep/settings.json。
在这个 Pod 里,四种情况给出的值都相同。请原样写下这个结果——不要编造说应该不同。重要的不是它们相同这个事实,而是具备了测量相同还是不同的流程。换一台机器,可能会得出不同的答案。
写下什么只在这台机器上成立
把 /root/ep/ep_report.md 写成 ## 무엇을 요청했고 무엇을 쥐었나、## 그래프가 쪼개지는 자리、## 층을 거듭하며 자란 오차、## 이 기계에서만 참인 것 四节(四个标题为韩文,依次意为“请求了什么,拿到了什么”“计算图被切开的位置”“逐层叠加后增长的误差”“只在这台机器上成立的事”)。可用的提供程序个数和整体误差增长倍数,要以数字写进去。
最后一节是这份报告的关键。请写下在这里测得的值里,哪些与这个 Pod 的提供程序配置和这些设置绑定在一起。也要写下没有测时间这件事。