简介:面向工业X射线检测、安检或工业缺陷检测等需要低延迟推理的视觉系统,这份C++部署工程以YOLOv8与TensorRT为主线,覆盖ONNX模型转换、TensorRT引擎构建、推理代码实现与性能调优完整流程。压缩包共85个文件,核心代码由4个cpp源文件、9个h头文件及1个hpp公共头文件组成;同时包含13个tlog日志、24个ipch预编译缓存、4个obj对象文件等构建中间产物,记录详细编译过程;另有测试图片和sln/vcxproj工程文件,整体约379.2MB,便于在Visual Studio中直接打开研读。资源源自zy_Xray_inspection项目,重点展示多尺度检测与SPP-Block结构如何在TensorRT加速管线中落地,提供从模型权重到Engine的转换脚本、C++推理实现和X射线图像测试样本,结合具体代码可直观理解内存分配、GPU显存拷贝与后处理逻辑,能帮助开发者掌握工程化部署关键环节。已有3356人学习这份资源,对正在研究TensorRT加速YOLOv8或需要相似部署参考的开发者,这套文件具有直接参考与复用价值,适合具备C++和深度学习基础的中高级开发者深入研读;压缩包内还包含result.png等输出示例,便于对照验证运行效果。

1. 为什么 yolov8 部署要落到 TensorRT 和 C++

用 conda 里的 Python 环境跑通 yolov8 训练和推理,只是完成了模型侧的一半工作。真正到了产线、实验设备、边缘盒子这些场景,模型要跑在别人没有 Anaconda、没有 PyTorch、甚至没有 GPU 驱动之外的任何依赖的机器上。yolov8 的 PyTorch 推理链路里,一次 forward 会带上 Python 解释器开销、torch 的 dispatch 开销和动态图机制,单帧延迟很难压进 5ms 以内。换成 TensorRT 做模型优化,再通过 C++ 直接调用 engine 做推理,能绕开 Python 的解释执行,把部署包收敛成一个可执行文件加一个 .engine 模型文件。这是目前 yolov8 落地到工业相机、自动驾驶预控制器、RK3588 这类嵌入式平台最常见的工程路径,也是本篇文章要解决的核心问题。本文适合已经用 yolov8 训练过自己的数据集,但现在卡在“模型怎么从 Python 工程变成 C++ 可调用产物”这一步的开发者。你需要具备基础的 C++ 语法、CMake 使用经验和一点 CUDA 概念,不需要会写 kernel。

2. TensorRT 优化 yolov8 的核心路径与运行时约束

2.1 TensorRT 对 yolov8 做了什么

TensorRT 本质上是 NVIDIA 提供的神经网络推理优化器,它把训练框架导出的模型做层融合、精度校准和内核自动调优,然后生成一个针对具体 GPU 架构优化的推理引擎。yolov8 的检测头包含多个卷积层和输出分支,整体结构不复杂,但 PyTorch 默认生成的网络描述文件存在大量冗余计算单元。

TensorRT 对 yolov8 的优化手段有三个层面。第一是层融合,把 Conv + BN + SiLU 这种固定组合融合成单一算子,减少 kernel 启动次数;第二是精度校准,把 FP32 权重降到 FP16 或 INT8,在基本不掉点的前提下减少显存带宽压力;第三是显存复用,推理过程中的中间特征图不需要反复申请释放显存,engine 构建时已经规划好完整的显存池。

C++ 推理相比 Python 端推理,省掉的不是模型本身的计算量,而是每一层算子调度之间的宿主端开销。Python 每跑一个算子就要经过一次 pybind 转换,C++ 直接调用 TensorRT 的 C API,一次 forward 可能快 20%-40%。

2.2 C++ 部署时 TensorRT 版本与 GPU 架构的匹配

部署第一步不是写代码,而是确认你的 GPU 架构和 TensorRT 版本能对上。GTX 1070 属于 Pascal 架构,计算能力是 6.1;GTX 1660 Ti 是 Turing 架构,计算能力 7.5;RTX 3090/4070 是 Ampere 架构(8.6);RTX 5070 是 Blackwell 架构(12.0)。TensorRT 官方对不同架构的支持范围有明确差异。

