1. 项目概述:为什么要在Windows上用C++搞ONNX Runtime?
如果你正在做AI模型部署,尤其是想把训练好的PyTorch或TensorFlow模型塞到C++应用里跑起来,那ONNX Runtime(ORT)绝对是你绕不开的一个工具。它是一个高性能的推理引擎,专门用来跑ONNX格式的模型。在Windows上用C++搭配Visual Studio 2022来搞这个,场景非常明确:你需要一个高性能、低延迟、能直接集成到现有C++桌面应用、游戏或者服务端程序中的AI推理模块。比如,你想在某个工业检测软件里加一个视觉缺陷识别,或者在游戏里搞个实时语音驱动的NPC表情,用Python做推理可能太重或者不好集成,这时候C++版的ONNX Runtime就是最优解。
我自己在好几个工业视觉项目里都这么干过,把PyTorch训练的模型转成ONNX,然后用C++的ORT封装成DLL,给主程序调用。整个过程最磨人的往往不是写代码,而是第一步:把环境配通。网上资料零散,版本对不上,编译报错能卡你半天。所以,这篇东西我就把从零开始,在Windows 11/10系统上,用VS2022配置C++版ONNX Runtime,并跑通第一个推理测试的完整过程,掰开揉碎了讲清楚。目标是让你看完就能动手,一次成功,避开我踩过的所有坑。
2. 环境准备与核心组件解析
配置环境不是简单地下个库就完事,你得知道每个组件是干嘛的,为什么选这个版本,这样才能在出问题时自己排查。
2.1 开发环境清单与版本选择
工欲善其事,必先利其器。以下是经过验证的组合,稳定性最高:
- 操作系统:Windows 10 64位(版本20H2或更高)或 Windows 11。必须是64位系统,因为ONNX Runtime的预编译包基本都是x64的。
- 开发工具:Visual Studio 2022(社区版即可)。关键点:安装时必须勾选“使用C++的桌面开发”工作负载,并且确保包含了“MSVC v143 - VS 2022 C++ x64/x86生成工具”和“Windows 10/11 SDK”。SDK版本选最新的或项目指定的,比如10.0.22621.0。
- ONNX Runtime:这里有个重要选择。官网提供了两种主要发行版:
- 预编译库(推荐给新手和快速原型):直接从GitHub Releases页面下载,比如
onnxruntime-win-x64-1.16.3.zip。里面包含了头文件(include)、导入库(lib)和动态链接库(dll)。优点是开箱即用,不用自己编译;缺点是功能固定,比如默认可能只包含CPU执行提供程序(EP)。 - 从源码编译:如果你想启用CUDA进行GPU加速,或者需要裁剪功能、进行深度定制,那就需要自己编译。这个过程稍复杂,需要CMake和对应的CUDA工具链。
- 预编译库(推荐给新手和快速原型):直接从GitHub Releases页面下载,比如
注意:对于绝大多数“配置和简单测试”的需求,强烈建议直接使用预编译的CPU版本。它能帮你跳过最复杂的编译坑,先快速建立起“环境可用”的信心。本篇主要基于预编译库进行。
2.2 获取ONNX Runtime预编译库
我们以CPU版本为例,演示如何获取。
- 打开ONNX Runtime的GitHub发布页:
https://github.com/microsoft/onnxruntime/releases - 在最新的稳定版(比如
v1.16.3)的“Assets”下拉列表中,找到名为onnxruntime-win-x64-1.16.3.zip的文件并下载。这个包包含了运行和开发所需的所有文件。 - 将ZIP包解压到一个你喜欢的、路径中不含中文或空格的目录。例如,我习惯放在
D:\Libs\onnxruntime。解压后的目录结构通常如下:
记住这个路径,我们稍后在VS项目中需要引用它。onnxruntime-win-x64-1.16.3/ ├── include/ # 头文件 (.h) │ └── onnxruntime/ ├── lib/ # 导入库文件 (.lib) │ └── onnxruntime.lib └── dll/ # 运行时动态库 (.dll) ├── onnxruntime.dll ├── onnxruntime_providers_shared.dll └── ... (其他可能的依赖dll)
2.3 准备一个简单的ONNX模型用于测试
光有引擎不行,还得有“燃料”——也就是ONNX模型。为了测试,我们可以用一个超级简单的模型。
方法一(推荐,使用Python快速生成): 如果你有Python环境,可以用以下脚本生成一个用于加法运算的模型:
import torch import torch.nn as nn import onnx class AddModel(nn.Module): def forward(self, x, y): return x + y model = AddModel() x = torch.randn(1, 3, 224, 224, requires_grad=False) # 示例输入1 y = torch.randn(1, 3, 224, 224, requires_grad=False) # 示例输入2 torch.onnx.export(model, (x, y), “simple_add.onnx”, input_names=[“input1”, “input2”], output_names=[“output”], opset_version=11) print(“模型已导出为 simple_add.onnx”)运行后得到simple_add.onnx文件。
方法二(备用,直接下载): 你也可以从ONNX模型库(如https://github.com/onnx/models)下载一个现成的简单模型,比如MNIST手写数字识别模型。但注意模型可能有一定复杂度。
将准备好的.onnx模型文件放在你的项目目录下,例如D:\MyORTProject\models\simple_add.onnx。
3. 创建与配置Visual Studio 2022 C++项目
这是核心环节,每一步配置都有其作用。
3.1 创建新项目
- 打开VS2022,选择“创建新项目”。
- 选择“控制台应用(C++)”,点击下一步。
- 输入项目名称(如
ORT_Test),选择合适的位置,点击“创建”。这样就得到了一个带有main.cpp的空白控制台项目。
3.2 配置项目属性(关键步骤)
我们需要告诉VS:头文件在哪、库文件在哪、链接哪个库、运行时去哪找DLL。
- 打开项目属性:在“解决方案资源管理器”中右键点击你的项目
ORT_Test,选择“属性”。 - 设置配置和平台:确保右上角的“配置”是“所有配置”,“平台”是“x64”。这样Debug和Release模式就一次性配好了。
第一步:配置包含目录(头文件路径)
- 在属性页中,导航到“C/C++” -> “常规” -> “附加包含目录”。
- 点击下拉箭头,选择“编辑”。
- 添加你解压的ONNX Runtime的
include文件夹的完整路径。例如:D:\Libs\onnxruntime\onnxruntime-win-x64-1.16.3\include。 - 为什么这么做:这样你在代码里写
#include <onnxruntime/core/session/onnxruntime_c_api.h>时,编译器才知道去哪找这个文件。
第二步:配置库目录(.lib文件路径)
- 导航到“链接器” -> “常规” -> “附加库目录”。
- 添加ONNX Runtime的
lib文件夹路径。例如:D:\Libs\onnxruntime\onnxruntime-win-x64-1.16.3\lib。 - 为什么这么做:链接器在将你的代码和ONNX Runtime库连接起来时,需要知道
onnxruntime.lib这个导入库文件的位置。
第三步:添加附加依赖项(要链接的库名)
- 导航到“链接器” -> “输入” -> “附加依赖项”。
- 添加
onnxruntime.lib。如果是Debug配置,并且你有对应的Debug版lib(通常预编译包只提供Release版),可能需要链接onnxruntime.lib(名字可能一样,但路径不同)。我们这里用Release版lib通常也可用于Debug链接,但运行时需要对应DLL。 - 为什么这么做:明确告诉链接器,我们需要和
onnxruntime.lib这个库进行静态链接(实际上是动态链接的导入库)。
第四步:配置运行时库(可选但建议)
- 导航到“C/C++” -> “代码生成” -> “运行时库”。
- 为了减少依赖,建议在Release配置下选择“多线程(/MT)”,在Debug配置下选择“多线程调试(/MTd)”。这样会将C++标准库静态链接到你的exe中,发布时不需要携带
msvcp140.dll等。但如果你选择动态链接(/MD),则需要确保目标机器上有对应的VC运行库。 - 注意:ONNX Runtime的预编译包通常是用
/MD或/MDd编译的。如果你选择/MT,可能会在链接时遇到“库冲突”的警告(LNK4098)。对于测试项目,忽略此警告或保持与ORT一致的/MD通常没问题。
第五步:复制DLL到输出目录(确保程序能运行)这是最容易忽略的一步,会导致“找不到onnxruntime.dll”的运行时错误。
- 在“解决方案资源管理器”中,右键项目 -> “添加” -> “新建筛选器”,命名为“dll”(方便管理)。
- 右键这个“dll”筛选器 -> “添加” -> “现有项”。
- 浏览到你解压的ONNX Runtime的
dll目录(例如...\dll),选择onnxruntime.dll和onnxruntime_providers_shared.dll,点击“添加”。 - 添加后,在解决方案资源管理器中右键点击这两个dll文件,选择“属性”。
- 在属性窗口中,将“复制到输出目录”设置为“如果较新则复制”。
- 为什么这么做:这样每次编译时,VS会自动将这些运行时必需的DLL复制到你的exe文件所在的输出目录(如
x64\Debug),确保程序启动时能加载到它们。
4. 编写第一个ONNX Runtime C++推理程序
环境配好了,我们来写代码。这个过程就像开车:启动引擎(初始化环境)、加载地图(加载模型)、设定路线(准备输入)、开车(执行推理)、看结果。
4.1 基本代码结构解析
我们将创建一个完整的main.cpp。我会逐段解释。
#include <onnxruntime/core/session/onnxruntime_c_api.h> #include <onnxruntime/core/session/onnxruntime_cxx_api.h> #include <vector> #include <iostream> #include <chrono> int main() { // --- 1. 初始化ONNX Runtime环境 --- Ort::Env env(ORT_LOGGING_LEVEL_WARNING, “TestONNXRuntime”); Ort::SessionOptions session_options; // 设置线程数(可选) session_options.SetIntraOpNumThreads(1); session_options.SetInterOpNumThreads(1); // 对于CPU,可以设置执行模式(默认就是ORT_SEQUENTIAL) session_options.SetExecutionMode(ORT_SEQUENTIAL); // --- 2. 创建会话(加载模型) --- const wchar_t* model_path = L“D:\\MyORTProject\\models\\simple_add.onnx”; // 注意宽字符和路径 Ort::Session session(env, model_path, session_options); // --- 3. 准备输入数据 --- // 3.1 获取模型输入输出信息 Ort::AllocatorWithDefaultOptions allocator; size_t num_input_nodes = session.GetInputCount(); std::vector<const char*> input_node_names(num_input_nodes); std::vector<Ort::AllocatedStringPtr> input_node_names_ptr; // 用于管理内存 std::vector<std::vector<int64_t>> input_node_dims; std::cout << “Number of inputs: “ << num_input_nodes << std::endl; for (size_t i = 0; i < num_input_nodes; i++) { auto input_name = session.GetInputNameAllocated(i, allocator); input_node_names[i] = input_name.get(); input_node_names_ptr.push_back(std::move(input_name)); auto type_info = session.GetInputTypeInfo(i); auto tensor_info = type_info.GetTensorTypeAndShapeInfo(); auto dims = tensor_info.GetShape(); input_node_dims.push_back(dims); std::cout << “Input[“ << i << “] name: “ << input_node_names[i] << std::endl; std::cout << “Input[“ << i << “] shape: “; for (auto dim : dims) { std::cout << dim << “ “; } std::cout << std::endl; } // 3.2 根据模型期望的维度创建输入数据(这里假设是float型,形状[1,3,224,224]) std::vector<float> input_tensor_values_1; std::vector<float> input_tensor_values_2; size_t total_size = 1 * 3 * 224 * 224; // 计算张量元素总数 input_tensor_values_1.resize(total_size, 1.0f); // 全部填充为1.0 input_tensor_values_2.resize(total_size, 2.0f); // 全部填充为2.0 // --- 4. 创建输入Tensor --- // 获取模型输入类型(通常是ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); // 创建输入Tensor对象 std::vector<Ort::Value> input_tensors; input_tensors.push_back(Ort::Value::CreateTensor<float>( memory_info, input_tensor_values_1.data(), total_size, input_node_dims[0].data(), input_node_dims[0].size() )); input_tensors.push_back(Ort::Value::CreateTensor<float>( memory_info, input_tensor_values_2.data(), total_size, input_node_dims[1].data(), input_node_dims[1].size() )); // --- 5. 准备输出 --- size_t num_output_nodes = session.GetOutputCount(); std::vector<const char*> output_node_names(num_output_nodes); std::vector<Ort::AllocatedStringPtr> output_node_names_ptr; for (size_t i = 0; i < num_output_nodes; i++) { auto output_name = session.GetOutputNameAllocated(i, allocator); output_node_names[i] = output_name.get(); output_node_names_ptr.push_back(std::move(output_name)); std::cout << “Output[“ << i << “] name: “ << output_node_names[i] << std::endl; } // --- 6. 执行推理 --- auto start_time = std::chrono::high_resolution_clock::now(); auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_node_names.data(), input_tensors.data(), input_tensors.size(), output_node_names.data(), output_node_names.size()); auto end_time = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time); std::cout << “Inference time: “ << duration.count() << “ ms” << std::endl; // --- 7. 解析输出结果 --- if (output_tensors.size() > 0 && output_tensors[0].IsTensor()) { float* floatarr = output_tensors[0].GetTensorMutableData<float>(); auto tensor_info = output_tensors[0].GetTensorTypeAndShapeInfo(); auto output_dims = tensor_info.GetShape(); size_t output_size = tensor_info.GetElementCount(); std::cout << “Output shape: “; for (auto dim : output_dims) { std::cout << dim << “ “; } std::cout << std::endl; std::cout << “Output size: “ << output_size << std::endl; // 简单验证:因为我们输入是全1和全2,加法模型输出应该是全3 std::cout << “First 10 elements of output: “; for (int i = 0; i < 10 && i < output_size; ++i) { std::cout << floatarr[i] << “ “; } std::cout << std::endl; // 简单检查 bool correct = true; for (int i = 0; i < output_size; ++i) { if (std::abs(floatarr[i] - 3.0f) > 1e-6) { correct = false; break; } } if (correct) { std::cout << “Inference result is correct! (All elements are ~3.0)” << std::endl; } else { std::cout << “Inference result might be wrong.” << std::endl; } } // --- 8. 清理(RAII对象会自动清理,这里无需手动释放)--- std::cout << “Done.” << std::endl; return 0; }4.2 代码关键点与内存管理心得
- 宽字符路径:
Ort::Session构造函数在Windows上接受const wchar_t*类型的模型路径。所以要用L“...”前缀,或者用std::wstring并调用.c_str()。 - 内存信息:
Ort::MemoryInfo::CreateCpu创建了一个描述内存位置(CPU)和分配器类型的对象。这是创建Tensor所必需的。 - RAII是救星:ONNX Runtime C++ API 使用了名为
Ort::的命名空间,并大量采用RAII(资源获取即初始化)设计。像Ort::Env,Ort::Session,Ort::Value这些对象,你不需要(也不应该)手动调用Release之类的函数。它们会在析构时自动清理资源。这极大地避免了内存泄漏。 - 管理字符串内存:
session.GetInputNameAllocated返回一个Ort::AllocatedStringPtr,这是一个智能指针,帮你管理从ORT内部获取的字符串内存。你必须保存好这些智能指针(比如放在一个vector里),确保在它们被使用期间(比如在session.Run调用时)不会被释放。这就是代码中input_node_names_ptr和output_node_names_ptr的作用。 - Tensor数据是连续的:
CreateTensor时,你传入的原始数据指针(如input_tensor_values_1.data())所指向的内存必须是连续的。使用std::vector是安全便捷的选择。
5. 编译、运行与结果验证
5.1 编译项目
- 在VS2022顶部工具栏,将“解决方案配置”切换到“Release”和“x64”。首次运行建议用Release,速度更快,且预编译库通常是Release版。
- 点击“生成” -> “生成解决方案”(或按F7)。如果前面的配置都正确,编译应该会成功。
- 如果遇到链接错误
LNK2019: 无法解析的外部符号...,请检查:- “附加依赖项”里是否正确添加了
onnxruntime.lib。 - “附加库目录”路径是否正确,并且该路径下确实有
onnxruntime.lib文件。 - 项目平台是否为
x64。
- “附加依赖项”里是否正确添加了
5.2 运行与调试
- 编译成功后,按
Ctrl + F5(开始执行不调试)运行程序。如果按F5调试,控制台窗口会在程序结束后立即关闭,不方便看输出。 - 观察控制台输出。你应该能看到类似以下的信息:
Number of inputs: 2 Input[0] name: input1 Input[0] shape: 1 3 224 224 Input[1] name: input2 Input[1] shape: 1 3 224 224 Number of outputs: 1 Output[0] name: output Inference time: 15 ms Output shape: 1 3 224 224 Output size: 150528 First 10 elements of output: 3 3 3 3 3 3 3 3 3 3 Inference result is correct! (All elements are ~3.0) Done. - 恭喜!这表示你的ONNX Runtime C++环境已经成功配置,并且完成了第一次推理。
5.3 可能遇到的运行时错误及解决
错误:
无法找到“onnxruntime.dll”或0xc000007b- 原因:这是最常见的问题。系统在运行你的exe时,在可搜索的路径(如exe所在目录、系统PATH)中找不到
onnxruntime.dll及其依赖(如onnxruntime_providers_shared.dll)。 - 解决:
- 确保按照3.2 第五步将DLL文件添加到了项目并设置了“复制到输出目录”。
- 去输出目录(如
x64\Release)下检查,是否确实有这些DLL文件。 - 如果还有问题,可以尝试将DLL所在目录(如
...\dll)添加到系统的PATH环境变量中(需要重启VS或电脑),但这通常不是最佳实践。
- 原因:这是最常见的问题。系统在运行你的exe时,在可搜索的路径(如exe所在目录、系统PATH)中找不到
错误:
std::bad_alloc或程序崩溃- 原因:可能是输入Tensor的形状(
input_node_dims)或数据与模型期望的不匹配。比如模型期望[1, 3, 224, 224]的输入,你却创建了[224, 224, 3]的数据。 - 解决:仔细核对控制台打印的模型输入输出形状,并确保你创建的
std::vector大小和CreateTensor时传入的total_size计算正确。
- 原因:可能是输入Tensor的形状(
推理时间异常长
- 原因:第一次运行可能会有初始化开销。但如果持续很长,检查是否在Debug模式下运行。Debug模式性能极差,务必使用Release模式进行性能测试。
- 优化:可以尝试在
SessionOptions中设置图形优化级别:session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);。
6. 进阶配置与性能调优要点
基础测试通过后,你可能想做得更多。这里分享几个实战中的进阶要点。
6.1 启用GPU加速(CUDA)
如果你有NVIDIA GPU并安装了CUDA,可以大幅提升推理速度。
- 获取支持CUDA的ONNX Runtime包:去GitHub Releases页面,下载带有
gpu标签的包,如onnxruntime-win-x64-gpu-1.16.3.zip。或者,从源码编译时指定--use_cuda。 - 修改代码:在创建
Ort::SessionOptions后,添加以下代码来指定CUDA执行提供程序。#include <onnxruntime/core/providers/cuda/cuda_provider_factory.h> // 需要CUDA版ORT头文件 ... Ort::SessionOptions session_options; OrtSessionOptionsAppendExecutionProvider_CUDA(session_options, 0); // 0表示设备ID - 确保DLL到位:CUDA版的包会包含额外的
onnxruntime_providers_cuda.dll等文件。同样需要将它们复制到输出目录,并且确保系统有正确的CUDA和cuDNN环境。 - 注意:模型和输入输出数据会在CPU和GPU之间移动,有拷贝开销。对于极小的模型,GPU加速可能不明显甚至更慢。
6.2 使用TensorRT进一步加速(仅限NVIDIA)
如果你追求极致的延迟,并且模型算子都被TensorRT支持,可以尝试TensorRT后端。
- 需要下载支持TensorRT的ONNX Runtime包或自行编译。
- 代码中需要先启用CUDA Provider,然后再追加TensorRT Provider,并设置相关参数(如最大工作空间大小、精度模式等)。配置更为复杂。
6.3 多线程与性能优化
- 设置线程数:
SetIntraOpNumThreads设置单个操作内部并行化的线程数(如一个矩阵乘法的并行计算)。SetInterOpNumThreads设置多个操作间并行执行的线程数。对于简单的顺序模型,InterOp意义不大。通常设置为物理核心数。 - 执行模式:
SetExecutionMode(ORT_SEQUENTIAL)是顺序执行,确定性好。ORT_PARALLEL可能在某些有并行分支的模型上提升速度。 - 优化级别:
session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);启用所有图优化,如常量折叠、算子融合等,通常能带来性能提升。
6.4 处理动态输入形状
很多模型(如NLP的BERT)支持动态批次大小或序列长度。在代码中,tensor_info.GetShape()返回的维度可能包含-1,表示该维度是动态的。
- 在运行时指定形状:你可以在创建输入Tensor时,使用你实际需要的形状。例如,模型输入形状是
[-1, 256],你可以在运行时指定为[16, 256]。 - 代码调整:对于动态维度,
input_node_dims中的对应值会是-1。你在创建Tensor时,需要将其替换为实际值。std::vector<int64_t> current_input_shape = input_node_dims[i]; for (auto& dim : current_input_shape) { if (dim == -1) { dim = your_actual_dim_value; // 例如,实际的批次大小 } } // 使用 current_input_shape 创建Tensor
7. 集成到实际项目中的注意事项
当你把ORT集成到大型C++项目中时,还有一些工程化的问题要考虑。
- 二进制兼容性:确保你的项目编译环境(特别是MSVC编译器版本和运行时库)与使用的ONNX Runtime预编译库一致。否则可能出现奇怪的链接或运行时错误。最好使用相同版本的VS编译整个项目。
- 依赖管理:不建议直接把ORT的DLL和LIB文件放在项目源码里。更好的做法是:
- 使用CMake的
find_package(如果ORT提供了CMake配置)。 - 或者,将ORT作为一个独立的“第三方库”包,在构建系统(如CMake)中通过
include_directories和link_directories引用。 - 在CI/CD流水线中,自动下载指定版本的ORT包。
- 使用CMake的
- 错误处理:示例代码为了简洁,没有做详细的错误处理。生产代码中,ORT的API调用应该用
try...catch包裹,并检查返回的Ort::Value是否有效。 - 内存与性能 profiling:使用性能分析工具(如VS的性能探查器、VTune)来定位推理瓶颈。可能是数据预处理、后处理,或者是模型本身的某些算子。
- 模型版本管理:ONNX模型本身可能有版本(opset版本)。确保你的ONNX Runtime版本支持模型使用的算子集版本。通常较新的ORT版本向后兼容性较好。
配置一次可能只需要半天,但一个稳定、高效的推理环境是后续所有AI应用开发的基石。花时间把这一步搞扎实,后面写业务逻辑才会顺畅。如果在配置过程中遇到本文未覆盖的奇怪问题,第一反应应该是去ONNX Runtime的GitHub Issues里搜索,大概率已经有人遇到并解决了。