C++ 深度学习(二)
上一篇我们把C++在深度学习底层的角色捋了一遍,从张量存储到基础算子,算是把地基打了。这一篇直接进入实战环节:用C++做模型的推理部署。
Python写训练loop很舒服,但真正把模型交到用户手里那一刻,C++的性价比就出来了——部署包体积小、启动快、内存可控,不用在那折腾Python解释器版本和一堆wheel依赖。如果你是那种“模型训好之后不知道怎么从PyTorch变成产品功能”的人,这篇能给你一条完整的路:从C++环境怎么搭、关键组件怎么理解,到模型怎么导出、怎么调优、线上崩了怎么排查。
这东西适合谁?适合已经能跑通Python端训练、但想把模型工程化的同学,也适合在Windows/Linux上做视觉、音频、检测类项目、需要实时推理的开发者。先说清楚,本文不打算教你用C++从零手写神经网络训练——那个成本太高,工程上也没必要。我们的核心思路是:训练交给Python生态,推理和部署交给C++。这个分工一旦想明白,后面的每一步都会顺很多。
1. 环境与工具链准备:C++深度学习项目的第一道坎
大部分人在“想用C++跑模型”这件事上,第一反应是打开IDE写代码,但卡在编译环境和依赖上。这很常见,因为深度学习C++项目跟平时写业务代码不一样,它涉及大量外部库,光靠自己手动下载配置能折腾一整天。
1.1 编译器怎么选:别小看这个选择
Windows上主流是MSVC(Visual Studio自带),Linux上是GCC或Clang,macOS是AppleClang。我见过不少人在Windows用MinGW编译Linux风格代码,结果链接第三方库时一堆unresolved external symbol,最后发现是ABI不一致。深度学习库的预编译包,在Windows上基本都跟进MSVC构建,在Linux上跟进GCC,这不是玄学,是构建工具链的ABI兼容性问题。
以MSVC为例,路径里通常需要装Visual Studio 2019或2022,只装Build Tools也行。命令行里用cmake -G "Visual Studio 17 2022" -A x64就能生成VS工程。如果你用的是vcpkg,它还会自动检测当前工具链,帮你把依赖编译成对应的ABI版本。记住一句话:编译器混用是C++项目最大的坑之一,论文代码或者开源库用什么编译器,你就尽量用同一家的同代编译器。
1.2 三个核心库的定位:LibTorch、ONNX Runtime、OpenCV
做C++推理,绕不开三个东西。我把它们的分工和适用场景放在下面这个表里,方便你对号入座:
| 库 | 定位 | 适合场景 | 体积/依赖 | 上手难度 |
|---|---|---|---|---|
| LibTorch | PyTorch官方C++ API | 和PyTorch训练生态无缝衔接,需要灵活改模型结构 | 较大,约数百MB,需配套torchscript模型 | 中 |
| ONNX Runtime | 跨平台推理引擎 | 工业部署主力,模型导出一次、多端运行 | 较小,可裁剪,CPU/GPU版本分开 | 低 |
| OpenCV | 图像处理库 | 图像前处理/后处理,自带DNN模块但灵活度有限 | 中等,依赖较多 | 低 |
我个人的建议是:如果模型迭代频繁、且你熟悉PyTorch,先用LibTorch跑通整个链路,后续再考虑切ONNX Runtime做最终交付。如果你的场景是“训练一次、到处部署”,比如同一套模型要放Windows、Linux甚至嵌入式设备,ONNX Runtime是更稳的选择。OpenCV不是推理引擎,但基本每个视觉项目都离不开它,后面做预处理要用的resize、normalize、cvtColor全靠它。
1.3 一个能跑的CMake最小工程
先说为什么用CMake而不是直接命令行编译。深度学习项目依赖多、链接复杂,CMake能跨平台地搞定“找库、配置、生成编译脚本”这一堆事。你写一次CMakeLists.txt,在Windows按VS工程编译,在Linux按Makefile编译,代码不用改。
下面是一个链接ONNX Runtime的最简工程骨架,我实测过,可以照抄:
cmake_minimum_required(VERSION 3.18) project(ort_demo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 如果使用了OpenCV find_package(OpenCV REQUIRED) # ONNX Runtime的头文件和库目录,也可以通过环境变量或者vcpkg自动发现 include_directories(${ORT_INCLUDE_PATH}) link_directories(${ORT_LIB_PATH}) add_executable(demo main.cpp) target_link_libraries(demo onnxruntime ${OpenCV_LIBS} )这里有一个部署上的关键选择:动态链接还是静态链接运行库。在Visual Studio里叫/MD(动态)和/MT(静态)。用/MD,可执行文件小,但客户机器上必须装对应版本的Visual C++ Redistributable;用/MT,体积大一些,但省去了分发运行库的麻烦。视觉项目在客户现场部署时,我吃过“缺msvcp140.dll运行库导致闪退”的亏,后来交付时直接把Redistributable一起带上,或者干脆/MT静态编译,省心很多。
2. 核心推理组件拆解:C++里的神经网络到底在算什么
很多人在Python里用PyTorch跑模型,觉得“forward”就是一个魔法。到了C++里没有Autograd、没有nn.Module,一切要自己面对内存和循环,心态容易崩。实际上,C++端的推理比训练简单得多:你要做的只有一件事——把输入数据按模型要求的格式喂进去,再把模型吐出的数字解释成有意义的结果。
2.1 Tensor与内存布局:比Python多操一百倍的心
Python里一个Tensor就是一个张量,你用起来很自由。C++里,Tensor本质上就是“一块连续内存+维度元数据+步长信息”。但工程里恰恰是这些细节决定性能。
先说内存布局。一个常见的问题是NCHW和NHWC之争:
- NCHW:通道优先,PyTorch默认布局,每个通道的数据连续排在一起
- NHWC:像素优先,TensorFlow更偏爱的布局,每个像素的各个通道紧挨着
CPU上,NHWC对某些逐像素计算(比如归一化)更友好,因为通道数据挨得近、缓存命中率高。GPU上卷积传统的NCHW更直接。用ONNX Runtime时,你需要确认模型导出时的布局,然后在预处理代码里转换成对应的排列方式。一个常见的错误是:Python里模型跑得好好的,换到C++推理效果崩了,回头查发现是OpenCV读进来的图片是HWC布局,模型要的是CHW,没有做permute。
还有一个跟C++特有相关的点:内存对齐。Python里你很少管对齐,但C++里当你自己开数组存权重或中间结果时,编译器通常默认8字节对齐,但SIMD向量化希望16或32字节对齐。所以你经常能看到aligned_alloc或者__declspec(align(16))这种代码。这不是洁癖,是给AVX指令用的。后面性能优化章节还会展开。
2.2 手写一个全连接层:看见推理的本质
拿全连接层开刀最直观。假设输入是4维向量,输出是3维向量,权重矩阵就是3x4,还配一个3维偏置。推理时做的事就是:
for out_idx in 0..2: acc = bias[out_idx] for in_idx in 0..3: acc += weight[out_idx][in_idx] * input[in_idx] output[out_idx] = activation(acc)就这么简单,三层循环。深度学习模型再大,推理的核心就是无数个这样的乘加累加。全连接层很像超市的价签打印机——每个输出价格要扫描所有输入商品的单价,乘以权重系数(订货量),最后把总价加上偏置(场景起步价)。
C++实现时可以写成:
void FCPredict(const float* input, const float* weight, const float* bias, float* output, int inDim, int outDim) { for (int o = 0; o < outDim; ++o) { float acc = bias[o]; for (int i = 0; i < inDim; ++i) { acc += weight[o * inDim + i] * input[i]; } output[o] = acc > 0.f ? acc : 0.f; // ReLU } }这段代码性能一般,但逻辑一目了然。实际工程里,这个操作会被GEMM(通用矩阵乘法)库取代,原理一样,只是把你手写的三层循环优化成缓存友好的分块计算。理解了这个底层过程,你就明白为什么说“推理时间=模型计算量/每秒浮点运算数”,也能明白为什么减少几个参数量、换更小的输入尺寸,对速度的影响是立竿见影的。
2.3 卷积里那点事:im2col和它的目的
CNN的核心是卷积,而卷积在计算机里并不像数学定义那么优雅地实现。最简单的理解方式:卷积就是对输入图像上一个局部窗口内的数值做加权求和,然后滑动窗口重复这个过程。
那为什么C++高性能推理库不直接写嵌套循环?因为每个窗口取数据的时候,内存访问是跳跃的,CPU缓存命中率很低。于是就有了im2col:把每个窗口取出来的数据按行摊平,变成一个大矩阵。这样,“卷积”就变成了“大矩阵乘以权重矩阵”,能直接调用高度优化的GEMM库来算。这本质上是用内存换计算效率——im2col会把数据复制多份,但复制和线性化之后,矩阵乘法对CPU/GPU来说快得多。
理解这一点很重要,因为很多人在用推理引擎时,会发现内存占用比模型体积大很多。这是正常现象,卷积层前向计算时经常要展开中间特征图,尤其是高分辨率输入。
此外还有Winograd和FFT两种加速思路,原理复杂一些,实际使用时直接交给推理引擎处理就行。但你要记住:不是所有算子都适合Winograd,推理引擎会自动选择最优实现,用户层面不需要干预。
3. 从Python到C++:模型导出与推理部署实操
这是大家最关心的环节。现在的深度学习工作流基本是Python训练、C++部署,中间的桥梁是标准化模型格式。我推荐用ONNX作为交换格式,因为它跨框架、跨平台,而且ONNX Runtime的C++ API非常简洁。下面这条链路我踩过很多坑,按步骤来基本一遍过。
3.1 训练与推理为什么非要分开
先聊一个很多人问过我的问题:为什么不用Python直接部署?答案很简单:Python部署需要解释器、一堆依赖库、还要处理GIL问题,内存占用大,冷启动慢。C++编译成单文件或少量文件,进程一启动就进main,没有解释器预热,也没有包导入开销。另外,很多业务系统是C++写的,比如Windows桌面客户端、监控后台服务,让它们去调Python接口,跨语言交互开销和维护成本都很高。反过来,把模型推理封装成一个C++接口,喂数据进、吐结果出,是最干净的架构。
训练和推理分离,还让团队分工更清晰:算法工程师负责Python端训练和调参,部署工程师负责C++端封装和性能优化。两边通过一个模型文件交互,互不阻塞。
3.2 导出ONNX模型的正确姿势
用PyTorch训练好的模型导出ONNX很简单,但有几个细节容易踩坑。以ResNet18为例:
import torch import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "resnet18.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size"}, "output": {0: "batch_size"} } )这里最值得注意的就是dynamic_axes。如果不设置动态维度,导出的模型会把输入尺寸固定成1x3x224x224,以后推理只能按这个规格来,一旦用户传一张不同分辨率的图,要么resize、要么直接报错。设了动态batch,就允许一次传多张图做batch推理,这对吞吐优化很有用。但要注意:动态维度会让推理引擎多做一些维度推导,性能略有损耗,如果业务里输入尺寸就是固定的,那可以果断去掉dynamic_axes,换来更稳的延迟。
另外,导出时务必model.eval(),去掉dropout和BN的training行为。导出前最好用torch.onnx.checker.check_model验证一次,很多上游错误能提前暴露。
3.3 用ONNX Runtime在C++里跑通一个完整例子
先说一个我认为最稳的工程结构:输入用OpenCV读取图片,预处理成模型需要的格式,构造ONNX Runtime的OrtValue,跑一次session,最后解析输出张量。
下面这段代码是可以直接编译运行的骨架。假设场景是一个医疗图像分类项目,比如口腔疾病图像识别,输入是224x224的彩色图,输出是N类别的概率。代码的关键处我加了注释:
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> #include <string> #include <algorithm> int main() { // 1. 创建环境,日志级别建议开启,排查问题必备 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "demo_env"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); // 线程数按核心数调整 session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // 2. 加载模型 Ort::Session session(env, L"resnet18.onnx", session_options); // 3. 输入输出信息 Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto input_shape = session.GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape(); int64_t input_w = input_shape[3]; // NCHW int64_t input_h = input_shape[2]; // 4. 读取并预处理图像 cv::Mat img = cv::imread("test.jpg"); // BGR布局 cv::resize(img, img, cv::Size(input_w, input_h)); img.convertTo(img, CV_32FC3, 1.0 / 255.0); // 归一化到[0,1] // 注意: ImageNet的mean/std,必须与训练时保持一致 const float mean[3] = {0.485f, 0.456f, 0.406f}; const float std[3] = {0.229f, 0.224f, 0.225f}; std::vector<float> input_tensor_values(1 * 3 * input_h * input_w); int idx = 0; for (int c = 0; c < 3; ++c) { // 通道维度,BGR映射到RGB顺序 for (int h = 0; h < input_h; ++h) { for (int w = 0; w < input_w; ++w) { float v = img.ptr<cv::Vec3f>(h)[w][2 - c]; // BGR->RGB input_tensor_values[idx++] = (v - mean[c]) / std[c]; } } } // 5. 构造输入Tensor并推理 std::vector<int64_t> input_shape_v = {1, 3, input_h, input_w}; Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_tensor_values.data(), input_tensor_values.size(), input_shape_v.data(), input_shape_v.size()); std::vector<const char*> input_names = {"input"}; std::vector<const char*> output_names = {"output"}; auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &input_tensor, 1, output_names.data(), 1); // 6. 解析输出 auto& output = output_tensors[0]; auto shape = output.GetTensorTypeAndShapeInfo().GetShape(); size_t num_classes = shape[1]; const float* output_data = output.GetTensorData<float>(); int best_class = 0; float best_score = output_data[0]; for (size_t i = 1; i < num_classes; ++i) { if (output_data[i] > best_score) { best_score = output_data[i]; best_class = static_cast<int>(i); } } std::cout << "Top class: " << best_class << " (" << best_score << ")" << std::endl; return 0; }这里有三个高频踩坑点,我必须单独拿出来说:
- 通道顺序。OpenCV读出来的图是BGR,训练时PyTorch里用的是RGB。如果你直接拿BGR数据喂模型,结果会很奇怪。上面代码里
2 - c这个索引变戏法就是在调通道顺序。在Python里你可能会用cv2.cvtColor(img, cv2.COLOR_BGR2RGB),C++同理。 - 归一化参数必须和训练时一模一样。很多人把分母的std写错,或者忘了减mean,导致推理精度下降,还以为是模型坏了。这个真不是小事,我见过一个现场项目因为mean写成了
{0.5,0.5,0.5},推理效果惨不忍睹,排查了整整一天。 - 输入张量的数据类型。模型一般是float32,你在C++端用
std::vector<float>没问题,但如果你习惯性用了double,OrtValue就会报类型不匹配,或者悄悄做隐式转换拖慢速度。保持float32是大家都舒服的状态。
3.4 LibTorch和TensorRT怎么选
如果你看完了上面例子,觉得ONNX Runtime的流程挺顺手,那就先沿这条路走。还有两个常见选项我简单说下取舍:
LibTorch适合“全栈都在PyTorch里、不想管ONNX算子兼容性”的场景。你可以用torch::jit::load加载TorchScript模型,直接在C++里做前向计算,API和Python端几乎同名,对PyTorch开发者极友好。缺点是部署体积大、CPU推理性能不如ONNX Runtime优化得彻底。
TensorRT是NVIDIA家的推理引擎,它能把模型编译成针对特定GPU高度优化的版本,INT8量化之后速度可以再翻倍。但它只支持NVIDIA GPU,而且模型导入时可能有算子不兼容。我的建议是:如果你的服务端推理卡是NVIDIA且延迟敏感,值得上TensorRT;否则ONNX Runtime CPU版本已经能覆盖绝大多数场景,省心很多。
4. 性能优化:为什么你的C++推理比别人慢
很多人的C++推理程序跑起来是能跑,但一问性能就支支吾吾。这个问题通常是优化优先级没想清楚,一顿瞎调,最后收益甚微。我按对性能影响从大到小排个序,你可以按这个顺序排查。
4.1 先问一句:瓶颈到底在哪
做过Profiling的人都知道,别猜,要量。如果推理时间已经由底层算子决定(比如ResNet50的卷积占了90%以上),那你优化“图像读取”这种外围代码是徒劳的。反之,如果你的预处理和后处理占了半天时间,说明模型本身没成为瓶颈,不如把精力放在业务逻辑和流水线上。
简单测一下各阶段耗时就行,不必上太高级的工具:
auto t1 = std::chrono::high_resolution_clock::now(); // 预处理阶段 auto t2 = std::chrono::high_resolution_clock::now(); // session.Run auto t3 = std::chrono::high_resolution_clock::now(); std::cout << "preprocess: " << elapsed(t1, t2) << "ms" << std::endl; std::cout << "inference: " << elapsed(t2, t3) << "ms" << std::endl;我遇到过很多次:模型推理只要10毫秒,但图像resize+归一化花了80毫秒。这时候该优化的不是ONNX Runtime,而是预处理这段代码的SIMD化或缓存设计。
4.2 算子层优化:GEMM库和SIMD指令
推理引擎自带的高性能GEMM已经很牛了,你一般不需要自己写矩阵乘。但有两种情况需要手动干预:一是某些算子不在引擎的优化覆盖范围,二是特定前处理/后处理自己写的算法需要加速。
一个典型的例子:图像归一化。你刚接触C++时可能这么写:
for (int i = 0; i < total; ++i) { out[i] = (src[i] - mean[c]) / std[c]; }编译器开/O2或-O2后,这种简单循环通常能被自动向量化,但如果编译器无法证明数据没有别名问题,它可能就不敢优化。这时你可以用#pragma omp simd,或者用Avx intrinsic手写:
#include <immintrin.h> __m256 mean_reg = _mm256_set1_ps(mean); __m256 std_reg = _mm256_set1_ps(std); for (int i = 0; i < total; i += 8) { __m256 v = _mm256_loadu_ps(&src[i]); v = _mm256_sub_ps(v, mean_reg); v = _mm256_div_ps(v, std_reg); _mm256_storeu_ps(&out[i], v); }这段代码一次处理8个float,吞吐量能提升好几倍。但注意:别在代码里蒙头手写SIMD,先确认编译器自动向量化的效果,再决定是否手动。现代编译器已经很强了,手动优化是最后一道保险,不是第一选择。
4.3 调度层优化:多线程和异步流水线
推理服务很少是单次调用,通常是连续不断的有请求进来。这时候瓶颈往往在“等待”,而不是“计算”。
设计思路很简单:把一次推理分成三段流水线——预处理线程、推理线程、后处理线程。预处理从请求队列拿原始数据,处理完放进中间队列;推理线程从中间队列取张量,跑完放进结果队列;后处理线程从结果队列取输出,翻译成业务结果。这就是经典的生产者-消费者模型。C++17里有std::jthread加两个有锁队列就能实现,不需要引入重型框架。
还有一个容易被忽略的优化:batch推理。单个请求推理一次,GPU利用率上不去;把4个请求攒成一批一起推理,总耗时并不比单个推理多多少,吞吐量却能提升好几倍。代价是第一个请求要多等一会儿积攒batch,所以这本质上是在延迟和吞吐之间做取舍。离线批处理任务可以放心用,实时交互系统要谨慎。
4.4 量化:最暴力的加速手段
前面说的都是“白嫖”性能,真正的杀手锏是量化。把模型从FP32降到INT8,CPU上通常能获得2-4倍的加速,内存占用直接缩到四分之一,而精度损失通常控制在1%-3%以内。ONNX Runtime里有官方的量化工具,也可以配合TensorRT做GPU上的INT8推理。
量化后的模型在CPU上效果是否好,跟算子有关,卷积和全连接量化成熟度高,但某些激活函数或归一化层就不一定能高效跑量化。所以我的建议是:量化前先跑通原始FP32的完整流程,再做量化,出问题时有对照基线。量化之后,一定要把原本的测试集重新跑一遍,确认精度落到业务容忍范围。
5. 生产环境中的常见问题与排查技巧实录
这个部分是我踩过的坑集中营,真实价值可能比前面所有代码都高。你如果做C++推理部署,迟早会碰见这些问题。
5.1 经典崩溃:access violation c0000005
C#调用C++的DLL,程序跑着跑着突然崩了,事件查看器里日志ID是c0000005(访问违规)。遇到这种问题,先别慌,这基本都是跨语言边界上传参或内存管理出问题了。
最常见的原因有三个:
- 结构体布局不一致。C#端的struct默认按托管布局排列,C++端按C++规则对齐,字段之间插入了padding,两边看到的长度和偏移都不一样,数据一错位就访问越界。C#端要显式加
[StructLayout(LayoutKind.Sequential)],还要保证字段类型和C++对应(int对应int,float对应float,不能混)。 - 内存分配和释放在不同模块。也就是说,C++ DLL里用
new分配了一块内存,返回给C#,C#用Marshal.FreeHGlobal去释放,或者反过来。不同运行时管理的内存堆不一样,轻则泄漏,重则直接崩溃。正确做法是:谁分配,谁释放,在C++侧提供成对的释放函数。 - 调用约定不匹配。C++默认的
__cdecl和C#默认的Winapi(实际是__stdcall)不一样,栈参数清理逻辑不同,参数一多就崩。C++导出函数时明确写成__declspec(dllexport),C#端声明时显式CallingConvention = CallingConvention.Cdecl。
排这种问题最有效的方式不是瞎改代码,而是抓dump文件。Windows上用ProcDump或者Visual Studio的诊断工具,抓一份崩溃现场,看崩溃的调用栈在哪个函数,很快就能定位。
5.2 VC++运行库缺失导致的启动闪退
一个特别真实的场景:你在自己机器上编译运行一切正常,把exe拷到客户机器上,刚打开就“无声无息”退出。这时候绝大多数情况是目标机器缺msvcp140.dll、vcruntime140.dll、vcruntime140_1.dll。
解决方案有三个层次,我按推荐顺序排列:
- 编译时选择静态链接运行库。MSVC里是把
/MD换成/MT(Release版)。CMake里对应set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"),这样exe体积会变大,但不再依赖外部VC运行库。 - 如果因为某些第三方库只提供了动态库版本、必须用
/MD,那就把对应的vcruntime和msvcpDLL放到exe同目录,或将VC++ Redistributable安装包作为部署步骤的一部分。 - 如果客户机器完全没有Windows更新,或者系统版本很老,还需要注意VC++运行库的版本号是否够新。可以把常用版本都装上,并不会出问题。
5.3 张量形状和数据类型不匹配:静默的错误最坑人
相比崩溃,更烦人的是“能跑,但结果不对”。几个高频原因:
- 预处理时把float32误传成float64。ONNX Runtime有时会帮你转换,但性能骤降,有时直接报错。用
GetTensorData<float>()取数据时尤其要确认模型输出也是float32。 - 输入的batch维度弄错。模型期望
[1,3,224,224],你却传了[3,1,224,224],ONNX Runtime会报警告,但可能仍然执行——结果就是一团乱码或张冠李戴。 - 归一化的mean/std不一致。训练时用的是
0.485,0.456,0.406,到C++里改成了0.5,0.5,0.5。这个错误不报异常,只在最终准确率上体现为“整体变差”,特别难排查。我的建议是:把预处理参数和模型文件版本一起打包写入配置文件,部署时加载配置,不要硬编码在代码里。
5.4 路径、编码和其他工程细节
Windows下中文路径是重灾区。C++读取文件路径时,如果直接用std::fstream打开中文路径可能会失败,而ONNX Runtime的Session构造函数支持宽字符,如L"模型.onnx"。我的经验是:在Windows上统一用宽字符接口,Linux上正常UTF-8即可。如果跨平台代码要复用,可以用std::filesystem::path来管理路径,它能帮你处理平台差异。
ONNX Runtime的日志也是排查利器。把环境变量ORT_LOG_LEVEL设成INFO或VERBOSE,能看到每个算子的执行时间和输入输出信息。这在怀疑某个自定义算子异常时尤其有价值。
最后说点我自己的体会
这整个“C++深度学习推理”的路子,我总结下来就是一句话:别贪多,先把最简链路跑通。很多人一上来就想搞一个灾难级的完整项目,摄像头实时检测、多线程、量化、TensorRT全都上,结果卡在第一步链接库上,心态崩了再也不想碰。正确路径是:先用我上面第三节的最简代码跑通一个ONNX模型,再逐步加预处理、加业务逻辑、加性能优化。每加一步都验证一次,这样出了问题你永远知道是哪一步改错了。
还要提醒大家,模型文件最好把版本号写进文件名,比如resnet18_v3.onnx,并在启动日志里打印加载的模型路径和版本。听起来简单,但我真的见过因为覆盖了旧模型、缓存没刷新,导致线上跑的版本和预期不一致,白白排查了一整天的情况。这种小习惯,代价几乎为零,收益却巨大。如果你后面遇到C++推理的问题,从本节的中文路径、动态形状、运行库依赖这几个切入点查,大概率能省很多时间。