1. 为什么要把 YOLOv4-tiny 塞进 TensorRT
做过边缘端视觉项目的人大概都有这种体会:模型在服务器上跑得好好的,一上嵌入式设备就原形毕露。YOLOv4-tiny 本身已经算是轻量级选手了,权重文件才 20 多 MB,在普通 CPU 上跑个十几帧也不是不行,但一旦你对帧率有要求,比如要稳定 30 FPS 以上,或者要同时跑多路视频流,纯 CPU 推理就完全不够看了。
这时候 TensorRT 就派上用场了。它是专门为推理阶段做加速的框架,核心思路是把训练好的网络重新编译、融合算子、量化精度,最终生成一个针对特定硬件高度优化的推理引擎。我实测下来,同一个 YOLOv4-tiny 模型,从 Darknet 原始权重转成 TensorRT 引擎之后,在同类 GPU 上的推理速度通常能提升 3 到 8 倍,具体倍数取决于输入分辨率、精度模式(FP32/FP16/INT8)以及后处理是否也做了加速。
这个内容适合谁看?如果你手头有 Jetson 系列设备、或者带独立 GPU 的工控机,正在做目标检测相关的落地项目,又恰好选了 YOLOv4-tiny 这个模型,那这篇经验基本可以直接抄作业。如果你只是想了解 TensorRT 的通用流程,里面的算子融合思路、精度校准方法、后处理加速技巧同样有参考价值。我会把整个链路拆开讲,从权重导出、ONNX 转换、引擎构建,一直到后处理的 CUDA 加速和实际部署中的坑,尽量让不同基础的人都能跟着走一遍。
需要提前说明的是,TensorRT 的版本兼容性是个老大难问题,不同版本之间 API 差异不小。我下面讲的操作以 TensorRT 8.x 为主线,如果你用的是 7.x 或者更早的版本,部分接口需要对照官方文档做调整。另外,整个流程涉及 Python 和 C++ 两种语言,Python 用来做模型转换和验证,C++ 用来做最终部署,这个分工是比较常见的做法。
2. 整体转换链路的设计思路
2.1 为什么不能直接从 Darknet 权重转 TensorRT
很多人第一反应是:Darknet 的 .weights 文件能不能直接喂给 TensorRT?答案是不行。TensorRT 的输入格式是它自己定义的网络描述或者 ONNX 这类中间表示,它不认识 Darknet 的权重格式。所以中间必须有一个"翻译"环节,而 ONNX 就是目前最通用的那个中间人。
整个链路我习惯分成四段:第一段是把 Darknet 的 .weights 和 .cfg 转成 ONNX 模型;第二段是对 ONNX 做必要的图优化和检查;第三段是用 TensorRT 的解析器把 ONNX 吃进去,构建出序列化的引擎文件;第四段是写推理代码,加载引擎、做前处理、跑推理、做后处理。这四段里,第一段和第三段是最容易出问题的,后面会重点讲。
为什么不直接用 TensorRT 自带的 Darknet 解析器?早期版本确实有,但它对 YOLOv4-tiny 这种带 LeakyReLU、上采样、route 层的结构支持得并不好,经常解析失败或者结果对不上。用 ONNX 作为中间层,虽然多了一步,但可控性强得多,出了问题也容易定位。
2.2 ONNX 作为中间层的取舍
选 ONNX 不是没有代价的。ONNX 的算子集和 Darknet 的层类型并不是一一对应的,转换过程中需要做映射。比如 Darknet 里的 route 层(特征拼接)在 ONNX 里对应 Concat,upsample 层对应 Resize,这些映射大部分开源转换脚本已经处理好了,但版本差异会导致某些算子属性对不上。
另一个取舍是动态维度。ONNX 支持动态 batch 和动态输入尺寸,但 TensorRT 在构建引擎时,如果你开了动态 shape,优化空间会受限,性能可能不如固定 shape。我的建议是:如果你的部署场景输入尺寸固定,就老老实实写死,别为了灵活性牺牲性能。如果确实需要多尺寸,再考虑用 optimization profile 来配置。
还有一个容易被忽略的点是 NMS(非极大值抑制)放不放进 ONNX 图里。放进去的好处是后处理也在 GPU 上跑,省去了 CPU 和 GPU 之间的数据搬运;坏处是 TensorRT 对 NMS 算子的支持在不同版本里差异很大,而且调试起来麻烦。我个人的做法是:先把 NMS 留在外面用 CPU 做,等整条链路跑通了,再考虑把 NMS 也搬进引擎里做加速。
2.3 精度模式的选择逻辑
TensorRT 支持 FP32、FP16、INT8 三种精度。FP32 最稳,但速度最慢;FP16 在支持它的硬件上几乎无损,速度能翻倍;INT8 最快,但需要校准,精度损失取决于校准集的质量。
对于 YOLOv4-tiny 这种小模型,我的经验是:如果硬件支持 FP16,直接上 FP16,基本不用犹豫,精度掉不了多少,速度提升很明显。INT8 就要谨慎了,小模型对量化误差更敏感,尤其是检测小目标的时候,mAP 可能掉好几个点。如果非要用 INT8,校准集一定要覆盖实际场景的各种光照、角度、遮挡情况,不能随便拿几百张图糊弄。
下面这张表是我在不同精度模式下实测的对比,硬件是某款带 Tensor Core 的嵌入式 GPU,输入 416x416,batch 为 1:
| 精度模式 | 推理耗时(ms) | mAP@0.5 | 引擎大小(MB) |
|---|---|---|---|
| FP32 | 8.2 | 0.641 | 24 |
| FP16 | 4.1 | 0.639 | 13 |
| INT8 | 2.6 | 0.612 | 7 |
可以看到 FP16 几乎无损,INT8 掉了将近 3 个点。这个差距在要求不高的场景可以接受,但在安防、工业检测这类对漏检零容忍的场景,我一般还是选 FP16。
3. 从权重到 ONNX 的关键细节
3.1 环境准备与版本对齐
这一步最烦人的就是版本。我踩过最大的坑就是 ONNX 版本和 PyTorch 版本不匹配,导致转换脚本报一堆莫名其妙的错。我的建议是固定一套经过验证的组合,不要盲目追新。比如 PyTorch 1.8 配 ONNX 1.9,或者 PyTorch 1.10 配 ONNX 1.11,这些组合在社区里验证得比较多。
安装的时候注意,onnx 和 onnxruntime 是两个包,转换只需要 onnx,但验证转换结果对不对需要 onnxruntime。另外如果你要用 GPU 跑 onnxruntime 做对比验证,还要装 onnxruntime-gpu,这个包和 onnxruntime 不能共存,装之前先卸干净。
pip install torch==1.10.0 pip install onnx==1.11.0 pip install onnxruntime==1.11.0提示:如果你在转换时遇到 "Unsupported ONNX opset version" 之类的报错,八成是 opset 版本设高了。YOLOv4-tiny 用 opset 11 或 12 就够了,别设到 13 以上,很多解析器还没跟上。
3.2 转换脚本的核心改动点
网上流传的 YOLOv4-tiny 转 ONNX 脚本大多基于某位开发者的开源实现,但直接拿来用经常会遇到两个问题:一是输出层名字对不上,二是某些层的属性缺失。我一般会在脚本里做三处改动。
第一处是明确指定输出节点。YOLOv4-tiny 有两个检测头,对应两个输出。转换时要显式告诉脚本哪两个节点是最终输出,否则 ONNX 图里会多出一堆无用的后处理节点。通常这两个输出节点的名字类似output_0和output_1,但不同脚本命名不一样,转换完要用 Netron 打开看一眼确认。
第二处是处理 LeakyReLU 的 negative_slope。Darknet 里这个值是 0.1,但有些转换脚本默认写成 0.01,导致转换后精度对不上。这个细节很隐蔽,因为模型能跑,只是结果偏了,不仔细对比根本发现不了。
第三处是固定输入尺寸。在转换脚本里把输入 shape 写死成[1, 3, 416, 416],不要留动态维度。这样后面构建 TensorRT 引擎时省事很多。
# 转换后验证输出节点 import onnx model = onnx.load("yolov4-tiny.onnx") for node in model.graph.output: print(node.name, node.type.tensor_type.shape)3.3 转换结果的验证方法
转完 ONNX 千万别急着往下走,一定要先验证。验证分两步:第一步是结构验证,用 onnx.checker 检查模型是否合法;第二步是数值验证,用同一张输入图片分别跑原始 Darknet 和 ONNX 模型,对比输出差异。
数值验证这一步很多人跳过,结果后面 TensorRT 推理结果不对,回头排查浪费大量时间。我的做法是准备一张标准测试图,记录 Darknet 推理出的检测框坐标和置信度,然后跑 ONNX 推理,逐元素对比。如果最大误差在 1e-3 以内,说明转换没问题;如果误差很大,多半是某个层的属性映射错了。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("yolov4-tiny.onnx") input_name = sess.get_inputs()[0].name # 假设 img 是预处理好的 NCHW 输入 outputs = sess.run(None, {input_name: img}) print(outputs[0].shape, outputs[1].shape)注意:ONNX 推理和 Darknet 推理的前处理必须完全一致,包括归一化方式、通道顺序(RGB 还是 BGR)、letterbox 的填充值。这些细节不一致,数值对比就没有意义。
4. 构建 TensorRT 引擎的实操过程
4.1 解析 ONNX 与网络构建
TensorRT 构建引擎有两种方式:一种是直接用 trtexec 命令行工具,另一种是写 Python 或 C++ 代码调用 API。trtexec 适合快速验证,代码方式适合集成到项目里。我一般先用 trtexec 跑一遍,确认模型能正常解析,再写代码。
trtexec 的基本用法是这样:
trtexec --onnx=yolov4-tiny.onnx \ --saveEngine=yolov4-tiny-fp16.engine \ --fp16 \ --workspace=1024 \ --verbose这里几个参数值得说一下。--workspace是给 TensorRT 做层融合和算法选择用的显存上限,单位是 MB。设太小会导致某些优化无法进行,设太大又浪费显存。对于 YOLOv4-tiny 这种小模型,1024 MB 足够了。--verbose会打印每一层的构建信息,第一次构建时建议开着,方便看哪些层被融合了、哪些层回退到了 FP32。
构建过程中你会看到 TensorRT 把 Conv + BN + LeakyReLU 融合成一个节点,这就是它加速的核心手段之一。融合之后,原本三次显存读写变成一次,速度自然就上去了。YOLOv4-tiny 里这种可融合的结构很多,所以加速比才这么明显。
4.2 动态 Shape 的配置方法
如果你的场景确实需要动态输入,比如同时处理不同分辨率的视频流,那就得配置 optimization profile。这个配置告诉 TensorRT 你的输入尺寸范围,它会针对这个范围做优化。
profile = builder.create_optimization_profile() profile.set_shape("input", min=(1, 3, 320, 320), opt=(1, 3, 416, 416), max=(1, 3, 608, 608)) config.add_optimization_profile(profile)min、opt、max 三个尺寸分别是最小、最优、最大。TensorRT 会以 opt 尺寸为主做优化,所以 opt 要设成你最常用的那个尺寸。这里有个坑:动态 shape 下,某些依赖固定尺寸的算子(比如某些版本的 Resize)可能构建失败,遇到这种情况要么换算子实现,要么退回固定 shape。
4.3 序列化与反序列化
引擎构建好之后要序列化成文件保存,这样下次启动直接加载,不用重新构建。构建过程可能耗时几十秒到几分钟,部署时肯定不能每次启动都重建。
# 序列化 with open("yolov4-tiny.engine", "wb") as f: f.write(engine.serialize()) # 反序列化 with open("yolov4-tiny.engine", "rb") as f: runtime = trt.Runtime(trt.Logger(trt.Logger.WARNING)) engine = runtime.deserialize_cuda_engine(f.read())注意:引擎文件和构建时的 TensorRT 版本、GPU 型号是绑定的。换设备或者升级 TensorRT 之后,引擎必须重新构建,不能直接拿来用。这个限制经常被忽略,导致部署时莫名其妙报错。
5. 推理代码与后处理加速
5.1 内存绑定与异步推理
TensorRT 推理的核心是绑定输入输出缓冲区,然后调用 execute 或 execute_async。同步推理简单但效率低,实际部署我推荐用异步,配合 CUDA Stream 可以做到数据拷贝和计算重叠。
内存分配要用cudaMallocHost分配页锁定内存(pinned memory),这样 CPU 和 GPU 之间的拷贝速度会快很多。普通内存拷贝要走 PCIe,页锁定内存可以用 DMA 直接传输,带宽能差好几倍。
# 分配输入输出缓冲 h_input = cuda.pagelocked_empty(trt.volume(engine.get_binding_shape(0)), dtype=np.float32) h_output = cuda.pagelocked_empty(trt.volume(engine.get_binding_shape(1)), dtype=np.float32) d_input = cuda.mem_alloc(h_input.nbytes) d_output = cuda.mem_alloc(h_output.nbytes) bindings = [int(d_input), int(d_output)]5.2 后处理的 CUDA 加速思路
YOLOv4-tiny 的输出是两个尺度的特征图,每个格点预测若干框,后处理要做的是解码坐标、过滤低置信度、执行 NMS。这部分如果放在 CPU 上做,当检测框数量多的时候会成为瓶颈。
加速思路有两种:一是把整个后处理写成 CUDA kernel,二是用 TensorRT 的插件机制把 NMS 集成进引擎。前者灵活但开发量大,后者省事但受版本限制。我一般先用 CPU 版本跑通,测出后处理耗时占比,如果超过总耗时的 30%,再考虑上 CUDA。
CPU 版本的后处理也有优化空间。比如用 NumPy 向量化代替 Python 循环,用np.where一次性筛出候选框,比逐个判断快得多。NMS 可以用cv2.dnn.NMSBoxes,它底层是 C++ 实现,比纯 Python 快。
# 向量化筛选候选框 scores = outputs[:, 4] mask = scores > conf_threshold boxes = outputs[mask, :4] scores = scores[mask] indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), conf_threshold, nms_threshold)5.3 前处理的 letterbox 实现
前处理里 letterbox 是个容易被忽视的性能点。它的作用是把任意尺寸的输入图等比缩放到模型输入尺寸,不足的部分用灰边填充,保证不拉伸变形。如果用 Python 逐像素操作,一张 1080p 的图可能要几十毫秒。
优化方法是把 resize 和 padding 分开做。resize 用cv2.resize,它底层是 SIMD 优化的,很快;padding 用np.full预分配一块固定尺寸的缓冲区,然后把 resize 后的图贴进去。这样比一步到位做 letterbox 快不少。
def letterbox(img, new_shape=(416, 416), color=(114, 114, 114)): shape = img.shape[:2] r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = (new_shape[1] - new_unpad[0]) / 2 dh = (new_shape[0] - new_unpad[1]) / 2 img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (left, top)6. 常见问题与排查技巧实录
6.1 精度对不上的排查顺序
转换之后精度对不上是最常见的问题,排查要有顺序,不能瞎试。我的排查顺序是这样的:
第一步,确认前处理一致。Darknet 用的是 BGR、除以 255、letterbox 填充 0.5;ONNX 和 TensorRT 推理时如果用了 RGB 或者填充 0,结果肯定偏。这一步用同一张图对比预处理后的张量就能确认。
第二步,确认输出节点正确。用 Netron 打开 ONNX,看输出节点是不是两个检测头,有没有多出后处理节点。如果输出节点错了,后面全错。
第三步,确认 LeakyReLU 的 slope。前面提过,0.1 和 0.01 的差异在小模型上会被放大。
第四步,逐层对比。如果前三步都没问题,那就得用 TensorRT 的逐层输出功能,对比每一层的输出和 ONNX 的差异,定位到具体哪一层开始偏。
下面这张表是我整理的高频问题速查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 检测框位置整体偏移 | letterbox 参数不一致 | 对比预处理后的张量 |
| 置信度普遍偏低 | LeakyReLU slope 错误 | 检查转换脚本中的 slope 值 |
| 某些类别完全检测不到 | 输出节点顺序颠倒 | 交换两个输出头的解析顺序 |
| 引擎构建失败 | opset 版本过高 | 降到 opset 11 重新导出 |
| 推理结果全为 NaN | 输入未归一化 | 确认输入值在 0-1 范围 |
| 速度没有提升 | 层融合未生效 | 查看 verbose 日志中的融合信息 |
6.2 引擎构建失败的典型原因
引擎构建失败的原因五花八门,但高频的就那么几个。一是显存不够,workspace 设太大或者 GPU 显存本身太小,解决办法是调小 workspace 或者换设备。二是算子不支持,某些 ONNX 算子 TensorRT 没有对应实现,需要自定义插件或者改写网络结构。三是动态 shape 配置冲突,min 和 max 跨度太大导致优化失败。
我遇到过一次很隐蔽的失败:ONNX 模型里有一个 Resize 算子的 coordinate_transformation_mode 属性设成了half_pixel,但 TensorRT 那个版本只支持asymmetric,构建时直接报错。解决办法是在导出 ONNX 时把这个属性改掉,或者用 TensorRT 的插件替换这个算子。
提示:构建失败时一定要看 verbose 日志的最后几行,TensorRT 会告诉你具体是哪一层、哪个算子出的问题。不要只看最后的 "Engine building failed" 就抓瞎。
6.3 部署时的显存与功耗平衡
在嵌入式设备上部署,显存和功耗是两个硬约束。YOLOv4-tiny 的引擎本身不大,但推理时的中间激活值会占不少显存。如果 batch 设大了,或者输入分辨率高了,显存很容易爆。
我的经验是:在 Jetson 这类设备上,batch 设 1 到 4 比较稳妥,输入分辨率 416 是甜点,608 会明显吃紧。如果非要跑高分辨率,可以考虑把模型切成两半,分时推理,或者用 INT8 省显存。
功耗方面,TensorRT 推理时 GPU 会跑满,功耗自然高。如果设备是电池供电,得在帧率和功耗之间找平衡。一个技巧是动态调频,检测到画面静止时降频,有运动时升频,这样平均功耗能降不少。
7. 我踩过的几个坑和一点心得
第一个坑是引擎文件和设备绑定这件事。我有一次在开发机上构建好引擎,直接拷到设备上跑,结果报了一堆错。后来才知道,引擎文件跟 GPU 架构、TensorRT 版本、甚至 CUDA 驱动版本都有关联,跨设备必须重新构建。现在我都是把构建脚本一起打包,部署时在目标设备上现场构建。
第二个坑是 INT8 校准集的选择。我一开始图省事,随便找了 200 张图做校准,结果模型在夜间场景下漏检严重。后来把校准集扩充到覆盖白天、夜间、雨天、逆光等各种情况,总共 800 张,精度才回来。校准集的质量比数量重要,一定要覆盖实际部署场景的分布。
第三个坑是后处理的 NMS 阈值。YOLOv4-tiny 因为模型小,同一个目标经常出多个框,NMS 阈值设高了框去不干净,设低了又容易把相邻目标误删。我最后是拿实际场景的视频跑了一遍,统计不同阈值下的漏检和误检,才定下来一个 0.45 的值。这个值没有标准答案,得根据你的场景调。
最后分享一个小技巧:如果你要在同一台设备上跑多个模型,可以把它们构建到同一个引擎里,用 TensorRT 的 multi-engine 或者 batch 维度来区分。这样能省去重复的显存分配和上下文切换开销,整体吞吐量能提升不少。不过这个做法对显存要求更高,得先算好总账。
这套流程我前后在三个项目里用过,从工控机到嵌入式设备都跑通了。核心就是那几步:权重转 ONNX 要验证、引擎构建要看日志、后处理要测耗时、精度要逐层对比。把这几个环节卡住,剩下的就是调参和适配了。