GPU 架构 计算能力 TensorRT 8.x 支持 TensorRT 10.x 支持 常见问题
Pascal 6.0/6.1 支持 部分支持 10.x 老卡驱动报 unsupported
Turing 7.5 支持 支持 主要支持单位
Ampere 8.0/8.6/8.9 支持 支持 生产环境最稳妥
Ada Lovelace 8.9 支持 支持 需要较新驱动
Blackwell 10.x/12.0 不支持 支持 需 RTX 50 系专用版本
TensorRT 版本 配套 CUDA 配套 cuDNN 适用场景
8.5.3 CUDA 11.8 cuDNN 8.6 老项目、嵌入式设备
8.6.1 CUDA 12.0 cuDNN 8.9 yolov8 经典组合
10.x CUDA 12.x cuDNN 9.x 新卡必须用

我一般在部署机上先执行 nvidia-smi 查看驱动版本,再用 nvcc --version 确认 CUDA 工具包版本,然后选择匹配的 TensorRT。注意 nvidia-smi 显示的 CUDA Version 是驱动支持的最高版本,不等于你机器上装了对应版本的 CUDA toolkit,这点最容易看错。

nvidia-smi --query-gpu=name,compute_cap,driver_version --format=csv
nvcc --version

这两个命令决定了你后续装哪个版本的 TensorRT。如果 GPU 是 GTX 1070 且计划用 TensorRT 10.x,需要先确认官方是否还提供对应计算能力 6.1 的预编译库,通常的做法是退回 TensorRT 8.6 + CUDA 11.8 组合。对于 RTX 5070 这类新卡,8.x 跑不了,必须装最新版 TensorRT 10 及以上,并且 PyTorch 侧导出 ONNX 的算子也要同步升级,否则后处理算子会导出失败。

2.3 TensorRT 推理的标准运行时状态机

TensorRT 的 C++ 推理有一个固定的对象生命周期,写这个流程的顺序错了,程序不是崩溃就是显存泄漏。

nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(modelData, modelSize);
nvinfer1::IExecutionContext* context = engine->createExecutionContext();

runtime 负责把 engine 文件装载进显存, engine 保存网络结构和权重, context 是每次推理的实际执行环境。同一个 engine 可以创建多个 context 来并发推理,但一个 context 同一时间只能执行一次 enqueueV3 。需要注意的是 deserializeCudaEngine 传入的是序列化后的 engine 字节流,不是 ONNX 文件,也不是权重文件。构建 engine 的时机一般在部署机上预先完成,运行机上只加载。

每次推理时先 context->setTensorAddress 绑定输入输出缓冲区的显存地址,然后调用 context->enqueueV3(stream) 把算子发射到 GPU 流里。从名字上也能看出来, enqueueV3 只是入队,真正的计算要等 cudaStreamSynchronize(stream) 执行完毕后才能读结果。这一点和 Python 端 torch.cuda.synchronize() 对应。

3. 从 yolov8 的 pytorch 权重导出 onnx 再到生成 engine

3.1 用 export.py 导出 ONNX 的参数怎么选

yolov8 官方仓库提供了 torch 到 ONNX 的导出脚本,核心命令如下,一般在训练环境(conda)中执行。使用 Python 环境跑导出,是为了借用 torch 的算子集做一次计算图固化。

yolo export model=yolov8s.pt format=onnx opset=12 half=False dynamic=True simplify=True

这条命令会读取 yolov8s.pt ,导出 yolov8s.onnx 。参数维度: opset=12 控制导出的算子版本,TensorRT 对低版本算子兼容性更好,如果 opset 太高,TensorRT 可能找不到对应实现; half=False 表示先导成 FP32 的 ONNX,不要直接导 FP16,engine 构建阶段的精度选择不完全等于权重精度; dynamic=True 允许 batch size 维度动态变化,TensorRT 侧用优化 profile 约束。

疑 opset 是否必须固定在 12?如果你用的 PyTorch 版本较新,可能默认导出 17 或 18,TensorRT 10.x 也能解析,但 torch 新版本导出的一些张量切片算子还是可能不支持。常见做法是先生成 opset=12 试试,如果导出失败再调高,而旧卡 6.1 平台建议就锁死 12。

