公司动态
Ubuntu下C++项目集成ONNX Runtime:从源码编译到模型推理验证全流程
1. 项目概述从部署到验证的完整闭环最近在搞一个C的模型推理服务框架选型上直接敲定了ONNX Runtime理由很简单微软出品生态成熟对ONNX格式的支持最原生而且C API的性能和可控性都足够好。目标环境是Ubuntu服务器纯CPU推理听起来是个标准操作但真动起手来从源码编译、库文件配置到写测试代码跑通第一个模型中间还是有不少细节值得说道。这不仅仅是执行几条命令更关乎如何建立一个可靠、可复现的部署基础。如果你也在为你的C项目集成ONNX Runtime而折腾或者对“安装后如何验证”这个看似简单实则关键的步骤心存疑虑那这篇从实战中踩坑总结出来的流程应该能帮你省下不少时间。简单说我们要完成三件事一是在Ubuntu上为C项目准备好ONNX Runtime的CPU版本库二是配置好开发环境比如CMake让项目能正确找到并链接这些库三是编写一个最小化的、但足够有说服力的测试代码确保从加载模型到执行推理的全链路是通的。这个过程会涉及系统依赖、编译选项、链接参数以及运行时路径等一系列问题任何一个环节的疏漏都可能导致编译失败或运行时崩溃。接下来我就把这次从零到一的完整过程连同其中的“坑”和技巧拆开揉碎了分享给你。2. 环境准备与ONNX Runtime源码编译在Ubuntu上为C使用ONNX Runtime主要有两种方式一是直接下载官方预编译的库文件二是从源码编译。对于生产环境或需要深度定制的场景我强烈推荐从源码编译。这能确保库文件与你的系统环境如GCC版本、C标准库完全匹配避免潜在的ABI不兼容问题并且可以精确控制编译选项例如关闭不必要的组件以减少二进制体积。2.1 系统基础依赖安装首先我们需要一个干净的Ubuntu系统这里以Ubuntu 20.04 LTS为例18.04或22.04原理相通。打开终端更新软件包列表并安装一些基础的开发工具和依赖。sudo apt update sudo apt upgrade -y sudo apt install -y build-essential cmake git wget unzip tarbuild-essential提供了GCC、G、make等核心编译工具链。cmakeONNX Runtime使用CMake作为构建系统这是必须的。git用于克隆ONNX Runtime的源码仓库。接下来安装ONNX Runtime编译所需的一些特定依赖。这些依赖主要涉及一些底层的计算库和工具。sudo apt install -y libssl-dev libprotobuf-dev protobuf-compilerlibssl-dev提供安全通信层支持某些网络模型加载或安全特性可能会用到。libprotobuf-dev和protobuf-compilerONNX模型格式基于Protocol Buffers编译ONNX Runtime需要这些库来解析.proto文件并生成对应的C代码。注意不同版本的ONNX Runtime对依赖的版本可能有细微要求。如果后续编译出错提示找不到某个特定版本的protobuf你可能需要从源码编译指定版本的protobuf。不过对于大多数CPU版本系统仓库提供的版本是足够的。2.2 获取ONNX Runtime源码我们不直接下载预编译包而是克隆官方Git仓库。这样能获取到最新的代码或指定的稳定版本。# 创建一个工作目录并进入 mkdir -p ~/onnxruntime_build cd ~/onnxruntime_build # 克隆ONNX Runtime仓库使用--recursive确保拉取子模块如onnx git clone --recursive https://github.com/microsoft/onnxruntime.git cd onnxruntime克隆完成后强烈建议切换到一个稳定的发布版本标签Tag而不是直接使用main分支。main分支是开发分支可能包含不稳定的代码。你可以通过以下命令查看可用标签并切换git tag -l | grep -E ^v[0-9]\.[0-9]\.[0-9]$ | sort -V | tail -10 # 查看最近的10个发布版本 git checkout v1.16.3 # 切换到v1.16.3版本这是一个长期支持版本 git submodule update --init --recursive # 切换标签后务必更新子模块2.3 配置与编译CPU版本现在进入最核心的编译环节。我们将在onnxruntime目录下创建一个独立的构建目录遵循CMake的“out-of-source build”最佳实践。cd ~/onnxruntime_build/onnxruntime mkdir build cd build接下来使用cmake命令进行配置。这里有一系列关键参数需要指定cmake .. \ -DCMAKE_BUILD_TYPERelease \ -Donnxruntime_BUILD_SHARED_LIBON \ -Donnxruntime_ENABLE_PYTHONOFF \ -Donnxruntime_ENABLE_TRAININGOFF \ -Donnxruntime_ENABLE_TRAINING_OPSOFF \ -Donnxruntime_ENABLE_GPUOFF \ -Donnxruntime_USE_CUDAOFF \ -Donnxruntime_USE_DNNLOFF \ -Donnxruntime_USE_OPENMPON \ -Donnxruntime_BUILD_UNIT_TESTSOFF参数解析与选型理由-DCMAKE_BUILD_TYPERelease生成优化过的发布版本库性能最好。调试阶段可以用Debug但最终部署用Release。-Donnxruntime_BUILD_SHARED_LIBON编译生成动态链接库.so文件。这通常是首选因为多个应用可以共享同一份库代码节省内存。如果希望将运行时静态链接到你的可执行文件中生成一个独立的二进制文件可以设为OFF但这会显著增大你的程序体积。-Donnxruntime_ENABLE_PYTHONOFF我们只使用C API关闭Python绑定以加快编译速度和减少依赖。-Donnxruntime_ENABLE_TRAININGOFF/-Donnxruntime_ENABLE_TRAINING_OPSOFF关闭训练相关操作符推理场景下不需要。-Donnxruntime_ENABLE_GPUOFF/-Donnxruntime_USE_CUDAOFF明确指定编译纯CPU版本。-Donnxruntime_USE_DNNLOFF关闭Intel DNNL (oneDNN) 加速库。虽然DNNL在某些Intel CPU上能提升性能但为了编译的通用性和简化依赖我们先关闭它。后续如果需要可以安装libdnnl-dev并重新编译。-Donnxruntime_USE_OPENMPON启用OpenMP支持允许ONNX Runtime利用多核CPU进行并行计算这对CPU推理性能提升至关重要。-Donnxruntime_BUILD_UNIT_TESTSOFF关闭单元测试编译进一步加快编译速度。配置完成后就可以开始编译了。使用make命令并指定并行编译的线程数以充分利用多核CPU加速编译过程。make -j$(nproc)$(nproc)会自动获取你CPU的核心数。编译过程可能需要10到30分钟取决于你的机器性能。耐心等待如果没有报错编译就成功了。2.4 编译产出物与安装可选编译完成后在build目录下的Linux子目录具体路径可能因版本略有不同通常是build/Linux/Release中你会找到关键的产出文件libonnxruntime.so.xxx主动态库文件。onnxruntime/lib头文件目录通常位于源码目录的include/onnxruntime/core/session等路径但编译后也会在构建目录中组织一份。关于“安装”标准的make install可能会将库和头文件安装到系统目录如/usr/local。但在生产环境中我更喜欢不进行系统安装而是将编译产物直接打包或复制到我的项目目录中。这样做的好处是环境隔离避免污染系统环境也避免被系统其他软件包更新意外影响。版本控制项目依赖的库版本清晰明确易于管理和部署。可移植性整个项目的依赖可以一起打包迁移到其他机器时环境一致。因此我们只需记住这些产出物的路径在下一节配置项目时直接引用即可。例如我将关键文件复制到一个独立的dist目录mkdir -p ~/onnxruntime_dist cp -r ~/onnxruntime_build/onnxruntime/build/Linux/Release/* ~/onnxruntime_dist/ # 通常需要的是 libonnxruntime.so* 文件和 include 头文件 # 实际头文件路径在源码目录~/onnxruntime_build/onnxruntime/include/onnxruntime cp -r ~/onnxruntime_build/onnxruntime/include ~/onnxruntime_dist/3. C项目配置与CMake集成现在ONNX Runtime的库已经准备好了我们需要在一个新的C项目中引用它。这里我使用CMake来管理项目这是C生态的事实标准能很好地处理库依赖和跨平台构建。3.1 项目结构设计假设我们的测试项目叫做onnxruntime_cpp_test目录结构如下~/projects/onnxruntime_cpp_test/ ├── CMakeLists.txt # 项目根CMake配置文件 ├── src/ │ └── main.cpp # 主测试代码 ├── lib/ # 放置第三方库如ONNX Runtime │ ├── include/ # 头文件 │ │ └── onnxruntime/ │ └── linux-x64/ # 平台相关的库文件 │ ├── libonnxruntime.so.1.16.3 │ ├── libonnxruntime.so - libonnxruntime.so.1.16.3 │ └── ... └── models/ # 存放测试用的ONNX模型文件 └── test_model.onnx我们将之前编译好的ONNX Runtime的include文件夹内容复制到lib/include/下将.so库文件复制到lib/linux-x64/下。这种结构清晰地将第三方依赖与项目自身代码分离。3.2 CMakeLists.txt 详细解析CMakeLists.txt是项目的构建蓝图。下面是一个详细配置示例并附上每一步的说明。cmake_minimum_required(VERSION 3.16) project(onnxruntime_cpp_test LANGUAGES CXX) # 1. 设置C标准 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 2. 定义ONNX Runtime库的路径 set(ONNXRUNTIME_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/lib) set(ONNXRUNTIME_INCLUDE_DIR ${ONNXRUNTIME_ROOT_DIR}/include) set(ONNXRUNTIME_LIB_DIR ${ONNXRUNTIME_ROOT_DIR}/linux-x64) # 3. 查找库文件 find_library(ONNXRUNTIME_LIB NAMES onnxruntime PATHS ${ONNXRUNTIME_LIB_DIR} NO_DEFAULT_PATH # 只在指定路径查找避免找到系统其他版本 ) if(NOT ONNXRUNTIME_LIB) message(FATAL_ERROR ONNX Runtime library not found in ${ONNXRUNTIME_LIB_DIR}) endif() # 4. 创建可执行文件目标 add_executable(${PROJECT_NAME} src/main.cpp) # 5. 为目标添加头文件包含路径 target_include_directories(${PROJECT_NAME} PRIVATE ${ONNXRUNTIME_INCLUDE_DIR}) # 6. 为目标链接库 target_link_libraries(${PROJECT_NAME} PRIVATE ${ONNXRUNTIME_LIB}) # 7. 设置运行时库路径 (RPATH) # 这允许可执行文件在运行时找到位于非标准路径的动态库 set_target_properties(${PROJECT_NAME} PROPERTIES BUILD_RPATH ${ONNXRUNTIME_LIB_DIR} INSTALL_RPATH ${ONNXRUNTIME_LIB_DIR} )关键点解析find_library与NO_DEFAULT_PATH我们明确指定了库的搜索路径并使用了NO_DEFAULT_PATH。这至关重要它防止CMake去系统的/usr/lib等目录查找可能存在的旧版本或其他版本的ONNX Runtime确保链接的是我们提供的特定版本。target_link_libraries将编译好的libonnxruntime.so链接到我们的可执行文件。RPATH设置这是解决运行时动态库加载问题的关键。BUILD_RPATH使得编译出的可执行文件在构建后能从${ONNXRUNTIME_LIB_DIR}路径加载.so文件方便本地测试。INSTALL_RPATH则指定了安装后的运行时库搜索路径。这比修改系统环境变量LD_LIBRARY_PATH更优雅、更可控。3.3 构建项目在项目根目录下执行标准的CMake构建流程mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)如果一切顺利你会在build目录下看到生成的可执行文件onnxruntime_cpp_test。此时你可以直接运行它因为它已经通过RPATH知道了去哪找libonnxruntime.so。./onnxruntime_cpp_test实操心得在开发机上使用RPATH非常方便。但在制作发布包时你需要根据目标部署环境重新考虑库的部署方式和RPATH的设置。一种常见做法是将libonnxruntime.so与可执行文件放在同一目录并将INSTALL_RPATH设置为$ORIGIN在Linux上表示可执行文件自身所在目录。4. 测试代码编写与模型推理验证测试代码的目标是验证整个工具链是否工作正常。我们需要一个简单的ONNX模型。如果你没有现成的可以用Python的onnx包快速导出一个。这里假设我们已经有一个models/simple_linear.onnx模型它实现了一个简单的线性变换y 2*x 1。4.1 测试代码实现 (src/main.cpp)下面是一个完整的、带有详细错误处理的测试代码#include iostream #include vector #include algorithm #include cassert // ONNX Runtime C API 核心头文件 #include onnxruntime/core/session/onnxruntime_cxx_api.h int main() { std::cout ONNX Runtime C CPU Inference Test \n; // 1. 初始化ONNX Runtime环境 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, TestSession); Ort::SessionOptions session_options; // 配置会话选项使用CPU执行器并启用多线程 session_options.SetIntraOpNumThreads(1); // 单个操作内部使用的线程数 session_options.SetInterOpNumThreads(1); // 多个操作间并行使用的线程数 // 对于CPU通常使用默认的“CPU”执行器。也可以显式设置 // Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CPU(session_options, 0)); // 2. 加载ONNX模型 const char* model_path ../models/simple_linear.onnx; // 相对于可执行文件位置的模型路径 std::cout Loading model from: model_path std::endl; Ort::Session session(env, model_path, session_options); // 3. 获取模型输入/输出信息 Ort::AllocatorWithDefaultOptions allocator; // 获取输入数量与名称 size_t num_input_nodes session.GetInputCount(); std::vectorconst char* input_node_names(num_input_nodes); std::vectorOrt::TypeInfo input_type_info(num_input_nodes); std::cout Number of inputs: num_input_nodes std::endl; for(size_t i 0; i num_input_nodes; i) { char* input_name session.GetInputName(i, allocator); input_node_names[i] input_name; input_type_info[i] session.GetInputTypeInfo(i); auto tensor_info input_type_info[i].GetTensorTypeAndShapeInfo(); // 获取输入维度 std::vectorint64_t input_dims tensor_info.GetShape(); std::cout Input i name: input_name , Shape: [; for (size_t j 0; j input_dims.size(); j) { std::cout input_dims[j]; if (j ! input_dims.size() - 1) std::cout , ; } std::cout ], Data Type: tensor_info.GetElementType() std::endl; // 注意GetInputName返回的指针需要由调用者释放使用allocator.Free allocator.Free(input_name); } // 获取输出数量与名称类似输入 size_t num_output_nodes session.GetOutputCount(); std::vectorconst char* output_node_names(num_output_nodes); std::cout Number of outputs: num_output_nodes std::endl; for(size_t i 0; i num_output_nodes; i) { char* output_name session.GetOutputName(i, allocator); output_node_names[i] output_name; std::cout Output i name: output_name std::endl; allocator.Free(output_name); } // 4. 准备输入数据 (假设我们的模型只有一个输入形状为 [1]) // 模型 y 2*x 1 float input_data[] { 3.0f }; // 输入 x 3.0 std::vectorint64_t input_shape {1}; // 形状为1的一维张量 // 计算输入数据所需的内存大小 size_t input_tensor_size 1; for (int64_t dim : input_shape) input_tensor_size * dim; // 创建Ort输入张量 auto memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data, input_tensor_size, input_shape.data(), input_shape.size() ); // 验证张量创建成功 assert(input_tensor.IsTensor()); // 5. 运行推理 std::cout \nRunning inference with input: input_data[0] std::endl; std::vectorOrt::Value input_tensors; input_tensors.push_back(std::move(input_tensor)); // 使用move转移所有权 std::vectorOrt::Value 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() ); // 6. 处理输出结果 std::cout Inference completed.\n; assert(output_tensors.size() 1 output_tensors.front().IsTensor()); Ort::Value output_value output_tensors.front(); auto tensor_info output_value.GetTensorTypeAndShapeInfo(); std::vectorint64_t output_shape tensor_info.GetShape(); size_t output_tensor_size 1; for (int64_t dim : output_shape) output_tensor_size * dim; // 获取输出数据的指针 float* output_data output_value.GetTensorMutableDatafloat(); std::cout Output shape: [; for (size_t i 0; i output_shape.size(); i) { std::cout output_shape[i]; if (i ! output_shape.size() - 1) std::cout , ; } std::cout ]\n; std::cout Output value(s): ; for (size_t i 0; i output_tensor_size; i) { std::cout output_data[i] ; } std::cout std::endl; // 验证结果 2*3 1 7 float expected_output 2.0f * input_data[0] 1.0f; std::cout Expected output: expected_output std::endl; if (std::abs(output_data[0] - expected_output) 1e-5) { std::cout \n✅ SUCCESS: ONNX Runtime C CPU inference test passed! std::endl; } else { std::cout \n❌ FAILED: Output mismatch! std::endl; return 1; } return 0; }4.2 代码关键点解析与注意事项环境与会话选项Ort::Env是全局环境一个进程一个即可。Ort::SessionOptions允许你精细控制会话行为如线程数、执行器CPU/GPU等。对于CPU推理设置合适的线程数对性能影响很大。内存管理ONNX Runtime C API大量使用智能指针和RAII资源获取即初始化设计如Ort::Session,Ort::Value这简化了内存管理。但特别注意GetInputName和GetOutputName返回的C风格字符串需要手动释放必须使用配套的allocator.Free()。张量创建Ort::Value::CreateTensor是创建输入张量的主要方式。你需要提供数据指针、数据大小、形状和数据类型。数据必须保持有效直到推理完成。运行推理session.Run是核心函数。注意input_tensors和output_tensors都是std::vectorOrt::Value。我们将输入张量std::move到向量中以避免不必要的拷贝。获取输出output_tensors中的Ort::Value包含了输出数据。使用GetTensorMutableDataT()或GetTensorDataT()获取数据指针。注意类型T必须与模型输出类型匹配。4.3 运行测试与结果分析在项目构建目录(build)下运行编译好的程序cd ~/projects/onnxruntime_cpp_test/build ./onnxruntime_cpp_test如果一切配置正确你应该能看到类似以下的输出 ONNX Runtime C CPU Inference Test Loading model from: ../models/simple_linear.onnx Number of inputs: 1 Input 0 name: input, Shape: [1], Data Type: 1 Number of outputs: 1 Output 0 name: output, Shape: [1], Data Type: 1 Running inference with input: 3 Inference completed. Output shape: [1] Output value(s): 7 Expected output: 7 ✅ SUCCESS: ONNX Runtime C CPU inference test passed!看到成功的提示就证明从库编译、项目链接到模型推理的整个C链路已经完全打通。这个测试代码虽然简单但涵盖了初始化、模型加载、数据准备、推理执行和结果获取这五个核心步骤是一个有效的“冒烟测试”。5. 常见问题排查与性能调优要点即使按照步骤操作你也可能会遇到一些问题。下面是一些常见问题的排查思路和解决方法。5.1 编译与链接阶段问题问题1CMake找不到ONNX Runtime库。现象CMake配置时报告Could NOT find ONNXRUNTIME_LIB。排查检查ONNXRUNTIME_LIB_DIR路径是否正确库文件如libonnxruntime.so.1.16.3是否确实存在。确认find_library中NAMES参数是否正确。库的文件名通常是onnxruntime链接时会自动添加lib前缀和.so后缀但有时版本号可能包含在名称中。你可以尝试NAMES onnxruntime onnxruntime.so.1.16.3。尝试在终端手动运行ldconfig -p | grep onnxruntime看系统是否注册了其他版本这可能干扰查找。问题2链接时出现未定义引用错误。现象make阶段报错如undefined reference toOrt::Env::Env(...)。排查库路径错误确保target_link_libraries链接的库文件路径正确。可以用message(${ONNXRUNTIME_LIB})在CMake中打印确认。C ABI不匹配ONNX Runtime是用特定版本的GCC编译的。如果你的项目使用不同版本的GCC尤其是新旧版本之间ABI有变化可能导致链接错误。确保你的开发环境GCC版本与编译ONNX Runtime时使用的版本尽可能一致。编译选项不匹配例如ONNX Runtime编译时启用了异常默认是开启的而你的项目用-fno-exceptions编译。需要统一编译选项。5.2 运行时问题问题3运行时报错“error while loading shared libraries: libonnxruntime.so.x: cannot open shared object file”。现象编译成功但运行可执行文件时找不到动态库。解决方案检查RPATH使用readelf -d ./onnxruntime_cpp_test | grep RPATH查看可执行文件的RPATH设置是否正确指向了你的库目录。临时设置LD_LIBRARY_PATHexport LD_LIBRARY_PATH/path/to/your/onnxruntime/lib:$LD_LIBRARY_PATH然后再次运行程序。这只是临时调试方法。永久方案确保CMake中BUILD_RPATH和INSTALL_RPATH设置正确或者将库文件复制到系统库路径如/usr/local/lib并运行sudo ldconfig不推荐可能引起冲突。问题4Session创建失败模型加载错误。现象Ort::Session构造函数抛出异常提示模型路径无效或模型格式错误。排查检查模型文件路径是否正确程序是否有读取权限。使用Python的onnx包检查模型是否有效python -c import onnx; model onnx.load(model.onnx); onnx.checker.check_model(model)。确认ONNX Runtime版本是否支持模型中的操作符OpSet。较新的模型可能包含旧版本运行时不支持的操作符。5.3 性能调优要点当基础功能跑通后下一步就是关注性能。对于CPU推理以下几个点至关重要会话选项调优SetIntraOpNumThreads设置单个操作如一个大的矩阵乘法内部可用的线程数。对于计算密集型操作设置为CPU物理核心数通常是个好起点。SetInterOpNumThreads设置可以并行执行的不同操作的数量。如果模型图中有可并行分支这个参数可以提高利用率。对于简单的顺序模型设置为1即可。建议通过性能测试如对不同线程数配置进行基准测试来找到最适合你模型和硬件的配置。并非线程越多越好过多的线程可能因上下文切换和资源竞争导致性能下降。使用更快的执行提供器我们编译的是默认的CPU版本。ONNX Runtime支持通过执行提供器Execution Provider, EP集成其他后端来加速。oneDNN (formerly DNNL)针对Intel CPU深度优化。编译时需开启-Donnxruntime_USE_DNNLON并安装libdnnl-dev。在代码中创建会话后调用OrtSessionOptionsAppendExecutionProvider_Dnnl。OpenVINO EP针对Intel CPU、集成显卡和神经计算棒深度优化能带来显著性能提升。这需要单独编译OpenVINO EP并链接。ACL (ARM Compute Library)针对ARM架构CPU如服务器上的Ampere Altra、手机芯片优化。选择策略根据你的部署硬件选择。在x86-64服务器上如果CPU是Intel的优先尝试DNNL或OpenVINO。输入/输出数据预处理后处理优化推理本身可能只占整个Pipeline的一小部分。确保你的数据准备如图像解码、归一化和结果解析代码也是高效的。避免在推理循环中进行不必要的内存分配和拷贝。使用RunOptions进行异步推理对于需要低延迟的场景可以研究使用带回调的异步推理API但这会显著增加代码复杂度。5.4 内存与资源管理会话复用创建Ort::Session开销相对较大。对于服务型应用应该在初始化时创建好会话并复用它们而不是每次推理都创建新的。内存泄漏检查虽然API使用了RAII但仍需确保没有循环引用或异常路径导致资源未释放。对于长时间运行的服务可以使用Valgrind等工具进行内存检查。批处理Batching如果可能将多个输入请求组合成一个批次进行推理可以大幅提高吞吐量因为硬件尤其是CPU的向量化指令能更高效地处理批量数据。这需要模型支持动态批次维度即输入形状中批次维为-1或可变。