1. 先搞清楚 TensorRT 在整条链路上到底占哪个位置
1.1 一个特别典型的场景
模型在 PyTorch 里训练完,验证集指标挺好看,一切都挺顺,然后同事把推理服务搭起来,用单张卡跑 ResNet 或者某个检测网络,一测发现单张图要十几毫秒,batch 一上去显存先炸了,吞吐上不去,延迟也下不来。这时候大多数人会开始翻资料,翻着翻着就翻到 TensorRT 这个名字——它做的事情很朴素:把你训练好的模型,编译成一个只针对当前这张显卡、当前这版库、当前这个 shape 组合的“专用执行文件”,也就是 engine。用生活里的话说,PyTorch 里的模型像一个通用工具箱,什么活都能干,但每次用都要先翻半天找工具;TensorRT 干的活是把这个工具箱按你的活儿重新打成一个专用工具包,打开就能用,中间那些通用性带来的开销全砍掉。
我先把结论摆这儿:TensorRT 不是“装个包就能提速三倍”的东西,它是一整套带约束的编译流程。你得先把模型导成 ONNX,再用 builder 把它编译成 engine,最后写一段代码去加载 engine、分配显存、喂数据、取结果。这三步里任何一步踩坑,后面都不会顺利。这篇内容就把这三步拆开讲透,安装怎么绕开版本坑、转换怎么处理动态 shape 和精度、推理 demo 怎么写才能跑起来且跑得快,全是我自己在实际项目里趟出来的东西,附上可以直接抄的脚本。初学者能照着跑通第一个 demo,有经验的人可以重点看第五节的排查表和第四节的内存管理细节。
1.2 TensorRT 到底在替你做什么
很多人对 TensorRT 的理解停留在“加速库”三个字,具体加速了什么说不清。简单拆一下它做的主要是四类事:第一是算子融合,把卷积、BN、激活这些连续的算子合并成一个 kernel,减少 kernel launch 次数和中间的显存读写;第二是精度选择,允许你把 FP32 降到 FP16 甚至 INT8,显存带宽压力直接减半或者减到四分之一,而卷积类模型对这点精度损失通常不敏感;第三是 kernel 自动调优,同一个卷积它会在几十种实现里实测挑一个最快的,这个过程叫 tactic 搜索,所以构建 engine 的时候你会看到它慢吞吞地跑很久;第四是显存复用,它会分析整个计算图,把生命周期不重叠的中间张量塞进同一块显存,这就是为什么 engine 跑起来的峰值显存经常比 PyTorch 小一截。
但也正因为它做了这些“编译期决策”,engine 就变成了强绑定产物。换一张架构不同的显卡,engine 不能用了;换一版 TensorRT,engine 大概率不能用了;换 CUDA 大版本,基本也别想。这是新手最容易踩的第一个坑:在开发机上构建好 engine,直接拷到部署机上跑,报一串反序列化失败的错。我的习惯是 engine 一律在目标机器上构建,或者至少保证构建环境和运行环境的显卡架构、驱动、TensorRT 版本、CUDA 版本完全一致。
1.3 什么情况下不建议上 TensorRT
这话得说在前面,省得有人白折腾。如果你的模型里有大量 TensorRT 不支持的算子,比如复杂控制流、动态循环、某些自定义算子,那你要么写 plugin,要么就得改结构,成本可能远高于收益。如果你的服务 QPS 本来就低,一张图几十毫秒完全够用,那上 TensorRT 的收益不明显,维护成本反而增加。还有就是小 batch、小模型的场景,比如一个很浅的分类网络,TensorRT 的收益可能只有 20% 到 30%,而 PyTorch 原生加torch.inference_mode()加torch.compile也能拿到一部分,这种时候是否值得引入一条新工具链,得算笔账。真正收益明显的场景是:中大模型、batch 稳定、QPS 高、延迟敏感、硬件固定。这四条的匹配度越高,TensorRT 越值。
2. 安装:先理版本矩阵,再动手
2.1 CUDA、cuDNN、TensorRT 三者的咬合关系
安装失败十次里有八次是版本不匹配。TensorRT 对 CUDA 大版本是有明确要求的,而且不同 TensorRT 大版本支持的 CUDA 范围不一样。大致情况是:TensorRT 8.6 这一支可以跑在 CUDA 11.8 和 CUDA 12.0 上;TensorRT 10.0 到 10.3 主要面向 CUDA 12.x,部分版本也提供 CUDA 11.8 的构建;TensorRT 10.4 之后基本就是 CUDA 12.x 的天下了。具体的对应表必须以官方 release notes 为准,因为小版本更新很频繁,我这里只给一个判断思路。
判断思路是什么?你先跑nvidia-smi看驱动版本和它支持的 CUDA 上限,再跑nvcc --version看你实际装的是哪个 CUDA 工具链,两个数经常对不上——驱动支持 12.4,但系统里装的是 11.8 的 toolkit,这很正常。TensorRT 关心的是运行时链接到哪个 CUDA runtime,而不是驱动支持多高。所以你要装哪个 TensorRT,取决于你打算用哪个 CUDA runtime 去编译和运行。我的建议是:别追最新,选一个生态稳定、你能找到一堆参考的版本组合,比如 CUDA 12.1 配 TensorRT 10.3 这一类的组合,用的人多,出问题好查。
还有 cuDNN,TensorRT 本身对 cuDNN 的依赖没有早期版本那么重了,但 PyTorch 导出 ONNX 前的验证、以及某些 plugin 实现里还会用到,装一个和 CUDA 版本匹配的 cuDNN 不会有坏处。表里我列一下常见的判断维度:
| 检查项 | 命令 | 关注点 |
|---|---|---|
| 驱动与 CUDA 上限 | nvidia-smi | 右上角显示的 CUDA Version |
| 实际 CUDA 工具链 | nvcc --version | 编译用的版本 |
| CUDA runtime | python -c "import torch;print(torch.version.cuda)" | PyTorch 链接的版本 |
| TensorRT 版本 | 见下节自检 | 必须与 CUDA 大版本匹配 |
| 显卡架构 | nvidia-smi --query-gpu=name --format=csv | 决定 engine 是否可复用 |
这里有个细节值得说:PyTorch 自带的 CUDA runtime 和你系统里的 CUDA toolkit 可能不是一个版本,而 pip 安装的 TensorRT 轮子又是针对特定 CUDA 版本编译的。三者里只要有一个错位,轻则 import 报符号找不到,重则运行到一半 segmentation fault。所以我更推荐用容器把环境钉死,或者至少用 conda 建一个干净环境专门跑 TensorRT,别和训练环境混在一起。
2.2 两条安装路径:解压包与 pip 轮子
TensorRT 的安装方式主要有两种,选哪种取决于你的用途。
第一种是官方压缩包,解压后得到一个目录,里面有lib、include、bin、python。这种方式的优点是完整,C++ 侧的头文件和库都在,trtexec这个命令行工具也直接能用;缺点是要手动配环境变量。典型做法是把解压出来的lib加进LD_LIBRARY_PATH,把python目录加进PYTHONPATH。我一般在~/.bashrc里写死:
export TRT_PATH=/opt/TensorRT-10.x.x.x export LD_LIBRARY_PATH=$TRT_PATH/lib:$LD_LIBRARY_PATH export PATH=$TRT_PATH/bin:$PATH export PYTHONPATH=$TRT_PATH/python:$PYTHONPATH注意顺序,TensorRT 的 lib 要放在前面,否则系统里如果有旧版本的libnvinfer.so,会被优先加载,然后你就开始怀疑人生了。改完记得source一下,再用ldd验证一下 Python 扩展到底链接到了哪个库。
第二种是 pip 轮子。TensorRT 10 之后,pip 上是按 CUDA 版本分包的,名字类似tensorrt-cu12,也有tensorrt-cu11这种。安装的时候版本号一定要显式指定,因为默认会拉最新,而最新版很可能和你环境里的 CUDA 对不上:
pip install tensorrt-cu12==10.3.0 --extra-index-url https://pypi.nvidia.com也有人用tensorrt这个元包让它自己挑,我不太推荐,出问题时排查路径太长。pip 装的好处是省事,Python 侧直接用;缺点是它只带 Python 绑定和运行时库,trtexec这类命令行工具不全,你要用还得单独装或者用压缩包。所以我实际的做法经常是:压缩包和 pip 混着来,Python 侧用 pip 保证干净,C++ 和 trtexec 用压缩包,两边指向同一个版本。
注意:不要在一个环境里同时装多个来源的 TensorRT。pip 装了一份、压缩包又加了一份到 PYTHONPATH,最后 import 到哪个纯看路径顺序,这种随机性带来的 bug 极难查。
2.3 装完必做的三步自检
装完别急着跑模型,先做三个检查,能把 80% 的安装问题挡在前面。
第一步,验证 Python 侧能 import 且版本正确:
import tensorrt as trt print(trt.__version__) print(trt.Builder)如果这一步就报ImportError: libnvinfer.so.10: cannot open shared object file,那就是LD_LIBRARY_PATH没配好或者装了不匹配的版本,别往下走了,先把库路径解决。
第二步,验证 builder 能创建并且能看到设备:
import tensorrt as trt logger = trt.Logger(trt.Logger.INFO) builder = trt.Builder(logger) print("max batch size:", builder.max_batch_size) print("platform has fast fp16:", builder.platform_has_fast_fp16) print("platform has fast int8:", builder.platform_has_fast_int8)platform_has_fast_fp16这个值很关键,它告诉你当前显卡跑 FP16 是不是走的高速通道。如果是 False,说明这张卡对 FP16 的支持是“能用但不快”的那种,那你开 FP16 的收益就要重新评估。
第三步,验证trtexec可用。跑一个空转的trtexec --help,能打出来就行。真实验证要等有 ONNX 模型之后,用它跑一次构建,这是后面第三节的内容。
3. 模型转换:ONNX 是必经之路,engine 是终点
3.1 ONNX 导出这一步最容易埋雷
TensorRT 支持从 ONNX 直接解析建图,也支持从 Caffe、UFF 这些老格式,但现在的项目基本都走 ONNX。因为 PyTorch 侧导出 ONNX 太方便了,一个torch.onnx.export就完事。方便归方便,这一步埋的雷最多。
先看一个标准的导出写法:
import torch model = YourModel().eval().cuda() dummy = torch.randn(1, 3, 224, 224).cuda() torch.onnx.export( model, dummy, "model.onnx", opset_version=13, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, do_constant_folding=True, )几个关键点。model.eval()不能省,否则 BN 和 Dropout 的行为和推理时不一致,导出来的图是错的,而且这个错误不会报异常,只会在结果上体现。opset_version要选一个 TensorRT 支持的范围,选太低有些算子表达不出来,选太高 TensorRT 解析不了,具体上限跟你用的 TensorRT 版本有关,一般 12 到 17 之间比较安全。dynamic_axes决定了哪些维度是动态的,这里只把 batch 设为动态,H 和 W 固定,这是最稳的做法。
导出之后别急着转 engine,先用 onnx 库读一遍,看看图的输入输出名字、shape、dtype 是不是和预期一致:
import onnx m = onnx.load("model.onnx") for inp in m.graph.input: print("input:", inp.name, [d.dim_value or d.dim_param for d in inp.type.tensor_type.shape.dim]) for out in m.graph.output: print("output:", out.name, [d.dim_value or d.dim_param for d in out.type.tensor_type.shape.dim])这一步能抓出很多问题,比如输出名字莫名其妙变成了/Concat_output_0,或者某个维度变成了unk__123(说明有没推导出来的动态维度)。名字最好在这里就改好,后面写推理代码时直接用这些名字,不然对不上就很麻烦。
如果图比较脏,比如有些冗余的 Reshape、Cast、连续 Transpose,可以用onnx-simplifier过一遍:
pip install onnx-simplifier python -m onnxsim model.onnx model_sim.onnx简化后的图通常更小、更容易被 TensorRT 优化,成功率也更高。但简化也可能改变一些边缘行为,比如某些动态维度会被固化,所以简化之后再检查一遍 shape,也要和原始 PyTorch 输出做数值对齐。
3.2 静态 shape 与动态 shape 怎么选
这个选择直接影响你的构建时间、推理性能和代码复杂度,得认真对待。
静态 shape 的意思是构建 engine 时把所有维度都固定死,比如固定成1x3x224x224。好处非常明显:TensorRT 可以把每一步的显存大小精确算出来,tactic 搜索可以针对这一种 shape 挑最优实现,跑起来最快、最省内存。坏处是换个 batch 大小就得重新构建,如果业务里 batch 会变,那就不适用。
动态 shape 是用一个优化配置(optimization profile)声明输入的最小、最优、最大三个形状,TensorRT 会为这三个范围构建,运行时只接受范围内的形状,而且实际形状落在“最优”附近时性能最好。写起来是这样:
profile = builder.create_optimization_profile() profile.set_shape( "input", min=(1, 3, 224, 224), opt=(8, 3, 224, 224), max=(16, 3, 224, 224), ) config.add_optimization_profile(profile)这里的取舍很有讲究。min和max之间跨度越大,构建时间越长、engine 越大、显存占用越多,因为 TensorRT 要为不同 shape 准备不同的 kernel 实现。我见过有人把 batch 设成 1 到 128,结果构建跑了四十分钟,engine 好几百兆,最后实际只用 batch 4。合理做法是:把opt设成你业务里最常见的那个 batch,max设成实际可能出现的最大值,别留太多余量。如果超了,宁可重新构建一个,或者拆成两个 engine。
还有一个常见误区:以为设了动态就能任意 shape。实际上超出了min到max范围,context.set_input_shape()会返回 False,然后execute_async会报错。这个检查一定要做,不要以为设了就万无一失。
3.3 先用 trtexec 跑通,再写代码
我一直建议的顺序是:先命令行验证,再写 Python。因为trtexec把所有可能的参数都暴露出来了,而且报错信息比 Python API 详细得多,定位问题快。
最小可用命令:
trtexec --onnx=model.onnx --saveEngine=model.plan --fp16如果是动态 shape:
trtexec --onnx=model.onnx --saveEngine=model.plan --fp16 \ --minShapes=input:1x3x224x224 \ --optShapes=input:8x3x224x224 \ --maxShapes=input:16x3x224x224这条命令会做三件事:解析 ONNX、构建 engine、把 engine 存盘。同时它还会跑一遍性能测试,输出每个层的耗时分解。这个层耗时分解特别有用,我经常用它来看哪一层最慢,是不是有某个算子掉到了不太理想的实现上。如果看到某个 Conv 的耗时占了大头而且数字很离谱,通常是这个卷积的 shape 或者输入布局不友好,比如通道数很小但 kernel 很大。
精度选择的参数也在这里。--fp16开启半精度,--int8开启八位整型,--best让它自己挑。实测下来绝大多数视觉模型开 FP16 精度损失都在千分位以内,速度收益 1.5 倍到 2 倍不等,看具体显卡。INT8 就要谨慎了,没有校准数据的情况下直接开,精度可能掉十几个点,完全不可用。关于 INT8 我们放到 3.5 单独说。
还有一个参数我强烈建议加上:--verbose。它会把构建过程的每一步打出来,包括解析到了多少层、哪些层被融合、哪一步耗时最长。第一次跑某个模型的时候,我基本都会开着它看一遍,心里有个数。
3.4 用 Python API 完整构建 engine
命令行验证通过之后,就可以写成脚本固化了,因为实际项目里你要做的事可能不止解析,还要插 plugin、改输出、加自定义层。完整的构建脚本大概长这样:
import tensorrt as trt def build_engine(onnx_path, engine_path, fp16=True, min_shape=(1, 3, 224, 224), opt_shape=(8, 3, 224, 224), max_shape=(16, 3, 224, 224), workspace_gb=4): 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(onnx_path, "rb") as f: if not parser.parse(f.read()): for i in range(parser.num_errors): print("parse error:", parser.get_error(i)) raise RuntimeError("ONNX parse failed") config = builder.create_builder_config() config.set_memory_pool_limit( trt.MemoryPoolType.WORKSPACE, workspace_gb * (1 << 30)) if fp16: config.set_flag(trt.BuilderFlag.FP16) dynamic = min_shape != max_shape if dynamic: profile = builder.create_optimization_profile() profile.set_shape("input", min_shape, opt_shape, max_shape) config.add_optimization_profile(profile) serialized = builder.build_serialized_network(network, config) if serialized is None: raise RuntimeError("engine build failed") with open(engine_path, "wb") as f: f.write(serialized) print("engine saved to", engine_path)几个参数的经验值。workspace_gb设 4 是个比较稳的起点,太小白某些 tactic 用不了会导致性能下降,太大对构建速度没有帮助还占显存。构建的时候如果显存不够会直接失败,这时候先看nvidia-smi,是不是别的进程占着。Logger.WARNING是生产用的级别,调的时候可以换成INFO或VERBOSE,日志量大但信息全。
build_serialized_network返回的是一个 host 内存对象,直接写文件就行,不需要先转 bytes。如果你用的是比较老的 TensorRT 8.x 版本,对应的可能是builder.build_engine()返回一个ICudaEngine对象,然后engine.serialize()得到同样的东西。新版把build_engine移除了,只保留序列化路径,所以网上很多老代码到你这里会直接报AttributeError,这就是版本差异。
3.5 INT8 量化:收益大但门槛也高
INT8 的吸引力在于,相比 FP16 再翻一倍的吞吐,显存带宽压力也再减半,对于显存吃紧或者追求极限吞吐的场景很有价值。但它需要校准,也就是拿一批有代表性的输入数据跑一遍,统计每一层激活值的分布,算出缩放系数。没有校准就强行开 INT8,TensorRT 会用默认系数,结果基本不可用。
校准数据的选取有个原则:要能覆盖线上真实分布。我见过用纯随机噪声做校准的,最后模型在某些真实样本上直接崩掉。一般几百张到一两千张就够,不必太多,但一定要有代表性。校准缓存的流程大致是用一个继承trt.IInt8EntropyCalibrator2的类实现get_batch和read_calibration_cache,然后挂到 config 上。
用trtexec会更省事:
trtexec --onnx=model.onnx --int8 --saveEngine=model_int8.plan \ --calib=calibration.cache \ --minShapes=input:1x3x224x224 --optShapes=input:8x3x224x224 \ --maxShapes=input:8x3x224x224第一次跑的时候缓存文件不存在,它会走校准流程并生成缓存;之后再跑就直接读缓存,速度快很多。
INT8 之后必须做精度对齐。我的做法是准备一百张左右的验证集图片,用 PyTorch 原始输出和 TensorRT 输出逐张比对,看余弦相似度或者 top-1 结果的一致率。如果一致率掉到 98% 以下,说明校准有问题,要么数据不够代表性,要么某些层不适合量化。TensorRT 提供了逐层精度对比的工具,可以定位到底哪一层量化后误差大,然后把那一层设成 FP16 或者 FP32,这是“混合精度”的常规做法。
4. 推理 Demo 编写:从加载到出结果
4.1 TensorRT 10 和 8.x 的 API 差得挺多
写推理代码前先确认版本,因为 TensorRT 10 的 API 变化不小,照着老博客抄很可能直接报错。主要差异集中在三块。
第一块是 IO 的访问方式。老版本用 binding 索引,engine.get_binding_shape(i)、engine.get_binding_dtype(i)、context.set_binding_shape(i, shape);新版本改成按张量名字,engine.get_tensor_name(i)、engine.get_tensor_shape(name)、context.set_input_shape(name, shape)。新方式更直观,也不容易索引错位。
第二块是执行方式。老版本是context.execute_async_v2(bindings, stream_handle),其中bindings是一个按 binding 顺序排列的设备指针列表;新版本是context.execute_async_v3(stream_handle),设备指针通过context.set_tensor_address(name, ptr)提前设好。新版本少了手动维护列表的麻烦。
第三块是网络创建。老版本必须显式传EXPLICIT_BATCH标志,新版本里隐式 batch 已经被移除,这个标志传不传都一样。所以你会看到两种写法在社区里并存,都不算错,但要知道自己在用哪个。
我下面的 demo 按 TensorRT 10 的写法来,8.x 的差异我标出来。
4.2 显存分配和数据搬运,这是性能的关键
很多人的 demo 跑通了但速度不理想,问题往往出在这一段。TensorRT 只负责算,数据从 CPU 到 GPU、结果从 GPU 回 CPU,这些都得你自己管。标准的做法是提前把输入输出缓冲区分配好,循环推理时只做拷贝,不做分配。
Python 侧一般用 pycuda 来做这件事,因为它对显存分配和异步拷贝的封装最直接:
import pycuda.driver as cuda import pycuda.autoinitpycuda.autoinit会自动初始化上下文,简单场景下很方便。但如果你的程序里已经有别的 CUDA 上下文(比如同时跑 PyTorch),就要小心上下文冲突,这时候最好显式管理。
输入侧用页锁定内存(pinned memory)能显著提升拷贝速度,因为普通内存的拷贝要走一次中转,页锁定内存可以直接 DMA:
h_input = cuda.pagelocked_empty(trt.volume(input_shape), dtype=np.float32) d_input = cuda.mem_alloc(h_input.nbytes)输出同理。这里的d_input是一个DeviceAllocation对象,传给 TensorRT 时需要int(d_input)转成地址。
还有一个容易忽略的点:循环推理时千万别在循环体里cuda.mem_alloc。显存分配是同步操作,而且会打断异步流水,看似只多了一行,实测能把吞吐拉低一大截。正确做法是在循环外分配好,循环内只做memcpy_htod_async和memcpy_dtoh_async。
4.3 一份能直接跑的完整推理脚本
下面这份代码是完整可运行的,改一下模型路径和 shape 就能用:
import numpy as np import tensorrt as trt import pycuda.driver as cuda import pycuda.autoinit class TRTEngine: def __init__(self, engine_path): self.logger = trt.Logger(trt.Logger.WARNING) with open(engine_path, "rb") as f: runtime = trt.Runtime(self.logger) self.engine = runtime.deserialize_cuda_engine(f.read()) if self.engine is None: raise RuntimeError("failed to deserialize engine") self.context = self.engine.create_execution_context() self.stream = cuda.Stream() self.buffers = {} for i in range(self.engine.num_io_tensors): name = self.engine.get_tensor_name(i) mode = self.engine.get_tensor_mode(name) dtype = trt.nptype(self.engine.get_tensor_dtype(name)) shape = tuple(self.engine.get_tensor_shape(name)) if mode == trt.TensorIOMode.INPUT: # 动态 shape 用 profile 里的 opt,实际调用时再 set_input_shape if -1 in shape: shape = tuple(self.context.get_tensor_shape(name)) shape = tuple(s if s > 0 else 1 for s in shape) h = cuda.pagelocked_empty(int(np.prod(shape)), dtype=dtype) d = cuda.mem_alloc(h.nbytes) self.buffers[name] = {"h": h, "d": d, "shape": shape, "dtype": dtype, "mode": trt.TensorIOMode.INPUT} else: h = cuda.pagelocked_empty(1, dtype=dtype) d = cuda.mem_alloc(1) self.buffers[name] = {"h": h, "d": d, "shape": shape, "dtype": dtype, "mode": trt.TensorIOMode.OUTPUT} def infer(self, inputs): # 1. 设置动态 shape 并重新分配输出缓冲 for name, data in inputs.items(): self.context.set_input_shape(name, data.shape) buf = self.buffers[name] if buf["shape"] != data.shape: buf["h"] = cuda.pagelocked_empty(int(data.shape[0] * np.prod(data.shape[1:])), dtype=buf["dtype"]) buf["d"] = cuda.mem_alloc(buf["h"].nbytes) buf["shape"] = data.shape for i in range(self.engine.num_io_tensors): name = self.engine.get_tensor_name(i) if self.engine.get_tensor_mode(name) == trt.TensorIOMode.OUTPUT: out_shape = tuple(self.context.get_tensor_shape(name)) buf = self.buffers[name] if buf["shape"] != out_shape: buf["h"] = cuda.pagelocked_empty(int(np.prod(out_shape)), dtype=buf["dtype"]) buf["d"] = cuda.mem_alloc(buf["h"].nbytes) buf["shape"] = out_shape # 2. 拷贝输入 for name, data in inputs.items(): buf = self.buffers[name] np.copyto(buf["h"], data.ravel()) cuda.memcpy_htod_async(buf["d"], buf["h"], self.stream) self.context.set_tensor_address(name, int(buf["d"])) # 3. 设置输出地址 for i in range(self.engine.num_io_tensors): name = self.engine.get_tensor_name(i) if self.engine.get_tensor_mode(name) == trt.TensorIOMode.OUTPUT: self.context.set_tensor_address(name, int(self.buffers[name]["d"])) # 4. 执行并取回 if not self.context.execute_async_v3(stream_handle=self.stream.handle): raise RuntimeError("execute failed") outputs = {} for i in range(self.engine.num_io_tensors): name = self.engine.get_tensor_name(i) if self.engine.get_tensor_mode(name) == trt.TensorIOMode.OUTPUT: buf = self.buffers[name] cuda.memcpy_dtoh_async(buf["h"], buf["d"], self.stream) outputs[name] = buf self.stream.synchronize() return {k: v["h"].reshape(v["shape"]) for k, v in outputs.items()}调用起来是这样:
engine = TRTEngine("model.plan") x = np.random.randn(1, 3, 224, 224).astype(np.float32) out = engine.infer({"input": x}) print(out["output"].shape)把这段和 PyTorch 的输出做数值对比,差异应该在 1e-3 以内(FP16 会更宽一点)。如果差异很大,先怀疑输入预处理是不是一致,比如归一化的均值方差、图像的通道顺序、是否做了 BGR 到 RGB 的转换。这些细节对不上,模型跑出来的结果是完全无意义的,但程序不会报错。
如果你的环境是 TensorRT 8.x,需要改的地方主要在infer里:把set_tensor_address和execute_async_v3换成bindings列表和execute_async_v2,set_input_shape换成context.set_binding_shape(index, shape)。
4.4 C++ 侧的几个关键差异
如果最终要上线,C++ 通常是绕不开的,因为 Python 侧的解释器开销、GIL、拷贝开销都会吃掉一部分性能。C++ 的核心流程和 Python 一致,但有几个地方要特别注意。
第一个是反序列化之后要检查engine->getNbIOTensors()是不是和你预期的一致,因为 engine 可能是旧版本构建的,能加载成功但 IO 布局不对。第二个是执行上下文可以考虑开多个,配合多流做并发,createExecutionContext()创建多个,每个绑定不同的流。第三个是enqueueV3返回 bool,一定要检查,返回 false 通常是 shape 不对或者地址没设。第四个是内存对齐,输入输出缓冲建议用cudaMalloc而不是malloc,跨设备拷贝才高效。
编译的时候要链nvinfer、nvinfer_plugin、cudart,头文件路径指到 TensorRT 的 include,库路径指到 lib。如果是动态库方式,运行环境里LD_LIBRARY_PATH一定要包含 TensorRT 的 lib 目录,否则启动就失败。
5. 踩坑记录与排查速查表
5.1 构建阶段的典型报错
构建阶段的问题最集中,我把遇到过的整理成表。
| 报错关键字 | 大概率原因 | 处理方式 |
|---|---|---|
Unsupported ONNX opcode | ONNX 里有 TensorRT 不认识的算子 | 换 opset、用 onnxsim 简化、写 plugin |
Assertion failed: dims.nbDims | 输入 shape 与 profile 不匹配 | 检查--minShapes写法和模型输入名 |
serialization failed | 显存或 workspace 不足 | 调小 workspace 或换更大的卡 |
Program overload/out of memory | 构建时显存不够 | nvidia-smi看占用,关掉其他进程 |
Input shape is not consistent | 动态维度没在 profile 里声明 | 补全 profile,所有动态维度都要覆盖 |
No importer registered for op | ONNX 版本太新 | 降 opset 版本重新导出 |
有一条经验特别值钱:构建失败的时候,把 logger 级别调到VERBOSE,然后仔细看最后二十行,几乎总能找到具体是哪一层出的问题。不要只看 Python 抛出来的那句RuntimeError,它只是结果,原因在前面。
还有一种迷惑情况:构建成功了,但推理结果全错。这时候先别怀疑 TensorRT,先怀疑输入。我遇到过的原因包括:PyTorch 里做了x = x / 255.0但推理代码里忘了、通道顺序 BGR 和 RGB 搞反了、NHWC 和 NCHW 布局没对上。排查方法是构造一个全 0 输入或者全 1 输入,两边跑,看中间层的输出是不是一致。全 0 输入下如果差异就很大,说明结构对不上而不是数值精度问题。
5.2 精度对齐的排查顺序
精度对不上是很常见的问题,我按这个顺序查,基本能定位到。
先确认精度模式。FP32 构建的 engine 应该和 PyTorch FP32 结果高度一致,如果这时候就有差异,那不是精度问题,是前面说的预处理问题或者 ONNX 导出问题。FP16 出现 1e-3 量级的差异是正常的,不用管。INT8 出现几个点的精度下降要按 3.5 说的逐层查。
再确认算子行为。有些算子在不同框架里默认行为不一样,比如某些归一化层的 epsilon 默认值、某些插值模式的坐标变换方式。这些差异在 ONNX 层面看不出来,但会体现在数值上。定位方式是逐层比对中间输出,把 PyTorch 里的中间激活值存下来,再在 ONNX 里对着同名节点取输出,一层层比。
最后才看是不是 TensorRT 优化导致的。极少数情况下某些融合会引入微小误差,可以用config.set_flag(trt.BuilderFlag.DISABLE_TIMING_CACHE)或者关掉某些优化做对照实验。不过这种情况真的很少,十次里有一次就不错了。
5.3 性能不达预期的几个原因
跑通了但速度不如预期,这个问题我见过太多次,原因无非几类。
最常见的是数据拷贝没做异步。如果你用的是memcpy_htod而不是memcpy_htod_async,整个流程就是串行的,GPU 算的时候 CPU 在等,CPU 拷贝的时候 GPU 在等。改成异步加双缓冲,实测吞吐能提升 30% 以上。
第二类是 batch 太小。batch 为 1 的时候,很多 kernel 的并行度根本吃不满,GPU 利用率可能只有 30%。如果延迟允许,把 batch 提到 4 或 8,单张图的平均耗时通常会降下来。这也是为什么 profile 里的 opt shape 要设成业务里常用的 batch,而不是 1。
第三类是输入尺寸和模型不匹配。比如模型是按 224 训练的,你喂 512 的图,计算量翻五倍,那慢是理所当然的。这种时候要么改模型,要么在预处理阶段 resize。
第四类是走了回退路径。某个算子 TensorRT 找不到高性能实现,会回退到效率较低的方式。trtexec --verbose的输出里能看到每一层的实现选择,如果看到某个层耗时异常,就是它了。解决办法是换一种等价的表达让 TensorRT 能识别,比如把某些 shape 操作合并、把 Conv 的 bias 显式写出来。
还有一类容易被忽略:PCIe 带宽。如果输入数据很大而且频繁传输,瓶颈可能在总线上而不是计算上。这时候要做的是把预处理也搬到 GPU 上,在 kernel 里直接做归一化和 resize,减少一次往返。
5.4 我踩过的两个坑
第一个坑是关于 engine 复用的。我以为 engine 只要 TensorRT 版本一致就能跨机器用,结果在一台机器上构建好拷到另一台,反序列化直接失败。后来才知道除了版本,显卡的计算能力(compute capability)也必须一致,不同架构的卡指令集不一样。所以现在我的流程里,engine 一律跟着部署环境走,构建脚本打包进部署流程,启动时检查一下有没有现成的 engine,没有再构建。
第二个坑是关于显存碎片的。服务长时间跑,每次请求重新分配输出缓冲,跑几个小时之后cudaMalloc开始失败。原因是反复分配释放造成了碎片。改成按最大 shape 预分配、运行时只做切片复用,问题就消失了。当然代价是显存占用按峰值算,但对于在线服务来说这是划算的,稳定性比省那点显存重要得多。
提示:如果你的服务需要在不同 batch 之间切换,最好把常用 batch 对应的 engine 都构建好,用的时候按 key 查表,而不是每次重新 build。构建一次几秒钟到几分钟,放在请求路径里是灾难。
6. 关于工具链版本的一个务实建议
说点我自己这几年折腾下来的体会。TensorRT 的版本更新很快,新版本往往带来更好的算子和更快的 kernel,但也带来 API 变动和兼容性问题。我现在的策略是:项目启动时锁定一套版本,包括 CUDA、TensorRT、驱动、PyTorch,全部写进镜像或者环境说明里,在项目周期内不动。等到项目稳定或者有明确的性能收益需求时,再整体评估是否升级,升级前跑一遍完整的精度和性能回归。
之所以这么保守,是因为我吃过一次亏:为了追一个新特性把 TensorRT 从 8.6 升到 10.x,结果 API 大改,推理代码里一堆get_binding_shape全要重写,同时某个自定义 plugin 在新版本下编译不过,卡了整整两天。那两天里业务侧还以为是我能力问题。后来我总结出一个原则:工具链升级要么在项目初期做,要么在项目结束做,绝不在中途做。
还有一个很小的技巧值得分享:构建 engine 的时候把构建日志和参数一起存下来,命名成model_fp16_b8_20240101.log这种形式。过了三个月回头查某个环境为什么性能差,你能靠这个日志立刻还原当时的构建条件,省掉大量回忆和猜测。这种习惯看起来琐碎,但在多环境、多版本的场景里,价值非常高。