3.2 用 trtexec 一键生成 tensorRT 引擎

TensorRT 安装包自带一个命令行工具 trtexec ,不需要写 C++ 构建代码就能验证 ONNX 能否转成 engine,这一步能过滤 80% 的模型层不兼容问题。

/opt/tensorrt/bin/trtexec \
  --onnx=yolov8s.onnx \
  --saveEngine=yolov8s.engine \
  --fp16 \
  --minShapes=images:1x3x640x640 \
  --optShapes=images:1x3x640x640 \
  --maxShapes=images:4x3x640x640 \
  --workspace=2048

--fp16 让 engine 使用半精度推理,yolov8 的检测头对精度变化不敏感,可以放心加。 --minShapes/optShapes/maxShapes 是动态输入维度三元组, images 需要和 ONNX 里实际输入节点名一致,可以用 trtexec --onnx=yolov8s.onnx --dumpProfile 先看节点名。 --workspace 是构建时显存上限,单位 MB。构建 engine 是个非常吃显存的过程,一张 8GB 显存的卡开 2GB workspace 足够,开太高可能直接 OOM。

3.3 C++ 侧直接构建 engine 的替代方案

生产环境里不一定有命令行工具,更常见的做法是把 ONNX 解析放进 C++ 程序里,调用 TensorRT 的 onnx parser 完成构建。核心流程是先创建 builder,再用 parser 解析 ONNX,把网络结构填充到 builder 配置里。

nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
const auto explicitBatch = 1U << static_cast<uint32_t>(nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
nvonnxparser::IParser* parser = nvonnxparser::createParser(*network, logger);
parser->parseFromFile("yolov8s.onnx", 1);

nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30);
config->setFlag(nvinfer1::BuilderFlag::kFP16);
nvinfer1::IHostMemory* serialized = builder->buildSerializedNetwork(*network, *config);

这段代码的核心是 createNetworkV2 里的 kEXPLICIT_BATCH 标志。yolov8 导出 ONNX 时如果带动态维度,必须显式开启这个标志,否则维度会被固定成 1。 buildSerializedNetwork 直接把构建好的 engine 序列化到内存,你可以把它写进文件,或者直接用字节数组部署。构建阶段和推理阶段分离是最佳工程实践,运行设备上永远不要放 ONNX parser,那是徒增体积和风险。

3.4 engine 序列化文件与运行环境的架构绑定

生成 engine 文件后要记住一个铁律:engine 是绑定 GPU 架构的二进制产物。在 RTX 3090 上用 trtexec 生成的 engine,直接拷贝到 GTX 1070 的机器上加载会报 invalid engine ,因为 TensorRT 构建时针对 SM 版本做了指令集优化,生成的 kernel 只对目标架构有效。

运行环境变化 engine 是否可用 处理方式
同 GPU,换 TensorRT 版本 不可用 用对应版本的 trtexec 重新构建
同 TensorRT,换 GPU 型号 不可用 将 build 阶段的 numa 信息清除后重建
从 FP32 构建到 FP16 部署 不可用 重新配置 builder flag 再序列化
动态 batch 需要 8 但只构建到 4 可加载但不可用 重新设置 maxShapes

你需要构建几个 engine 就准备几套构建产物 CI 脚本,把 engine 生成纳入部署流程,而不只是拷贝文件。

4. C++ 工程里加载 engine 并完成一次完整推理

4.1 最小工程目录与 CMakeLists 的配置方法

C++ 部署 yolov8 的工程结构不需要复杂,一个推理类加一个 main 函数就够了,但目录划分最好一开始就清晰。

yolov8_trt/
├── CMakeLists.txt
├── src/
│   ├── yolov8_trt.hpp
│   ├── yolov8_trt.cpp
│   └── main.cpp
└── models/
    ├── yolov8s.engine
    └── labels.txt

yolov8_trt.hpp 定义推理类对外暴露的接口, main.cpp 只负责读图片、调推理、画框。关键在 CMakeLists 里的依赖查找,TensorRT 安装后头文件不在系统默认搜索路径,必须手动指定。

