☰
C++部署ONNX模型实战:从PyTorch导出到ONNX Runtime推理全流程
2026/10/19 3:05:44 网站建设 项目流程

1. 为什么要在C++里跑ONNX模型

把PyTorch训练好的网络搬到C++环境里做推理,这件事在工程落地阶段几乎绕不开。Python生态适合做实验、调参、验证想法,但真正要部署到生产环境,比如嵌入式设备、桌面客户端、服务端高并发场景,C++依然是主力选择。原因很直接:启动速度快、内存占用可控、没有GIL限制、方便和现有C++代码库集成。

ONNX(Open Neural Network Exchange)在这里扮演的是中间桥梁的角色。PyTorch的模型文件是.pt或.pth,C++端没法直接加载,而ONNX定义了一套与框架无关的计算图表示。PyTorch自带的torch.onnx.export可以把模型转成.onnx文件,C++端再用ONNX Runtime加载这个文件执行推理。整条链路是:PyTorch训练 → 导出ONNX → C++加载ONNX → 推理输出。

这套方案适合谁?如果你已经用PyTorch训好了模型,现在需要把它集成到一个C++项目里,又不想引入libtorch这种体积庞大的依赖,ONNX Runtime是比较务实的选择。它的C++ API相对简洁,编译产物也不算夸张,跨平台支持做得比较到位。下面我会把整个流程拆开,从导出、环境搭建、C++代码编写到踩坑排查,一步步说清楚。

2. 模型导出:从PyTorch到ONNX的关键操作

2.1 导出前的准备工作

导出之前有几件事必须先确认。第一,模型要处于eval()模式,这一点经常被忽略。训练模式下Dropout和BatchNorm的行为和推理模式不一样,如果忘了切换,导出的ONNX模型推理结果会和预期有偏差。第二,确认输入张量的形状和数据类型,动态维度的地方要提前想清楚哪些轴需要支持变化。

import torch import torch.onnx model = MyNetwork() model.load_state_dict(torch.load("model_weights.pth")) model.eval() # 必须切换 dummy_input = torch.randn(1, 3, 224, 224)

dummy_input的作用是让PyTorch走一遍前向计算,记录下计算图。它的形状决定了导出后ONNX模型的默认输入形状,但通过dynamic_axes参数可以标记哪些维度是动态的。

2.2 导出参数详解与实操

