简介:面向深度学习部署与边缘计算场景,这套资源聚焦YOLOv5 ONNX模型的TensorRT INT8量化实现,适合需要提升推理速度、降低内存占用的算法工程师、嵌入式开发者及模型优化学习者。通过将FP32模型转换为8位整数计算,可显著减小模型体积并加速推理,在精度损失可控的前提下提升实际运行效率。资源包共10个文件,包含3个Python源脚本及对应编译缓存,用于实现校准器与模型转换逻辑;2个TensorRT引擎文件提供YOLOv5s与NanoDet的INT8版本;2个校准缓存文件对应量化校准数据。压缩包约6.95MB,覆盖校准器定义、ONNX模型导入、引擎构建与序列化的完整工具链。已有4470人学习下载。内容提供可直接加载的TRT引擎和校准缓存,便于快速复现或二次开发;脚本中清晰展示了设置校准批次、逐样本调用add_input、完成Calibration及build_engine的典型写法,对理解动态形状设置、校准数据分布和量化精度平衡很有帮助。
1. TensorRT INT8 量化 YOLOv5:先算清楚这笔账再动手
目标检测模型部署到生产环境,速度永远是绕不开的坎。YOLOv5 在 PyTorch 里跑得再欢,到了 GPU 服务器上面对实时视频流,帧率上不去就是废纸。TensorRT INT8 量化是英伟达官方钦定的加速路线之一:把 YOLOv5 的权重从 FP32 压到 INT8,用 8 位整数做推理,算力开销直接砍半甚至更多。但这条路不是跑一条命令就能通的——量化校准、精度回退、算子兼容、版本匹配,每一步都能让模型精度从 0.89 掉到 0.4,也能让推理速度翻三倍。本文面向的是想把 YOLOv5 部署到 TensorRT 上的工程人员,尤其是手里有 ONNX 模型、被高延迟卡住的人。我会按「pt 转 onnx → 环境评估 → INT8 量化 → 构建 engine → 精度验证」这条链路,把能复现的步骤、参数和坑全部摆到台面上。
2. 从 YOLOv5 到 ONNX:导出参数与两点必查项
2.1 pt 转 onnx:官方导出命令怎么改才不挖坑
YOLOv5 官方仓库里自带export.py,但直接跑默认参数导出的 ONNX 往往在 TensorRT 那里吃闭门羹。我一般用下面这组参数:
python export.py \ --weights yolov5s.pt \ --include onnx \ --opset 14 \ --imgsz 640 \ --batch-size 1 \ --simplify \ --dynamic逐项拆开说:--opset 14是 ONNX 算子集的版本。TensorRT 8.x/10.x 对 opset 14 以上的兼容性最好,低于 13 容易在Slice、Split这些算子上报不支持;--simplify会调用 onnx-simplifier 把常量折叠掉、把冗余节点合并,这一步在导出时顺手做掉,省得后面跑polygraphy时被一堆Shape算子干扰;--dynamic让导出的模型带动态轴,后面在 TensorRT 里才有资格配 profile,否则 batch 固定死,线上并发稍微波动就崩。
导完先别急着拿去量化,做两个检查。第一,用onnxruntime跑一遍,确认 ONNX 输出结果和 PyTorch 原始模型差距在 1e-3 级别。第二,用onnx.checker校验模型结构,再用onnxruntime的get_providers()看能不能正常加载。这两个检查加起来不超过五分钟,但能提前拦住八成后续问题——比如某个算子导出后 shape 变成了动态,TensorRT 转换时直接报InvalidNode。
2.2 opset、动态轴与半精度残差:三个参数决定后续能不能量化
很多人忽略--opset和量化之间的关系。INT8 量化在 TensorRT 内部本质上是把算子替换成 INT8 实现,但替换的前提是算子本身能被解析成标准计算图。opset 太低,导出时 ONNX 里会出现一堆兼容性算子,比如Slice+Concat拼出来的Padding,TensorRT 的 parser 不认识这些组合,量化无从谈起。opset 提高之后算子更原子化,TensorRT 更容易匹配到自己的 INT8 kernel。
动态轴这里有个容易被忽视的点:--dynamic导出的模型,输入 shape 是[-1, 3, 640, 640]。在 ONNX 阶段无所谓,但进了 TensorRT,你就必须为动态轴指定一个 profile,否则构建时直接报Profile was not specified for dynamic input。我在第 4 章会专门讲这个配置,先记住结论:导出时开--dynamic是对的,但后面要为它补一个显式的 range 声明,否则等于给自己埋雷。
还有一个「半精度残差」的概念值得说清楚:某些算子(比如Exp、Pow、Sigmoid)在 FP16 下计算会有精度损失,ONNX 模型里这些节点的输出如果直接拿去做 INT8 量化,误差会被放大。常见做法是导出一个 FP32 的 ONNX 做量化基准,不要用 FP16 ONNX 去校准。也就是说,--half这个参数在导出时先不要加,等 TensorRT 构建时再决定要不要开 FP16——这两个阶段是独立的,混在一起会导致精度排查时不知道锅该甩给谁。
2.3 用 onnxruntime 做一次推理基线,量化前后才有对照
在做任何量化之前,先把 ONNX 模型在 onnxruntime 下的表现记录下来,这是后面判断量化「有没有变差」的唯一依据。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("yolov5s.onnx", providers=["CUDAExecutionProvider"]) input_name = sess.get_inputs()[0].name x = np.random.rand(1, 3, 640, 640).astype(np.float32) outputs = sess.run(None, {input_name: x}) print([o.shape for o in outputs])这里有个容易自信过头的环节:随机噪声输入测出来的延迟没有参考价值(现代 GPU 对全零或随机数据容易走 cache / 不再做实际计算)。要做延迟测试,请使用真实视频帧或至少一张有纹理的图,否则量化前后的帧率对比会被系统性低估。
3. TensorRT 环境与 INT8 原理:算力、版本和校准算法怎么选
3.1 TensorRT 10.x 对 GTX1070 这类老卡的兼容边界
先说结论:RTX 30 系以下的卡(GTX 10 系、20 系)对 TensorRT INT8 的收益要打折扣。原因是 INT8 推理在 TensorRT 里有两条实现路径:一条是走 Tensor Core(需要 Turing 架构及以上),另一条是走 CUDA Core 的 DP4A 指令(Pascal 架构,GTX 1070 属于这一代)。Tensor Core 的 INT8 吞吐量是 FP16 的两倍,而 DP4A 模式下 INT8 往往只比 FP32 快 30%~60%,并且对精度损失更敏感。
TensorRT 10.x 官方安装包要求 CUDA 12.x,如果你的生产环境还在 CUDA 11.x,要么升系统驱动和 CUDA,要么退回 TensorRT 8.6 GA 版。我踩过的最典型的坑是:TensorRT 10.0 的.whl装上了,import tensorrt也成功了,但 build engine 时报Error: could not find a suitable global execution context,查了半天发现是显卡驱动版本过低导致的 CUDA runtime 不匹配。结论:在 GTX1070 上想用 TensorRT 10.x,请先核验nvidia-smi里的 CUDA Driver Version 是否 ≥ 12.0,否则老老实实用 TensorRT 8.6 + CUDA 11.8 组合。
另外注意 TensorRT 的 pip 包(tensorrt-x.x.x-cp38-cp38-linux_x86_64.whl)不包含trtexec,你需要单独安装TensorRT本体(deb/rpm/tar 包)。很多人到import tensorrt成功就以为环境齐了,结果跑trtexec找不着命令,这算是最不等的坑——但先装好 pip 包再装本体,顺序反了可能导致两个版本的 Python bindings 冲突。
3.2 INT8 校准的三件事:校准集、校准算法、可复现性
INT8 量化在 TensorRT 里的做法是:先给定输入数据分布(即校准集),让模型跑一遍 FP32 推理,记录每层激活值的分布,再用校准算法确定「FP32 到 INT8」的缩放系数。这个环节是精度损失的真正来源。
校准集选取有一条硬规则:必须覆盖真实业务场景的输入分布。做车检就用车的图,做人脸就用脸的图,拿 COCO 的通用图片去校准一个专门检测钢材表面缺陷的模型,大概率量化后 mAP 直接崩掉几个点。数量上 500 张以内足够,我一般用 300~500 张真实样本,超过 1000 张边际收益几乎为零,只会拖慢校准时间。
校准算法在 TensorRT 里有三个选择:LegacyCalibrator、EntropyCalibratorV2、MinMaxCalibrator。工程上默认选EntropyCalibratorV2,它对分布尾部不敏感,能兼顾大部分场景的精度;MinMax在极端值较多的场景(比如小目标占比极高)下表现得更好,但代价是整体精度可能被拖低。没有一个算法能通吃所有模型,所以做量化时留一手:把EntropyCalibratorV2和MinMaxCalibrator都跑一遍,各构建一个 engine,对比精度后再选,这个时间成本值得花。
3.3 FP32 vs FP16 vs INT8:算力需求差异到底怎么影响部署
先给一张对比表,后面所有决策都围绕它展开:
| 精度模式 | 存储占用(相对 FP32) | 推理延迟(相对 FP32) | 适用架构 | 精度风险 |
|---|---|---|---|---|
| FP32 | 1x | 1x(基准) | 全架构 | 无 |
| FP16 | 0.5x | 0.6~0.8x | Turing+ | 极低 |
| INT8 (Tensor Core) | 0.25x | 0.3~0.5x | Turing+ | 中高 |
| INT8 (DP4A) | 0.25x | 0.5~0.9x | Pascal/Volta | 中高 |
先说背景:TensorRT 生成 engine 时会把整个模型按层分派给不同精度的 kernel,INT8 量化真正快在哪?卷积、全连接、矩阵乘法这类计算密集型算子受益最大,而Resize、Concat、Sigmoid这类内存/函数型算子基本吃不到红利。YOLOv5s 的 backbone 里 80% 的计算量集中在卷积层,所以 INT8 化之后整个模型的速度提升明显,但如果你是跑一个几乎全由ElementWise算子组成的轻量分类模型,INT8 收益就很有限,甚至因为反量化开销而变慢——这就是「算力需求差异」的实际含义。
落到部署上,如果你用的是 GTX 1070 这类 Pascal 卡,INT8 的收益大概率不会到 2 倍,此时更推荐用 FP16 做首版,稳定后再评估是否值得付出校准和调优的成本去换 INT8 的增量。RTX 3060 以上的卡才值得优先把 INT8 作为目标方案。树莓派 5 这类 ARM 平台不建议硬上 TensorRT,INT8 的量化和部署链路更适合放到 Jetson 系列。
4. 用 Python API 构建 INT8 engine:最小可跑脚本与参数说明
4.1 从 ONNX 到 INT8 engine:完整 build 脚本
TensorRT 提供两种构建路径:trtexec命令行和 Python API。生产环境里我建议用 Python API,原因是校准器(Int8Calibrator)必须自己写,trtexec虽然方便但没法自定义数据源。下面这份脚本是完整可跑的 INT8 构建流程:
import tensorrt as trt import numpy as np import os TRT_LOGGER = trt.Logger(trt.Logger.WARNING) class EntropyCalibrator(trt.IInt8EntropyCalibrator2): def __init__(self, calib_imgs, input_shape=(1, 3, 640, 640)): super().__init__() self.calib_imgs = calib_imgs # 校准图片路径列表,比如 300 张 jpg self.input_shape = input_shape self.batch_size = input_shape[0] self.calib_data = self._load_calib_data() self.calib_idx = 0 def _load_calib_data(self): import cv2 data = [] for path in self.calib_imgs: img = cv2.imread(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (self.input_shape[2], self.input_shape[3])) img = img.astype(np.float32) / 255.0 data.append(img) return np.stack(data).astype(np.float32) def get_batch_size(self): return self.batch_size def get_batch(self, names, p_int): if self.calib_idx + self.batch_size > len(self.calib_data): return None batch = self.calib_data[self.calib_idx:self.calib_idx + self.batch_size] batch = np.ascontiguousarray(batch) self.calib_idx += self.batch_size return [batch] # 注意:返回的是 list,顺序要匹配 names def read_calibration_cache(self): if os.path.exists("calib.cache"): return open("calib.cache", "rb").read() return None def write_calibration_cache(self, cache): with open("calib.cache", "wb") as f: f.write(cache) def build_int8_engine(onnx_path, calib_imgs, engine_path): builder = trt.Builder(TRT_LOGGER) network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser = trt.OnnxParser(network, TRT_LOGGER) with open(onnx_path, "rb") as f: assert parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) # 1GB workspace config.set_flag(trt.BuilderFlag.INT8) config.int8_calibrator = EntropyCalibrator(calib_imgs) profile = builder.create_optimization_profile() # 动态 shape 的最小/最优/最大 batch,必须按这个顺序 profile.set_shape("images", (1, 3, 640, 640), (1, 3, 640, 640), (8, 3, 640, 640)) config.add_optimization_profile(profile) engine = builder.build_serialized_network(network, config) with open(engine_path, "wb") as f: f.write(engine) print(f"[OK] INT8 engine saved to {engine_path}") if __name__ == "__main__": build_int8_engine("yolov5s.onnx", ["calib/img_001.jpg", "..."], "yolov5s_int8.engine")这段代码里有几个地方是血泪经验换来的。get_batch返回的数据必须是np.ascontiguousarray,TensorRT 要求连续内存,否则在校准循环中会出现Segmentation fault;返回值必须是一个 list,元素顺序要跟names参数一致(通常只有一个输入,直接return [batch]即可);read_calibration_cache和write_calibration_cache一定要实现,前者让第二次构建直接跳过校准阶段,后者把校准结果持久化,否则每次 build 都要重新校准一遍,时间白白烧掉。
4.2 profile 与动态 shape:四个必调参数
上一节脚本里出现了set_shape,这里的参数直接影响引擎能在什么输入尺寸下工作:
| 参数 | 含义 | 推荐值 | 踩坑说明 |
|---|---|---|---|
| min shape | 最小输入尺寸 | (1, 3, 640, 640) | 不能低于 1,YOLOv5 的 stride 是 32,所以宽高必须是 32 的倍数 |
| opt shape | 最优输入尺寸 | (1, 3, 640, 640) | 实际业务中最高频的 shape,写错了性能会严重退化 |
| max shape | 最大输入尺寸 | (8, 3, 640, 640) | 超过这个值 build 会直接报错,线上并发打满时就直接崩 |
| workspace | GPU 显存上限 | 1GB 起步 | 2GB 会让 TensorRT 自动选择更多融合策略,但显存小就别硬撑 |
关于opt shape多说一句:TensorRT 在构建时会对opt形状做全图优化,如果你的线上输入大多是 480x480,而这里填了 640x640,构建出来的 engine 在这些小尺寸输入上的性能会明显差于「按 480x480 做 opt」的版本。所以拿到别人给的 engine 或者网上下的现成 engine,先确认它的 opt shape 和你的输入分布匹不匹配。
4.3 序列化 engine 并保存到磁盘:反序列化后还能不能直接用
engine 构建完成后不是直接用engine = builder.build_serialized_network(...)的结果在内存里跑,而是要先保存成.engine文件,部署时加载。加载方式如下:
import tensorrt as trt TRT_LOGGER = trt.Logger(trt.Logger.WARNING) runtime = trt.Runtime(TRT_LOGGER) with open("yolov5s_int8.engine", "rb") as f: engine_bytes = f.read() engine = runtime.deserialize_cuda_engine(engine_bytes)注意:.engine文件严格绑定 TensorRT 版本、CUDA 版本、GPU 架构。同一份 engine 在 A 机器上 build,拷到 B 机器上(哪怕 B 的显卡型号完全一样)也可能 load 失败,报错通常是Engine is incompatible with current runtime或者直接segfault。这不是玄学,是因为 engine 里包含了针对特定硬件架构的 kernel 选择结果。所以生产环境的正确做法是:在每台目标机器上各自执行一次 build(可用缓存校准表加速),或者用容器镜像把 build 环境整个复制过去,保证版本一致。
另一个序列化相关的常见错误是deserialize_cuda_engine返回的 engine 对象是空指针,但 Python 端不抛异常。出现这种情况,先检查.engine文件大小——如果只有几百字节,那大概率是 build 失败但异常被吞了。解决办法是在 build 端加builder_network.is_valid()校验(代码里可以提前调用这个接口),以及设置环境变量TF_CPP_MIN_LOG_LEVEL=0看完整日志。
5. INT8 量化避坑笔记:5 个让 engine「翻车」的常见原因
5.1 现象:量化后 mAP 掉 10 个点以上 → 原因:校准集和业务数据分布不匹配 → 解决:换校准集
这是我遇到的最高频问题。有个项目做布匹瑕疵检测,第一次量化用了 COCO 的图做校准,结果是 mAP 从 0.92 掉到 0.71,完全不可用。排查了半天,最后发现是校准集里没有一张是布匹纹理图,EntropyCalibratorV2计算出的激活值分布完全不贴合真实输入的分布。解决方式是:从真实产线抽了 400 张代表性图片重新校准,mAP 回到 0.88。血的教训:校准集不是「随便找 500 张图」,而是「最能代表线上输入的那批图」。
5.2 现象:INT8 engine 推理速度没提升反而变慢 → 原因:小算子反量化开销大于收益 → 解决:用层级精度控制或换 FP16
YOLOv5s 里有一些Sigmoid、Mul、Add算子,这些算子本身计算量极小,但 INT8 推理前需要做反量化(dequantize),引入额外开销。如果整个模型都强制 INT8,计算密集型卷积省下来的时间可能被这些额外开销吃掉。遇到这种情况,查看trtexec --dumpLayerInfo的输出,找出耗时占比 top10 的算子中哪些其实没吃到 INT8 红利(通常是非卷积算子),然后在的network层设置layer.precision = trt.float32把这些层踢出 INT8 范围。也可以用config.set_flag(trt.BuilderFlag.FP16)做一版 FP16 engine 对比,很多场景 FP16 更「够用」。
5.3 现象:构建 engine 时报InvalidNode,指向ScatterND或NonZero→ 原因:ONNX opset 太低或算子不兼容 → 解决:重新导出 ONNX 并 open--simplify
YOLOv5 的 NMS 后处理如果写在模型里(导出时--include onnx默认不含 NMS),一般不会碰到;但如果自定义的网络里用了ScatterND、NonZero这类算子,TensorRT 的解析器在 INT8 模式下支持不全。查看 ONNX 模型中是否有这些算子,用onnx-graphsurgeon移除后处理节点,只保留conf和bbox输出。也可以换 opset 再导出一次试试——同一种结构在 opset 12 和 14 下解析路径完全不同。
5.4 现象:校准过程卡死,get_batch只被调用一次 → 原因:校准数据没做np.ascontiguousarray或 batch 返回方式不对 → 解决:检查内存连续性并正确实现get_batch返回 list
前面脚本里那段np.ascontiguousarray(batch)不是白写的。TensorRT 的校准器在遍历校准集时,要求每次get_batch返回的内存必须是连续且对齐的;如果返回了非连续数组,它不会报错,而是直接返回None,同步表现为「只校准了一次就结束了」。排查这个问题的标准手段:在get_batch里打印batch.shape和batch.flags['C_CONTIGUOUS'],确认每个 batch 都正常返回。
5.5 现象:部署到新机器后 engine load 出现Segmentation fault→ 原因:TensorRT/CUDA 版本不一致 → 解决:卸载重装确切版本,或用 Docker 固化环境
.engine文件天生是版本敏感的。生产环境最常见的问题就是「测试机上 build 好了,拷到生产机直接崩」。解决路径有两条:一是严格统一镜像,开发机、CI、生产机都装同一个 Ubuntu + CUDA + TensorRT 组合;二是写一个启动脚本,每次服务上线先在目标机上构建 engine,再把 engine 缓存到本地磁盘(利用read_calibration_cache避免重复校准)。后者虽然多花几分钟启动时间,但彻底消除了版本不匹配的玄学问题。
6. 验证与调优:用一张图把 INT8 的精度损失量化出来
6.1 简化版 mAP 验证脚本:用自己的数据跑一遍就够
很多人对 INT8 量化的顾虑集中在「精度到底掉了多少」,总想找个标准数据集系统性评估。真到了工程落地阶段,其实不需要跑完整 COCO mAP——把你自己业务里最典型的一批测试图(100~200 张)拿出来,统计模型在 INT8 和 FP32 两种 engine 下的检测结果,算出 mAP@0.5 即可。
import numpy as np from pathlib import Path # 伪代码结构:用同一个 NMS 后处理逻辑分别喂给两个 engine def infer_all(engine_path, test_imgs, label_map): results = [] runtime = trt.Runtime(TRT_LOGGER) engine = runtime.deserialize_cuda_engine(open(engine_path, "rb").read()) for img in test_imgs: # preprocess 后推理,收集 dets [x1,y1,x2,y2,conf,cls] dets = engine_infer(engine, img) results.append(dets) return results fp32_dets = infer_all("yolov5s_fp32.engine", test_imgs) int8_dets = infer_all("yolov5s_int8.engine", test_imgs) map_fp32 = compute_map(fp32_dets, gt_annots) map_int8 = compute_map(int8_dets, gt_annots) print(f"FP32 mAP@0.5: {map_fp32:.4f} | INT8 mAP@0.5: {map_int8:.4f}")这里的核心认知是:对比的是「同一条后处理链路下的两个 engine」,而不是和 PyTorch 源码比。PyTorch 的 NMS 阈值和 TensorRT 里你自定义的 NMS 阈值如果设得不一样,精度差异根本没法定位。调优技巧是先用torch.hub.load('ultralytics/yolov5')的默认参数跑一遍同批图片,把它的 mAP 作为上限参照,再调 TensorRT 后处理的 conf-thres 和 iou-thres,让 INT8 engine 的 mAP 尽量逼近 FP32。
6.2 给生产环境的 INT8 落地建议:什么时候该上 INT8,什么时候别硬上
INT8 量化的收益和成本在不同硬件架构上差距很大。我们用一组实际数据说话:
| 场景 | GPU 架构 | CPU 主干耗时(ms) | INT8 vs FP16 收益 | 建议 |
|---|---|---|---|---|
| 视频流检测(1080p 25fps 单路) | Ampere/RTX30 | 12 | 快 50% | 优先上 INT8 |
| 大批量离线推理(batch=16) | Turing | 20 | 快 40% | 上 INT8,校准集按业务选 |
| 多路低分辨率(320x320 8路) | Pascal/GTX1070 | 5 | 几乎无收益 | 用 FP16 |
| 边缘盒子(树莓派5 / Jetson) | ARM | 40 | Jetson 上显著 | Jetson 可上,树莓派不推荐 |
如果拿不准,我的习惯是先做一次 20 分钟的「短跑验证」:用 FP32 和 INT8 各构建一版 engine,在同样输入上跑 500 帧测平均延迟、用 100 张业务图测 mAP,算出「帧率提升 / mAP 损失」这个比值。比值大于 1.5(比如帧率快了一倍,精度只掉了 0.3%)就值得推上线;比值小于 1(比如只快了 20%,精度掉 2%)就果断退回 FP16。这个习惯帮我避开了至少三个「看起来很美、上线就翻车」的 INT8 方案。
最后补一个 pybind 层面的技巧:build engine 时给config.set_flag(trt.BuilderFlag.REFIT)留个后门,线上发现精度异常时可以增量更新 engine 的部分层,不用重新全量 build。这个 flag 只在 Turing 以上架构可用,但它是我列出来的所有「后悔药」里最实用的一颗——量化精度这事,谁都不敢保证一次到位,给自己留条回退路,才敢放心把 INT8 推上生产。希望这些踩出来的经验,能帮你把 INT8 量化这条路走顺。
本文还有配套的精品资源,点击获取