首页 / 资讯中心 / 文章详情

YOLOv5+TensorRT+C++低延迟推理服务完整实践

YOLOv5+TensorRT+C++低延迟推理服务完整实践 ★ FEATURED ARTICLE
我去年给一个工业质检项目做系统重构时遇到一个非常典型的场景已有的 PyTorch YOLOv5 模型在 GPU 上推理效果不错但一接到五六路实时视频流就顶不住延迟从 30ms 一路飙升到 300msCPU 也被 Python GIL 卡得死去活来。后来把整条链路换成了 YOLOv5 TensorRT C 的推理服务配合 HTTP 接口对外输出延迟压到个位数毫秒吞吐翻了不止一个量级。这篇文章就把这套方案的完整实践拆开讲清楚包括模型转换流程、TensorRT 引擎封装、C 服务端与客户端实现、常见性能瓶颈和大量踩坑记录适合正在做模型服务化落地、或者对低延迟目标检测有要求的开发者照着走完就能搭一套能扛压力的生产级推理服务。1. 整体思路与技术选型拆解先说结论一套推理服务的性能瓶颈往往不在 GPU 算力而在数据搬运、线程调度和协议开销这些容易被忽视的环节。Python 推理原型能跑通业务但到了并发场景会暴露 GIL 和解释器开销的问题。所以这套方案把模型推理和网络服务完全用 C 实现核心思路可以拆成三层来看。1.1 为什么必须是 TensorRTYOLOv5 的原生 PyTorch 模型直接上生产环境最大的问题是算子执行依赖 PyTorch 的调度框架每一个 op 都有额外开销。TensorRT 做的事情本质上是把计算图做融合和优化把很多小的 op 合并成大 kernel同时自动选择最优的 kernel 实现还为特定 GPU 生成专用的 CUDA 代码。实测数据很直观在同样的 GPU 上YOLOv5s 用 PyTorch FP16 推理单张 640x640 图像大约需要 12~15ms转换成 TensorRT FP16 引擎后可以压到 3~5ms。如果是更大的模型如 YOLOv5m优化幅度更明显因为模型参数和计算量更大算子融合的收益也更显著。另外 TensorRT 支持动态 shape 输入这让服务端能灵活处理不同分辨率的请求而不需要重新构建引擎这在生产环境中非常实用。有的业务方会送 1280x1280 的大图做小目标检测有的只传 640x640动态 shape 让同一个引擎能覆盖不同场景避免了维护多份引擎文件的麻烦。1.2 为什么用 C 而不是继续用 Python很多人觉得 Python 封装 TensorRT 也能用确实能用但性能天花板差一截。主要问题有三个第一Python 的 GIL 限制了多线程推理。TensorRT 的推理本身是异步的但是在 Python 里做异步处理需要释放 GIL实际操作的时候很容易出现锁竞争多路视频流场景下 CPU 占用高且不稳定。第二Python 的显存管理不够精细。TensorRT 的上下文和执行上下文绑定显存Python 侧的显存生命周期管理和 buffer 复用很难做到精细控制频繁申请释放显存会带来不小的开销。第三HTTP 服务和推理引擎耦合在一起时Python 的异步框架和 C 推理引擎之间的数据类型转换、内存拷贝成本很高。一次请求从收到图像数据到送入 GPU中间多几次拷贝延迟就上去了。C 的优势是可以直接控制 CUDA 流、显存池、线程亲和性还能把 HTTP server 和推理引擎放进同一个进程共享内存缓冲区真正做到零拷贝转发。这套方案我做下来单机单卡就能扛住几十路视频流同时请求延迟还稳定在 10ms 以内。1.3 HTTP 协议层的选择推理服务对外提供 HTTP 接口这是最常见的需求因为业务方大部分用 Python/Java/Go 写后端直接走 HTTP 最省事。HTTP 协议本身有开销但可以通过设计来降到最低使用长连接避免频繁 TCP 握手请求体和响应体都用二进制格式不使用 JSON 传图片数据服务端采用异步多线程模型不阻塞 IO 线程这里选型时对比过低层方案如 gRPC 和自研 TCP 协议gRPC 性能确实好但引入 protobuf 编译链对部分团队比较重HTTP二进制 body 的性价比最高兼容性也最好。核心架构图大概是这样的文字描述HTTP Client (发送图像二进制) ↓ C HTTP Server (接收请求) ↓ 内存池中暂存图像数据 ↓ TensorRT 引擎 (CUDA Stream 异步推理) ↓ 后处理 (NMS 坐标转换) ↓ HTTP Response (返回检测结果 JSON/二进制)这个链路里没有 Python 参与数据从 socket 到 GPU 显存之间只经过一次拷贝剩下的都是用指针和内存池操作这是性能优势的根本来源。2. 模型转换与 TensorRT 引擎构建有了整体设计现在进入实操环节。第一步工作就是把 PyTorch 的 YOLOv5 权重转换成 TensorRT 推理引擎。这条路线上有几种方案我推荐使用 ONNX 作为中间格式原因很简单可控性强、调试方便、不依赖某个特定仓库的封装。2.1 PyTorch → ONNX 导出细节YOLOv5 官方仓库自带 export.py但直接用它导出的 ONNX 往往不是最优的。核心问题在于 YOLOv5 的检测头里有大量循环和切片操作ONNX 导出后可能产生很多小算子后续转 TensorRT 时效率不高。我用的导出姿势是写一个单独的 Python 脚本不直接用官方的 export 逻辑。关键点如下import torch from models.experimental import attempt_load model attempt_load(yolov5s.pt, map_locationcpu) model.eval() # 关键指定动态输入维度 dummy_input torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolov5s.onnx, opset_version12, do_constant_foldingTrue, input_names[images], output_names[output], dynamic_axes{ images: {0: batch, 2: height, 3: width}, output: {0: batch} } )几个容易踩坑的点opset_version 不要太高opset 13 导出的某些算子 TensorRT 支持不完善opset 12 目前兼容性最好。dynamic_axes 必须加如果不加动态维度导出的 ONNX 会锁定成 1x3x640x640 的固定 shape后续想换分辨率就得重新转引擎。导出后一定要用 onnx-simplifier 简化这个工具能把计算图中的冗余 op 合并掉让 TensorRT 转换过程更顺畅最终引擎推理速度也有提升。python -m onnxsim yolov5s.onnx yolov5s_sim.onnx简化前后的模型大小差别不大但 node 数会明显变少TensorRT 构建时间缩短不少。2.2 ONNX → TensorRT 引擎构建与量化选择ONNX 转 TensorRT 有三种方式trtexec 命令行工具、Python API、C API。开发阶段用 Python API 验证最快但生产环境的最终引擎构建脚本我推荐用 C 或 trtexec 固化下来避免环境依赖问题。构建 FP16 引擎的核心代码C 版本节选核心逻辑nvonnxparser::IParser* parser nvonnxparser::createParser(*network, gLogger); parser-parseFromFile(yolov5s_sim.onnx, 1); // 配置构建选项 builder-setMaxBatchSize(1); // 后面再说 batch 怎么处理 builder-setMaxWorkspaceSize(1 30); // 1GB workspace // 开启 FP16 if (builder-platformHasFastFP16()) { config-setFlag(BuilderFlag::kFP16); } // 动态 shape 配置 config-setProfile(profile); profile-setDimensions(images, OptProfileSelector::kMIN, Dims4(1, 3, 640, 640)); profile-setDimensions(images, OptProfileSelector::kOPT, Dims4(8, 3, 640, 640)); profile-setDimensions(images, OptProfileSelector::kMAX, Dims4(16, 3, 640, 640)); // 构建引擎 IHostMemory* serializedModel builder-buildSerializedNetwork(*network, *config);这里解释几个关键选择背后的逻辑FP16 到底开不开如果 GPU 是 20 系列以上FP16 是性价比最高的优化手段推理速度提升几乎翻倍精度损失在 YOLOv5 这种检测任务里通常可以忽略。但要注意有些自定义 op 不支持 FP16如果转换时报错需要把这些 op 标记为 FP32。我的经验是 YOLOv5 原生的模型结构没有这种问题但如果你在模型里加了自定义模块比如某些注意力机制那就得检查一下。Workspace 到底设多大这个参数直接影响 TensorRT 能选用的 kernel 实现。设太小很多融合 kernel 无法使用设太大构建时 GPU 显存容易爆。我一般先设 1GB 试构建如果显存不够就调小到 512MB性能差异不大但稳定性会好很多。动态 shape 的 MIN/OPT/MAX 怎么选这三个值影响 TensorRT 做 kernel 选择的依据。MIN 和 MAX 定义了你将来实际推理时可能用到的 shape 范围OPT 是最常见的大小。我的建议是 MIN 用小分辨率如 320x320OPT 用最常见的 640x640MAX 设成你未来可能用到的最大分辨率如 1280x1280这样灵活性最好。2.3 Engine 序列化与反序列化构建好的引擎可以直接保存成二进制文件部署时加载不用每次重新构建。// 保存引擎 std::ofstream file(yolov5s_fp16.engine, std::ios::binary); file.write(static_castchar*(serializedModel-data()), serializedModel-size()); // 加载引擎 - 注意必须带 destroy 保护 std::ifstream file(yolov5s_fp16.engine, std::ios::binary); file.seekg(0, std::ios::end); size_t size file.tellg(); file.seekg(0, std::ios::beg); std::vectorchar data(size); file.read(data.data(), size); // 运行时反序列化 runtime createInferRuntime(gLogger); engine runtime-deserializeCudaEngine(data.data(), size);这里的顺序很关键TensorRT 引擎是跟具体 GPU 型号绑定的不同型号之间不能通用。同一型号但不同驱动/CUDA 版本也可能有问题。生产环境最好在目标机器上重新构建一次或者用与目标机器完全一致的环境构建。3. TensorRT C 推理引擎的完整封装引擎文件只是一个静态的权重图描述要真正跑起来还需要一个完整的执行上下文和显存管理策略。这部分封装做不好后面性能会大打折扣。3.1 推理类的核心设计我习惯把整个推理服务封装成一个类对外暴露最简单的接口内部做好 buffer 管理和流管理。class Yolov5TensorRT { public: Yolov5TensorRT(const std::string engine_path); ~Yolov5TensorRT(); // 对外推理接口输入图像数据输出检测结果 std::vectorDetection infer(const cv::Mat image); private: void allocateBuffers(); void preprocess(const cv::Mat image); void postprocess(float* output, int batch_size); nvinfer1::IRuntime* runtime_; nvinfer1::ICudaEngine* engine_; nvinfer1::IExecutionContext* context_; cudaStream_t stream_; // GPU 显存 buffer void* buffers_[2]; // 输入输出大小 int input_size_; int output_size_; int input_h_; int input_w_; };几个核心设计点需要展开讲显存 buffer 预分配而不是每次推理都申请。这一点特别重要。TensorRT 推理需要把输入数据放到 GPU 显存、输出数据从显存取回。如果每次都 cudaMalloc/cudaFree显存碎片化和分配开销会严重拖慢推理。我的做法是在引擎加载后一次性拿到所有输入输出 buffer 的大小然后用 cudaMalloc 分配好整个生命周期内复用。CUDA stream 必须自己创建。不用默认 stream。原因是默认 stream 是同步的多线程推理时会互相阻塞。创建独立的 stream推理可以真正异步执行CPU 线程可以去做图像预处理和后处理让 GPU 和 CPU 并行工作。输入数据的预处理必须在 GPU 上做。很多初学者把图像缩放、归一化、BGR→RGB 转换放在 CPU 上做然后用 cudaMemcpy 拷到显存这会在 CPU 端浪费大量时间。正确做法是把预处理写成 CUDA kernel直接在 GPU 上做把原始图像数据拷到显存后就直接在显存里完成所有预处理。3.2 CUDA Kernel 加速的预处理这里贴一个我自己写的预处理 kernel 的核心片段功能是把 OpenCV 的 BGR 图像转换成 TensorRT 需要的 RGB、归一化到 [0,1]、同时完成 letterbox resize__global__ void preprocess_kernel( uint8_t* src, int src_h, int src_w, float* dst, int dst_h, int dst_w, float ratio, float pad_h, float pad_w ) { int idx blockIdx.x * blockDim.x threadIdx.x; int total dst_h * dst_w; if (idx total) return; int dst_y idx / dst_w; int dst_x idx % dst_w; // 逆变换找到原图坐标 int src_x (dst_x - pad_w) * (1.0f / ratio); int src_y (dst_y - pad_h) * (1.0f / ratio); float r, g, b; if (src_x 0 src_x src_w src_y 0 src_y src_h) { // BGR - RGB 并归一化 int src_idx (src_y * src_w src_x) * 3; b src[src_idx] / 255.0f; g src[src_idx 1] / 255.0f; r src[src_idx 2] / 255.0f; } else { // 填充区域 r 114.0f / 255.0f; g 114.0f / 255.0f; b 114.0f / 255.0f; } // YOLOv5 输入是 NCHW 布局 dst[0 * dst_h * dst_w idx] r; dst[1 * dst_h * dst_w idx] g; dst[2 * dst_h * dst_w idx] b; }这个 kernel 的一个线程处理一个目标像素用逆变换的方式从原图采样。不需要像 OpenCV 的 resize 先生成一张中间图再转格式省掉一次内存读写和一次 kernel launch。实测这个 kernel 在 640x640 输入下只花 0.2ms 左右比 CPU 预处理的 3~4ms 快了一个数量级。3.3 推理执行与结果解析推理过程本身不复杂但有一些细节影响稳定性bool Yolov5TensorRT::infer(const cv::Mat image, std::vectorDetection results) { // 1. 把 OpenCV 图像拷贝到 GPU 显存固定分配过的 pinMemory 更高效 uint8_t* gpu_src gpu_src_buffer_; cudaMemcpyAsync(gpu_src, image.data, image.rows * image.cols * 3, cudaMemcpyHostToDevice, stream_); // 2. 执行预处理 CUDA kernel preprocess_kernelgrid_size, block_size, 0, stream_( gpu_src, image.rows, image.cols, (float*)buffers_[0], input_h_, input_w_, ratio, pad_h, pad_w ); // 3. 执行 TensorRT 推理 context_-enqueueV2(buffers_, stream_, nullptr); // 4. 把输出从显存拷回 CPU cudaMemcpyAsync(output_cpu_, buffers_[1], output_size_, cudaMemcpyDeviceToHost, stream_); // 5. 同步 cudaStreamSynchronize(stream_); // 6. 后处理 NMS postprocess(output_cpu_, results); return true; }输出 buffer 为什么每个请求都拷回 CPU有人觉得可以直接在 GPU 上做 NMS 避免拷贝但这会大大增加工程的复杂度。YOLOv5 的原始输出大小是 (batch, 25200, 85)其中 85 是 4 个坐标 1 个置信度 80 类分数这个数据量只有 25200 * 85 * 4 字节 ≈ 8.6MB 左右拷贝耗时不到 1ms。常规做法是拷回 CPU 再做 NMS性能损失可接受。NMS 后处理也要注意效率。Python 版本用 numpy 做 NMS 很顺手但 C 里如果用 std::sort 对 25200 个框排序再一个个遍历在低端 CPU 上可能要花 3~5ms。这里可以做一些简单的优化先用置信度阈值过滤一遍比如把低于 0.25 的框直接丢掉通常能过滤掉 90% 以上的框剩下的数量就很少了再用标准 NMS 就很轻松。4. HTTP 服务器与客户端的完整实现推理引擎封装好了接下来要做的是网络层。HTTP 服务器的设计直接影响服务的吞吐能力这里面的门道比想象中多。4.1 服务器选型Drogon 还是 Boost.BeastC 写 HTTP 服务器主流方案就两个Drogon 和 Boost.Beast。我两个都试过最终选的是 Drogon原因很实际Drogon 自带高性能 IO 协程框架接口更现代业务代码写起来更像 Flask 的风格上手成本低Drogon 支持 pipelining 和全异步处理单线程事件循环可以处理大量并发连接Boost.Beast 更底层灵活但需要自己管理连接生命周期、异步读写、buffer 生命周期工作量大了不少如果对 HTTP 服务器特别熟、团队有底层网络库经验Boost.Beast 可以做更极致的优化但普通业务团队我还是推荐 Drogon性能已经足够好开发效率高一截。4.2 服务端接口设计接口路径和格式设计如下POST /v1/detect Content-Type: application/octet-stream Body: 原始图像数据 (JPEG/PNG 编码的二进制) Response: { code: 0, detections: [ {x: 100, y: 150, w: 200, h: 300, score: 0.92, class_id: 0}, ... ], infer_time_ms: 3.2 }这里有两个设计决策需要说明为什么不用 JSON 传图片用 Base64 编码图片塞进 JSON 是很多人的第一直觉但这会让图片体积膨胀 33%同时 JSON 解析和序列化的 CPU 开销不可忽视。直接传二进制 body 最省事OpenCV 解码也就是一两行代码的事。为什么响应用 JSON因为检测结果量很小一般就几个框JSON 序列化开销可以忽略同时业务方解析方便。如果追求极致性能可以把响应也做成二进制 proto但对大多数场景没这个必要。4.3 多线程并发处理与线程池设计Drogon 的事件循环跑在 IO 线程上但推理这块儿不能直接扔到 IO 线程里因为 enqueueV2 是异步非阻塞的而后处理 NMS 是 CPU 密集操作会卡住事件循环。我的设计是三层线程模型IO 线程Drogon 默认一般是 CPU 核心数负责接收 HTTP 请求、解析请求体然后把任务分发到推理线程队列推理线程池2~4 个线程每个线程有自己的 CUDA stream负责从队列取任务、执行推理、做后处理响应回调推理完成后IO 线程仍然存活可以直接通过 Drogon 的回调把结果写回客户端这种模型的好处是 IO 和计算分离即使推理繁忙也不会阻塞新请求的接收多个推理线程并发跑多个 CUDA streamGPU 利用率更高。4.4 客户端的异步调用封装服务器做完了配套 C 客户端也要提一下因为在性能测试和嵌入式调用场景里C 客户端比 Python 客户端更能还原真实压力。用 Drogon 自带的 HttpClient 写起来很舒服核心代码短且直白#include drogon/drogon.h void detectAsync(const std::string image_path, std::functionvoid(const std::vectorDetection) callback) { auto client drogon::HttpClient::newHttpClient(http://127.0.0.1:8080); auto req drogon::HttpRequest::newHttpRequest(); req-setPath(/v1/detect); req-setMethod(drogon::Post); req-setContentType(drogon::CT_APPLICATION_OCTET_STREAM); std::ifstream file(image_path, std::ios::binary); std::string body((std::istreambuf_iteratorchar(file)), std::istreambuf_iteratorchar()); req-setBody(body); client-sendRequest(req, [callback](ReqResult result, const HttpResponsePtr response) { if (result ! ReqResult::Ok) { callback({}); return; } auto json response-getJsonObject(); // 解析 JSON填充 Detection 列表 callback(detections); }); }同步接口不用自己写用 std::promise/std::future 包一层异步接口就行。生产环境建议全部走异步别开同步接口。5. 性能优化实战从 CPU 到 GPU 的全链路调优这套系统搭建完成后性能测试是必须的一步。我第一次搭完自测发现单路推理延迟只有 4ms但并发一上到 20 路延迟就飙到 50ms 以上。经过几轮调优把问题一个个击破最终稳定在并发 30 路延迟不超 15ms。下面把这些优化点完整列出来。5.1 连接复用与 HTTP Keep-Alive第一个坑就是客户端不断建立新 TCP 连接。测试报告显示每秒新建 100 个连接握手开销占到总耗时 30% 以上。Drogon 和大多数 HTTP 客户端都支持连接池必须做好两点服务端开启 keep-aliveDrogon 默认开启客户端使用连接池模式不要每次请求重新创建 HttpClientDrogon 客户端连接池配置方式auto client drogon::HttpClient::newHttpClient(http://127.0.0.1:8080, 4, 8); // 第二三个参数是连接池大小范围4 个起始连接最多 8 个这个调整立竿见影QPS 提升了将近一倍。5.2 图像解码优化避免 OpenCV imdecode 的重复开销服务端收到了 JPEG 图片第一件事是解码成 BGR 图像。用 cv::imdecode 可以跑但每次解码都会重新分配内存同时 imdecode 是 CPU 密集操作并发高时 CPU 会成为瓶颈。优化方案是在 NVIDIA 显卡上用 GPU 解码 JPEG用 nvjpeg 库。这个库把解码过程全部搬到 GPU 上一张 1080P 的 JPEG 解码时间从 5~8ms 降到 0.5ms 左右而且完全不占用 CPU 核心。用 nvjpeg 替换 imdecode 的代码片段nvjpegJpegState_t jpeg_state; nvjpegCreateSimple(handle); nvjpegJpegStateCreate(handle, jpeg_state); // 每次请求 nvjpegDecode(handle, jpeg_state, (const unsigned char*)jpeg_data, jpeg_size, NVJPEG_OUTPUT_BGR, output_desc, (void*)gpu_buffer, stream_);这个优化在纯图片请求场景下效果惊艳。但是要注意一点如果业务方发的图片格式五花八门PNG、BMP、WebPnvjpeg 只支持 JPEG需要做格式白名单检查不支持的格式回退到 OpenCV 解码。5.3 动态 Batch 合并推理这是压测发现的第二个瓶颈。20 路并发请求每路都单独跑一次推理GPU 利用率只有 40% 左右。原因是单张 640x640 的输入对 GPU 来说太小kernel 启动开销占了很大比例。解决思路是动态 batch不在每个线程收到请求后立刻推理而是设置一个纳秒级的聚合窗口比如 2ms把这 2ms 内到达的所有请求合并成一个 batch一起提交给 TensorRT。batch4 时吞吐提升 3 倍batch8 时提升约 5 倍。实现思路是在推理线程里维护一个待处理队列用一个循环收集任务void inferLoop() { while (running) { std::vectorRequestTask batch; std::unique_lockstd::mutex lock(mutex); cond_var.wait_for(lock, std::chrono::milliseconds(2), []{ return !queue.empty(); }); // 收集最多 8 个任务 while (!queue.empty() batch.size() 8) { batch.push_back(std::move(queue.front())); queue.pop(); } lock.unlock(); if (!batch.empty()) { // 把 batch 中的图像拷贝进连续的显存 buffer prepareBatchInput(batch); // 一次推理 context_-enqueueV2(buffers_, stream_, nullptr); cudaStreamSynchronize(stream_); // 分别后处理 for (auto task : batch) { postprocessAndRespond(task); } } } }这个 2ms 的等待窗口是一个权衡等待太久单请求延迟上升等待太短batch 集不拢。可以根据实时 QPS 做自适应调整但当前固定 2ms 已经能很好兼顾延迟和吞吐。5.4 避免显存拷贝和锁的争抢梳理整个链路数据流动路径是网卡缓冲区 → 服务端内存 → GPU 显存 → CPU 内存 → 响应。每一段都有优化空间。首先是接收请求时避免额外拷贝。Drogon 收到的请求体默认是 std::string如果要传给 nvjpeg可以直接用 string.data() 指向的数据不需要再复制一份。有些封装代码喜欢先构造 cv::Mat 再复制完全没必要。其次是推理线程之间不要互相抢锁。上面的批处理队列是共享的用互斥锁保护入队出队没问题但要保证锁的临界区尽量短不要在锁里面做任何耗时操作。最后是后处理结果也不要每次都动态分配容器。我在 Detection 结构上用固定数组比如最多 100 个检测框后处理时只改数量不改大小避免堆分配的开销。5.5 CPU 亲和性与 NUMA 优化这个优化属于“锦上添花”但对多路视频流场景很有帮助。绑核可以避免线程在不同核心间切换带来的缓存失效。Drogon 的 IO 线程绑前一半 CPU 核心推理线程绑后一半核心可以在启动时通过 Drogon 的线程属性设置或者自己用 sched_setaffinity 绑核。实测结果绑核之后CPU 缓存命中率提升线程调度开销减少延迟抖动从 ±5ms 降到 ±1ms 以内输出更稳定。6. 常见问题与排查技巧实录这两个月来做过的踩坑总结有些问题排查起来特别费时间写出来帮后面的人少走弯路。6.1 引擎加载报错Error Code 1 / 2TensorRT 最常见的问题是加载引擎时报各种 error code。我遇到的场景有三种报错 1CUDA 初始化失败。检查 GPU 驱动和 CUDA 版本是否匹配TensorRT 版本和 CUDA 版本一一对应比如 TensorRT 8.6 要求 CUDA 11.8 或 12.x。用nvidia-smi看驱动用nvcc --version看 CUDA两者必须兼容。报错 2引擎文件损坏或不匹配。检查构建引擎的机器和推理机器 GPU 型号是否一致即使都是 3090不同厂商的卡也可能有差异。稳妥方案是部署机上现场构建。报错 3显存不足。构建引擎时 workspace 设太大或者显存被其他进程占用。用nvidia-smi查看显存占用把 workspace 调小到 512MB 试试。6.2 推理结果全为零或 NaN模型能跑但检测结果全为空或者出现 NaN基本是预处理和后处理的数据格式问题。排查思路一条条来检查输入归一化范围YOLOv5 训练时是把像素除以 255 归一化到 [0,1]如果预处理忘了归一化或者归一化范围错误比如标准化的均值方差和训练不一致结果肯定不对。检查通道顺序训练用 RGBOpenCV 读出来是 BGR不转换的话颜色通道错乱小目标可能完全检不到。检查 NCHW vs NHWC 布局TensorRT 的输入是 NCHW后处理解析输出时要注意输出 tensor 的布局搞错布局结果全是乱的。检查输出解析的维度YOLOv5 的输出是(1, 25200, 85)的布局。85 4 (x,y,w,h) 1 (objectness) 80 (classes)。解析时索引算错一个位置结果就是垃圾数据。有个小技巧在 Python 端先用 PyTorch 推理同一张图拿到一个参考输出然后在 C 端打印同样的输入和输出逐位对比很快能定位问题出在哪一端。6.3 并发上去之后延迟突增这个问题排查了很久原因是多个 CUDA stream 在同一个 GPU 上并发时kernel 之间的切换开销被低估了。特别是有多个推理线程各自创建 streamGPU 上频繁切换很多小型 kernel反而不如单个 stream 顺序执行高效。最终的解法有两个方向把并发控制在合理范围。推理线程池默认设 2~4 个不要贪婪地设 8 个。GPU kernel 密集场景线程越多切换越高GPU 利用率反而下降。使用 CUDA Graph 把推理流程固化。TensorRT 8.0 以上支持对整个推理过程做 graph capture一次捕获后每次执行就是一个 graph launchkernel 启动开销基本归零。这里比较复杂但收益很大。6.4 内存泄漏和显存泄漏长期运行的服务最怕内存泄漏。我这个系统跑三天之后 RSS 涨了 30%排查发现几个点Drogon 的回调 lambda 里捕获了 this 指针如果请求量大回调排队时会持有 this 指针导致析构不释放资源cv::Mat 的生命周期没管理好在某些分支没有释放引用导致 OpenCV 内部缓存一直增长TensorRT 的 context 和 engine 没有正确 destroy析构函数里忘了释放或者释放顺序错误解决办法正确使用 RAII 管理 TensorRT 对象用智能指针包装 engine/runtime/context跑长测时每 10 万次请求快照一次 RSS 和显存画出曲线观察趋势。6.5 多路视频流场景的帧率抖动问题如果业务方要处理的是视频流而不是单张图片还有一个额外问题视频流是顺序帧单帧处理时间波动会造成视频卡顿。我的经验是服务端做一个小队列视频帧先入队然后按固定节奏出队推理在队列深度和延迟之间做平衡。处理不过来时丢帧比推迟处理好得多尤其是实时监控场景旧帧没有保留价值。7. 压测与性能基准数据调优到一定程度需要一场正式的压测来验证系统能不能扛住生产流量。压测工具我用的自己写的 C 多线程压测客户端比 Apache ab 更贴近真实业务场景。7.1 压测环境与参数测试环境虚构代称GPU英伟达消费级显卡显存 8GBCPU8 核桌面级处理器内存16GB系统Ubuntu 20.04CUDA 11.8TensorRT 8.6模型YOLOv5s FP16 引擎输入 640x640压测场景客户端并发 20 路每个连接持续发送 640x640 JPEG 图片平均大小 150KB统计延迟分位数和吞吐。7.2 最终性能数据调优完成后的实测数据指标优化前优化后单请求平均延迟45ms8.6msP99 延迟120ms16.8ms吞吐量 (QPS)45260GPU 利用率35%78%CPU 利用率90% (忙在解码)65% (忙在 NMS)瓶颈分析优化后瓶颈已经不在 GPU 推理了。260 QPS 再往上压延迟就开始明显抬升看火焰图发现主要耗时分布在下游 NMS约 30%、HTTP 序列化约 15%、剩余在解码和其他 IO。如果想要继续提升可以考虑把 NMS 也搬到 GPU 上用 CUDA 实现或者用 TensorRT 自带的 EfficientNMS plugin国产化和 batchedNMSPlugin 都是现成方案但加了之后灵活性会差一些需要自己权衡。7.3 如何把方案迁移到其他模型YOLOv5 这套思路可以迁移到几乎任何目标检测模型流程都一样PyTorch 导出 ONNX注意 dynamic_axesONNX 简化TensorRT 构建 FP16 引擎封装 C 推理类做好显存管理和流管理接入 HTTP 服务唯一需要重新写的就是后处理部分。YOLOv8 和 YOLOv5 的检测头不同输出格式也不同YOLOv8 是(1, 84, 8400)的布局解析逻辑差别很大。如果用的不是 YOLO 系列比如 Faster R-CNN输出结构完全不同需要单独适配。针对 YOLOv8官方库已经提供了 TensorRT 导出工具链但我的建议还是 ONNX 手动走一遍因为官方工具封装太黑盒出了问题很难排查手动流程每一步的输出都能检查定位问题快很多。8. 实操中沉淀的几条经验最后分享几条这次项目里比较关键的实际经验不一定都在文档里写得到但实际用起来很重要。第一TensorRT 引擎的序列化和反序列化在容器化部署时要特别小心。Docker 容器里如果 GPU 驱动和宿主机不对齐加载引擎时经常报奇怪的错误。我的做法是引擎文件不放在镜像里而是容器启动后由初始化脚本检测 GPU 信息再现场构建这样镜像可以在任何 GPU 机器上跑只是启动时间多了十几秒但换来了稳定性。第二日志和监控一定要做细。我在推理服务里埋了三个关键指标请求队列深度、单次推理 GPU 时长、整体端到端延迟。这三个指标直接反映系统状态队列深度持续增加说明服务快顶不住了GPU 时长异常说明引擎可能退化或者显存碎片化。配合指标告警很多问题在用户感知之前就被处理掉了。第三压测项目要尽早做、持续做。我第一次压测已经是部署前一周结果发现一堆性能问题差点上线出事故。现在我做推理服务都是边写边压每完成一个模块就搭一个 Docker 压测环境跑一轮问题早期暴露修复成本低很多。第四不要过度优化。我的方案里有一些思路比如 GPU 解码 JPEG、动态 batch 合并都是在压测复盘后按需加的。如果一开始就全部设计进去开发周期会拉长不少。合理的节奏是先搭一条能跑的完整链路测出瓶颈再针对瓶颈逐项优化。性能调优是迭代的过程不是一步到位的过程。这套 YOLOv5 TensorRT C 的 HTTP 推理服务方案我后来在好几个项目里复用包括图像分类、旋转目标检测、甚至一些分割模型换掉模型和预处理后处理模块就行。框架本身是通用的关键在于把 TensorRT 的引擎管理、显存管理、并发模型和网络层之间的衔接做扎实这个地基打好了上层无论换什么模型都能快速落地。
阅读完成 · 觉得有帮助?
咨询建站