torch.onnx.export( model, dummy_input, "model.onnx", export_params=True, opset_version=12, do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size"} } )

逐个说这些参数的实际意义。export_params=True表示把权重也写进ONNX文件,否则只导出结构。opset_version是算子集版本,选12是比较稳妥的,太新的版本可能C++端ONNX Runtime不支持,太老的版本某些算子表达不了。do_constant_folding=True会做常量折叠优化,把能提前算的常量表达式算掉,减小计算图规模。

input_names和output_names建议显式指定,因为C++端获取输入输出节点时要用到这些名字。如果不指定,ONNX会自动生成类似input.1、output.1这样的名字,代码里硬编码容易出错。

dynamic_axes是动态维度的关键。上面例子中batch维度、高、宽都是动态的,这样C++端可以传入不同尺寸的输入。但要注意,不是所有模型都支持任意动态尺寸,比如某些含有全连接层的模型对输入尺寸有硬性要求,动态轴标错了会在推理时报形状不匹配。

提示:导出后强烈建议用onnx.checker.check_model做一次校验,再用onnxruntime在Python端跑一遍推理,和PyTorch的输出对比数值差异。这一步花几分钟,能省掉后面在C++端排查半天的痛苦。

2.3 导出后的模型检查

import onnx onnx_model = onnx.load("model.onnx") onnx.checker.check_model(onnx_model) import onnxruntime as ort import numpy as np sess = ort.InferenceSession("model.onnx") input_name = sess.get_inputs()[0].name result = sess.run(None, {input_name: dummy_input.numpy()}) print(result[0].shape)

用Netron这个工具可视化ONNX文件也很直观,能看到每个节点的连接关系、输入输出形状、算子类型。如果发现某些算子显示为自定义算子或者形状异常,就要回头检查导出逻辑。

3. C++端环境搭建与ONNX Runtime集成

3.1 ONNX Runtime的获取方式

ONNX Runtime官方提供了预编译包,也支持从源码编译。对于大多数场景,直接下载预编译包最省事。官方发布页有Windows、Linux、macOS的预编译版本,包含头文件、静态库和动态库。

如果目标平台比较特殊,比如某些嵌入式Linux或者需要特定编译选项,那就得从源码编译。源码编译的流程大致是:克隆仓库、用CMake配置、指定--config Release、开启需要的执行提供器(比如CPU、CUDA)。编译时间取决于机器性能,一般十几分钟到半小时不等。

拿到预编译包后,目录结构通常是这样的:

onnxruntime/ ├── include/ │ └── onnxruntime_cxx_api.h │ └── ... ├── lib/ │ ├── libonnxruntime.so (Linux) │ ├── onnxruntime.lib (Windows) │ └── ...

3.2 CMake工程配置

在C++项目里集成ONNX Runtime,CMake是最常见的方式。下面是一个可用的CMakeLists.txt片段:

cmake_minimum_required(VERSION 3.15) project(onnx_inference_demo) set(CMAKE_CXX_STANDARD 17) set(ONNXRUNTIME_ROOT "/path/to/onnxruntime") include_directories(${ONNXRUNTIME_ROOT}/include) add_executable(inference_demo main.cpp) target_link_libraries(inference_demo ${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so )

Windows下链接的是.lib文件,Linux下是.so。如果用的是静态库,链接顺序和依赖项会多一些,建议优先用动态库,部署时把对应的动态库文件一起带上就行。

注意:ONNX Runtime的动态库在运行时需要能被找到。Linux下可以设置LD_LIBRARY_PATH,或者把.so放到系统库路径,或者在编译时设置RPATH。Windows下把.dll放到可执行文件同目录即可。这个问题在开发阶段容易被忽略,因为IDE可能自动处理了路径,但部署到干净环境就报找不到库。

3.3 头文件与命名空间

ONNX Runtime的C++ API主要包含在onnxruntime_cxx_api.h里。这个头文件封装了C API,提供了更友好的C++接口。核心类包括:

  • Ort::Env:环境对象,全局一个即可
  • Ort::Session:会话对象,加载模型并执行推理
  • Ort::SessionOptions:会话配置,比如线程数、优化级别
  • Ort::AllocatorWithDefaultOptions:默认分配器
  • Ort::Value:张量数据封装

命名空间是Ort,所有类型都带这个前缀。代码里一般会using namespace Ort;简化书写,但要注意避免和项目里其他同名符号冲突。

4. C++推理代码完整实现

4.1 加载模型与创建会话

#include <onnxruntime_cxx_api.h> #include <vector> #include <iostream> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "inference"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); const char* model_path = "model.onnx"; Ort::Session session(env, model_path, session_options); Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); std::cout << "Input name: " << input_name.get() << std::endl; std::cout << "Output name: " << output_name.get() << std::endl; return 0; }

Ort::Env的构造函数第一个参数是日志级别,开发阶段可以用ORT_LOGGING_LEVEL_VERBOSE看详细日志,生产环境用WARNING或ERROR减少输出。SetIntraOpNumThreads设置算子内部并行线程数,一般设成CPU核心数或者略少。SetGraphOptimizationLevel设为ORT_ENABLE_ALL会启用所有图优化,包括算子融合、常量折叠等,对推理速度有正面影响。

GetInputNameAllocated返回的是Ort::AllocatedStringPtr,这是ONNX Runtime 1.13之后的新API,之前的老版本用的是GetInputName返回裸指针。用新API更安全,不需要手动管理内存。

4.2 构造输入张量

输入张量的构造是C++端最容易出问题的地方。ONNX Runtime要求输入数据是连续的、按行优先排列的。假设输入是[1, 3, 224, 224]的float张量:

std::vector<int64_t> input_shape = {1, 3, 224, 224}; size_t input_tensor_size = 1 * 3 * 224 * 224; std::vector<float> input_tensor_values(input_tensor_size); // 填充数据,注意内存布局要和PyTorch一致 auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_tensor_values.data(), input_tensor_size, input_shape.data(), input_shape.size());

内存布局这里要特别小心。PyTorch的默认张量布局是NCHW,也就是batch、通道、高、宽。如果你在C++端从图像读取数据,常见的OpenCV默认是HWC格式,需要手动转换。转换逻辑是:对于每个通道c、每行h、每列w,目标索引是c * H * W + h * W + w,源索引是h * W * C + w * C + c。这个转换写错了,推理结果会完全乱掉,但程序不会报错,排查起来很隐蔽。

提示:建议在Python端先保存一份输入数据和对应的输出结果作为基准,C++端用同样的输入跑一遍,逐元素对比输出差异。如果差异很大,优先检查输入数据的布局和归一化参数是否一致。

4.3 执行推理与获取输出

const char* input_names[] = {input_name.get()}; const char* output_names[] = {output_name.get()}; auto output_tensors = session.Run( Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); float* output_data = output_tensors[0].GetTensorMutableData<float>(); auto output_shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); std::cout << "Output shape: "; for (auto dim : output_shape) { std::cout << dim << " "; } std::cout << std::endl;

session.Run的第二个参数是输入名数组,第三个是输入张量数组,第四个是输入数量,第五个是输出名数组,第六个是输出数量。输出返回的是std::vector<Ort::Value>,每个元素对应一个输出节点。

GetTensorMutableData<float>()返回的是指向输出数据的指针,数据类型要和模型定义的一致。如果模型输出是float32,这里就写float;如果是int64,就写int64_t。类型写错了不会编译报错,但运行时会得到错误的数据或者崩溃。

4.4 完整代码示例

把上面的片段拼起来,一个完整的推理流程如下:

#include <onnxruntime_cxx_api.h> #include <vector> #include <iostream> #include <algorithm> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "inference"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, "model.onnx", session_options); Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); std::vector<int64_t> input_shape = {1, 3, 224, 224}; size_t input_size = 1 * 3 * 224 * 224; std::vector<float> input_data(input_size, 0.5f); auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_size, input_shape.data(), input_shape.size()); const char* input_names[] = {input_name.get()}; const char* output_names[] = {output_name.get()}; auto outputs = session.Run( Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); float* output_data = outputs[0].GetTensorMutableData<float>(); auto shape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); std::cout << "Output shape: "; for (auto d : shape) std::cout << d << " "; std::cout << "\nFirst 5 values: "; for (int i = 0; i < 5 && i < shape[1]; ++i) { std::cout << output_data[i] << " "; } std::cout << std::endl; return 0; }

这段代码可以直接编译运行,前提是ONNX Runtime的库路径配置正确,且model.onnx文件在当前目录。

5. 常见问题与排查技巧实录

5.1 模型加载失败

最常见的报错是Failed to load model。原因通常有几个:文件路径不对、ONNX文件损坏、ONNX Runtime版本和模型opset不兼容。排查顺序是先用Python的onnxruntime加载同一个文件,如果Python端能加载,说明文件本身没问题,问题在C++端的路径或库版本。如果Python端也加载失败,那就是导出环节出了问题,重新导出并检查opset版本。

路径问题在Windows上尤其常见,因为中文路径、空格路径都可能导致加载失败。建议把模型文件放在纯英文、无空格的路径下测试。

5.2 输入形状不匹配

报错信息类似Got invalid dimensions for input。这通常是因为C++端构造的输入形状和模型期望的不一致。模型期望的形状可以通过session.GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape()获取。如果模型导出时标记了动态维度,获取到的形状里会有-1,表示该维度可以是任意值。

还有一种情况是动态维度标记了但C++端传了固定形状,这本身没问题,只要固定形状在动态维度允许的范围内即可。但如果模型没有标记动态维度,C++端传了不同的形状就会报错。

5.3 推理结果和Python不一致

这是最让人头疼的问题,因为程序不报错,只是结果不对。排查思路按优先级排列:

排查项检查方法常见原因
输入数据布局对比C++和Python的输入张量前几个值NCHW vs HWC转换错误
归一化参数检查均值、标准差是否一致图像预处理参数不同
数据类型确认float32 vs float64类型不匹配导致数值异常
模型模式确认导出时是eval模式Dropout/BatchNorm行为差异
输出节点确认取的是正确的输出多输出模型取错节点

我踩过最坑的一次是输入图像的归一化参数在C++端写错了,Python端用的是mean=[0.485, 0.456, 0.406],C++端写成了[0.5, 0.5, 0.5],结果分类结果完全乱套。这种问题只能靠逐项对比来定位。

5.4 内存与性能问题

ONNX Runtime默认使用内存池(Arena Allocator),在频繁创建和销毁会话的场景下可能导致内存持续增长。如果是在服务端长期运行,建议复用Ort::Session对象,不要每次推理都重新加载模型。Ort::Env也应该是全局唯一的。

性能方面,SetIntraOpNumThreads和SetInterOpNumThreads的配置会影响推理速度。前者控制单个算子内部的并行度,后者控制不同算子之间的并行度。对于大多数模型,设置IntraOp线程数为CPU核心数,InterOp保持默认即可。如果模型有大量小算子,可以适当增加InterOp线程数。

提示:ONNX Runtime支持多种执行提供器(Execution Provider),比如CPU、CUDA、TensorRT等。如果机器有GPU,可以在创建Session时添加CUDA执行提供器,推理速度会有明显提升。添加方式是session_options.AppendExecutionProvider_CUDA(cuda_options),需要链接对应的CUDA版本库。

5.5 多输入多输出模型处理

有些模型有多个输入或多个输出,处理方式和单输入单输出类似,只是数组长度变了:

std::vector<const char*> input_names = {"input1", "input2"}; std::vector<Ort::Value> input_tensors; input_tensors.push_back(std::move(tensor1)); input_tensors.push_back(std::move(tensor2)); std::vector<const char*> output_names = {"output1", "output2"}; auto outputs = session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensors.data(), input_tensors.size(), output_names.data(), output_names.size());