cmake_minimum_required(VERSION 3.18)
project(yolov8_trt)

find_package(CUDA REQUIRED)
include_directories(/opt/tensorrt/include /usr/local/cuda/include)
link_directories(/opt/tensorrt/lib /usr/local/cuda/lib64)

find_library(TENSORRT_LIB nvinfer)
find_library(ONNX_PARSER_LIB nvonnxparser)

add_executable(run_infer src/main.cpp src/yolov8_trt.cpp)
target_link_libraries(run_infer
  ${TENSORRT_LIB}
  ${ONNX_PARSER_LIB}
  ${CUDA_LIBRARIES}
  opencv_core opencv_imgproc opencv_imgcodecs)

find_library 比 find_package 直白,避免 TensorRT 官方 CMake 配置文件没被正确识别时卡住。OpenCV 用于图像读取和解析,注意 imread 出来的是 BGR 顺序,yolov8 训练时是 RGB,转换顺序错了检测结果会错乱。链库的顺序有讲究,nvinfer 依赖于 cudart,所以 CUDA 库写在 TensorRT 后面。

4.2 定义输入输出 Buffer 与内存分配

推理类初始化时要做的第一件事是读取 engine 文件到内存,然后通过 engine->getNbIOTensors() 获取输入输出张量数量。yolov8s 的 ONNX 通常有 1 个输入 images 和 1 个输出 /output0 ,但有些自定义导出会带上 NMS 层,输出可能变成 4 个,注意核对。

void Yolov8TRT::loadEngine(const std::string& enginePath) {
    std::ifstream file(enginePath, std::ios::binary);
    std::vector<char> data((std::istreambuf_iterator<char>(file)),
                            std::istreambuf_iterator<char>());

    runtime = nvinfer1::createInferRuntime(logger);
    engine = runtime->deserializeCudaEngine(data.data(), data.size());
    context = engine->createExecutionContext();

    for (int i = 0; i < engine->getNbIOTensors(); ++i) {
        auto name = engine->getIOTensorName(i);
        auto dims = engine->getTensorShape(name);
        auto type = engine->getTensorDataType(name);
        size_t vol = 1;
        for (int d = 0; d < dims.nbDims; ++d) vol *= dims.d[d];
        if (engine->getTensorIOMode(name) == nvinfer1::TensorIOMode::kINPUT) {
            inputSize = vol * 4;
            cudaMalloc(&inputBuf, inputSize);
            context->setTensorAddress(name, inputBuf);
        } else {
            outputSize = vol * 4;
            cudaMalloc(&outputBuf, outputSize);
            context->setTensorAddress(name, outputBuf);
        }
    }
}

vol * 4 里的 4 是 FP32 的字节数。输出维度是 1x84x8400, vol 算出来正好是 705600,乘以 4 就是 2.8MB 显存。 setTensorAddress 在 TensorRT 8.5 之后是推荐的绑定方式,替代老 API 里的 setBindingDimensions 和 enqueue 的 index 传参。准备完显存后还需要 cudaStreamCreate 创建推理流,不要用默认流,多流能提升并发场景的利用率。

4.3 推理循环的数据搬运与执行

推理循环的逻辑分为三步:数据拷入 GPU、执行 engine、结果拷回 CPU。yolov8 的输入数据是一个归一化后的 NCHW 张量,从 OpenCV 的 Mat 转换来的时候要注意, cudaMemcpyAsync 只负责搬运,不做通道变换和归一化。

bool Yolov8TRT::infer(cv::Mat& image, std::vector<Detection>& detections) {
    cv::Mat letterboxed;
    float scale = letterbox(image, letterboxed, 640, 640);
    letterboxed.convertTo(letterboxed, CV_32FC3, 1.0f / 255.0f);

    std::vector<cv::Mat> channels(3);
    cv::split(letterboxed, channels);
    std::vector<float> inputData(3 * 640 * 640);
    for (int c = 0; c < 3; ++c)
        memcpy(inputData.data() + c * 640 * 640,
               channels[c].data, 640 * 640 * sizeof(float));

    cudaMemcpyAsync(inputBuf, inputData.data(), inputSize,
                    cudaMemcpyHostToDevice, stream);
    context->enqueueV3(stream);
    cudaStreamSynchronize(stream);
    cudaMemcpyAsync(outputHost.data(), outputBuf, outputSize,
                    cudaMemcpyDeviceToHost, stream);
    cudaStreamSynchronize(stream);

    float* out = outputHost.data();
    decodeOutputs(out, scale, detections);
    return true;
}

