模型明明加载成功,推理结果为什么还是错的?从 ONNX 契约到双后端部署

部署视觉模型时,最容易让人放松警惕的一行日志是:

1
model loaded successfully

程序没有抛异常,session.Run() 也返回了张量,但检测框全部挤在图像左上角;把 ONNX Runtime 换成 OpenVINO 后,输出又和 Python 相差很大。此时继续调线程数、打开 FP16,通常只会让错误跑得更快。

问题在于,模型可加载只证明运行时能解析计算图,不证明输入名称、形状、数据类型、内存布局、预处理和后处理都满足模型契约。本文用一个可独立生成的小模型跑通 C++ 最小闭环,再把同一套检查方法扩展到 ONNX Runtime 与 OpenVINO 的真实部署、性能调优和结果对齐。

文中的 C++ 接口以 ONNX Runtime 1.26 和 OpenVINO 2026.2 为参照。其他版本的 API、可用设备及系统要求可能不同,需要结合实际安装版本验证;尤其是 OpenVINO 2026 已停止支持 macOS x86,CPU 插件最低要求也发生了变化。

1. “加载成功”究竟证明了什么?

ONNX(Open Neural Network Exchange)是模型交换格式,不是执行引擎。真正执行计算图的是推理运行时:ONNX Runtime 通过执行提供程序(Execution Provider,EP)把节点分配给 CPU、CUDA、TensorRT 等后端;OpenVINO 则会把模型编译到 Intel CPU、GPU、NPU 或 AUTO 选择的设备。

一次完整推理实际包含下面这条链路:

1
2
3
4
5
6
7
8
9
10
11
PyTorch / 其他训练框架
↓
导出 ONNX
↓
检查模型契约
↓
选择推理后端
├── ONNX Runtime:跨平台、后端可切换
└── OpenVINO:Intel CPU / GPU / NPU 上重点优化
↓
预处理 → 创建输入张量 → 执行计算图 → 解释输出 → 后处理

“加载成功”通常只覆盖到“运行时能读取模型并创建会话/编译模型”。后面任何一个箭头出错,都可能得到类型正确、数值却毫无意义的输出。更麻烦的是,这类错误往往不会崩溃。

ONNX Runtime 和 OpenVINO 的核心区别:

项 ONNX Runtime OpenVINO
定位 通用 ONNX 推理运行时 Intel 推理优化和部署工具链
输入模型 主要是 .onnx,也支持 ORT format 可直接读 .onnx,也可读 OpenVINO IR .xml/.bin
加载入口 Ort::Session ov::Core::read_model + compile_model
执行后端 CPU、CUDA、TensorRT、OpenVINO EP、DirectML 等 CPU、GPU、NPU、AUTO 等
优化时机 创建 Session 时做图优化和 EP 划分 compile_model 时做设备相关编译和优化
启动成本 通常较轻,取决于优化级别和 EP 编译阶段可能更重,但可用 cache 缓解
适合场景 希望保持 ONNX、跨平台部署、后端灵活切换 Intel 平台上追求 CPU/GPU/NPU 推理性能

先用下面的选择作为起点,而不是结论:

场景 更适合
只想跨平台跑 ONNX ONNX Runtime
Windows / Linux / macOS 都要部署 ONNX Runtime
需要 CUDA / TensorRT ONNX Runtime + 对应 Execution Provider
Intel CPU 上 batch=1 视频检测 OpenVINO 值得优先测试
Intel GPU / NPU OpenVINO
希望同一个 ONNX 文件对比不同后端 两者都直接加载同一个 ONNX,再统一输入与计时口径

最终仍要用目标机器、目标模型和真实输入做数值与性能验证。某个后端在一台 CPU 上更快,不能推出它在另一代 CPU、不同 batch 或不同模型上仍然更快。

2. 为什么输入 shape 对了,结果仍可能完全错误?

假设模型输入形状是 [1, 3, 640, 640],下面两段缓冲区都有 1 × 3 × 640 × 640 个 float:

1
2
NCHW: RRR... GGG... BBB...
NHWC: RGBRGBRGBRGB...

元素总数相同,运行时不会因为布局错误而拒绝推理,但卷积看到的像素含义已经变了。类似地,OpenCV 默认读取 BGR,而不少训练流程使用 RGB;忘记除以 255 也不会改变 shape,却会把数值尺度放大 255 倍。

因此,部署前必须把“模型契约”当成接口文档,而不是只记一个输入尺寸。

至少要确认:

项 例子 说明
输入名 images C++ 推理时可能需要按名字传入
输入形状 [1, 3, 640, 640] NCHW 还是 NHWC 必须确认
输入类型 float32 常见视觉模型是 FP32
图像颜色 RGB / BGR OpenCV 默认 BGR,训练代码常用 RGB
数值范围 0~1 / -1~1 是否除以 255,是否减均值除方差
resize 方式 resize / letterbox 检测框回映射依赖这个信息
输出形状 [1, 84, 8400] / [1, 300, 6] 决定后处理怎么写
NMS 位置 模型内 / C++ 外部 YOLO 导出时经常不同

