1. 背景痛点:为什么“裸跑”YOLO在视频场景会卡成 PPT
在视频检测场景里,直接把 PyTorch 权重拿来推理,就像用自行车拉集装箱——能跑,但体验感人:
- 延迟高:Python 端每帧 80~120 ms,1080p/30fps 的视频根本追不上
- 内存占用大:框架+模型+特征缓存轻松吃掉 4 GB,嵌入式设备直接报警
- CPU 打满:后处理 NMS 没优化,单核 100%,风扇起飞
- 显存碎片化:每帧
new/delete,跑 10 min 就 OOM
一句话:工业级落地,必须“编译一次,跑到死”的 Engine 方案。
2. 技术选型:ONNX Runtime vs TensorRT
在 C++ 生态里,ONNX Runtime 和 TensorRT 都能跑 YOLO,但定位不同:
| 维度 | ONNX Runtime | TensorRT |
|---|---|---|
| 开发成本 | 低,直接Ort::Session | 高,需先转 Engine |
| 性能天花板 | 中等(GPU 后端) | 极高(Kernel 融合 + FP16/INT8) |
| 动态 shape 支持 | 好 | 需要OptimizationProfile |
| 插件生态 | 少 | 丰富(DCNv2、BatchedNMS) |
| 跨平台 | 好 | 仅限 NVIDIA |
结论:视频检测追求吞吐,TensorRT 是“亲儿子”;ONNX Runtime 适合快速验证原型。下文全部基于 TensorRT 8.x 的.engine文件展开。
3. 核心实现:一条流水线吃光 GPU
3.1 数据流向总览
摄像头 → OpenCV 解包 → 原始帧队列 → 预处理线程 → Batch 拼接 → TensorRT → 后处理线程 → 画框 → 编码推流3.2 OpenCV 解包与帧队列
// 生产者:把 cv::Mat 塞进线程安全队列 void Producer(cv::VideoCapture* cap, ThreadSafeQueue<cv::Mat>* q) washed by std::thread { cv::Mat frame; while (cap->read(frame)) { q->Push(frame.clone()); // 深拷贝,避免野指针 } }3.3 TensorRT Engine 加载(关键代码)
class TrtEngine { public: explicit TrtEngine(const std::string& engine_file) { std::ifstream file(engine_file, std::ios::binary); file.seekg(0, std::ios::end); size_t size = file.tellg(); file.seekg(0, std::ios::beg); std::vector<char> buffer(size); file.read(buffer.data(), size); runtime_.reset(nvinfer1::createInferRuntime(logger_)); engine_.reset(runtime_->deserializeCudaEngine(buffer.data(), size)); context_.reset(engine_->createExecutionContext()); // 显式绑定输入输出索引 input_idx_ = engine_->getBindingIndex("images"); output_idx_ = engine_->getBindingIndex("output0"); } void Infer(const float* input, float* output, cudaStream_t stream) { void* bindings[] = {input, output}; context_->enqueueV2(bindings, stream, nullptr); } private: std::unique_ptr<nvinfer1::ICudaEngine> engine_; std::unique_ptr<nvinfer1::IExecutionContext> context_; std::unique_ptr<nvinfer1::IRuntime> runtime_; int input_idx_, output_idx_; };3.4 预处理:归一化 + NCHW
__global__ void PreprocessKernel(uint8_t* src, float* dst, int dst_h, int dst_w) { int x = blockIdx.x * blockDim.x + threadIdx.x; int y = blockIdx.y * blockDim.y + threadIdx.y; if (x >= dst_w || y >= dst_h) return; int src_idx = (y * dst_w + x) * 3; int dst_idx = y * dst_w + x; // BGR→RGB, 0-255→0-1 float r = src[src_idx + 2] / 255.0f; float g = src[src_idx + 1] / 255.0f; float b = src[src_idx + 0] / 255.0f; dst[dst_idx] = (r - 0.485f) / 0.229f; dst[dst_idx + dst_h * dst_w] = (g - 0.456f) / 0.224f; dst[dst_idx + 2 * dst_h * dst_w] = (b - 0.406f) / 0.225f; }3.5 后处理:GPU 端 NMS
使用官方efficientNMSPlugin,直接输出keep_flag,省掉 CPU 回拷;若手写,参考:
std::vector<Box> CpuNms(const std::vector<Box>& boxes, float thresh) { std::vector<Box> keep; std::sort(boxes.begin(), boxes.end(), [](const Box& a, const Box& b) { return a.score > b.score; }); for (const auto& b : boxes) { bool suppressed = false; for (const auto& k : keep) { float iou = ComputeIoU(b, k); if (iou > thresh) suppressed = true; } if (!suppressed) keep.push_back(b); } return keep; }4. 性能优化三板斧
4.1 CUDA Graph:把 30 次 kernel 启动压成 1 次
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); engine_->Infer(d_input, d_output, stream); cudaStreamEndCapture(stream, &graph); cudaGraphInstantiate(&exec, graph, nullptr, nullptr, 0); // 以后直接 cudaGraphLaunch(exec, stream);实测 1080Ti 上 640×640 单帧延迟从 7 ms → 4.2 ms。
4.2 内存池:避免cudaMalloc卡顿
class CudaBufferPool { public: void* Request(size_t bytes) { std::lock_guard<std::mutex> lk(mu_); if (pool_.count(bytes) && !pool_[bytes].empty()) { void* ptr = pool_[bytes].back(); pool_[bytes].pop_back(); return ptr; } void* ptr; cudaMalloc(&ptr, bytes); return ptr; } void Return(size_t bytes, void* ptr) { std::lock_guard<std::mutex> lk(mu_); pool_[bytes].push_back(ptr); } private: std::unordered_map<size_t, std::vector<void*>> pool_; std::mutex mu_; };4.3 量化:FP16 vs INT8
- FP16:几乎不掉精度,延迟再降 30%,打开方式:
builder->setFlag(nvinfer1::BuilderFlag::kFP16) - INT8:需要 500 张真实场景图做校准,mAP 掉 1% 以内,延迟再降 50%,适合批量大、精度容忍高的业务
5. 避坑指南:那些让我加班到凌晨两点的 bug
5.1 多 batch 显存溢出
现象:设max_batch=8,实际喂 4 张图就 OOM。
根因:Engine 构建时maxWorkspaceSize给太小,TensorRT 会回退到cudaMalloc临时显存。
解决:config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1<<30);// 1 GB
5.2 分辨率动态变化
摄像头中途从 1080p 切到 720p,Engine 固定输入尺寸会炸。
方案:构建时加OptimizationProfile,允许最小 480×270、最优 640×640、最大 1920×1080,运行时context->setBindingDimensions()动态切换即可。
6. 延伸思考:多模型级联
单 YOLO 只能给出“有缺陷”,若想定位“哪类缺陷 + 精细分割”,可再挂一个轻量化 Seg 模型:
YOLO (检测 ROI) → 裁剪小图 → UNet (分割) → 像素级 mask整条链路仍用同一套内存池 + CUDA Graph,只需把二级模型再deserializeCudaEngine一次,上下文独立即可。吞吐下降 <15%,但业务价值翻倍。
7. 小结与个人体验
把上述模块拼接完,我手里的 1660s 在 720p/30fps 视频上跑 YOLOv5m,单卡吞吐冲到 95 fps,GPU 利用率 65%,风扇噪音“可接受”。最重要的是,代码一次编译,现场部署直接拷.exe + .engine就行,运维同事再不用装 5 G 的 PyTorch 环境。
如果你也想从零体验“让 AI 听懂、看懂、秒回”,可以顺手试试这个动手实验——从0打造个人豆包实时通话AI。我这种 C++ 老鸟原本只关心“跑 YOLO”,跟着实验把 ASR+LLM+TTS 串成 Pipeline 后,发现语音交互的延迟也能压到 500 ms 内,整套思路对做边缘对话盒子很有启发。小白照抄实验手册也能跑通,算是给枯燥的模型部署加点“人味”吧。