面向:已有 PyTorch / TensorFlow 训练经验、需要把模型部署到生产环境(服务端 / 边缘设备 / 工业现场)的开发者。 技术栈:Python 3.8+ / C++17,ONNX Runtime 1.17+,Windows / Linux。
1. 背景
1.1 深度学习部署的经典痛点
训练框架(PyTorch / TensorFlow)的设计重心是"快速迭代模型",到了生产推理阶段会暴露出几个结构性问题:
| 痛点 | 表现 |
|---|---|
| 框架依赖重 | 推理进程必须携带整个训练框架,启动慢、内存常驻 GB 级 |
| 跨框架迁移难 | PyTorch 模型无法直接跑在 TF Serving 上,换框架等于重写推理代码 |
| 硬件加速碎片化 | CUDA / TensorRT / OpenVINO / DirectML 各有各的专有格式与 SDK |
| 算子实现取向 | 训练框架的算子偏重正确性与灵活性,而非推理性能(算子融合、内存复用) |
1.2 ONNX 与 ONNX Runtime 的定位
- ONNX(Open Neural Network Exchange):2017 年微软与 Facebook 联合推出的开放模型格式,本质是一份计算图中间表示(IR)。它定义算子的标准语义,让 PyTorch / TensorFlow / PaddlePaddle 训练的模型都能导出为同一份 .onnx 文件。
- ONNX Runtime(简称 ORT):微软开源的高性能推理引擎,核心用 C++ 实现,提供 Python / C# / Java / JavaScript 等绑定,覆盖 Windows / Linux / macOS / iOS / Android。它的设计哲学是:格式中立(接得住任意框架导出的 ONNX)+ 运行时统一优化(同一份模型在 CPU / GPU / NPU 上拿到尽可能高的性能)。
一句话概括:ONNX 解决"模型怎么交换",ONNX Runtime 解决"模型怎么跑得快"。
1.3 与主流推理方案横向对比
| 方案 | 模型格式 | 硬件覆盖 | 关键特点 | 典型场景 |
|---|---|---|---|---|
| PyTorch eager 推理 | .pt | CPU / CUDA | 上手最快、性能一般、依赖重 | 实验验证 |
| TorchScript | .pt | CPU / CUDA | 图模式、算子覆盖受限 | PyTorch 生态内部署 |
| ONNX Runtime | .onnx | CPU / CUDA / TensorRT / OpenVINO / DirectML / ROCm | 跨框架、优化器丰富、开源、可裁剪 | 通用生产推理首选 |
| TensorRT | .engine | 仅 NVIDIA GPU | 极致性能、闭源、构建慢 | NVIDIA 独占加速 |
| OpenVINO | .xml/.bin | Intel CPU/GPU/NPU | Intel 硬件深度优化 | Intel 边缘设备 |
| TFLite | .tflite | CPU / GPU / NPU | 移动端生态成熟 | 手机 / 嵌入式 |
| TVM | 编译产物 | 多硬件 | 编译式优化、学习曲线陡 | 自研编译器场景 |
1.4 与既有技术博客系列的定位差异
本系列已写过OpenCV dnn 模块(第 75 篇):它内置了部分 ONNX 模型加载与推理能力,但算子覆盖少、无执行提供方体系、优化能力弱,适合"OpenCV 项目里顺手跑个小模型"。本篇是完整的 ONNX Runtime 推理引擎主线:会话生命周期、执行提供方(EP)调度、IO Binding 零拷贝、图优化、量化、多线程配置、跨语言(Python/C++)双视角,定位是"把 ONNX 模型真正部署进生产系统"。
2. 核心概念
2.1 计算图与算子(Graph & Op)
.onnx 文件内容是一张有向无环计算图:Node(算子实例,如 Conv、MatMul)按 Input → Output 连接,模型级元信息(opset 版本、ir_version、生产者)记录在头部。ORT 加载后先做图解析,再做拓扑排序与优化。
2.2 Session(会话)
ORT 的核心抽象。一次 Session 加载一份模型,可反复 Run 推理。Session 内部持有:
- 优化后的图
- 选定执行提供方(EP)分配的算子内核
- 内存分配器 / Arena
关键约束:一个 Session 实例默认只能被单个线程安全地调用 Run。多线程并发推理要么每个线程一个 Session(各自独立加载),要么用 ORT 1.17+ 的 Session::Run 加锁或复用 IO Binding 的线程安全模式,不能裸共享。
2.3 执行提供方(Execution Provider, EP)
EP 是 ORT 的可插拔后端抽象。常见 EP 及优先级顺序:
| EP | 硬件 | 安装包 | 说明 |
|---|---|---|---|
| CPUExecutionProvider | CPU | 内置 | 兜底,任何模型都能跑 |
| CUDAExecutionProvider | NVIDIA GPU | onnxruntime-gpu | 通用 CUDA 内核 |
| TensorrtExecutionProvider | NVIDIA GPU | onnxruntime-gpu + TensorRT | 走 TensorRT 引擎,需额外安装 |
| OpenVINOExecutionProvider | Intel CPU/GPU/NPU | 需自行构建或 pip 专用包 | Intel 硬件加速 |
| DirectMLExecutionProvider | Windows GPU(AMD/Intel/NVIDIA) | onnxruntime-directml | DirectX 12 统一加速 |
| ROCmExecutionProvider | AMD GPU | onnxruntime-rocm | AMD Linux |
EP 列表按传入顺序优先选择:ORT 逐个尝试,第一个能支撑整图的 EP 被采用;不支持则回退到下一个,最后兜底 CPU。这个"静默回退"机制既是优点也是大坑(见常错点)。
2.4 OrtValue 与 IO Binding
- OrtValue:ORT 中统一的数据容器,承载张量(Tensor)、序列(Sequence)、映射(Map)等类型。Python 侧由 numpy 数组自动包装。
- IO Binding:显式把输入输出张量绑定到预先分配的内存(如 GPU 显存、特定 CPU 缓冲区),避免推理时反复拷贝。高吞吐场景的性能关键。
2.5 图优化级别
ORT 对加载的图做三档优化,默认 ORT_ENABLE_ALL:
| 级别 | 值 | 内容 |
|---|---|---|
| ORT_DISABLE_ALL | 0 | 关闭所有图优化,仅做基础拓扑处理 |
| ORT_ENABLE_BASIC | 1 | 常量折叠、冗余节点消除等基础优化 |
| ORT_ENABLE_EXTENDED | 2 | 基础 + 部分算子融合(如 Conv+BN) |
| ORT_ENABLE_ALL | 99 | 全部优化,含算子融合与布局优化,默认 |
3. API 说明
3.1 Python API
3.1.1 会话创建
import onnxruntime as ort so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.intra_op_num_threads = 4 # 算子内并行线程 so.inter_op_num_threads = 2 # 算子间并行线程 so.log_severity_level = 3 # 0=VERBOSE 1=INFO 2=WARNING 3=ERROR # providers 按优先级传入;"CPUExecutionProvider" 永远放在最后兜底 sess = ort.InferenceSession( "model.onnx", sess_options=so, providers=["CUDAExecutionProvider", "CPUExecutionProvider"], )3.1.2 输入输出元信息
for inp in sess.get_inputs(): # 返回 NodeArg 列表 print(inp.name, inp.type, inp.shape) # e.g. "input", "tensor(float)", [1, 3, 224, 224] for out in sess.get_outputs(): print(out.name, out.type, out.shape)3.1.3 推理
import numpy as np x = np.random.randn(1, 3, 224, 224).astype(np.float32) # dtype 必须匹配模型 outputs = sess.run( output_names=["output"], # 要取出的输出名,None 表示取全部 input_feed={"input": x}, # 键必须是 get_inputs() 里的名字 ) pred = outputs[0]3.1.4 RunOptions
ro = ort.RunOptions() ro.log_severity_level = 3 # 高阶:ro.terminate 可在多线程场景中断当前 run outputs = sess.run(["output"], {"input": x}, run_options=ro)3.1.5 IO Binding(Python)
import numpy as np from onnxruntime.capi.onnxruntime_pybind11_state import OrtValue as _OrtValue # 一般用 numpy 直接绑 io = sess.io_binding() # 输入绑定到 CPU 缓冲 x = np.random.randn(1, 3, 224, 224).astype(np.float32) io.bind_cpu_input("input", x) # 输出预分配并绑定 out_shape = (1, 1000) out_buf = np.empty(out_shape, dtype=np.float32) io.bind_output("output", out_buf) # 之后 out_buf 会被推理结果原地填充 sess.run_with_iobinding(io) result = io.get_outputs()[0].numpy() io.clear_binding_inputs() io.clear_binding_outputs()3.1.6 量化(onnxruntime.quantization)
from onnxruntime.quantization import quantize_dynamic, QuantType, quantize_static # 动态量化:无需校准数据,直接把权重转 int8,推理时动态反量化 quantize_dynamic("model.onnx", "model_dynamic_q.onnx", weight_type=QuantType.QInt8) # 静态量化:需要校准数据集,性能更优但流程复杂 # quantize_static("model.onnx", "model_static_q.onnx", calibration_data_reader=reader)3.2 C++ API(Ort C++ API)
核心类全部位于 Ort:: 命名空间,RAII 管理生命周期。
#include <onnxruntime_cxx_api.h> // 1. 环境(全局一份即可) Ort::Env env(OrtLoggingLevel::ORT_LOGGING_LEVEL_WARNING, "my-app"); // 2. 会话选项 Ort::SessionOptions so; so.SetIntraOpNumThreads(4); so.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // 3. 创建会话(同时传入 EP 配置) Ort::Session session(env, L"model.onnx", so); // CUDA EP 需要在 SessionOptions 上 AppendExecutionProvider_CUDA(...)3.2.1 输入输出元信息
size_t in_count = session.GetInputCount(); for (size_t i = 0; i < in_count; ++i) { auto name = session.GetInputNameAllocated(i, Ort::Allocator::GetWithDefaultOptions()); auto type_info = session.GetInputTypeInfo(i); // Ort::TypeInfo // type_info.GetTensorTypeAndShapeInfo() 可拿 shape / type } size_t out_count = session.GetOutputCount();3.2.2 构造输入并推理
#include <vector> // 输入数据(1x3x224x224 的 float 张量) std::vector<float> input_data(1 * 3 * 224 * 224, 0.0f); std::array<int64_t, 4> input_shape{1, 3, 224, 224}; // 显式 CPU 内存描述(Arena 分配器 + 默认 mem type) auto mem_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); // 用输入数据构造 OrtValue(不拷贝:直接引用 input_data.data()) Ort::Value input_tensor = Ort::Value::CreateTensor<float>( mem_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 输入/输出名(C++ 侧需要 char* 数组) const char* input_names[] = {"input"}; const char* output_names[] = {"output"}; // 推理 std::vector<Ort::Value> outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); // 取出结果张量 float* out_data = outputs[0].GetTensorMutableData<float>(); std::vector<int64_t> out_shape = outputs[0].GetTensorTypeAndShapeInfo().GetShape();3.2.3 关键类速查
| 类 | 职责 |
|---|---|
| Ort::Env | 日志级别、线程池(可选) |
| Ort::SessionOptions | 图优化、线程数、EP 追加、内存 arena 开关 |
| Ort::Session | 模型加载与 Run |
| Ort::Value | 张量/序列容器,CreateTensor / GetTensorMutableData |
| Ort::MemoryInfo | 内存位置描述(CPU/GPU、分配器类型) |
| Ort::RunOptions | 单次运行控制 |
| Ort::TypeInfo | 输入输出类型/形状查询 |
4. 详细使用说明
4.1 安装
# CPU 版 pip install onnxruntime # GPU 版(CUDA 12 + cuDNN 8/9,注意版本配套) pip install onnxruntime-gpu # Windows DirectML 版 pip install onnxruntime-directml # C++:vcpkg vcpkg install onnxruntime # 或 NuGet:Microsoft.ML.OnnxRuntime4.2 模型导出(PyTorch → ONNX)
import torch model = torchvision.models.resnet18(pretrained=True).eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "resnet18.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, # 声明动态 batch opset_version=17, )导出后先用 onnx.checker.check_model 校验,再用 onnxruntime 跑一次 dummy 对比 PyTorch 输出。
4.3 Python 完整推理示例(含预处理/后处理)
import numpy as np import onnxruntime as ort from PIL import Image # ---------- 预处理:与训练时完全一致 ---------- img = Image.open("cat.jpg").convert("RGB").resize((224, 224)) arr = np.asarray(img, dtype=np.float32) / 255.0 # 归一化到 [0,1] mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) arr = (arr - mean) / std arr = arr.transpose(2, 0, 1) # HWC -> CHW x = arr[np.newaxis, ...].astype(np.float32) # (1,3,224,224) # ---------- 推理 ---------- sess = ort.InferenceSession("resnet18.onnx", providers=["CPUExecutionProvider"]) result = sess.run(["output"], {"input": x})[0] # (1,1000) # ---------- 后处理 ---------- import torchvision.transforms.functional as F idx = int(np.argmax(result[0])) # 配合 ImageNet 标签表得到类别名;softmax 可选(argmax 不受单调变换影响)4.4 动态形状的两种处理
- 固定形状:导出时不声明 dynamic_axes,输入维度写死为 [1,3,224,224]。性能最优、内存可预分配,但不能换 batch。
- 动态形状:声明 dynamic_axes,推理时传不同 batch。注意动态轴在 GPU 上会触发重新分配,吞吐略降;且部分算子不支持动态轴。
实际部署建议:固定形状 + 固定 batch(如 8/16),用批处理换吞吐;实在需要弹性再上动态轴。
4.5 批处理推理(Python)
def infer_batch(sess, imgs: list[np.ndarray]) -> np.ndarray: """imgs: 每张已预处理为 (3,224,224) float32 的数组""" x = np.stack(imgs, axis=0) # (N,3,224,224) return sess.run(["output"], {"input": x})[0]4.6 C++ 完整最小示例
#include <onnxruntime_cxx_api.h> #include <vector> #include <array> #include <iostream> int main() { Ort::Env env(OrtLoggingLevel::ORT_LOGGING_LEVEL_WARNING, "demo"); Ort::SessionOptions so; so.SetIntraOpNumThreads(4); so.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, L"model.onnx", so); std::vector<float> data(1 * 3 * 224 * 224, 1.0f); std::array<int64_t, 4> shape{1, 3, 224, 224}; auto mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input = Ort::Value::CreateTensor<float>( mem, data.data(), data.size(), shape.data(), shape.size()); const char* in_name[] = {"input"}; const char* out_name[] = {"output"}; auto outputs = session.Run(Ort::RunOptions{nullptr}, in_name, &input, 1, out_name, 1); float* out = outputs[0].GetTensorMutableData<float>(); std::cout << "output[0] = " << out[0] << std::endl; return 0; }4.7 IO Binding 零拷贝(性能关键)
默认 sess.run() 在 Python 侧存在 numpy → OrtValue 的包装开销,且在 CPU↔GPU 之间可能产生隐式拷贝。高吞吐路径(视频流、工业视觉逐帧推理)必须用 IO Binding:
io = sess.io_binding() # 输入侧:直接绑定 numpy 内存 io.bind_cpu_input("input", x) # 输出侧:预分配 buffer,避免每次 run 新建输出张量 out_buf = np.empty((1, 1000), dtype=np.float32) io.bind_output("output", out_buf) for frame in frames: x = preprocess(frame) io.bind_cpu_input("input", x) # 重新绑定或复用同一块内存 sess.run_with_iobinding(io) # out_buf 已被覆盖,直接用 process(out_buf)GPU 场景还可用 io.bind_output("output", gpu_device) 直接把输出留在显存,减少 D2H 拷贝。
4.8 性能优化实践清单
| 手段 | 做法 | 收益 |
|---|---|---|
| 图优化 | 保持默认 ORT_ENABLE_ALL | 算子融合、常量折叠 |
| 线程调优 | intra_op_num_threads 通常设为物理核数;inter_op 默认 1 | 单算子并行 vs 多算子流水 |
| 批处理 | 固定 batch 推理 | 吞吐线性提升 |
| IO Binding | 复用输入输出缓冲 | 减少分配与拷贝 |
| 量化 | 动态量化(易)/ 静态量化(优) | 体积 -75%,速度 1.5~4x |
| 预热 | 首次 run 前跑 3~5 次 dummy | 消除初始化抖动 |
| 多实例 | 每核/每卡一个 Session | 并发吞吐扩展 |
5. 常错点 / 坑(20 条)
- 输入形状不符:模型期望 [1,3,224,224],传了 [224,224,3](忘了转 CHW),或忘了加 batch 维。报错多为 shape mismatch。先 get_inputs()[0].shape 打印确认。
- 归一化参数与训练不一致:训练用 mean/std 归一化,推理直接喂原始像素 → 精度断崖。预处理必须与训练完全一致(含 resize 插值方式、归一化、通道顺序)。
- dtype 不匹配:模型是 tensor(float)(float32),传了 float64 的 numpy 数组。numpy 默认 float64,必须 .astype(np.float32)。
- providers 缺失导致静默回退 CPU:写了 providers=["CUDAExecutionProvider"] 但 CUDA 组件不匹配,ORT 抛错;只写了 CPU 但想用 GPU,则一直 CPU 在跑。确认方式:ort.get_available_providers() 查看编译进哪些 EP,sess.get_providers() 查看本次实际启用哪些。
- CUDA/cuDNN 版本不配套:onnxruntime-gpu 对 CUDA/cuDNN 有严格版本要求(如 ORT 1.17 要求 CUDA 12.x + cuDNN 8.x)。版本错配报 DLL load failed 或 requires cuDNN 类错误,查官方兼容表。
- 动态轴未声明:导出时没写 dynamic_axes,推理想换 batch → 报错。要么重新导出声明动态轴,要么固定形状。
- 输入名写错:input_feed={"data": x} 但模型输入名是 "input"。用 sess.get_inputs()[0].name 取值,不要凭记忆。
- 忘记 eval 模式导出:PyTorch 导出前必须 model.eval(),否则 BatchNorm/Dropout 处于训练行为,推理结果错误。
- 模型包含不支持的算子:自定义算子 / 太新算子 → 加载失败。对策:换 opset、改模型结构、或注册 Custom Op。
- opset 版本问题:导出用 opset 17,运行时 ORT 太老不支持 → 报算子缺失。升级 ORT 或降低 opset。
- 多线程共享 Session:多个线程同时调 sess.run() 会数据竞争。对策:每线程独立 Session(模型不大时首选),或外部加锁。
- IO Binding 输出缓冲复用陷阱:bind_output 绑定的 buffer 必须在 run_with_iobinding 期间保持存活且可写;绑了 GPU 输出又用 CPU 代码读 get_outputs()[0].numpy() 会隐式 D2H 拷贝,语义容易搞混。
- 量化后精度骤降:动态量化对敏感算子(如检测头的某些层)伤害大。对策:静态量化 + 校准集,或混合量化(敏感层保持 fp32)。
- intra/inter 线程数盲调:intra_op_num_threads 设为逻辑核数(超线程)反而变慢;inter_op 大于 1 在小模型上增加调度开销。以实测为准,别凭直觉。
- CPU 与 GPU 结果不一致:浮点累加顺序不同导致轻微差异,属正常现象;若差异巨大则是算子实现差异或数据拷贝 bug。
- Windows 中文路径:C++ 的 Ort::Session 构造用 std::wstring(L"..."),传窄字符串中文路径可能失败;模型文件路径别带中文更省心。
- 内存占用暴涨:默认 CPU Arena 会缓存大块内存(enable_cpu_mem_arena 默认开)。内存敏感场景(嵌入式)可关掉,或用 RunOptions 限制。
- Python GIL 与异步:sess.run 会释放 GIL(内部 C++ 执行),但输入构造/输出解析仍持 GIL;想在 asyncio 中跑推理,用 loop.run_in_executor 或独立线程池,别直接阻塞事件循环。
- 模型校验跳过:导出的 onnx 没跑 onnx.checker.check_model、没做输出对比,部署时才发现数值全错。导出后必须做"框架输出 vs ORT 输出"一致性验证(容差 1e-4 级别)。
- 静态量化校准数据泄漏:用测试集做量化校准,导致评估指标虚高。校准集必须与测试集分离。
6. 总结
6.1 适用场景
| 场景 | 推荐组合 |
|---|---|
| 服务端推理(微服务/离线批处理) | Python + CUDA EP + 固定 batch + IO Binding |
| 工业视觉 / 边缘盒子 | C++ + CPU/DirectML EP + 量化 + 线程调优 |
| 嵌入式(树莓派/工控机) | 动态量化 + enable_cpu_mem_arena=false + 低线程数 |
| 多硬件统一交付 | 一套 .onnx + EP 列表配置,按机器选择 |
6.2 选型决策树
需要部署模型? ├─ 只在 NVIDIA GPU → TensorRT(极致)或 ORT+CUDA(省事) ├─ 只在一类 Intel 设备 → OpenVINO 原生 ├─ 跨框架 / 跨硬件 / 快速上线 → ONNX Runtime(首选) ├─ 移动端 / 嵌入式 → TFLite(生态)或 ORT Mobile └─ 极致自定义算子优化 → TVM / 手写内核
6.3 工业数采 / 边缘 AI 实践建议
- 链路:传感器/CNC 数据 → 采集网关(C++/Go)→ 数据规整 → 边缘推理盒子(ORT C++,量化 int8)→ 结果回流 Kafka/MQTT。
- 模型治理:每版模型固定 opset + ORT 版本,做"框架输出 vs ORT 输出"回归测试再上线。
- 性能基准:上线前记录 首延迟(预热后)/ 稳态吞吐 / P99 延迟,量化与线程参数以 A/B 实测为准。
- 故障预案:EP 回退打日志、模型热加载失败回退旧版本、显存 OOM 自动降级 CPU。
6.4 FAQ 速查
| 问题 | 答案 |
|---|---|
| 如何确认 GPU 生效? | ort.get_available_providers() 看编译 EP;sess.get_providers() 看本次启用 EP |
| 模型加载报算子缺失? | 升级 ORT / 降低 opset / 换模型结构 / 注册 Custom Op |
| 如何加速小模型? | 动态量化 + 固定形状 + 减少线程开销(inter_op=1)+ 预热 |
| 动态轴和 IO Binding 兼容吗? | 可以,但输出缓冲形状需匹配实际输出,动态形状需重新 bind |
| 输出概率全接近 0? | 检查是否对 logits 误做 softmax 两次,或归一化不一致 |
| Session 能跨线程共享吗? | 默认不行;多线程推理用每线程 Session 或加锁 |
| GPU 显存暴涨? | 检查是否每帧新建输出张量未释放,用 IO Binding 复用缓冲 |
| 如何减小模型体积? | 动态量化(权重 int8)+ 移除冗余输出节点 |