还要确认动态维度的含义。ONNX 元数据中可能出现 -1,也可能是带名称的符号维度;它表示该维度要到运行时才能确定,并不意味着可以传入任意值。模型中的算子、位置编码或后处理仍可能限制合法范围。

建议加载模型后先打印输入输出:

1
2
3
4
5
input name: images
input shape: [1, 3, 640, 640]
input type: float32
output name: output0
output shape: [1, 84, 8400]

3. 如何构造一个真正可复现的最小闭环?

直接把示例写死为 best.onnx,读者并没有这个文件,也不知道它的输入契约。这里先生成一个只有两个算子的模型:

1
输入 X[1,4] → Mul(2) → Add(1) → 输出 Y[1,4]

输入 [1, 2, 3, 4] 时,预期输出必须是 [3, 5, 7, 9]。它没有图像预处理和复杂后处理,因此非常适合验证模型路径、名称、shape、数据类型、动态库和 C++ API 是否正确。

3.1 生成测试模型

运行环境:Python 3.10+,需要安装在项目虚拟环境中的 onnx。不要为了这个示例修改全局 Python 环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# make_demo_model.py
import onnx
from onnx import TensorProto, checker, helper

x = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 4])
y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 4])

scale = helper.make_tensor("scale", TensorProto.FLOAT, [1], [2.0])
bias = helper.make_tensor("bias", TensorProto.FLOAT, [1], [1.0])

graph = helper.make_graph(
[
helper.make_node("Mul", ["X", "scale"], ["scaled"]),
helper.make_node("Add", ["scaled", "bias"], ["Y"]),
],
"linear_demo",
[x],
[y],
[scale, bias],
)

model = helper.make_model(
graph,
producer_name="blog-demo",
opset_imports=[helper.make_opsetid("", 18)],
)
checker.check_model(model)
onnx.save(model, "demo.onnx")
print("saved demo.onnx")
1
python make_demo_model.py

checker.check_model 检查模型是否符合 ONNX 规范,但它仍不验证模型是否满足业务准确率,也不保证某个特定后端支持所有算子。模型来自不可信来源时,还要在隔离环境中检查资源消耗,不能把格式校验等同于安全审计。

如果旧版运行时报告“不支持 IR version 或 opset”,应让导出工具、ONNX opset 和运行时版本形成经过验证的组合,而不是随意改写模型元数据中的版本号。版本号描述的是模型实际使用的规范,手工调小并不会自动把新算子转换成旧语义。

3.2 用 ONNX Runtime C++ 校验契约并执行

ONNX Runtime 的加载核心是创建一个 Ort::Session:

1
2
3
4
5
6
7
Ort::Env
↓
Ort::SessionOptions
↓
设置图优化、线程数、Execution Provider
↓
Ort::Session(env, model_path, session_options)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <array>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#include <onnxruntime_cxx_api.h>

int main() {
try {
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "contract_demo");
Ort::SessionOptions options;
options.SetGraphOptimizationLevel(
GraphOptimizationLevel::ORT_ENABLE_ALL
);

// ORT_TSTR 同时兼容 Windows 的宽字符路径和 POSIX 字符路径。
Ort::Session session(env, ORT_TSTR("demo.onnx"), options);
if (session.GetInputCount() != 1 || session.GetOutputCount() != 1) {
throw std::runtime_error("expected exactly one input and one output");
}

Ort::AllocatorWithDefaultOptions allocator;
auto input_name_holder = session.GetInputNameAllocated(0, allocator);
auto output_name_holder = session.GetOutputNameAllocated(0, allocator);
const std::string input_name{input_name_holder.get()};
const std::string output_name{output_name_holder.get()};

const auto input_info = session.GetInputTypeInfo(0)
.GetTensorTypeAndShapeInfo();
const auto model_shape = input_info.GetShape();
const std::vector<int64_t> expected_shape{1, 4};

if (input_info.GetElementType() !=
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) {
throw std::runtime_error("input must be float32");
}
if (model_shape != expected_shape) {
throw std::runtime_error("unexpected input shape");
}

std::array<float, 4> input_data{1.0f, 2.0f, 3.0f, 4.0f};
std::array<int64_t, 2> input_shape{1, 4};
auto memory_info = Ort::MemoryInfo::CreateCpu(
OrtArenaAllocator,
OrtMemTypeDefault
);
auto input_tensor = Ort::Value::CreateTensor<float>(
memory_info,
input_data.data(),
input_data.size(),
input_shape.data(),
input_shape.size()
);

const char* input_names[] = {input_name.c_str()};
const char* output_names[] = {output_name.c_str()};
auto outputs = session.Run(
Ort::RunOptions{nullptr},
input_names,
&input_tensor,
1,
output_names,
1
);

if (outputs.size() != 1 || !outputs[0].IsTensor()) {
throw std::runtime_error("expected one tensor output");
}
const auto output_info = outputs[0].GetTensorTypeAndShapeInfo();
if (output_info.GetElementType() !=
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT ||
output_info.GetShape() != expected_shape) {
throw std::runtime_error("unexpected output contract");
}

const float* output = outputs[0].GetTensorData<float>();
std::cout << input_name << " -> " << output_name << ":";
for (std::size_t i = 0; i < output_info.GetElementCount(); ++i) {
std::cout << ' ' << output[i];
}
std::cout << '\n';
return 0;
} catch (const Ort::Exception& error) {
std::cerr << "ONNX Runtime error: " << error.what() << '\n';
} catch (const std::exception& error) {
std::cerr << "contract error: " << error.what() << '\n';
}
return 1;
}

