本节摘要:Execution Provider 是硬件翻译官,Kernel 是单算子实现,OrtValue 是跨 EP 张量载体。数值语义契约要求输出偏差在声明精度下可解释。
报错与文档里反复出现的名词,先在这里钉死关系:ExecutionProvider 是 ORT 与硬件之间的插件层,负责声明能力、注册 kernel、管理设备内存;Kernel 是某个算子在某个 EP 下的具体实现,一个 ONNX 算子可以对应多个 Kernel;KernelDef 是 Kernel 的注册描述(支持的输入类型、约束);OrtValue 是承载张量数据的统一载体,跨越 EP 边界时被拷贝或共享。
| 术语 | 角色 | 对应代码 |
|---|---|---|
| ExecutionProvider | 注册 kernel、管设备内存 | CUDAExecutionProvider |
| Kernel | 单算子在 EP 下的实现 | Conv 的 cuDNN 实现 vs MLAS 实现 |
| KernelDef | 描述 kernel 支持的类型约束 | 输入必须是 float32 |
| OrtValue | 跨 EP 拷贝载体 | session.run 的输入输出类型 |
| Graph Partition | 把图按能力切给各 EP | 分区后的子图集合 |
EP 通过 GetCapability() 提交可接管子图:输入节点集合、输出节点、内部执行的属性。ORT 的 Partition 器综合所有 EP 的能力声明,把整图切成若干连续子图,每个子图交付给最匹配的 EP。边界处如果两个 EP 的 device 不同(GPU 与 CPU),就会插入内存拷贝。
同一个 Conv 节点,CUDA EP 用 cuDNN/cuBLAS,CPU EP 用 MLAS——这就是为什么跨 EP 输出会有微小差异。MLAS(Microsoft Linear Algebra Subroutine)针对 AVX-512 做了分块优化,与 cuBLAS 的累加顺序不同,浮点结果不会逐 bit 一致。
输出差 0.02 算不算 bug?取决于根因。归好类再决定要不要排查:
1e-7 量级,可接受tf32 中间计算,误差可到 1e-3~1e-2,需按业务阈值验收判断方法很简单:同一模型、同一输入,分别在 CPU(FP32 基线)与目标 EP 上跑,逐算子对比中间张量的最大绝对误差,误差来源一目了然。对序列模型还要对比 logits 的顺序,避免 RNN 展开方向不同引入的错位。
把"对比"沉淀成可复用的函数,而不是每次手工看输出:
import numpy as np import onnxruntime as ort def max_diff(sess_cpu, sess_ep, feeds): out_cpu = sess_cpu.run(None, feeds)[0] out_ep = sess_ep.run(None, feeds)[0] abs_diff = np.abs(out_cpu.astype(np.float64) - out_ep.astype(np.float64)) rel_diff = abs_diff / (np.abs(out_cpu).astype(np.float64) + 1e-8) return abs_diff.max(), rel_diff.max() sess_cpu = ort.InferenceSession("mlp.onnx", providers=["CPUExecutionProvider"]) sess_ep = ort.InferenceSession("mlp.onnx", providers=["CUDAExecutionProvider"]) feeds = {"input": np.random.randn(4, 256).astype(np.float32)} print(max_diff(sess_cpu, sess_ep, feeds))
阈值按场景定:分类模型看 argmax 是否一致;回归模型看相对误差 < 1e-4;医疗/金融类高风险场景必须对 FP32 基线做全量验收。2023 年 ORT-GPU Arena 目标将显存碎片率压到 5% 以下,那是第4章的主场,但验收流程从本章就应建立。
语义契约可以写成一个边界不等式:设 FP32 参考输出为 y_ref,目标 EP 输出为 y_ep,则验收要求对给定输入集 D 满足:
sup_{x in D} || f_ep(x) - f_ref(x) ||_2 / (|| f_ref(x) ||_2 + eps) <= tolerance
tolerance 按三类根因分级:累加顺序差异给 1e-6,FP16/TF32 模式给 1e-3,语义差异必须为 0(否则是 bug)。注意 tolerance 是"上界保证",不是"一次运行的实测值"——多跑几组输入,取最大的那个误差,才是可复现的契约。代码里 np.max(abs_diff) 计算的就是这个量。
session.run(None, {"input": x}) 背后会做一次张量到 OrtValue 的转换。GPU 场景下每次 run 都走 numpy 转换会把数据搬运开销叠加到延迟里,正确做法是用 IO Binding 预先分配设备内存:
import onnxruntime as ort sess = ort.InferenceSession("mlp.onnx", providers=["CUDAExecutionProvider"]) io = sess.io_binding() dev = ort.OrtDevice("cuda", 0) x = ort.OrtValue.ortvalue_from_numpy(x_np, dev) # 直接放在 GPU io.bind_ortvalue_input("input", x) io.bind_output("logits", dev) sess.run_with_iobinding(io) out = io.get_outputs()[0].numpy()
IO Binding 让输入输出都停留在设备内存,绕开 Host/Device 往返。对低延迟服务,这个改动常能省掉 10%~30% 的每请求开销,也是理解 OrtValue 契约价值的直接例证:OrtValue 不只是"张量载体",它携带 device 与内存类型信息,决定了数据能否被零拷贝复用。
| 根因 | 典型量级 | 是否接受 | 处置 |
|---|---|---|---|
| 累加顺序(同 FP32) | 1e-7 | 可接受 | 无需处理 |
| FP16 / TF32 中间计算 | 1e-3 ~ 1e-2 | 按业务定 | 敏感层升 FP32 |
| 算子语义差异(NaN 传播等) | 可到无穷 | 不可接受 | 对齐实现 / 换 EP |
| 量化误差累积 | 1e-2 以上 | 按业务定 | 第5章校准与排除 |
这张表是跨 EP 验收的决策入口:先量化误差落在哪个量级,再决定动模型、动配置还是动 EP。注意第三类是红线——它意味着某个算子在两个 EP 上的实现不是数学等价的,属于需要上报的语义级缺陷,不能靠阈值糊弄过去。
⚠️ TensorRT EP 与 CUDA EP 不等价——TRT 会对子图额外编译与融合,输出与 CUDA EP 也可能有差,失败时需回退 CUDA 再比对。
💡 sess.get_providers() 看实际激活 EP。配置里写了 CUDA 但实际列表只有 CPU,说明 wheel 装的是 CPU 版或设备不可用,先查这里再查模型。
下一章:Session 内部第一段流水线——图加载、常量折叠与算子融合。