注意input_tensors里的元素要用std::move转移所有权,因为Ort::Value是不可拷贝的。输出同样按顺序对应output_names。

6. 工程化实践与进阶建议

6.1 封装成可复用的推理类

在实际项目里,不会把推理代码散落在各处,通常会封装成一个类。这个类负责加载模型、管理会话、提供推理接口。接口设计上,输入可以是std::vector<float>加形状,输出也是类似的结构,这样上层业务代码不需要关心ONNX Runtime的细节。

封装时要注意异常处理。ONNX Runtime的C++ API在出错时会抛出Ort::Exception,需要在合适的地方捕获并转换成项目统一的错误处理方式。不要让它直接抛到上层导致程序崩溃。

6.2 模型版本管理与热更新

生产环境里模型可能会迭代更新,如果每次更新都要重启服务,体验不好。一种做法是定期检查模型文件是否有变化,如果有就重新加载会话。重新加载时要注意线程安全,正在进行的推理不能被打断。可以用读写锁或者双缓冲的方式,新会话准备好之后再切换指针。

模型文件本身建议加上版本号或者哈希值,方便追踪当前运行的是哪个版本。日志里记录模型加载时间和版本信息,出问题时能快速定位。

6.3 跨平台编译注意事项

Windows下用MSVC编译时,ONNX Runtime的预编译库通常是Release模式,如果项目用Debug模式链接会报错。解决办法是项目也切到Release,或者自己从源码编译Debug版本的ONNX Runtime。