假设 ONNX Runtime 解压在 $ORT_ROOT,Linux/macOS 可按实际目录编译:

1
2
3
4
5
clang++ -std=c++17 -O2 -Wall -Wextra \
-I"$ORT_ROOT/include" onnx_contract_demo.cpp \
-L"$ORT_ROOT/lib" -Wl,-rpath,"$ORT_ROOT/lib" \
-lonnxruntime -o onnx_contract_demo
./onnx_contract_demo

预期输出:

1
X -> Y: 3 5 7 9

如果链接器找不到 libonnxruntime,先检查下载包的架构是否和编译目标一致;如果程序启动时找不到动态库,检查 rpath 和实际库目录。Windows 需要使用对应的 .lib/.dll 与编译器配置,不能直接照搬这条 POSIX 命令。

3.3 这段代码真正保护了什么?

Ort::Env 持有运行时环境,生命周期必须长于 Ort::Session;多个 session 通常可以共享一个 Env。SessionOptions 只在创建 session 时生效,所以图优化、线程数和 EP 都必须在构造 Session 之前配置。

名称持有器由 ONNX Runtime 分配。示例立即把名称复制进 std::string,避免后续代码错误保存已经失效的裸指针。随后先验证输入数量、float32 类型和 [1,4] shape,最后才创建张量。真正修复“加载成功但输入错误”的不是 Run(),而是 在 Run() 之前把运行时元数据与应用预期逐项比对。

对动态 shape,不能直接拿模型中的 -1 创建张量。应用应根据本次请求构造全为正数的实际 shape,并检查元素个数是否等于缓冲区长度。真实视觉模型还要把颜色顺序、归一化参数、resize/letterbox 规则和输出语义纳入同一份契约。

4. ONNX Runtime 的优化为什么不能修复错误输入?

4.1 图优化:等价改写,不是精度转换

ONNX Runtime 创建 Session 时会做图优化。图优化不是量化,它不会把 FP32 模型自动变成 INT8,而是对计算图做等价改写。

常见优化级别:

级别 含义 典型优化
ORT_DISABLE_ALL 关闭优化 调试数值问题时可用
ORT_ENABLE_BASIC 基础优化 常量折叠、删除 Identity/Dropout、简单算子融合
ORT_ENABLE_EXTENDED 基础 + 扩展优化 更多算子融合,通常和 EP 支持有关
ORT_ENABLE_ALL 开启全部优化 包含 layout 类优化,通常追求性能时使用

常见写法:

1
2
Ort::SessionOptions options;
options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);

虽然官方文档说明默认会启用优化,但工程里建议显式写出,便于阅读和排查。

4.2 线程数:更多不等于更快

CPU 推理经常要调线程数,不是线程越多越快。

1
2
3
4
5
6
7
8
Ort::SessionOptions options;

// 单个算子内部的并行度,例如 Conv / MatMul 内部并行。
options.SetIntraOpNumThreads(4);

// 多个算子之间的并行度;只在并行图执行模式下生效。
options.SetExecutionMode(ExecutionMode::ORT_PARALLEL);
options.SetInterOpNumThreads(1);

建议测试组合:

场景 起步配置
单路实时视频,batch=1 先测试默认值,再小范围测试 intra=1/2/4/...
多路视频,每路一个 session 每个 session 少给线程,避免互相抢
图中有独立分支且启用 ORT_PARALLEL 再对比 inter=1/2/4

性能测试时要分开统计:

1
2
3
4
preprocess time
inference time
postprocess time
end-to-end time

只看 session.Run() 会忽略预处理、NMS、绘制和数据拷贝。

默认的顺序执行模式(ORT_SEQUENTIAL)不会使用 inter-op 线程池,只设置 SetInterOpNumThreads 不会产生预期效果。并行图执行也不是必然更快:多数 CNN 算子内部已经并行,额外并发可能带来调度和缓存竞争。线程配置必须结合物理核心、进程内 session 数量以及请求并发度实测。

4.3 Execution Provider:警惕“静默回退”

Execution Provider,简称 EP,决定节点交给哪个后端执行。

常见 EP:

EP 适用硬件 说明
CPU EP 通用 CPU 默认可用,跨平台最稳
CUDA EP NVIDIA GPU 通用 GPU 推理
TensorRT EP NVIDIA GPU 编译成本更高,性能可能更好
OpenVINO EP Intel CPU/GPU/NPU 在 ORT 里调用 OpenVINO 后端
DirectML EP Windows GPU Windows 生态常见

CPU EP 不需要额外 append。其他 EP 需要安装带对应 EP 的 ONNX Runtime,并在创建 session 前配置。

伪代码结构:

1
2
3
4
5
6
7
Ort::SessionOptions options;
options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);

