很多做部署的同事第一次听到“PyTorch 直接转 TensorRT”这个说法时,第一反应基本都一样:这俩不是隔着一条河吗?确实,PyTorch 是动态图设计,TensorRT 要的是静态优化后的 engine 文件,中间还夹着 ONNX、算子映射、动态 shape 这些麻烦事。但现实是,把 PyTorch 模型转成 .trt 文件跑推理,再叠一层 INT8 量化,几乎成了工业部署绕不开的标准动作。这篇文章不整虚的,我把从 PyTorch 推理性能瓶颈、版本匹配、转换路径、INT8 校准数据准备,到 TRT 文件加载运行、精度治理和常见报错定位的整套流程完整写出来,所有步骤都是我在实际项目里验证过的。适合正在被推理延迟和显存占用折磨的算法工程师,也适合刚接触 TensorRT、想把原理和实操一次性打通的新手。
1. 为什么非要用 TensorRT,PyTorch 推理到底慢在哪
1.1 PyTorch 推理慢的根源:动态图与算子级开销
先说结论:PyTorch 不是不能做推理,而是它的设计目标首先是训练。训练场景下,模型结构要灵活可变,梯度要回传,每个算子都要动态派发,这带来的代价就是大量不必要的运行时开销。你拿 PyTorch 直接跑推理,最直观的感受是 GPU 利用率忽高忽低,显存占用却不小,同样的模型换到推理引擎里,延迟往往能砍掉一大半。
拆开来看,PyTorch 推理慢主要有三个原因。第一个是算子级调度开销,PyTorch 以 Python 层调度为核心,逐算子下发到 GPU,每个算子都要经历从 Python 层创建张量、分配显存、启动 kernel、等待结果、回收缓存这一整套流程。对小模型来说,CPU 侧的下发开销甚至可能超过 GPU 的实际计算时间,表现为 GPU 在等 CPU。第二个是 kernel 实现偏通用,PyTorch 的算子要覆盖各种输入 shape、各种 dtype、各种设备,所以它选的是稳妥的通用实现,而不是针对某一块 GPU 的极致实现。第三个是缺少层融合,相邻算子之间的中间结果可以不用落到显存,但 PyTorch 默认不做这种跨算子级别的优化,于是每一层都在读写显存。
这三个问题叠加起来,在那些算子很多但单个算子计算量不大的模型上尤其致命。比如检测模型里的特征金字塔、注意力结构,动辄几百个节点,显存读写占了绝大部分开销,这种情况下 PyTorch 的推理效率远低于理论峰值。
1.2 TensorRT 的核心优化手段:层融合、内核调优与显存复用
TensorRT 做的事,本质上就是把上面三个问题一次性解决掉。它拿到模型描述之后,会做几件改变性能格局的事情。
第一件是层融合。最典型的例子是 Conv + BatchNorm + ReLU 这个组合,在 PyTorch 里要跑三次 kernel,中间结果写两遍显存再读两遍;在 TensorRT 里这三个算子会被融合成一个 CBR 结构,中间结果不落显存,读写次数直接砍掉三分之二。类似的融合还包括相邻的 elementwise 算子、concat 前后的 layout 转换、以及残差结构里的 add 操作。模型越深、算子越碎,融合带来的收益越大。
第二件是内核自动调优。TensorRT 在构建 engine 时会针对目标 GPU 架构,对每个算子的 kernel 实现做搜索和比选,包括卷积算法的选择、tile 尺寸、共享内存分配策略、向量化访存方式,搜完之后把最优方案直接固化到 engine 里。这就是为什么“构建一次”这么慢的原因——它不是简单的翻译,而是在替你做一轮针对性的性能优化。
第三件是显存复用。引擎构建时能算出每个中间张量的生命周期,然后把生命周期不重叠的张量放到同一块显存区域,最终整个模型只需要一块很小的显存池,峰值占用大幅下降。同一个模型在 PyTorch 里显存占用可能是几个 GB,到了 TensorRT 里降到几百 MB 并不稀奇。
这三件事叠加起来,FP32 精度下通常能拿到 1.5 到 3 倍的加速。如果再开 FP16,带宽压力减半,速度还能再往上走一档;而 INT8 量化把张量压到 8 位之后,数据量变成原来的四分之一,硬件低精度计算单元又能进一步加速卷积和矩阵乘,整体收益就更夸张了。这也是为什么 CV 检测、分类这类算子规整的模型,TensorRT 几乎成了事实标准的部署方案。
2. 转 TRT 前的准备工作:版本匹配、模型体检与 ONNX 简化
2.1 版本组合匹配:CUDA、PyTorch、ONNX、TensorRT 一个都不能乱
我在实际转换中遇到的第一个拦路虎,不是模型多复杂,而是版本对不上。PyTorch、ONNX、TensorRT、CUDA 这四个东西是强联动的,任何一个版本不匹配,转换过程就会出现各种匪夷所思的报错,一会儿说找不到动态库,一会儿说算子版本不支持,一会儿又是跟 CUDA 驱动不兼容。
我的建议是动手之前先把版本固定下来,写进环境配置文件。我个人用过比较稳妥的组合是 PyTorch 2.x、ONNX 1.15 左右、TensorRT 8.6 或 10.x、CUDA 11.8 或 12.x。具体数字不重要,重要的是你必须确认三件事:一是 TensorRT 版本对应的 CUDA 版本和你机器的显卡驱动匹配;二是 PyTorch 的 ONNX 导出器和 ONNX 这个 Python 包版本兼容,不然导出过程会静默生成结构有问题的图;三是 TensorRT 的 Python 包、C++ 运行时库、还有trtexec工具必须来自同一个版本,混装是灾难。
还有个容易被忽视的点:.trt文件本身是绑定硬件架构和 TensorRT 版本的。你在开发机上用 TensorRT 10 转出来的 engine,放到只装了 TensorRT 8 的生产机上直接加载失败,甚至同样是 TensorRT 10,换了 GPU 型号也可能加载不了。所以工程上要把“构建 engine”和“运行 engine”的环境分开管理,生产机的 TensorRT 版本必须和构建机的完全一致。
2.2 模型体检清单:提前识别不支持的算子和导出风险
版本搞定之后,别急着转,先给模型做个体检。TensorRT 能识别的是它内置支持的算子集合,PyTorch 里很多高层 API 导出成 ONNX 之后会展开成一系列基础算子,大部分没问题,但有几类大概率要出状况。
第一类是动态控制流,比如 Python 里的 if 分支、for 循环、动态索引。这类结构在导出 ONNX 时要么被静态展开,要么直接失败,而且失败的信息往往很含糊。第二类是自定义算子,你自己写的前向函数如果没有注册 ONNX 导出符号,导出时直接抛 NotImplemented 错误。第三类是非常规算子,比如某些注意力实现里的复杂 reshape、特定版本的上采样、GridSample,转出来的 ONNX 结构奇奇怪怪,TensorRT 解析时容易挂在某个不起眼的节点上。
体检最务实的办法是分层做。第一步,先跑通torch.onnx.export,再用onnx.checker.check_model检查导出的 ONNX 结构是否合法。第二步,把 ONNX 喂给trtexec或者 OnnxParser,看哪个节点报 unsupported,报错信息里一般会给出节点类型和名字,回到 ONNX 图里一查就能定位。我习惯在体检阶段把模型最容易出问题的部位单独拎出来测,比如 backbone 单独导一次,head 单独导一次,这样定位问题节点特别快,不用每次都在整个模型里大海捞针。
2.3 ONNX 图简化:onnxsim 与 graphsurgeon 的常规操作
PyTorch 导出的 ONNX 图通常夹杂着大量冗余节点,比如多余的 cast、identity、transpose、reshape。这些节点本身不影响正确性,但在 TensorRT 解析时可能增加失败概率,也会让 kernel 选择变差。所以拿到 ONNX 之后,我习惯先做一轮图简化。
常用的工具是onnx-simplifier,一行命令就能跑:
python -m onnxsim model.onnx model_sim.onnx它的作用是做常量折叠、公共子表达式消除、去掉无用节点,输出一个更干净的图。如果模型里有复杂的动态 shape 操作,onnxsim搞不定,那就得用onnx-graphsurgeon手动处理,这个库可以让你直接在图上删除、替换、插入节点,灵活度很高,代价是要理解图的拓扑。我通常的处理顺序是:PyTorch 导出原始 ONNX,用 onnxsim 简化,如果还有解析失败的节点,再用 graphsurgeon 手工替换成等价实现。每一步都保留一份中间文件,出问题能快速对比到底哪一步引入的。
3. PyTorch 转 TRT 的三条主流路径与工程选型
3.1 路线一:PyTorch 导出 ONNX,再经 OnnxParser 构建 engine
这是最通用、也是我最推荐的路径。ONNX 是个中转格式,几乎所有推理框架都认,以后你要是换推理后端也方便。整个流程三步:PyTorch 导出 ONNX、ONNX 转 TensorRT engine、序列化保存为 .trt 文件。
PyTorch 导出 ONNX 时,有几个参数必须注意。opset_version建议设到 13 以上,太低很多算子导出不了,太高又可能引入 TensorRT 不支持的新结构;input_names和output_names一定要显式指定,后面转 TRT 和运行时绑定缓冲区都要用名字;如果模型要支持动态 batch,必须在dynamic_axes里声明。核心代码长这样:
import torch model.eval() dummy_input = torch.randn(1, 3, 640, 640).cuda() torch.onnx.export( model, dummy_input, "model.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch"}, "output": {0: "batch"} } )导出之后,ONNX 转 TensorRT 这一步我常用 Python API 控制整个构建过程,因为生产环境里校准、动态 shape、序列化这些都要精细控制。核心代码是创建 builder 和 network,用 OnnxParser 解析 ONNX,然后配置优化选项并构建 engine:
import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network( 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) ) parser = trt.OnnxParser(network, logger) with open("model_sim.onnx", "rb") as f: assert parser.parse(f.read()), "ONNX 解析失败" config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) config.set_flag(trt.BuilderFlag.FP16) engine = builder.build_serialized_network(network, config) with open("model.trt", "wb") as f: f.write(engine)这里有个容易踩的坑:EXPLICIT_BATCH这个 flag 必须传,否则 network 默认走 implicit batch 模式,后面动态 shape 和按名字绑定张量都没法用。还有,TensorRT 8.x 之后推荐用build_serialized_network,它返回的是序列化字节,直接写文件就行;老教程里的build_engine返回的是 engine 对象,API 不一样,别拿老代码硬套新版本。
3.2 路线二:torch2trt 直接转换,快但坑也明显
网上很多人推荐torch2trt,它的思路是直接 hook 住 PyTorch 的层,把模型转成 TRT engine,不需要走 ONNX 中间格式。坦白说,它的优势非常突出:API 简洁,对开发者友好,尤其适合内部快速验证。
from torch2trt import torch2trt model_trt = torch2trt(model, [dummy_input], fp16_mode=True) torch.save(model_trt.state_dict(), "model_trt.pth")但我必须说它的短板。第一是算子覆盖有限,遇到模型里有它没注册的算子,直接报错,而且手动扩展很麻烦。第二是对 PyTorch 版本耦合强,PyTorch 升级后经常出现兼容问题,项目一升级就得跟着改。第三是它转出来的 engine 优化程度不如官方路径,毕竟它是在线转换,没法做那么彻底的图优化和 kernel 搜索。
我的判断是:torch2trt适合原型验证,不适合正式生产。如果你想快速感受一下 TensorRT 的加速效果,用它最快;但要交付到线上,还是老老实实走 ONNX 路线,可控性差太远了。
3.3 路线三:trtexec 命令行快速出 engine
trtexec是 TensorRT 自带的命令行工具,它的价值在于快速验证和基准测试。拿到 ONNX 之后,一行命令就能出 engine:
trtexec --onnx=model_sim.onnx --saveEngine=model.trt --fp16 --workspace=4096如果走 INT8,加上--int8并指定校准数据目录,比如--calib=calib.txt,其中每一行是校准图片的路径。trtexec还会在构建完成后自动跑一轮性能测试,输出 min/max/mean 延迟和吞吐,用来粗看模型的加速效果非常方便。
不过trtexec的短板也很明显:它对校准数据格式要求固定,动态 shape 的配置靠命令行参数写起来很绕,复杂的预处理也没法做。所以我的用法是:用trtexec做 smoke test,验证 ONNX 能不能被解析、大概跑到什么速度;正式构建 engine 还是用 Python API,因为工程化的东西最终要落在代码里,命令行不好维护。
3.4 三条路径的选型对照
我把三条路线的定位整理成一张表,方便你按场景直接选:
| 路线 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ONNX + OnnxParser(Python API) | 正式部署、复杂模型 | 全流程可控,适合集成进工程 | 代码量稍大,需要理解 TensorRT API |
| ONNX + trtexec | 快速验证、简单模型、性能粗测 | 命令行一下,零代码 | 校准、动态 shape、预处理不好控制 |
| torch2trt | 原型验证、Quick Demo | 上手最快,代码量最少 | 算子覆盖有限、版本耦合强、优化略差 |
我的默认选择永远是 ONNX + Python API,因为部署这件事最终要落到工程化,不可控的东西越少越好。trtexec 用来做验证,torch2trt 在时间极紧的时候救急,仅此而已。
4. INT8 量化实操:校准数据集、校准器与精度治理
4.1 INT8 为什么能提速,代价又是什么
FP32 转 FP16,张量占用的显存减半,带宽压力减半,精度损失在大部分模型上可忽略。而 INT8 是直接把每个张量从 32 位压到 8 位,数据量变成原来的四分之一,在显存带宽受限的模型上,收益比 FP16 再高一档,同时硬件里的低精度计算单元能进一步加速卷积和矩阵乘。同样是卷积,INT8 的吞吐常常能到 FP32 的数倍。
但代价很明确:准确率可能掉。TensorRT 的 INT8 量化默认是训练后量化(PTQ),模型不需要重训,只需要喂一批校准数据,统计每个张量的数值分布,算出从 FP32 映射到 INT8 的缩放因子。PTQ 的精度损失一般能控制在 1% 到 3% 以内,但如果校准数据选得不好,或者模型对量化本身就敏感,掉点可能超过 5%。这时候就得靠校准策略、敏感层分析和混合精度来补救。
另外要注意,INT8 量化只在支持的 GPU 架构上才有硬件加速收益,老一点的架构可能只是“能跑”而并不快。所以做 INT8 之前,先确认目标部署硬件的特性是否支持低精度计算。
4.2 校准数据集准备:分布匹配与样本多样性
INT8 量化做得成不成功,七成取决于校准数据集。校准数据不是随便找一堆图片喂进去就完事,它的核心是覆盖模型在真实部署时可能遇到的所有数值范围。以检测模型为例,如果训练数据里全是白天场景,部署时经常有夜间输入,那校准数据里必须包含足够比例的暗光样本,否则量化出来的缩放因子会在暗光输入下误差放大,效果肉眼可见地变差。
校准数据量一般不用太大,几百到一千张足够,关键是多样性。我通常从验证集里抽样,保证类别均衡、亮度分布均衡、目标尺寸分布均衡。抽样时还有个容易忽略的细节:校准数据必须走和训练完全一致的预处理流程,归一化的均值方差、缩放尺寸、通道顺序都不能变,否则统计出来的数值分布是错的,量化结果会莫名其妙地差。
我踩过一个具体的坑:第一次做 INT8 时,校准数据用的是 JPEG 原图直接喂,没有做归一化,结果量化后 mAP 掉了八个点,查了半天才发现是预处理不一致。把预处理补上之后,掉点立刻回到一个点以内。这件事没有捷径,校准数据来不得半点马虎。
4.3 自定义 calibrator 的完整实现与参数细节
走 TensorRT Python API 做 INT8,标准做法是继承IInt8Calibrator的子类,把校准数据一批一批喂进去,TensorRT 在构建过程中会反复调用你的校准器来获取数据。比较常用的是EntropyCalibrator2这种校准算法,对应一个叫IInt8CalibratorEntropyCalibrator2的基类,需要实现四个方法:
import tensorrt as trt import pycuda.driver as cuda import pycuda.autoinit import numpy as np class MyCalibrator(trt.IInt8CalibratorEntropyCalibrator2): def __init__(self, data_loader, cache_file): super().__init__() self.data_loader = data_loader self.cache_file = cache_file self.batch_size = data_loader.batch_size self.current_index = 0 self.device_inputs = [] def get_batch_size(self): return self.batch_size def get_batch(self, names): if self.current_index >= len(self.data_loader): return None batch = next(iter(self.data_loader)) self.current_index += 1 # 关键:必须返回 GPU 显存中的数据 if not self.device_inputs: self.device_inputs = [ cuda.mem_alloc(batch.nbytes) for _ in names ] cuda.memcpy_htod(self.device_inputs[0], batch) return self.device_inputs def read_calibration_cache(self): try: with open(self.cache_file, "rb") as f: return f.read() except FileNotFoundError: return None def write_calibration_cache(self, cache): with open(self.cache_file, "wb") as f: f.write(cache)这里最容易被忽略的点是get_batch必须返回 GPU 显存里的数据指针。很多人第一次写时图省事直接返回 CPU 的 numpy 数组,TensorRT 也不报错,但校准结果是错的,精度掉点就很诡异。我统一用pycuda把 CPU 数据拷到 GPU 再返回,问题立刻消失。
构建时把校准器挂到 config 上:
config.set_flag(trt.BuilderFlag.INT8) config.int8_calibrator = MyCalibrator(calib_loader, "calib.cache")校准算法上,TensorRT 提供三种选择:EntropyCalibrator、EntropyCalibrator2和MinMaxCalibrator。我的经验是默认用EntropyCalibrator2,它在大多数模型上精度表现最好,因为基于信息熵的思想能在降低量化误差的同时尽量保留分布信息;如果模型对量化特别敏感,可以试试MinMaxCalibrator,它简单粗暴地按最小最大值定缩放因子,某些情况下反而更稳;EntropyCalibrator偏老,资料少,不建议再碰。
校准结束后,TensorRT 会把缩放因子缓存到 cache 文件里,第二次构建时直接读缓存就行,不用再跑校准数据集,构建速度快很多。我建议把 calibration cache 纳入版本管理,模型结构不变时直接用缓存,谁改了模型再重新生成。
4.4 量化精度掉点的系统排查步骤
INT8 转换完成后,第一件事是和 FP16 或 FP32 的 baseline 做精度对比,逐项看 mAP、accuracy 的差异。如果掉点在可接受范围内,直接用;掉点明显时,我按下面这个顺序排查。
第一步,检查校准数据。这是最常见犯错的点,数据分布和部署场景不匹配。换一组更贴近真实的校准集,往往就好了。
第二步,做逐层敏感度分析。把每一层单独设成 FP32,其余层保持 INT8,跑一遍验证集,找出对精度影响最大的那几层,在构建时把这几层单独保留为 FP32。TensorRT 的 Python API 支持对指定层设置精度,虽然操作繁琐,但在极端情况下非常有效。
第三步,考虑混合精度方案。有些模型的输出层、位置回归层对数值精度极敏感,而浅层特征对量化不敏感,这时候让浅层走 INT8、深层走 FP16,往往能把掉点控制住。
第四步,如果以上都不行,退回部分 INT8。比如只量化 backbone 的前面几层,后面的头保持 FP16。这是工程里常用的妥协方案,虽然收益少了,但稳定性高。我自己在项目里做过的最极端一次,是把整个检测头的关键层全部保留 FP16,只量化特征提取网络,掉点从 5% 压到 0.5%,速度仍然比 FP16 快了不少。
5. 加载 TRT 文件运行推理:缓冲区绑定与动态 shape 处理
5.1 反序列化 engine 与执行上下文创建
转出来的 .trt 文件本质上是序列化后的 engine,运行时要反序列化。加载代码各家写法大同小异,核心是用Runtime从字节流创建 engine,再创建 execution context。需要再次强调:engine 文件只能在生成它的 TensorRT 版本和 GPU 架构上加载,跨版本、跨机型基本不行。
import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) runtime = trt.Runtime(logger) with open("model.trt", "rb") as f: engine_bytes = f.read() engine = runtime.deserialize_cuda_engine(engine_bytes) context = engine.create_execution_context()加载之后,可以从 engine 里查询输入输出的名字、shape、数据类型。TensorRT 8.5 之后推荐用按名字查询的接口,比老版本的按索引方便很多,也不容易把输入输出搞反。
5.2 推理主流程:显存分配、数据拷贝与 execute_v2
拿到 context 之后,跑推理最关键的一步是绑定输入输出缓冲区。TensorRT 不像 PyTorch 那样自动管理张量,它要求你显式指定每个输入输出的显存地址。流程是:查询张量信息、分配显存、把预处理好的数据拷进输入显存、设置张量地址、执行、把输出从显存拷回 CPU。
import numpy as np import pycuda.driver as cuda import pycuda.autoinit input_name = "input" output_name = "output" input_shape = (1, 3, 640, 640) output_shape = (1, 25200, 85) input_dtype = trt.nptype(engine.get_tensor_dtype(input_name)) output_dtype = trt.nptype(engine.get_tensor_dtype(output_name)) input_data = np.random.rand(*input_shape).astype(input_dtype) output_data = np.empty(output_shape, dtype=output_dtype) d_input = cuda.mem_alloc(input_data.nbytes) d_output = cuda.mem_alloc(output_data.nbytes) cuda.memcpy_htod(d_input, input_data) context.set_tensor_address(input_name, int(d_input)) context.set_tensor_address(output_name, int(d_output)) context.execute_v2(bindings=[]) cuda.memcpy_dtoh(output_data, d_output)注意老版本用的是execute加 bindings 列表索引的方式,新版本推荐set_tensor_address+execute_v2。我在项目里统一用新 API,因为按名字绑定不容易搞错顺序。另外,输入数据在送入 engine 之前,预处理必须在 CPU 侧完成,或者你自己写 CUDA kernel 在 GPU 上做,TensorRT 不会帮你做归一化、resize、通道转换这些事。
5.3 动态 batch 与动态 shape 的运行时配置
如果构建 engine 时声明了动态维度,运行时必须显式设置每个输入张量的 shape,这一步很多人会忘。构建成功、加载也成功,一执行就报 shape 不匹配,基本就是这个原因。
context.set_input_shape(input_name, (batch_size, 3, 640, 640))设置完 shape 之后再绑定地址、执行,就正常了。但要注意,动态 shape 的 engine 在性能上会比固定 shape 稍弱,因为构建时 TensorRT 要覆盖一个 shape 范围,没办法对每一个具体 shape 都做到最优 kernel 选择。所以我的建议是:如果业务上 batch 大小和输入尺寸是固定的,一定要用固定 shape 构建 engine,把性能榨干;只有 batch 会变、或者输入尺寸确实不能锁死的场景,才考虑动态 shape。
5.4 性能验证:吞吐、延迟与显存峰值测量
跑通推理之后,别急着上线,先做一轮性能验证。我习惯测四个指标:单次推理延迟(latency)、吞吐(throughput,每秒处理帧数)、显存峰值占用、以及不同 batch 下的表现趋势。
测延迟时要注意 warmup。TensorRT 的 engine 第一次执行会有 kernel 加载和缓存建立的额外开销,所以我一般先跑几十次热身,再统计后面的耗时。测吞吐则建议用完整的 batch,比如一次喂 16 张图,看每秒能处理多少张。显存峰值可以用nvidia-smi或者在代码里轮询记录。这些数据最好和 FP16、FP32 的 baseline 放一起对比,才能说明 INT8 量化的真实收益。
我实际项目里的一组典型数据是:检测模型在 FP16 下延迟 12ms,INT8 下延迟 6ms,吞吐翻了一倍多,显存占用从 1.8GB 降到 700MB。这个收益在不同模型上有差异,但方向是一致的。
6. 高频报错、踩坑记录与最后的实操建议
6.1 算子不支持与解析失败的定位方法
先说我遇到最多的一类报错:ONNX 解析时提示某个unsupported operator。根本原因是 ONNX 图里有 TensorRT 没实现的算子。排查思路是固定的:第一步,看报错信息里给出的节点类型和名字;第二步,回到 ONNX 图里定位,用onnx.load加载后打印节点信息;第三步,如果是冗余节点,用onnxsim或onnx-graphsurgeon清掉;第四步,如果是真不支持的算子,把模型里对应实现替换成 TensorRT 支持的等价结构。
举个例子,某些版本的上采样算子 TensorRT 支持不好,换成resize的显式写法或者改用转置卷积实现,问题就解决了。这里的关键在于:模型结构不是不能动的,只要数值等价,部署侧的微调是允许的,前提是转换后输出和 PyTorch 的对齐结果一致。
6.2 显存不足与构建卡死的处理
构建 engine 时报显存不足,多半是 workspace 设置太大,或者某些大算子的 kernel 搜索太激进。解决办法很直接:调低config.set_memory_pool_limit,或者构建时去掉--fp16、限制 tactic 来源,缩小搜索空间。
运行时显存不足,优先检查是不是同时加载了多个 engine。每个 engine 都会占用一块显存池,多个模型共存时很容易爆显存。我的做法是把不用的 engine 释放掉,或者在同一进程里做好显存复用管理。另外一个常见问题是,execution context 的数量也会影响显存,多个 context 并发执行时显存是叠加的。
构建过程长时间卡住,也常见。TensorRT 第一次构建要跑 kernel 搜索,某些算子在特定 shape 下搜索空间特别大,可能要跑很久。解决办法是用config.set_tactic_sources限制搜索源,或者用trtexec加--tacticSources参数排除耗时的策略源。如果定位到某个算子卡住,可以把它单独提出来构建一次,验证是不是它的问题。
6.3 输出结果错误与精度掉点的排查清单
如果推理能跑通但输出完全不对,先别怀疑量化,按这个顺序查:第一,输入数据的排布和预处理。训练时是 BGR 还是 RGB、归一化均值方差是多少、输入缩放是否一致,部署时都必须完全复刻。我遇到过构建、加载、执行全对,就是输出差一点点的案例,最后发现是图像预处理时通道顺序搞反了,数据分布完全乱了。
第二,检查dynamic_axes和运行时set_input_shape是否一致。固定 shape 构建的 engine 不会报错,但如果输入尺寸不对,预处理结果就和训练分布不一致,输出也会漂。第三,检查输入输出 dtype。PyTorch 默认是 FP32,如果你构建时开了 FP16,输入送进去的是 FP32,TensorRT 有时不会自动转换,需要你自己把输入 cast 成 FP16。
INT8 相关的精度掉点排查,上一节写了完整顺序,这里补充一条:校准数据返回 GPU 显存这个点,第一次做 INT8 的人极其容易搞错。如果校准数据的get_batch没返回 GPU 指针,TensorRT 不报错但校准结果是错的,精度掉点就很诡异。这是我亲测踩过的坑。
6.4 高频问题速查表
| 问题现象 | 常见原因 | 处理方式 |
|---|---|---|
| PyTorch 导出 ONNX 报错 | 自定义算子未注册 | 为算子写导出符号或用基础算子重写 |
| ONNX 解析报 unsupported | 模型含 TensorRT 不支持的算子 | onnxsim 简化、graphsurgeon 替换等价结构 |
| 构建 engine 显存不足 | workspace 过大或 kernel 搜索激进 | 调低显存池、限制 tactic 来源 |
| 运行时报 shape 不匹配 | 动态 shape 未调用 set_input_shape | 执行前显式设置输入 shape |
| 输出完全不对 | 预处理不一致或 dtype 不匹配 | 核对通道顺序、归一化、dtype |
| INT8 精度大幅掉点 | 校准数据分布不匹配 | 换校准集、逐层敏感度分析、混合精度 |
| engine 加载失败 | 跨 TensorRT 版本或跨 GPU 架构 | 确保构建机和运行机环境完全一致 |
| 首次构建特别慢 | kernel 自动搜索耗时 | 限制 tactic 来源、排除无关策略源 |
6.5 给新手的几条实操建议
最后分享几条我踩坑踩出来的习惯,算是经验之谈。
第一,转换的每一步都留中间产物。PyTorch 导出 ONNX 后先保存一份原始文件,再做简化时另存一份,出问题能快速对比是哪一步搞坏的。我以前图省事只留最终 engine,出了精度问题只能从头再来,浪费了大量时间。
第二,构建脚本和 calibration cache 一起提交到仓库。让同事不用重复校准,也避免不同人构建出不同效果的 engine。校准缓存是构建流程的一部分,不是临时文件。
第三,上线前跑一整套回归测试,包括 FP16 和 INT8 的精度对比、不同 batch 大小的性能测试、长稳运行测试。别只跑一两个样例就宣称完成,量化模型的精度衰减有时候只在特定输入分布下才暴露。
第四,TensorRT 版本升级后,旧的 .trt 文件基本都要重新构建,别指望跨版本加载。CI 里最好把 TensorRT 版本固化,不然某天环境一升级,线上模型全部加载失败,这种事故我见过不止一次。
根据我个人的经验,TensorRT 这套东西本质上不复杂,但细节多如牛毛,特别是 INT8 量化的校准数据准备和精度治理,需要耐心一版一版试。如果你第一次做就能把版本匹配、模型体检、校准数据这些基本功做扎实,后面基本是水到渠成的事。还有一个建议:任何新的部署环境,先拿一个小模型把整套流程跑通,再去碰大模型,排查问题的时间能省下一大半。