inputData 的组织顺序是 NCHW,即先将 640x640 的所有 R 通道数据连续存放,再存 G 通道和 B 通道,而不是 OpenCV 默认的 HWC 交错排列。 cv::split 在这里就是做通道分离,分离之后的每个 Mat 的数据区可以直接 memcpy 到对应通道偏移位置。 scale 是 letterbox 变换的比例系数,后处理还原坐标时要用到,别丢了。

5. Yolov8 输出解析与 nms 后处理在 C++ 中的实现

5.1 输出张量的排布与坐标还原

yolov8 的输出张量维度是 [1, 84, 8400],84 是 4 个框坐标加 80 个类别得分,8400 是三个尺度特征图展平后的锚点总数。C++ 里把这个三维数据读出来时必须搞清楚排列方式, /output0 实际的内存布局是先把 84 个通道的全部数据展开,再对应到 8400 个位置。

遍历每个锚点时,先找到它的前 4 个值作为 cx、cy、w、h,然后遍历从 4 到 83 的类别分数找最大值。注意这里的框坐标是模型在 640x640 输入下的绝对像素值,不是归一化数值,所以后处理时要把它们还原到原图尺寸。

for (int i = 0; i < 8400; ++i) {
    float* ptr = output + i * 84;
    float cx = ptr[0];
    float cy = ptr[1];
    float w = ptr[2];
    float h = ptr[3];

    float maxScore = 0;
    int classId = -1;
    for (int c = 4; c < 84; ++c) {
        if (ptr[c] > maxScore) {
            maxScore = ptr[c];
            classId = c - 4;
        }
    }
    if (maxScore < confThresh) continue;

    float x1 = (cx - w * 0.5f - padW) / scale;
    float y1 = (cy - h * 0.5f - padH) / scale;
    float x2 = (cx + w * 0.5f - padW) / scale;
    float y2 = (cy + h * 0.5f - padH) / scale;
    detections.push_back({x1, y1, x2, y2, maxScore, classId});
}

output + i * 84 的索引方式对应的是 [8400, 84] 的物理排布,所以写代码时先把它看作二维数组,别被三维张量的形状带偏。 padW 和 padH 是 letterbox 时在两侧补的像素数, scale 是等比缩放系数,这两个值必须和预处理时保持一致,否则框的位置会整体偏移。

5.2 NMS 计算与置信度阈值的选择

C++ 中实现 NMS 不需要引入额外依赖,基于面积交并比的排序过滤就够用。yolov8 在训练时用了 TAL 标签分配,输出的类别分数偏高,置信度阈值可以设置在 0.3-0.5 之间。如果检测框之间存在大量重叠,先按类别分组再分别做 NMS,能同时保留不同类别的重叠目标。

std::vector<Detection> nms(std::vector<Detection>& dets, float iouThresh) {
    std::sort(dets.begin(), dets.end(),
              [](const Detection& a, const Detection& b) { return a.conf > b.conf; });
    std::vector<bool> removed(dets.size(), false);
    std::vector<Detection> result;

    for (int i = 0; i < dets.size(); ++i) {
        if (removed[i]) continue;
        result.push_back(dets[i]);
        for (int j = i + 1; j < dets.size(); ++j) {
            if (removed[j]) continue;
            if (dets[i].classId != dets[j].classId) continue;
            float inter = intersectionArea(dets[i], dets[j]);
            float iou = inter / (area(dets[i]) + area(dets[j]) - inter);
            if (iou > iouThresh) removed[j] = true;
        }
    }
    return result;
}

iouThresh 一般设 0.45,目标密集场景可以调到 0.5。 intersectionArea 计算两个矩形左上角和右下角的最大最小值,注意坐标已经在原图上,不存在 letterbox 偏移问题。这段代码是串行版本,CPU 上处理 8400 个候选框大约耗时 0.5ms 到 1ms,对端到端延迟影响可接受。如果目标是跑多路视频流,可以把 NMS 放进多个线程里并行执行,每路各做各的过滤。