// CUDA / TensorRT / OpenVINO EP 的具体 API 和 ORT 版本、构建方式有关。
// 原则是:先 append EP,再创建 Ort::Session。

Ort::Session session(env, model_path, options);

选择 EP 的经验:

  • 先用 CPU EP 跑通输入输出和数值。
  • 再切 CUDA / TensorRT / OpenVINO 等加速后端。
  • EP 按优先级选择可执行子图;保留 CPU EP 作为后备时,部分节点可能回退到 CPU。程序能跑不代表整个模型都在 GPU 上。
  • 每切一次 EP,都查看运行时日志或 profiling,并重新对比输出数值和性能。
  • 不要只看平均耗时,至少看 p50、p95、p99。

4.4 离线优化模型:为什么不能随意跨环境复制?

如果不想每次启动都重复做一部分图优化,可以让 ONNX Runtime 创建 session 时把优化后的模型保存出来。

1
2
3
4
5
Ort::SessionOptions options;
options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
options.SetOptimizedModelFilePath(ORT_TSTR("best.ort_optimized.onnx"));

Ort::Session session(env, ORT_TSTR("best.onnx"), options);

注意:

  • 不要覆盖原始 best.onnx。
  • 扩展和布局优化可能与 EP、硬件及 ORT 版本相关;不要未经验证就把优化产物复制到另一套环境。
  • 排查问题时保留原始模型,方便对比。
  • 如果使用 TensorRT 等会编译 engine 的 EP,还要单独考虑该 EP 自己的 cache。

4.5 Python 侧如何快速检查?

Python 适合先建立黄金输出,再和 C++ 对齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import onnx
import numpy as np
import onnxruntime as ort

model = onnx.load("demo.onnx")
onnx.checker.check_model(model)

requested_provider = "CPUExecutionProvider"
if requested_provider not in ort.get_available_providers():
raise RuntimeError(f"provider unavailable: {requested_provider}")

session = ort.InferenceSession(
"demo.onnx",
providers=[requested_provider],
)
for item in session.get_inputs():
print("input:", item.name, item.type, item.shape)
for item in session.get_outputs():
print("output:", item.name, item.type, item.shape)
print("active providers:", session.get_providers())

x = np.array([[1, 2, 3, 4]], dtype=np.float32)
y = session.run(["Y"], {"X": x})[0]
np.testing.assert_allclose(y, [[3, 5, 7, 9]], rtol=0, atol=1e-6)
print(y)

get_available_providers() 反映当前安装包真正包含的 EP。仅仅在配置里写了 CUDA 并不能证明 CUDA EP 已安装或成功接管节点;还要检查 session 的活动 provider、日志与 profiling。

需要研究离线图优化时,可以另存优化产物:

1
2
3
4
5
6
7
8
9
10
11
import onnxruntime as ort

options = ort.SessionOptions()
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
options.optimized_model_filepath = "best.ort_optimized.onnx"

session = ort.InferenceSession(
"best.onnx",
sess_options=options,
providers=["CPUExecutionProvider"],
)

不要让生成优化模型的步骤覆盖正确性测试。应先对原始模型建立黄金输出,再验证优化模型在约定容差内保持一致。

5. 如何用 OpenVINO 跑同一份模型?

OpenVINO 的核心流程:

1
2
3
4
5
6
7
8
9
10
11
ov::Core
↓
read_model 读取 ONNX / IR
↓
可选:reshape 成静态输入
↓
compile_model 编译到 CPU / GPU / NPU / AUTO
↓
create_infer_request
↓
set_tensor + infer + get_output_tensor

OpenVINO 的关键点是 compile_model。模型不是读进来就直接跑,而是要编译成目标设备上的可执行图。这个阶段会做图变换、算子选择、低精度相关优化、内存规划等。

本文示例以 OpenVINO 2026.2 为参照。该系列的 CPU 插件要求 AVX2,且不再支持 macOS x86;GPU/NPU 是否可用还取决于具体硬件与驱动。部署到旧机器之前必须先核对对应版本的系统要求,不能只看代码能否编译。

5.1 C++ 直接加载 ONNX

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <array>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <openvino/openvino.hpp>

int main() {
try {
ov::Core core;
core.set_property(ov::cache_dir("./ov_cache"));

auto model = core.read_model("demo.onnx");
if (model->inputs().size() != 1 || model->outputs().size() != 1) {
throw std::runtime_error("expected one input and one output");
}

auto compiled_model = core.compile_model(
model,
"CPU",
ov::hint::performance_mode(ov::hint::PerformanceMode::LATENCY)
);
const ov::Shape expected_shape{1, 4};
if (compiled_model.input().get_element_type() != ov::element::f32 ||
compiled_model.input().get_shape() != expected_shape) {
throw std::runtime_error("unexpected input contract");
}

std::array<float, 4> input_data{1.0f, 2.0f, 3.0f, 4.0f};
ov::Tensor input_tensor(
ov::element::f32,
expected_shape,
input_data.data()
);

auto request = compiled_model.create_infer_request();
request.set_input_tensor(input_tensor);
request.infer(); // 同步调用;返回时本次推理已经完成。

const ov::Tensor output = request.get_output_tensor();
if (output.get_element_type() != ov::element::f32 ||
output.get_shape() != expected_shape) {
throw std::runtime_error("unexpected output contract");
}

const float* values = output.data<const float>();
std::cout << compiled_model.input().get_any_name() << " -> "
<< compiled_model.output().get_any_name() << ':';
for (std::size_t i = 0; i < output.get_size(); ++i) {
std::cout << ' ' << values[i];
}
std::cout << '\n';
return 0;
} catch (const std::exception& error) {
std::cerr << "OpenVINO error: " << error.what() << '\n';
return 1;
}
}

