VS2022下基于ONNX Runtime的C++模型部署与推理实战

发布时间:2026/10/2 5:29:38
VS2022下基于ONNX Runtime的C++模型部署与推理实战 先聊点实际的。做模型部署的人大概率都经历过这个场景模型在 PyTorch 里跑得好好的一到交付阶段就发现对方机器上连 Python 都没装更别提那些 deep learning 框架了。这时候你手头可能只有一个.onnx文件却要在 Visual Studio 2022 里把它跑起来。我最近刚好把一个分类模型的推理环境从零配好顺手把几个折腾过的小问题记录成这篇内容。这套环境解决的核心问题是让 C 桌面程序直接加载 ONNX 模型做推理不依赖 Python不依赖 PyTorch部署时只需带上onnxruntime.dll和模型文件。适合想把模型嵌入 Windows 桌面软件、工业上位机或者做原型验证的开发者。里面会涉及 VS2022 的组件选择、ONNX Runtime 的获取方式、PyTorch 转 ONNX 的实操代码以及 C 侧的推理代码和常见崩溃排查。整个过程我尽量按“先懂原理再动手配置”的顺序来讲每个步骤背后为什么这么做也会补充说明。1. 为什么要把模型导出成 ONNX又为什么在 VS2022 里跑1.1 先弄清 ONNX 和 ONNX Runtime 的关系很多刚接触的人会把ONNX当成一个“能跑模型的工具”其实它是Open Neural Network Exchange一种描述计算图的开放格式。.onnx文件里面存放的是网络结构、权重参数和算子序列本质上是一份“模型的设计图纸”。ONNX Runtime 才是真正执行推理的引擎它读取.onnx文件把计算图调度到 CPU、GPU 或者 NPU 上执行。用一个比喻就很好理解把.onnx文件当作一道菜的食谱ONNX Runtime 就是掌勺的厨师。食谱写得再规范没有厨师也不会自动变成菜。所以你配置环境时真正要装的是ONNX Runtime 的 C 库VS2022 的角色则是提供编译 C 工程的工作台。这个区分很重要因为它决定了你排查问题的方向。比如模型加载失败可能是.onnx文件本身有问题也可能是 ONNX Runtime 版本太旧不认新算子链接错误则八成是工程配置的问题。不是同一个环节而新手很容易把这些问题混在一起徒增排查难度。1.2 选择 VS2022 作为推理环境的原因既然 ONNX Runtime 官方提供了 Python、C#、C 等多种语言的 API为什么偏偏要在 Visual Studio 2022 里折腾 C 环境核心原因是交付形态。Python 方案开发效率高但在生产环境要捆绑完整的 Python 解释器和一堆依赖库体积大、启动慢、版本冲突风险高。C 推理程序则只要把onnxruntime.dll和模型文件拷过去配合 VC 运行库就能独立运行特别适合做成服务、插件或者嵌入现有桌面软件。VS2022 在这里承担的是工程管理和编译职责。它集成了 MSBuild、NuGet 包管理、调试器配置好 include 目录和库目录后写代码、跑调试、查崩溃一条龙。对常年和 Windows 桌面程序打交道的开发者来说这是最顺手的选择——你最终要交付的可能恰恰就是一个 Windows 可执行文件。当然如果你只是想把模型快速验证一下完全可以用 Python 的 onnxruntime没必要上 C。这篇文章默认你已经决定要用 C 路线所以后续内容不会再讨论 Python 推理的脚本写法只在导出和量化环节用到 Python 工具。2. VS2022 环境准备安装组件与获取 ONNX Runtime2.1 VS2022 安装时需要勾选的组件我见过不少人把 VS2022 装完建第一个工程才发现连 C 编译器都没有。这是因为 VS2022 默认只装了编辑器相关的轻量组件C 编译工具链需要单独添加。安装时选择工作负载至少勾选“使用 C 的桌面开发”。这个工作负载会包含 MSVC 编译器如MSVC v143、Windows SDK 和 CMake 工具。如果打算从 NuGet 下载 ONNX Runtime 并直接在工程里引用建议再多勾一个“适用于 Windows 的 C CMake 工具”不过我们接下来要讲的方式是传统的.vcxproj工程用不到 CMake所以也可以不勾。Visual Studio 2022 Community 版是免费的个人学习和开源项目使用不受影响。企业商用请自行确认许可证条款这一块我不展开。安装完成后打开项目属性的“常规”页面把平台工具集确认成Visual Studio 2022 (v143)。ONNX Runtime 官方发布包通常针对这个工具集做过兼容测试匹配度最高。2.2 获取 ONNX Runtime 库NuGet 与手动下载两种方式ONNX Runtime 的 C 库获取渠道主要有两个我在实际项目中都试过各有利弊。第一种是NuGet 包管理器。在 VS2022 里右键项目选择“管理 NuGet 程序包”搜索Microsoft.ML.OnnxRuntime装完以后系统会自动把onnxruntime.dll、onnxruntime.lib和头文件复制到项目目录并且自动配置好链接关系最省事。缺点是 NuGet 对个别老版本支持不友好想指定特定版本时搜索列表不全而且 GPU 版可能需要额外处理 CUDA 依赖。第二种是GitHub Releases 手动下载。打开 ONNX Runtime 的 GitHub Release 页面找onnxruntime-win-x64-1.x.x.zip这类文件。以我用的 1.17.1 为例解压后目录结构如下onnxruntime-win-x64-1.17.1/ ├── include/ │ └── onnxruntime/ │ ├── core/ │ │ ├── session/ │ │ │ ├── onnxruntime_c_api.h │ │ │ └── onnxruntime_cxx_api.h │ ├── onnxruntime_cxx_inline.h │ └── ... ├── lib/ │ └── onnxruntime.lib └── bin/ └── onnxruntime.dll手动下载的好处是版本、CPU/GPU 形态一目了然还能配环境变量供多个工程共用适合团队统一管理。缺点是要自己配 include 目录、库目录和 DLL 路径步骤多一步但对理解整条链路很有帮助。我个人的建议是第一次配置先用 NuGet 快速跑通流程确认环境没问题后再切到手动解压的方式整理一份固定版本打好包放进版本库。毕竟团队协作时不能要求每个人 NuGet 源里的版本都和你一致。2.3 理解 ONNX Runtime 的 Release 版本与 API 版本ONNX Runtime 的版本号格式是1.x.y例如 1.17.1、1.19.2。这个版本号和 PyTorch 的导出opset_version有对应关系稍后导出模型时要留意。如果在网页上看到最新的 1.22 或者更高的版本请先确认它是不是 LTS 或者稳定发布而不是 RC 版。C API 这些年整体稳定但跨几个大版本仍可能出现函数签名调整。例如老的代码里会有Ort::Session::Run的重载差异新版本引入RunOptions参数。为了避免项目升级时出现大量编译错误强烈建议在工程里锁定一个 ONNX Runtime 版本并写清楚 README防止后来者随手更新到不兼容的新版本。还有一个容易忽略的点ONNX Runtime 官方 Release 页面会区分CPU 包、GPU 包CUDA、DirectML 包。下载时文件名里有gpu才是 CUDA 版本dml才是 DirectML 版本。名字里有win-x64表示 Windows 64 位linux-x64则是 Linux 版本不要搞混。3. PyTorch 导出 ONNX从模型文件到推理图3.1 导出前的模型冻结与预处理很多人导出 ONNX 失败或者推理结果不对根源其实在导出前的准备没做扎实。两件小事必须确认。第一把模型切换到eval模式并关闭梯度。PyTorch 模型的BatchNorm和Dropout在训练和推理时的行为不同如果用默认的model.train()导出ONNX 图里可能残留训练相关的逻辑。正确做法是model.eval() import torch with torch.no_grad(): # 这里再执行导出第二预处理方式必须和训练时完全一致。如果训练时做的是Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])那么导出的 ONNX 模型接收的输入也应该是经过同样归一化的张量。很多人导出的模型结构没问题但手动推理时图方便的“少几步预处理”结果就是分类分数对不上。这两个问题在 PyTorch 里不明显因为运行时会自动计算梯度、维护状态到 ONNX 里计算图被固化后所有动态行为都会变成静态逻辑排查难度陡增。3.2 使用 torch.onnx.export 导出模型的完整流程导出 ONNX 的核心函数是torch.onnx.export。它需要你提供一个“哑输入”目的是让模型走一次前向计算从而把动态的网络结构固定成静态的计算图。下面是一个完整的导出脚本示例适合大部分视觉分类模型import torch import torchvision.models as models model models.resnet18(pretrainedTrue) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, resnet18.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version17, do_constant_foldingTrue ) import onnx onnx_model onnx.load(resnet18.onnx) onnx.checker.check_model(onnx_model) print(check passed)opset_version这里我给的 17如果你的 ONNX Runtime 版本较旧可以调成 15 或者 14。版本越高支持的算子越丰富但也要 Runtime 端跟得上。反之版本太低可能因为缺少某些算子而导出失败。do_constant_foldingTrue会把模型里可以提前算好的常量先折叠成数值减少推理时的计算量建议开着。导出完成后用onnx.checker.check_model校验一下能发现大量的结构性问题。3.3 动态轴与固定轴的选择上面代码里我用了dynamic_axes把 batch 维度设成了动态。这意味着推理时你可以传入(2, 3, 224, 224)或者(8, 3, 224, 224)的输入不必固定成 1。但动态轴不是白给的它会带来三个隐藏成本一是 ONNX 图里需要保留输入形状的计算分支模型文件会变大一点二是部分算子对动态形状的支持不友好可能触发到效率较低的实现三是推理框架需要为不定形状做内存规划首轮推理可能变慢。我的实际建议是如果业务里 batch 永远是 1就不要加动态轴让所有维度固定死模型和推理代码都更简单。只有在线服务需要同时处理多路请求才考虑把 batch 维度设为动态。至于 height、width 这类空间维度除非确实需要多尺寸输入否则务固定——动态 H/W 导致的性能回退非常明显。4. 在 VS2022 中编写 C 推理代码4.1 创建项目并配置 include/lib 路径在 VS2022 里新建一个“空项目”或者“控制台应用”把解决方案配置切成x64和Release。强烈建议不要用 Debug 配置跑后续步骤因为 ONNX Runtime 的 C 库对 Debug/Release 混用极其敏感稍后很容易出现堆损坏之类的难查崩溃。手动解压 ONNX Runtime 后需要把它的路径告诉工程右键项目打开“属性”。选择 “VC 目录” - “包含目录”填你的解压路径\include。“VC 目录” - “库目录”填你的解压路径\lib。“链接器” - “输入” - “附加依赖项”填写onnxruntime.lib。把bin目录下的onnxruntime.dll复制到项目的输出目录通常是x64\Release或者用生成后事件自动拷贝。配置完成后编译一个空程序测试确认头文件能被找到。此处我踩过的坑是包含目录写了英文路径没问题但某同事的路径里有空格编译时出现一些莫名其妙的宏定义错误换成无空格路径后一切正常。Windows 下的工具链对带空格路径的支持一直算不上完美。4.2 用 ONNX Runtime C API 写一段可用的推理代码ONNX Runtime 的 C API 头文件是onnxruntime_cxx_api.h核心对象有三个Ort::Env环境、Ort::SessionOptions会话配置、Ort::Session会话。一段能跑通的分类推理代码如下#include onnxruntime/core/session/onnxruntime_cxx_api.h #include vector #include iostream int main() { // 1. 创建环境 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, test); // 2. 配置会话 Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel(GRAPH_OPTIMIZATION_LEVEL_EXTENDED); // 3. 创建会话并加载模型 const wchar_t* model_path Lresnet18.onnx; Ort::Session session(env, model_path, session_options); // 4. 准备输入数据这里用随机数代替真实图像预处理结果 std::vectorfloat input_data(1 * 3 * 224 * 224, 0.5f); // 5. 构建输入张量 std::vectorint64_t input_shape {1, 3, 224, 224}; Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 6. 构造输入输出名数组 const char* input_names[] {input}; const char* output_names[] {output}; // 7. 执行推理 auto output_tensors session.Run(Ort::RunOptions{nullptr}, input_names, input_tensor, 1, output_names, 1); // 8. 取出输出 float* output_data output_tensors[0].GetTensorMutableDatafloat(); auto output_shape output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); std::cout output dims: ; for (auto dim : output_shape) { std::cout dim ; } std::cout std::endl; return 0; }这段代码的逻辑是把一张224x224的图经预处理后展平成(1,3,224,224)的张量输入模型然后从输出里拿到各类别的分数。实际接入图像时你要把原始图像解码、缩放、减均值、除方差这些步骤补在前面输出一般还要加一层 softmax 才能得到概率。有几个容易翻车的地方input_names和output_names必须和导出 ONNX 时设置的名称完全一致否则Run会报错CreateTensorfloat默认要求输入数据是连续内存的std::vectorfloat如果你传了一个二维的std::vectorstd::vectorfloat内存不连续就会有问题。4.3 内存管理与 Session 复用的小技巧ONNX Runtime 的Session对象加载模型时会做大量的解析和优化一次性开销很大实测有些模型在几百毫秒甚至秒级。如果程序里每次推理都重新创建 Session性能会很难看。正确做法是程序启动时创建一次 Session并常驻内存只在推理函数里不断调用Run。Ort::Session本身是 move-only 类型你可以用std::unique_ptrOrt::Session保存指针也可以直接让主类持有Ort::Session成员初始化时构造一次。另外注意输入 Tensor 的内存复用。上面代码里每次构造input_tensor时input_data这个std::vector的地址是稳定的你可以反复把新数据写入同一块 buffer然后用同一个 shape 重新构造 Tensor。相比每次重新new一块内存这种方式可以让推理过程中的内存波动小很多程序长时间运行更稳定。还有一个容易被忽略的细节首次调用Run时可能有额外的初始化开销。比如某些算子需要分配工作区、CUDA 上下文初始化。所以线上程序里最好在启动完成后做一次“预热推理”用一张全零或者随机的输入跑几次再进入正常的业务循环。5. 推理跑完只是开始性能优化与 INT8 量化5.1 CPU 推理的线程配置与性能对比很多人在配置SessionOptions时直接不管线程数结果模型在大核 CPU 上跑出来的速度和小核差不多或者出现 CPU 占用率忽高忽低的现象。这里有两个参数需要理解SetIntraOpNumThreads(n)控制单个算子内部的线程数比如矩阵乘法MatMul会用多个线程并行计算。SetInterOpNumThreads(n)控制计算图中可并行算子之间的线程数。绝大多数模型是顺序执行的层堆叠层与层之间没有太多并行机会所以inter_op设成 1 就够了。intra_op则要根据模型算子的密度来设置经验值通常是物理核心数的一半到全部。我实测过一个较小的分类模型在 8 核机器上intra_op从 1 提到 4 时推理时间从 23ms 降到 15ms从 4 提到 8 时反而几乎没有变化。原因很简单单层计算量不够大线程调度开销已经超过了并行收益。线程数不是越多越好盲目拉高可能还会造成不同线程争抢内存带宽。建议在你的目标机器上做几个档位的实验分别设 1、4、8 跑 100 次推理取平均挑最短的那个。5.2 GPU 加速的推理配置如果模型比较大CPU 推理时间无法满足需求就要上 GPU。ONNX Runtime 提供了 CUDA Execution Provider。在 Windows 上启用 GPU 推理有两个前置条件一是下载 GPU 版本的 ONNX Runtime 包如带gpu字样的 zip或者 NuGet 上的Microsoft.ML.OnnxRuntime.Gpu二是本机装好匹配的 CUDA 和 cuDNN 版本。不同 ONNX Runtime 版本对 CUDA/cuDNN 的要求不同官方文档里有明确的版本对照表下载时留意目录名里的版本信息即可。代码里启用 GPU 其实只加一行Ort::SessionOptions session_options; OrtCUDAProviderOptions cuda_options{}; session_options.AppendExecutionProvider_CUDA(cuda_options);然后创建 Session 时ONNX Runtime 会在加载模型时尝试把算子分配到 GPU 上分配不了的算子自动回退 CPU。强调一个经验模型很小的时候GPU 推理不一定比 CPU 快。GPU 的 kernel 启动有固定开销哪怕一个算子只算 1ms调度它也要耗掉零点几毫秒。像 ResNet18 这种轻量模型在低端 GPU 上跑可能还不如高端 CPU 快。上 GPU 之前先用 profiling 工具看下推理时间分布确认瓶颈真的在计算而不是在数据流水线再考虑加显卡。5.3 关于 INT8 量化的两句实话热词里出现了“.onnx量化int8”很多文章把它吹得很神实际上它有两个前提一是 ONNX Runtime 本身支持量化模型的加速二是量化模型要在特定硬件上有原生的 INT8 算子。如果没有后者可能模型体积变小但推理速度没有显著提升。ONNX Runtime 官方提供了一个 Python 量化工具onnxruntime.quantization可以快速把 FP32 模型转成 INT8 或 UINT8。用法非常简单from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( resnet18.onnx, resnet18_int8.onnx, weight_typeQuantType.QInt8 )这是动态量化也叫权重量化只把权重转成 INT8激活值仍按 FP32 计算。优点是无需校准数据缺点是加速上限有限。想进一步压榨性能需要做静态量化也就是收集一批代表性数据把激活值分布也统计出来量化到 INT8。这一步就复杂多了操作不当掉精度很可能。我的做法是先跑动态量化用精度验证脚本对比量化前后的输出差异。如果精度损失在可接受范围内就直接替换模型文件推理代码一行不用改如果精度掉得厉害再考虑静态量化或者混合精度。你没有必要一上来就追求全 INT8生产中稳定可控比理论加速更重要。6. 常见问题与排查技巧实录6.1 一运行就报“找不到 onnxruntime.dll”这是最常见的启动崩溃原因是程序运行时没有在当前目录或 PATH 里找到onnxruntime.dll。ONNX Runtime 的 lib 目录里提供的是导出库和链接库不是运行时依赖库运行时 DLL 在 bin 目录。解决方法最直接是把onnxruntime.dll复制到exe输出目录或者在项目属性的“生成事件”里加一条 copy 命令。还可以把它放到系统 PATH 目录中但不推荐容易污染环境。另外注意 Debug/Release 的匹配。如果编译用的是 Release 配置但 DDebug 运行动态库可能混入不同版本的 VC 运行库导致崩溃。统一从 Release 开始能少掉一半的坑。6.2 “No such file or directory”和头文件找不到如果程序开头#include onnxruntime/core/session/onnxruntime_cxx_api.h编译都过不去说明 include 目录没配对。VS2022 的“VC 目录”里填入路径后可能需要重启 IDE 才能生效少数情况还会因为路径写在用户级而不是项目级导致换一台机器路径失效。建议把所有依赖路径尽量改成相对路径或者统一放到环境变量里引用。团队协作时用相对路径能减少“在我机器上明明可以”的尴尬。6.3 推理结果和 PyTorch 不一致这个问题出现频率非常高。顺序先从最简单的开始排查检查点说明输入预处理是否和导出前一致归一化的 mean/std、通道顺序RGB/BGR、缩放算法bilinear/bicubic每一样都要对上。模型模式导出时是否model.eval()了训练模式下 BatchNorm 统计量和推理不同。输入尺寸模型期望 224x224你喂进来的图是否已经 resize动态轴和固定轴是否匹配输出后处理输出 logits 和概率是两回事需不需要 softmax / argmax还有一点ONNX 图的输入输出名如果设置不清C 侧可能取错编号的 output_tensor。建议导出后用 Netron 打开.onnx文件肉眼确认输入输出的名称和形状再对应写 C 代码。6.4 明明装了 GPU 版却还走 CPU 推理不少人在 GPU 包上花了时间但发现 GPU 占用率一直是 0%。一般有两个原因一是代码里没追加 CUDA 执行提供器Session 默认还是走 CPU二是模型里有不支持的算子GPU 图构建失败全部回退 CPU。排查时可以把日志级别调成ORT_LOGGING_LEVEL_VERBOSE观察会话初始化日志里有没有Adding CUDA execution provider的字样。如果日志显示某个算子无法在 CUDA 上执行就要考虑更换模型实现或者升级 ONNX Runtime 版本。还要检查一个细节CUDA 包通常要加载onnxruntime_providers_cuda.dll如果这个 DLL 不在程序目录或 PATH 中执行提供器注册同样会失败。6.5 模型加载慢但推理快的场景有些模型文件很大Session 创建时就要做图优化耗时好几秒而真正推理一次只要几十毫秒。这在工程上不是大问题因为 Session 常驻。但如果你的程序每次启动都要加载模型用户会觉得体验极差。优化方向有三个一是打开GRAPH_OPTIMIZATION_LEVEL_EXTENDED级别的优化让模型在加载时做常量折叠和算子融合虽然加载更慢但运行时更快二是把模型文件保存为优化后的 ORT 格式用Ort::Session::Save接口减少重复加载开销三是调整启动流程先显示界面或进度条后台加载模型。我实际测试中用 ORT 格式保存后加载时间能减少 30% 左右。不过 ORT 格式绑定 ONNX Runtime 版本升级 Runtime 后需要重新生成所以要注意版本控制。写在最后的习惯这套环境配好之后我给自己定了一个固定流程PyTorch 导出 ONNX - Python 脚本用 onnxruntime 和原始 PyTorch 模型做一次逐层输出对比 - 确认无误后再写 C 推理代码。每次修改模型或者升级 ONNX Runtime 版本都重新走一遍这个流程。这个习惯救了我很多次因为模型图和推理代码的 bug往往在第一步就能暴露出来。如果你刚开始配置建议先用一个结构简单的模型比如 ResNet18把整条链路跑通再替换成你自己的模型。链路通了之后后续换模型基本就是改改输入输出名和预处理非常省事。还有一个小技巧在 VS2022 里调试的时候可以设置环境变量ORT_LOG_SEVERITYwarning让 ONNX Runtime 的日志输出到调试窗口很多崩溃原因在日志里写得很明确比盲猜要快得多。这套配置已经稳定运行了好几个月我在几个不同机器上都复现过按文中的步骤走理论上不会差太多。