5.3 letterbox 与 opencv 预处理的精度对齐

C++ 侧 letterbox 是 yolov8 部署踩坑重灾区。Python 端的 letterbox 会用 cv2.resize 加灰色填充,C++ 这边要实现成完全一致的逻辑,否则模型输入分布变化导致精度剧降。

float letterbox(const cv::Mat& src, cv::Mat& dst, int targetSize) {
    float scale = std::min(targetSize * 1.0f / src.cols,
                           targetSize * 1.0f / src.rows);
    int newW = std::round(src.cols * scale);
    int newH = std::round(src.rows * scale);
    int padW = (targetSize - newW) / 2;
    int padH = (targetSize - newH) / 2;

    cv::Mat resized;
    cv::resize(src, resized, cv::Size(newW, newH), 0, 0, cv::INTER_LINEAR);
    cv::copyMakeBorder(resized, dst, padH, padH, padW, padW,
                       cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114));
    return scale;
}

缩放插值算法必须用 INTER_LINEAR ,Python 端导出 ONNX 时的预处理不一定影响推理,但运行时用 INTER_AREA 会带来高频率细节的明显损失。填充值 114 是 yolov8 训练时的默认 padding 值,不要改成 0,否则边缘区域会产生大量虚假激活值。

6. C++ 侧推理精度验证与性能排查技巧

6.1 用 python 推理结果当基准逐帧对比

engine 构建完成且 C++ 推理代码跑通后,第一件事不是测帧率,而是验证检测结果和 Python 端是不是一致的。常见做法是准备十张有代表性的图片,分别用 Python 端 PyTorch 或 TensorRT 推理保存检测框坐标,再用 C++ 部署程序跑同样图片,把结果对比出来。

坐标误差允许值一般设置成 2 个像素以内。超过这个值通常是预处理不一致,优先排查三个位置: cv::split 后的通道顺序、letterbox 的 padding 值是不是 114、输入数据的归一化系数是不是 1/255。若所有图的框都整体往右下偏移,大多是 padW 和 padH 没除以 scale;若只在物体边缘出现明显误检,多半是插值方式选错。

./run_infer --engine=yolov8s.engine --image=test.jpg --output=result.jpg

跑通后还可以在 GPU 上开启 cudaEvent 来测量推理耗时,区分预处理时间、engine 执行时间和 NMS 时间。用 nvidia-smi dmon -d 1 看显存占用,如果每次推理都会先涨后降,说明有显存分配泄漏,通常是部分网络分支的临时张量没有通过 TensorRT 的显存池管理。

6.2 engine 加载失败时的日志分级判断方法

TensorRT 的 ILogger 输出是排错第一依据。默认 logger 只打印错误信息,调试时把 severity 调成 kINFO ,能看到每层算子的形状推导和融合情况。

class Logger : public nvinfer1::ILogger {
    void log(Severity severity, const char* msg) noexcept override {
        if (severity != Severity::kINTERNAL_ERROR)
            std::cout << msg << std::endl;
    }
};

报 [E] 2: invalid engine 说明 engine 文件和当前 TensorRT 版本或 GPU 型号不匹配;报 [E] 4: unsafe mode 说明有层走的是 fallback 的插件实现,比如某些动态 Resize 算子,需要检查算子是否显式触发 kFP16 精度下的精度问题。日志出现 [W] Detected invalid timing 通常无害,但出现 [E] Could not find implementation 就得回退到 FP32 构建跑一遍确认是不是精度模式的问题。最后建议在程序入口把 TensorRT 版本打印出来, getInferLibVersion() 返回的是构建时版本,和运行时版本混淆排查起来很痛苦。

整个 C++ 部署链路里最值得花时间调试的是输出解析的维度理解,一旦 84 和 8400 两个数在代码里写反,检测框要么全部越界要么全部为空。建议在 decode 函数入口打印输出张量的前 16 个 float 值,对照 Python 端同一张图的输出做二分定位,能省下大半天的排查时间。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