最小 CMakeLists.txt:

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.16)
project(openvino_contract_demo LANGUAGES CXX)

find_package(OpenVINO REQUIRED COMPONENTS Runtime)

add_executable(openvino_contract_demo openvino_contract_demo.cpp)
target_compile_features(openvino_contract_demo PRIVATE cxx_std_17)
target_link_libraries(openvino_contract_demo PRIVATE openvino::runtime)
1
2
3
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release -j2
./build/openvino_contract_demo

预期输出同样是 X -> Y: 3 5 7 9。如果 find_package 失败,应按 OpenVINO 安装方式设置其提供的环境或 OpenVINO_DIR,不要把某台机器上的绝对路径写进项目。

5.2 read_model、compile_model 和 infer 分别做了什么?

read_model 把 ONNX 或 IR 读取为与设备无关的 ov::Model,此时适合检查端口、调整 shape 或嵌入预后处理。compile_model 才会针对 CPU、GPU、NPU 或 AUTO 做设备相关编译,并返回 ov::CompiledModel。create_infer_request 创建一次执行所需的状态,infer() 是阻塞调用;需要并发时,应使用多个 infer request 或异步 API,而不是让多个线程无保护地共享一个 request。

示例中的 ov::Tensor 直接引用 input_data 的外部内存,没有复制。因此数组必须一直存活到同步 infer() 返回;改为异步推理后,它必须存活到请求完成。若希望张量自己持有内存,可以先按类型和 shape 构造张量,再把数据复制进去。

也可以用 core.compile_model("demo.onnx", "CPU") 直接从路径编译。只有在需要 reshape 或其他模型级变换时才必须显式调用 read_model;两种路径都应通过实测判断启动时间和缓存效果,不要仅凭调用形式推断性能。

5.3 Python 直接加载 ONNX

Python 侧可以快速验证 OpenVINO 是否能加载同一个 ONNX:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import numpy as np
import openvino as ov

core = ov.Core()
core.set_property({"CACHE_DIR": "./ov_cache"})

model = core.read_model("demo.onnx")

compiled_model = core.compile_model(model, "CPU")
infer_request = compiled_model.create_infer_request()

input_data = np.array([[1, 2, 3, 4]], dtype=np.float32)
result = infer_request.infer({compiled_model.input(0): input_data})

output = result[compiled_model.output(0)]
np.testing.assert_allclose(output, [[3, 5, 7, 9]], rtol=0, atol=1e-6)
print(output)

这段 Python 代码不仅打印 shape,还用明确的容差断言数值。工程回归测试也应如此:比较完整输出或稳定摘要,而不是只验证“没有抛异常”。

6. OpenVINO 的编译与优化发生在哪里?

OpenVINO 的优化主要发生在这些位置:

1
2
3
4
5
6
7
8
9
10
11
ONNX / IR
↓
read_model
↓
模型级变换:形状推导、常量折叠、算子融合
↓
compile_model
↓
设备级优化:CPU/GPU/NPU kernel 选择、精度策略、线程/stream、内存规划
↓
infer_request

6.1 ONNX 直接加载 vs 转 OpenVINO IR

OpenVINO 可以直接读取 ONNX:

1
2
auto model = core.read_model("best.onnx");
auto compiled_model = core.compile_model(model, "CPU");

也可以先离线转换成 OpenVINO IR:

1
2
best.xml
best.bin

两种方式的区别:

方式 优点 缺点
直接加载 ONNX 简单,方便和 ONNX Runtime 对比同一个模型 首次加载可能包含转换和编译成本
离线转 IR 启动更稳定,部署依赖更少,方便 FP16 权重压缩 多一个转换步骤,需要管理 .xml/.bin

如果只是 benchmark ONNX Runtime 和 OpenVINO,同一个 best.onnx 都直接加载,比较更干净。

如果是正式部署,建议测试:

1
2
3
4
best.onnx 直接加载
best.xml / best.bin 加载
开启 cache
不开启 cache

6.2 Python 转 OpenVINO IR

1
2
3
4
5
6
7
8
9
10
11
from pathlib import Path
import openvino as ov

model_path = Path("best.onnx")
output_path = Path("best_openvino.xml")

model = ov.convert_model(str(model_path))

# compress_to_fp16=True 会把权重压缩到 FP16,通常可减少模型体积。
# 如果要严格保留 FP32 权重,可设置 compress_to_fp16=False。
ov.save_model(model, str(output_path), compress_to_fp16=True)

生成文件:

