1. 为什么要在 C++ 里折腾 ONNX Runtime 加 CUDA 这套组合
把训练好的 YOLOv5 模型从 Python 环境搬到 C++ 工程里跑,还要用上 CUDA 加速,这件事我在几个实际交付的项目里反复做过。第一次做的时候踩了不少坑,后来摸清了套路,现在基本上半天就能从零搭出一套能跑通的推理管线。这篇文章就把我这些年攒下来的经验完整地摊开讲一遍,从方案选型到环境配置,从预处理对齐到后处理解码,再到性能调优和问题排查,尽量做到你照着做就能复现。
先说清楚这套方案到底解决什么问题。YOLOv5 训练出来的是 PyTorch 的权重文件,Python 里推理很方便,但很多实际场景要求低延迟、低资源占用、无 Python 依赖,比如嵌入式设备、工业质检工控机、边缘计算盒子,或者需要集成进已有 C++ 桌面应用的场合。这时候就需要把模型导出成中间格式,再用 C++ 推理引擎加载执行。ONNX Runtime 是微软开源的跨平台推理引擎,支持 CPU、CUDA、TensorRT 等多种执行后端,API 稳定,社区活跃,是我目前最推荐的选择之一。CUDA 加速则是把推理过程中大量的矩阵运算交给 GPU 并行处理,实测在合适的显卡上能把单帧推理时间压到 CPU 的几分之一甚至更低。
适合谁来读这篇内容?如果你已经会用 Python 跑 YOLOv5 推理,但没做过 C++ 部署,这篇能帮你把整条链路打通;如果你有 C++ 基础但没接触过 ONNX Runtime,文中关于 API 调用、内存管理、张量操作的细节会让你少走很多弯路;如果你已经在用 CPU 推理想切到 CUDA,性能调优和踩坑那几节应该对你最有直接帮助。我默认读者了解基本的 C++ 语法和 CMake 构建流程,对深度学习推理有个模糊概念就行,不需要你是 CUDA 专家。
需要提前说明的是,文中涉及的具体版本号、路径、参数值都是基于我实际使用的环境给出的参考值,你照着配的时候要结合自己的显卡型号、驱动版本、CUDA 版本做对应调整。另外所有代码片段都是示意性的核心逻辑,完整工程还需要你自己补全头文件引用、错误处理和资源释放。下面进入正题。
2. 整体方案设计与技术选型拆解
2.1 从 PyTorch 到 ONNX 再到 C++ 的链路为什么这么设计
整条链路的核心思路是"训练归训练,部署归部署"。YOLOv5 的官方仓库基于 PyTorch,训练和调参都在 Python 生态里完成,这部分不动。部署阶段需要的是一个与训练框架解耦的推理格式,ONNX 就是这个中间桥梁。ONNX 定义了一套标准的计算图表示,不同框架导出的模型都能被不同推理引擎加载,相当于给模型换了个"通用插头"。
为什么不在 C++ 里直接调 LibTorch?LibTorch 确实能直接加载 PyTorch 模型,但它体积大、依赖重,编译出来的可执行文件动辄几百 MB,而且 CUDA 版本和 PyTorch 版本强绑定,升级维护很麻烦。ONNX Runtime 相对轻量,运行时库可以按需裁剪,CUDA 支持通过独立的执行提供器接入,版本管理更灵活。另一个选择是 TensorRT,性能上限确实更高,但它对算子支持有约束,模型转换过程中经常遇到不支持的层需要手写插件,调试成本高。ONNX Runtime 在易用性和性能之间取了个不错的平衡点,对大多数项目来说够用了。
导出环节有个关键点必须强调:YOLOv5 的官方 export.py 脚本在导出 ONNX 时,默认会把后处理(也就是 NMS 非极大值抑制)留在模型外面,导出的输出是三个尺度的原始特征图。这意味着 C++ 端需要自己实现解码和 NMS。另一种做法是导出时加上 --include nms 参数把 NMS 也固化进 ONNX 图里,C++ 端直接拿最终检测框。两种方式各有取舍,我后面会详细对比。我个人的习惯是导出不带 NMS 的版本,把后处理放在 C++ 里用 CPU 做,原因是 NMS 本身计算量不大,放在 CPU 上反而避免了 GPU 和 CPU 之间来回拷贝数据的开销,而且调试起来直观得多。
2.2 ONNX Runtime 的 CUDA 执行提供器是怎么工作的
ONNX Runtime 的架构里有个"执行提供器"(Execution Provider,简称 EP)的概念。你可以把它理解成推理引擎的"动力来源",每个 EP 负责把计算图里的算子分配到特定硬件上执行。默认的 CPU EP 用 CPU 算,CUDA EP 把能放到 GPU 上的算子交给 CUDA 核函数执行,TensorRT EP 则进一步做图优化和算子融合。
CUDA EP 的工作流程大致是这样:加载模型时,ONNX Runtime 会遍历计算图,判断每个算子是否有对应的 CUDA 实现,有的就标记为在 GPU 上执行,没有的(比如某些特殊算子)就回退到 CPU。推理时,输入数据需要先拷贝到 GPU 显存,中间结果在显存里流转,最终输出再拷贝回主机内存。这个"拷贝进、拷贝出"的过程是有开销的,所以如果你的模型很小、推理时间很短,拷贝开销占比就会很高,CUDA 加速的收益反而不明显。YOLOv5 这个体量的模型,输入 640x640 的情况下,GPU 推理的收益是很显著的。
有个细节很多人忽略:CUDA EP 默认会占用一定量的显存作为内存池,避免频繁申请释放。这个池子的大小可以通过配置项调整。如果你的显存紧张,或者同一张卡上要跑多个模型,就需要关注这个参数,否则可能出现显存不够用的报错。我在一个多模型并发的项目里就遇到过这个问题,后来把每个会话的内存池上限调小才解决。
2.3 版本匹配这张表比什么都重要
这套方案里最容易翻车的地方不是代码,是版本。ONNX Runtime 的 CUDA 版本、CUDA Toolkit 版本、显卡驱动版本、cuDNN 版本,这四者之间必须严格匹配,错一个就可能编译不过或者运行时报错。我见过太多人卡在这一步,代码没问题,就是跑不起来,最后发现是 cuDNN 版本对不上。
下面这张表是我整理的经验对照,具体以你下载的 ONNX Runtime 版本对应的官方说明为准,这里给的是常见组合的参考:
| 组件 | 常见匹配组合 A | 常见匹配组合 B | 说明 |
|---|---|---|---|
| ONNX Runtime | 1.16.x | 1.17.x | 选 GPU 版本,不是 CPU 版本 |
| CUDA Toolkit | 11.8 | 12.2 | 必须与 ORT 编译时用的版本一致 |
| cuDNN | 8.9.x | 8.9.x 或更高 | 大版本要对,小版本尽量贴近 |
| 显卡驱动 | 满足 CUDA 最低要求 | 满足 CUDA 最低要求 | 驱动版本要够新 |
| CMake | 3.20 以上 | 3.20 以上 | 低版本可能找不到 CUDA |
我的建议是:先去 ONNX Runtime 的发布页面确认你要下载的那个版本是用哪个 CUDA 版本编译的,然后 CUDA Toolkit 和 cuDNN 就照着这个版本来装,不要自己发挥。显卡驱动装最新的稳定版基本都能向下兼容。这套组合确定下来之后,整个项目的依赖就锁死了,后面不要再随意升级其中任何一个。
3. 环境搭建与模型导出的实操细节
3.1 模型导出这一步的坑比想象中多
导出命令本身不复杂,在 YOLOv5 的仓库目录下执行类似这样的命令:
python export.py --weights yolov5s.pt --include onnx --img 640 --batch 1 --opset 12但这里面有几个参数值得掰开说。--img 640指定输入分辨率,这个值必须和 C++ 端预处理时的 resize 尺寸完全一致,否则检测框坐标全错。--batch 1是导出单批次推理的模型,部署场景通常一次处理一帧,用 batch 1 最省显存也最简单。--opset 12是 ONNX 的算子集版本,选太低可能不支持某些算子,选太高可能 ONNX Runtime 版本不支持,12 是个比较稳妥的选择,具体要看你的 ORT 版本支持到哪个 opset。
导出完成后,建议用 Netron 这个工具打开生成的 .onnx 文件看一眼计算图结构。重点确认两件事:输入节点的名字和形状,输出节点的名字和形状。YOLOv5 导出的模型输入通常叫images,形状是[1, 3, 640, 640],输出通常是三个,名字类似output、350、361这种,形状分别是[1, 25200, 85]或者三个不同尺度的特征图。这些名字在 C++ 代码里要用到,写错了就取不到数据。
注意:不同版本的 YOLOv5 导出的输出节点名字可能不一样,甚至同一版本在不同环境下导出也可能有差异。不要照抄网上的代码里的节点名,一定用 Netron 打开你自己的模型确认。
还有一个容易忽略的点:导出时如果模型里有动态维度(比如 batch 维度标成了 dynamic),C++ 端创建输入张量时就要对应处理。我一般导出时就把形状固定死,避免动态维度带来的额外复杂度。如果确实需要动态 batch,那在 C++ 里创建张量时要把维度信息传对。
3.2 C++ 工程依赖的引入方式
ONNX Runtime 的 C++ 使用需要两个东西:头文件目录和库文件。从官方发布页下载的 GPU 版本压缩包里,include 目录下是头文件,lib 目录下是 onnxruntime.lib(Windows)或 libonnxruntime.so(Linux)。CUDA 版本还需要链接 CUDA 的运行时库。
用 CMake 管理的话,核心的链接配置大概是这样:
find_package(CUDA REQUIRED) include_directories(${ONNXRUNTIME_ROOT}/include) target_link_libraries(your_target ${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib ${CUDA_LIBRARIES} cudart )Linux 下把 .lib 换成 .so,路径对应调整。这里有个实操心得:ONNX Runtime 的 GPU 包在运行时还需要能找到 CUDA 和 cuDNN 的动态库,Windows 下要把对应的 dll 放到可执行文件同目录或者加进 PATH,Linux 下要确保 ldconfig 能找到或者设置 LD_LIBRARY_PATH。我习惯在项目里建个 third_party 目录,把 ORT 的库和 CUDA 相关的 dll/so 都放进去,构建脚本里统一处理拷贝,这样换机器部署时不会因为找不到库而崩。
另外提醒一句,ONNX Runtime 的 GPU 版本和 CPU 版本是分开的包,下载的时候别下错了。GPU 包体积大很多,里面包含了 CUDA EP 的实现。如果你下的是 CPU 包,代码里指定 CUDA EP 会直接报错说找不到该提供器。
3.3 验证环境是否真的通了
在写完整推理代码之前,我强烈建议先写一个最小验证程序:加载模型、创建会话、打印输入输出节点信息、跑一次空推理。这一步能帮你把环境问题提前暴露出来,而不是等到几百行代码写完才发现库没链对。
最小验证的核心逻辑是创建环境、设置会话选项、加载模型、查询输入输出信息。如果这一步能跑通,说明库链接、CUDA 运行时、模型文件都没问题。跑不通的话,报错信息通常会告诉你缺什么,按提示补就行。我遇到过最常见的是 cuDNN 版本不匹配导致的加载失败,报错信息里会提到某个 dll 加载不了,这时候去核对版本表基本就能定位。
4. 核心代码实现:从预处理到后处理的全链路
4.1 会话创建与 CUDA 执行提供器的配置
创建推理会话是整个流程的起点。核心是构造Ort::SessionOptions,往里面添加 CUDA 执行提供器,然后加载模型。代码骨架大概是这样:
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolov5"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(1); session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); OrtCUDAProviderOptions cuda_options; cuda_options.device_id = 0; cuda_options.arena_extend_strategy = 0; cuda_options.gpu_mem_limit = 2ULL * 1024 * 1024 * 1024; cuda_options.cudnn_conv_algo_search = OrtCudnnConvAlgoSearchExhaustive; session_options.AppendExecutionProvider_CUDA(cuda_options); Ort::Session session(env, model_path, session_options);这里几个参数值得解释。device_id指定用哪块 GPU,多卡机器上要选对。gpu_mem_limit是显存池上限,我设成 2GB,对于 yolov5s 这个体量绰绰有余,设太小可能不够用,设太大又浪费。cudnn_conv_algo_search设为 exhaustive 会让 cuDNN 在首次推理时穷举卷积算法找最快的,代价是首次推理慢一点,但后续每次都快,适合长期运行的服务。如果是一次性推理或者对启动时间敏感,可以改成 heuristic。
SetIntraOpNumThreads(1)这个设置是因为计算主要交给 GPU 了,CPU 端的线程数不需要多,设成 1 减少线程调度开销。如果你的模型里有部分算子回退到 CPU 执行,可以适当调大。
提示:会话对象和环境的生命周期要管理好。Ort::Env 通常全局一个就够,Ort::Session 可以按模型创建多个。注意 Session 必须在 Env 之后销毁,否则可能出问题。用智能指针或者注意作用域顺序。
4.2 图像预处理:letterbox 是绕不开的关键
YOLOv5 训练时用的是 letterbox 预处理,也就是保持宽高比缩放图像,不足的部分用灰色填充到目标尺寸。这一步在 C++ 端必须严格复现,否则检测框位置会偏。很多人在这里翻车,用普通的 resize 直接拉伸,结果模型输出的框和实际物体对不上。
letterbox 的逻辑是:计算缩放比例,取长边缩放到 640 的比例,短边按同比例缩放,然后计算需要填充的像素数,上下或左右均匀填充。填充值 YOLOv5 用的是 114(灰色)。缩放后的图像要转成 RGB 通道顺序(OpenCV 默认读进来是 BGR),归一化到 0 到 1 之间,然后按 CHW 排列(通道在前),最后组装成 float 数组。
这里有个性能细节:预处理在 CPU 上做,如果每帧都做完整的 resize 加填充加归一化,开销不小。我的做法是用 OpenCV 的cv::resize配合cv::copyMakeBorder完成缩放和填充,然后用cv::dnn::blobFromImage一步完成归一化和 CHW 转换,这个函数内部做了优化,比手写循环快不少。实测 640x640 的预处理在普通 CPU 上单帧一两毫秒,可以接受。
预处理完成后,需要把数据拷贝进 ONNX Runtime 的输入张量。这里要注意张量的形状和数据类型必须和模型输入完全一致。创建输入张量的代码大致是:
std::array<int64_t, 4> input_shape{1, 3, 640, 640}; auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, blob_data.data(), blob_data.size(), input_shape.data(), input_shape.size());数据从 CPU 内存到 GPU 显存的拷贝由 ONNX Runtime 内部处理,你不需要手动调 cudaMemcpy,这是用 ORT 的好处之一。
4.3 推理执行与输出解析
推理调用本身很简单,一行session.Run(...)就搞定。但输出解析是重头戏。前面说过,不带 NMS 导出的模型输出是三个尺度的特征图,形状分别是[1, 3, 80, 80, 85]、[1, 3, 40, 40, 85]、[1, 3, 20, 20, 85](以 640 输入、80 类为例)。每个格子的 85 个值里,前 4 个是框的坐标偏移,第 5 个是置信度,后面 80 个是类别概率。
解码的逻辑是:对每个尺度的每个格子,根据预设的 anchor 和格子的位置,还原出框的中心点和宽高,然后乘以置信度得到每个类别的分数,过滤掉低于阈值的,剩下的做 NMS。这部分代码量不小,但逻辑是固定的。我建议把解码和 NMS 封装成独立函数,输入是三个尺度的原始输出和原始图像尺寸,输出是检测框列表。
坐标还原有个容易错的地方:模型输出的框坐标是相对于 640x640 输入图像的,要映射回原始图像尺寸,需要减去 letterbox 的填充偏移,再除以缩放比例。如果这一步算错,框的位置就会整体偏移。我的经验是把 letterbox 时的缩放比例和填充偏移记录下来,解码时用同一组参数反算,这样不会出错。
NMS 的实现用 OpenCV 的cv::dnn::NMSBoxes就行,不用自己写。传入框列表、分数列表、分数阈值和 IoU 阈值,它会返回保留的索引。注意这个函数要求框的格式是[x, y, w, h],如果你的框是[x1, y1, x2, y2]要先转换。
4.4 内存管理与性能优化的几个实招
推理循环里最容易出性能问题的地方是内存的反复申请释放。每帧都创建新的输入张量、新的输出向量,虽然 ORT 内部有内存池,但外层的 vector 反复分配还是有开销。我的做法是预分配好输入输出的缓冲区,循环里复用。输入 blob 的 vector 在初始化时 reserve 好大小,每帧只覆盖数据不重新分配。
另一个优化点是输出张量的获取方式。session.Run返回的是std::vector<Ort::Value>,每次调用都会构造新的对象。如果对性能极致敏感,可以用Ort::IoBinding把输入输出绑定到固定的显存地址,减少拷贝。不过 IoBinding 的使用复杂度高一些,对大多数场景,普通的 Run 调用已经够快,先跑通再优化。
实测数据供参考:在一块中端独立显卡上,yolov5s 模型 640 输入,单帧推理(不含预处理和后处理)大约在 5 到 10 毫秒量级,预处理加后处理在 CPU 上大约 3 到 5 毫秒,整体单帧十几毫秒,能跑到几十帧每秒。具体数字因硬件而异,这里只是给个量级参考。如果你的场景对延迟要求极高,可以考虑把后处理也搬到 GPU 上,或者用 TensorRT EP 进一步压榨性能。
5. 常见问题排查与避坑经验实录
5.1 加载模型就报错的几种典型情况
第一种,报错说找不到 CUDA 执行提供器。这通常是因为你链接的是 CPU 版本的 ONNX Runtime 库,或者 GPU 版本的库没放对位置。检查方法很简单,看 lib 目录下的库文件名,GPU 版本通常带 gpu 字样或者体积明显更大。确认下载的是 GPU 包,并且链接路径指向正确。
第二种,报错提到 cuDNN 相关的 dll 加载失败。这就是版本不匹配的典型症状。去核对 ONNX Runtime 版本对应的 cuDNN 版本要求,重新下载对应版本的 cuDNN,把 dll 放到可执行文件能找到的路径。Windows 下常见的是把 cudnn 的 dll 复制到 exe 同目录,Linux 下确认 LD_LIBRARY_PATH 包含 cuDNN 的 lib 目录。
第三种,模型加载成功但推理时报形状不匹配。这多半是输入张量的形状和模型期望的不一致。用 Netron 确认模型的输入形状,检查 C++ 里创建张量时传的 shape 数组是否一致。特别注意 batch 维度,模型如果是固定 batch 1,你传了别的值就会报错。
5.2 检测结果不对怎么一步步定位
检测框位置偏移或者检测不到东西,排查要分层次。先确认预处理是否和训练时一致,重点是 letterbox 的填充值和缩放逻辑。可以拿一张测试图,在 Python 里用官方代码跑一遍,记录输出的框坐标,然后在 C++ 里跑同一张图,对比中间结果。如果预处理后的 blob 数据就有差异,问题在预处理;如果 blob 一致但输出不同,问题在推理或后处理。
坐标映射错误是最常见的。检查解码时用的缩放比例和填充偏移是不是 letterbox 时记录的那组值,检查有没有把相对坐标正确映射回原图。我踩过一次坑,填充偏移算的时候用了整数除法导致精度丢失,框整体偏了几个像素,后来改成浮点运算就好了。
类别标签错乱的话,检查类别顺序。YOLOv5 输出的类别概率顺序和训练时的类别列表顺序一致,如果你的 C++ 端维护的标签列表顺序不对,就会出现框对了但标签错了的情况。这个错误很隐蔽,因为框看起来是对的,容易忽略。
5.3 性能不达预期的调优方向
如果推理速度比预期慢很多,先确认 CUDA EP 真的生效了。可以在创建会话后打印一下会话的提供器列表,或者在推理时用 GPU 监控工具看显卡有没有负载。如果显卡没动静,说明算子都跑在 CPU 上了,检查 CUDA EP 是否成功添加。
确认 CUDA 生效后,看瓶颈在哪。用计时器分别统计预处理、推理、后处理三段耗时。如果推理占大头,考虑换更小的模型(yolov5n 或 yolov5s)、降低输入分辨率、或者用 TensorRT EP。如果预处理占大头,优化 resize 的实现,或者考虑用 GPU 做预处理。如果后处理占大头,检查 NMS 的输入框数量是不是太多,可以在 NMS 之前先做一轮粗过滤减少候选框。
还有一个容易被忽略的点:首次推理会包含 cuDNN 算法搜索和显存分配的开销,耗时明显高于后续推理。做性能测试时要先跑几帧预热,统计稳定后的耗时,不要把首帧算进去。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 加载模型报找不到 CUDA EP | 用了 CPU 版库或库路径错 | 确认下载 GPU 包,检查链接路径 |
| 报 cuDNN dll 加载失败 | cuDNN 版本不匹配 | 核对版本对照表,重装对应版本 |
| 推理报形状不匹配 | 输入张量形状与模型不符 | Netron 确认输入形状,检查 shape 数组 |
| 检测框整体偏移 | letterbox 参数不一致或坐标映射错 | 对比 Python 端中间结果,检查缩放和偏移计算 |
| 检测不到目标 | 置信度阈值过高或预处理错误 | 降低阈值测试,检查归一化和通道顺序 |
| 推理速度慢 | CUDA EP 未生效或模型过大 | 监控 GPU 负载,确认 EP 列表,考虑换小模型 |
| 显存不足报错 | 内存池上限设置过大或多模型并发 | 调小 gpu_mem_limit,错峰加载模型 |
| 首帧特别慢 | cuDNN 算法搜索开销 | 预热几帧后再计时,或改 heuristic 搜索 |
6. 工程化落地时值得多花心思的几个地方
6.1 把推理封装成可复用的类
一次性脚本和工程代码的区别在于可维护性。我习惯把整个推理流程封装成一个类,构造函数里完成环境、会话、输入输出信息的初始化,提供一个detect方法接收图像返回检测结果。这样上层业务代码不用关心 ONNX Runtime 的细节,换模型或者换引擎时改动范围可控。
类内部要管理好几个状态:输入输出节点的名字(用session.GetInputNameAllocated获取后存成字符串)、输入输出的形状、letterbox 的参数。这些在构造时确定一次,推理时直接用,避免每帧重复查询。节点名字的获取要注意 ONNX Runtime 的 API 返回的是分配的内存,需要正确释放,用Ort::AllocatorWithDefaultOptions配合GetInputNameAllocated是标准做法。
6.2 异常处理不能省
ONNX Runtime 的 C++ API 通过抛异常来报告错误,Ort::Exception携带了错误码和描述。工程代码里必须用 try-catch 包住可能出错的调用,尤其是会话创建和推理执行。捕获后记录日志,给出可读的错误信息,而不是让程序直接崩掉。我在一个长时间运行的服务里就因为没有捕获推理异常,某次显存波动导致整个进程退出,后来加上异常处理并做了重试逻辑才稳定。
资源释放也要注意。Ort::Session、Ort::Value 这些对象在析构时会释放底层资源,用 RAII 的方式管理最省心。避免手动 new/delete,用栈对象或者智能指针。特别是在循环里创建的对象,确保每轮结束能及时释放,不然显存会慢慢涨上去。
6.3 多线程场景下的注意事项
如果推理服务要处理并发请求,会话对象本身是线程安全的,多个线程可以共享同一个 Session 并发调用 Run。但要注意输入输出张量是每次调用独立的,不能共享。我的做法是每个线程维护自己的输入缓冲区和输出解析逻辑,共享 Session 和 Env。这样既利用了 GPU 的并行能力,又避免了数据竞争。
不过 GPU 的并行能力有限,并发线程数不是越多越好。实测在单卡上,并发数超过一定值后吞吐不再提升反而下降,因为 GPU 的计算单元被打满了,线程切换和显存竞争带来额外开销。具体的最佳并发数要压测确定,一般和显卡的计算单元数量相关。我通常从 2 到 4 开始试,找到吞吐拐点。
6.4 模型更新与版本管理
实际项目里模型会迭代,新版本模型替换旧版本时,要确保 C++ 端的预处理和后处理逻辑与新模型匹配。如果新模型改了输入分辨率或者类别数,代码里的硬编码参数就要同步改。我的做法是把这些参数抽成配置文件,模型路径、输入尺寸、类别数、阈值都从配置读,换模型时只改配置不改代码。这样降低了出错概率,也方便做 A/B 测试。
另外建议在加载模型时校验一下输入输出的形状是否符合预期,不符合就报错退出,而不是带着错误的假设继续跑。这个校验逻辑很简单,但能避免很多莫名其妙的检测异常。
7. 我个人在实际操作中的几点体会
这套方案我从最早的 ONNX Runtime 1.8 版本用到现在,中间经历过好几次版本升级带来的适配问题,也踩过 cuDNN 版本不匹配、letterbox 参数算错、CUDA EP 没生效这些坑。回过头看,最耗时间的往往不是写代码,而是环境配置和结果对齐。所以我现在做新项目,第一步永远是搭最小验证程序确认环境通了,第二步是用同一张图对比 Python 和 C++ 的中间结果确认预处理一致,这两步做扎实了,后面的工作会顺很多。
性能方面,不要一上来就追求极致。先把功能跑通,用计时器找到真正的瓶颈,再针对性地优化。很多时候瓶颈不在推理本身,而在数据拷贝或者预处理。我见过有人花大力气换 TensorRT,结果发现预处理占了七成时间,换了引擎提升有限。先测量再优化,这个原则在部署场景里尤其重要。
最后分享一个小技巧:调试检测结果时,把预处理后的图像和检测框画出来保存成图片,肉眼对比 Python 端的结果,比盯着数字看坐标高效得多。很多偏移和缩放的问题,画出来一眼就能看出来。这个习惯帮我省了大量排查时间。