Linux下要注意glibc版本兼容性。在高版本系统上编译的二进制放到低版本系统上可能跑不起来。如果目标环境版本较低,建议在对应版本的环境里编译,或者用容器固定编译环境。

macOS下ONNX Runtime支持CPU和CoreML执行提供器,如果要做iOS或macOS原生应用,可以考虑用CoreML加速。

6.4 量化与模型压缩

如果推理速度或模型体积是瓶颈,可以考虑量化。ONNX Runtime支持动态量化和静态量化,动态量化最简单,把权重从float32转成int8,推理时动态计算激活值的量化参数。静态量化需要校准数据集,精度损失通常更小。

量化后的模型在C++端加载方式和普通模型一样,不需要改代码。但要注意量化模型的输入输出可能还是float32,内部计算用int8,所以C++端的数据类型不用变。

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "model.onnx", "model_quantized.onnx", weight_type=QuantType.QUInt8 )

量化带来的加速在CPU上比较明显,通常有2到4倍的提升,精度损失在1%以内。如果对精度要求极高,可以先在验证集上对比量化前后的指标,确认可接受再上线。

6.5 日志与性能监控

ONNX Runtime本身有日志系统,可以通过Ort::Env的日志级别控制输出。但在生产环境,更推荐在业务层记录推理耗时、输入形状、输出统计等信息。这些数据对于排查性能瓶颈和异常输入很有帮助。

推理耗时建议分别记录预处理、推理、后处理三个阶段。有时候推理本身很快,但前后处理占了大部分时间,优化方向就不一样。输入形状的分布也能反映实际请求的特征,如果发现某些形状特别慢,可以针对性优化。

7. 一些实操心得

导出ONNX模型时,如果模型里有自定义算子,PyTorch导出可能会失败或者导出成不支持的算子。这种情况下要么用ONNX支持的等效算子重写,要么注册自定义算子到ONNX Runtime。后者工作量较大,除非必要,一般优先考虑前者。

C++端的输入数据准备往往比推理本身更耗时。如果输入是图像,解码、缩放、归一化这些操作在C++里可以用OpenCV或者自己写SIMD优化。实测下来,图像预处理有时候能占到整个流程一半以上的时间,值得花精力优化。

ONNX Runtime的版本更新比较快,新版本可能引入API变化。升级版本时要注意GetInputName这类API是否还在,新项目直接用GetInputNameAllocated更稳妥。如果项目依赖特定版本,建议在CMake里锁定版本号,避免不同机器上编译出不同结果。

最后分享一个小技巧:如果C++端推理结果和Python对不上,可以把C++端的输入数据保存成二进制文件,在Python里加载后喂给ONNX Runtime,对比输出。这样能快速定位是输入数据的问题还是推理过程的问题。这个办法帮我省过很多次排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询