1
2
best_openvino.xml
best_openvino.bin

C++ 加载 IR:

1
2
3
ov::Core core;
auto model = core.read_model("best_openvino.xml");
auto compiled_model = core.compile_model(model, "CPU");

这里的 compress_to_fp16=True 主要压缩可压缩的浮点权重,不等于强制所有算子都以 FP16 计算。最终执行精度由设备能力、插件策略和编译属性共同决定,仍要用目标硬件检查精度与耗时。

6.3 固定输入形状

动态 shape 更灵活,但固定 shape 往往更利于推理优化。

如果实际部署一直是 1x3x640x640:

1
2
3
auto model = core.read_model("best.onnx");
model->reshape(ov::Shape{1, 3, 640, 640});
auto compiled_model = core.compile_model(model, "CPU");

Python:

1
2
3
model = core.read_model("best.onnx")
model.reshape([1, 3, 640, 640])
compiled_model = core.compile_model(model, "CPU")

经验:

  • 实时视频检测通常 batch 固定为 1。
  • 输入分辨率固定时,不要保留无意义的动态维度。
  • 如果确实需要多分辨率,先测试动态 shape 的首次推理和后续推理耗时。

6.4 性能提示:LATENCY 和 THROUGHPUT

OpenVINO 推荐优先使用高层 performance hint,而不是一上来手动调很多底层参数。

低延迟模式:

1
2
3
4
5
auto compiled_model = core.compile_model(
model,
"CPU",
ov::hint::performance_mode(ov::hint::PerformanceMode::LATENCY)
);

高吞吐模式:

1
2
3
4
5
auto compiled_model = core.compile_model(
model,
"CPU",
ov::hint::performance_mode(ov::hint::PerformanceMode::THROUGHPUT)
);

区别:

模式 目标 适合
LATENCY 单次请求尽快返回 摄像头实时检测、交互式应用
THROUGHPUT 单位时间处理更多请求 多路视频、批处理、服务端推理

注意:

  • THROUGHPUT 可能增加加载时间和内存使用。
  • 单路视频不一定适合 THROUGHPUT。
  • 多路视频可以测试多个 infer request 或异步推理。

6.5 模型编译缓存

OpenVINO 的 compile_model 可能比较耗时,特别是 GPU/NPU 或复杂模型。可以启用模型 cache:

1
2
3
4
ov::Core core;
core.set_property(ov::cache_dir("./ov_cache"));

auto compiled_model = core.compile_model("best.onnx", "CPU");

效果:

1
2
3
4
5
第一次启动:
读取模型 → 编译模型 → 写入 cache → 推理

第二次启动:
命中 cache → 跳过部分耗时编译步骤 → 更快进入推理

注意:

  • cache 目录要可读写。
  • 模型文件、OpenVINO 版本、设备、编译参数变化后,缓存可能失效。
  • 不要把 cache 当作原始模型的替代品。

6.6 精度优化

OpenVINO 的常见精度路径:

精度 说明
FP32 最稳,便于对齐原始模型
FP16 / BF16 常用于减少带宽和提升吞吐,是否加速取决于硬件
INT8 需要量化和校准,性能收益依赖模型结构和硬件支持

示例:保存 IR 时压缩权重到 FP16:

1
2
3
4
import openvino as ov

model = ov.convert_model("best.onnx")
ov.save_model(model, "best_fp16.xml", compress_to_fp16=True)

示例:CPU 编译时给推理精度提示:

1
2
3
4
5
auto compiled_model = core.compile_model(
model,
"CPU",
ov::hint::inference_precision(ov::element::bf16)
);

精度优化原则:

  • 先用 FP32 跑通并对齐结果。
  • 再尝试 FP16 / BF16 / INT8。
  • 每次改精度都要做准确率和数值对比。
  • 模型变小不等于一定变快,尤其是 INT8,如果硬件 kernel 不匹配,可能只省体积不省时间。

6.7 用 benchmark_app 做基准测试

OpenVINO 自带 benchmark_app,适合先测纯模型推理性能。

低延迟:

1
benchmark_app -m best.onnx -d CPU -hint latency -shape "[1,3,640,640]"

高吞吐:

1
benchmark_app -m best.onnx -d CPU -hint throughput -shape "[1,3,640,640]"

测试 IR:

1
benchmark_app -m best_openvino.xml -d CPU -hint latency

但要记住:

1
2
3
4
5
6
7
8
9
benchmark_app 测的是模型推理为主
真实程序还包括:
解码
resize / letterbox
BGR/RGB 转换
HWC/NCHW 转换
NMS
绘制
视频读写

所以正式报告里应该同时给:

1
2
3
4
model inference latency
end-to-end latency
FPS
p50 / p95 / p99

7. 从最小示例走向工程封装

如果一个 C++ 项目里同时支持 ONNX Runtime 和 OpenVINO,可以抽象成同一个推理接口,但不要为了抽象牺牲可读性。

配置示例:

1
2
3
4
5
model_path: ./deploy/best.onnx
model_backend: openvino
openvino_device: CPU
input_width: 640
input_height: 640

后端选择逻辑:

1
2
3
4
5
6
7
if (config.model_backend == "onnxruntime") {
loadOnnxRuntime(config);
} else if (config.model_backend == "openvino") {
loadOpenVINO(config);
} else {
throw std::runtime_error("unsupported model backend");
}

ONNX Runtime 加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
void loadOnnxRuntime(const Config& config) {
session_options_.SetGraphOptimizationLevel(
GraphOptimizationLevel::ORT_ENABLE_ALL
);
session_options_.SetIntraOpNumThreads(config.num_threads);

const std::filesystem::path model_path{config.model_path};
ort_session_ = std::make_unique<Ort::Session>(
ort_env_,
model_path.c_str(),
session_options_
);
}

这里使用 std::filesystem::path,使 .c_str() 在 Windows 上提供宽字符路径、在 POSIX 平台提供窄字符路径。完整源文件需要包含 <filesystem>。如果项目只支持明确的平台,也可以使用该平台对应的 ORTCHAR_T 路径,但不要用强制类型转换掩盖编码问题。

OpenVINO 加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void loadOpenVINO(const Config& config) {
ov_core_.set_property(ov::cache_dir(config.openvino_cache_dir));

auto model = ov_core_.read_model(config.model_path);
model->reshape(ov::Shape{1, 3, config.input_height, config.input_width});

ov_compiled_model_ = ov_core_.compile_model(
model,
config.openvino_device,
ov::hint::performance_mode(ov::hint::PerformanceMode::LATENCY)
);

ov_infer_request_ = ov_compiled_model_.create_infer_request();
}

统一推理时,两边都接收同一种预处理结果:

1
2
3
4
struct ModelInput {
std::vector<float> values;
std::array<int64_t, 4> shape; // {1, 3, H, W}
};

统一结构只是建立了共同入口,还必须在入口处验证 values.size() 是否等于 shape 各维乘积,并让两个后端读取同一块已经完成预处理的缓冲区。这样才有机会保证:

1
2
3
4
5
同一张图
同一套预处理
同一个 ONNX 模型
分别送入 ONNX Runtime / OpenVINO
再比较输出和耗时

服务端还要区分“已编译模型”和“单次请求状态”。OpenVINO 的 InferRequest 保存输入、输出和执行状态,并发请求不应无保护地共享上面那个 ov_infer_request_;应按并发度创建 request 池或使用异步队列。ONNX Runtime 侧也要避免多个请求共享可变输入/输出缓冲区。先把并发模型说明白,再决定 session、request 和张量分别由谁持有。

8. VisionTrack 的同模型 CPU 基准与并发边界

本节于 2026-10-03 对照 已发布提交 d8688e3 更新,采用有环境与原始样本记录的 2026-09-03 合并报告,替换早期缺少测量条件的性能表。

8.1 测量对象与统计口径

两个后端读取相同 ONNX 文件,输入首帧使用同一套 C++ OpenCV 预处理。每个模型、后端和线程数组合预热 10 次、测量 50 次,共 3 轮;每组 150 个样本,总计 3000 个样本。测试采用单实例、batch=1、同步 CPU 推理,加载和预热不计入正式结果。

条件 值
CPU Xeon Gold 5218,32 个物理核心 / 64 个逻辑 CPU
ONNX Runtime 1.23.2
OpenVINO 2026.1.0
大模型输入 [1, 3, 736, 1280]
小模型输入 [1, 3, 384, 640]

thread_num 分别映射为 ORT 的 SetIntraOpNumThreads 和 OpenVINO 的 ov::inference_num_threads。纯后端统计 Session::Run() / InferRequest::infer(),完整 infer 另包含 Tensor 准备、输出 shape、decode 与 NMS。

8.2 平均延迟与尾延迟分别比较

模型 ORT 最优均值,16 线程 OpenVINO 最优均值,16 线程 比值
1280 × 736 137.38 ms 91.98 ms 1.49x
640 × 384 46.23 ms 22.90 ms 2.02x

OpenVINO 大模型 8 线程的均值为 108.15 ms,P95 为 114.28 ms;16 线程均值更低,但 P95 为 146.99 ms。小模型 8 / 16 线程的 P95 分别为 32.91 / 34.48 ms。最低平均延迟不自动带来最稳定的响应。

8.3 Session、请求池与多路调度

当前 VisionTrack 不只处理单张图片:上传视频进入有界任务池,多路实时流通过共享 InferenceScheduler 调度模型,LK 光流、ByteTrack 与历史回放校正维护轨迹。每个 OpenVINO InferRequest 的可变状态由请求池管理,模型加载对象和单次执行对象需要分开理解。

1
2
3
4
5
配置与模型加载
-> YoloEngine / 已编译模型
-> 共享 InferenceScheduler / 请求池
-> 各路视频解码、光流与状态
-> JSON 或实时 SSE 输出

High/Low Session、请求池大小、模型线程数、解码和光流都会竞争 CPU;单实例最优线程数只是候选配置。多路报告还要检查实际模型刷新率、排队与过期、尾延迟、RSS、退出清理和业务质量,不能只看进程 CPU 较低。

8.4 量化与就绪检查的边界

2026-09-10 已留档的量化实验表明,旧 INT8 模型在 OpenVINO 和 ORT 上的精度与速度不同,u8s8 per-channel 调整后仍需保留精度回归。不能把模型能编译、INT8 文件更小或一次推理成功等同于上线完成。

详细量化数值和接口分工见 ONNX 导出与部署笔记。/health 只证明存活,/ready 只检查流管理器;当前实时事件缓存不持久化,进程重启后不能续传,Web 工作台也尚未接入实时流管理。

9. 常见误区与排查方法

9.1 ONNX Runtime 能加载,OpenVINO 加载失败

可能原因:

  • ONNX 里有 OpenVINO 暂不支持或支持不完整的算子。
  • 动态 shape 太复杂。
  • 导出 ONNX 的 opset 版本不合适。
  • 模型里包含自定义算子。

处理顺序:

1
2
3
4
5
1. 用 onnx.checker 检查模型
2. 固定输入 shape 后再试
3. 用 OpenVINO Python 先 convert_model
4. 查看失败算子
5. 必要时重新导出 ONNX,降低或调整 opset

9.2 推理结果不一致

优先检查:

1
2
3
4
5
6
7
BGR / RGB
NCHW / NHWC
是否除以 255
是否减均值除方差
letterbox padding 是否一致
输出是否已经包含 NMS
score 阈值和 NMS 阈值是否一致

对齐方法:

1
2
3
4
5
6
同一张图片
保存预处理后的 input tensor
Python ORT 跑一次
C++ ORT 跑一次
OpenVINO 跑一次
逐层或至少逐输出比较 max abs diff

9.3 模型变小了但没有变快

常见于 FP16 / INT8:

  • 权重变小只代表磁盘和内存占用下降。
  • 是否加速取决于硬件是否有对应快速 kernel。
  • INT8 如果量化节点和后端 kernel 不匹配,可能出现反量化开销。
  • batch=1 小模型有时瓶颈不在计算,而在内存访问、预处理或后处理。

9.4 OpenVINO 首次启动慢

这是正常现象,原因是 compile_model 做了设备相关编译。

优化方式:

1
2
3
ov::Core core;
core.set_property(ov::cache_dir("./ov_cache"));
auto compiled_model = core.compile_model("best.onnx", "CPU");

也可以离线转 IR:

1
2
3
4
import openvino as ov

model = ov.convert_model("best.onnx")
ov.save_model(model, "best_openvino.xml", compress_to_fp16=True)

10. 什么时候该用哪个后端?

ONNX Runtime 更适合跨平台、需要 CUDA/TensorRT/DirectML 等多种硬件后端,或希望尽量保留统一 ONNX 部署入口的项目。OpenVINO 更适合明确部署在受支持的 Intel 平台,并愿意针对 CPU、GPU、NPU 的编译、缓存和性能提示做验证的项目。

如果模型只在训练框架里离线运行,或团队尚未固定预处理与输出契约,过早引入多个推理后端只会扩大排查面。先用一个后端建立“同一输入得到同一输出”的黄金样例,再增加第二个后端做性能比较。

11. 上线前检查清单

加载阶段:

  • 模型路径正确,文件没有被误覆盖。
  • 打印并记录输入输出 name、shape、dtype。
  • 动态 shape 已按部署场景固定或正确处理。
  • ONNX Runtime 显式设置图优化级别。
  • CPU 线程数经过小范围测试。
  • OpenVINO 明确指定设备,例如 CPU、GPU、NPU 或 AUTO。
  • OpenVINO 根据场景设置 LATENCY 或 THROUGHPUT。
  • OpenVINO 正式部署时启用 cache。

数值阶段:

  • 同一张图片在 Python 和 C++ 侧预处理结果一致。
  • ONNX Runtime 和 OpenVINO 输出数值在可接受范围内。
  • 后处理、阈值、NMS 和坐标回映射一致。

性能阶段:

  • 单独统计预处理、推理、后处理、端到端耗时。
  • 至少报告 p50、p95、p99,而不是只看平均值。
  • benchmark_app 结果和真实程序结果分开看。
  • 不同后端使用同一输入、同一模型和同一测试口径。

12. 总结:先证明结果正确,再证明它足够快

回到开头,模型加载成功却输出错误,通常不是“模型玄学”,而是应用和模型对输入输出的理解不一致。最可靠的处理方式不是立即换后端,而是把问题拆开:

  1. 用可预测的小模型验证运行时、链接和张量 API。
  2. 从运行时读取 name、shape、dtype,并与应用契约显式比对。
  3. 固定同一份预处理张量,逐一比较 Python、C++ 和不同后端的输出误差。
  4. 正确性对齐后,再分别测预处理、推理、后处理与端到端尾延迟。
  5. 图优化、FP16、INT8、线程和缓存都是性能工具,不能替代契约校验。

可以直接落地的一条建议是:为每个上线模型保存一份固定输入张量、预期输出摘要和允许误差,把它加入部署侧回归测试。这样升级 ONNX Runtime、OpenVINO、驱动或模型时,不必等到业务结果异常才发现契约已经漂移。

13. 参考资料