ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TensorRT 部署全链路:环境安装、ONNX 转 engine 与推理加速

TensorRT 部署全链路:环境安装、ONNX 转 engine 与推理加速

TensorRT 这套东西,第一次上手的人大概率会在装环境那一步就被劝退:CUDA 版本对不上、cuDNN 找不到、import tensorrt报符号缺失、trtexec 跑起来又提示某个算子不支持。等环境终于通了,模型转过去精度掉一截,或者推理速度跟预期差出好几倍。这篇就按我自己的实操顺序,把 TensorRT 的安装、模型转换、推理 demo 编写这三段完整走一遍,中间该踩的坑、该核对的参数、该提前算好的尺寸都写清楚。内容偏工程落地,适合已经能把模型跑起来、想把它推到生产环境做推理加速的开发者,也适合刚接触推理引擎、想搞清楚 ONNX 到 engine 这条链路到底发生了什么的人。环境版本迭代快,具体版本号以你手头文档为准,但思路和排查方法是通用的。

1. 整体设计与思路拆解

1.1 从一次推理延迟说起:TensorRT 到底在优化什么

很多人对 TensorRT 的第一印象是"快",但快在哪,说不清楚。我做过一个图像分类模型的实际对比,同一张 224×224 的输入,在同一个 GPU 上,原生框架跑一次前向大概 8~12 毫秒,转到 TensorRT 之后稳定在 2 毫秒出头。这个差距不是靠"换个更快的库"实现的,而是 TensorRT 在构建 engine 阶段对整个计算图做了重构。

它做的事情大致分四层。第一层是图层面的等价变换,把能合并的算子合并掉,比如 Conv + Bias + ReLU 直接融合成一个节点,减少 kernel launch 次数和中间张量的显存读写。第二层是精度层面的选择,FP16 或 INT8 让每个乘加运算的吞吐翻倍,同时用不同的数据布局减少访存压力。第三层是kernel 自动调优,同一层卷积有几十种实现方式,TensorRT 会在构建阶段把候选 kernel 全部跑一遍,挑一个在当前 GPU 上最快的。第四层是显存规划,把整个推理过程中所有中间张量的生命周期算清楚,让它们复用同一块显存,避免反复申请释放。

理解这四层很重要,因为它直接决定了你后面调试时的判断方向。比如推理慢,可能是 kernel 没选中(shape 太怪),可能是精度没开,也可能是你在 Python 侧反复做了 host 和 device 之间的数据拷贝,白白浪费了优化收益。我见过太多人 engine 建好了,结果推理代码里每次都新建 context、每次都重新分配显存,实际耗时比原生框架还高,然后回头怀疑是 TensorRT 不行。

1.2 三条落地路线的取舍:ONNX 中转、原生 API 重建、框架内置集成

把模型送上 TensorRT,常见三条路。

第一条是 ONNX 中转:先把模型导出成 ONNX,再用 trtexec 或 Python API 把 ONNX 转成 engine。这条路覆盖的模型类型最广,PyTorch、Paddle 之类的框架只要支持 ONNX 导出就能走通,出问题的时候可以用 Netron 打开 ONNX 直接看图,定位是哪一层挂了。缺点是中间多一层翻译,某些自定义算子需要在 ONNX 里注册插件,转换链路会长一点。

第二条是用 TensorRT 的原生 API 手工重建网络,就是一层一层network.add_convolution那样写。这条路可控性最高,可以精确指定每一层的精度、输入输出,甚至跳过某些你没用到的分支。但工作量巨大,模型稍微改一下就要重写,一般只有极少数对性能极度敏感、或者结构特别简单的网络才这么干。

第三条是框架侧的内置集成,把 TensorRT 当作后端直接编译模型。这条路最省事,代码几乎不用改,但灵活度最低,很多构建参数(比如优化 profile、量化校准器)暴露得不完整,遇到问题不好调。

我的建议是:除非模型结构极简或者有极端性能诉求,一律先走 ONNX 这条线。它的调试信息最完整,社区里能查到的案例最多,出问题也知道去哪里找。等 ONNX 这条线跑通、性能数字心里有底了,再考虑要不要往更底层钻。

1.3 版本矩阵这门"隐形的必修课"

TensorRT 有一个特点,就是强依赖环境。它跟 CUDA 版本、cuDNN 版本、GPU 架构驱动版本是一套绑死的组合,任何一环对不上都跑不起来。更麻烦的是,TensorRT 自己的大版本之间 API 差异也很大,网上搜到的代码很可能是另一个大版本写的,直接抄会报一堆属性不存在的错误。

所以我一般会先做一件事:把当前的组合固定下来,写进项目文档,后面所有人按这个来。固定的时候要看三个东西,一是 GPU 的算力等级(决定能用哪些 kernel 和精度模式),二是 CUDA 的运行版本,三是 TensorRT 的版本。这三者的对应关系在每个版本的官方文档里都有一张表,安装前先看表,不要装完再查。

一个很典型的坑是 pip 安装。TensorRT 从某个大版本开始,pip 包名会带上 CUDA 主版本后缀,装错了包名,import tensorrt虽然成功,但一建 engine 就报符号找不到。判断方法很简单,装完之后立刻跑一次版本打印和一次空网络构建,两个都过了才算装对。

2. 环境准备与安装:把版本核对做在动手之前

2.1 先确认三件事,再决定怎么装

动手之前,我习惯先跑三条命令把底摸清。

nvidia-smi nvcc --version python -c "import torch; print(torch.version.cuda)"

第一条看驱动版本和 GPU 型号,第二条看 CUDA 编译器版本,第三条看当前框架编译时用的 CUDA 版本。这里有个细节特别容易出问题:nvidia-smi右上角显示的是驱动支持的最高 CUDA 版本,不是当前实际使用的版本。真正决定你编译环境的是nvcc --version。我遇到过不止一次,nvidia-smi显示支持 12.x,nvcc却是 11.x,然后照着 12 的文档装 TensorRT,最后一个符号都对不上。

还有一个隐藏项是 Python 版本和虚拟环境。TensorRT 的 wheel 包是按 Python 版本和平台编译的,3.8 和 3.11 装的是不同的包。如果项目里有多个 Python 版本,务必在虚拟环境里装,别装到系统 Python 上,后面换项目会打架。我的习惯是每个 TensorRT 项目单独开一个 conda 环境,环境名里带上 TRT 版本和 CUDA 版本,比如trt10_cu12,一眼就知道自己是哪套。

2.2 pip 安装和压缩包安装的差别在哪

pip 安装是最省事的,一条命令把库和 Python 绑定都装好,适合快速验证和 Python 侧推理的场景。它的坑主要在包名和后缀:CUDA 12 对应的包和 CUDA 11 对应的包不是同一个名字,装错了可能装的是另一套运行时。装完之后建议立刻核对安装路径,确认它装到了你当前激活的环境里,而不是全局。

压缩包安装更接近"完整版",除了 Python 绑定,还带 C++ 头文件、静态库、trtexec命令行工具、polygraphy之类的辅助工具。如果你后面要写 C++ 推理程序,或者想用trtexec快速做转换验证,就必须用这种方式。步骤是解压、把库目录加到LD_LIBRARY_PATH、把 Python 目录加到PYTHONPATH、再装对应版本的 Python wheel。

export TRT_PATH=/opt/TensorRT export LD_LIBRARY_PATH=$TRT_PATH/lib:$LD_LIBRARY_PATH export PATH=$TRT_PATH/bin:$PATH export PYTHONPATH=$TRT_PATH/python:$PYTHONPATH

这几行我建议直接写进~/.bashrc或者项目的env.sh里,每次开新终端都 source 一下。用临时 export 的方式,换个窗口就失效,排查问题时容易误判。

容器化部署的,直接拉带 TensorRT 的基础镜像最省心,版本组合是别人调好的,你只要保证宿主机的驱动版本够新就行。这是我目前最推荐的生产环境做法,尤其是多机部署的时候,环境一致性比什么都重要。

2.3 装完必须做的两次自检

装完不要急着写代码,先做两次自检。

第一次是最小导入检查:

python -c "import tensorrt as trt; print(trt.__version__); print(trt.Logger(trt.Logger.WARNING))"

能打印出版本号并且成功构造 Logger,说明 Python 绑定和底层动态库都加载正常。如果这里报libnvinfer.so找不到,就是LD_LIBRARY_PATH没配好;如果报undefined symbol,基本是版本不匹配,Python 包和底层库不是同一个版本。

第二次是构建一个空 engine,验证 builder 链路是否完整:

import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) config = builder.create_builder_config() print("builder ok", builder.platform_has_fast_fp16, builder.platform_has_fast_int8)

platform_has_fast_fp16和platform_has_fast_int8这两个布尔值很有用,它告诉你当前 GPU 是不是有专门针对半精度或八位整数的加速单元。如果 FP16 是 False,你后面开了 FP16 标志也拿不到多少收益,就别在这上面浪费调试时间。有trtexec的话再跑一下trtexec --version,能出来就说明命令行工具也通了。

3. 模型转换:从框架权重到可执行的 engine

3.1 ONNX 导出这一步,参数比想象中重要

导出 ONNX 是整条链路里最容易埋雷的一步。我总结下来有四个参数必须显式指定:opset 版本、输入输出名字、动态轴、模型是否处于推理模式。

import torch model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "model.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch"}, "output": {0: "batch"}, }, do_constant_folding=True, )

model.eval()不能省。训练模式下的 Dropout 和 BatchNorm 行为和推理时不一样,导出来的图会带着训练态的逻辑,结果就是精度对不上,而且这种偏差往往很隐蔽,只在某些样本上表现出来。torch.no_grad()也建议套上,避免导出时把梯度相关的节点带进图里。

opset 版本的选择有个经验值:不要用太新的,也不要用太旧的。太旧的 opset 缺少某些算子的标准定义,导出时会退化成一堆基础算子拼凑,图变得又大又慢;太新的 opset 可能你的 TensorRT 版本还没支持,解析时直接报不支持。我一般选当前 TensorRT 版本明确支持的中档 opset,堵车的时候往下降一档。

动态轴要不要设,取决于你的实际场景。如果推理时 batch 固定就是 1,别设动态轴,静态 shape 能让 TensorRT 把 kernel 调到最优。如果有变长输入或者变 batch 的需求,就必须在导出时声明动态轴,否则转换后的 engine 只认那一个尺寸。

3.2 用 trtexec 做第一次转换验证

ONNX 导出之后,我强烈建议先用trtexec转一遍,而不是直接上 Python API。原因是trtexec的报错信息更具体,能直接告诉你哪一层不支持、哪个算子需要插件,而且它自带性能测试,转完顺手就能看到吞吐和延迟。

trtexec \ --onnx=model.onnx \ --saveEngine=model_fp16.plan \ --fp16 \ --minShapes=input:1x3x224x224 \ --optShapes=input=8x3x224x224 \ --maxShapes=input=16x3x224x224 \ --memPoolSize=workspace:4096MiB \ --verbose

这里有几个点值得展开。--fp16是开启半精度,前提是前面自检里platform_has_fast_fp16为 True。三个 shape 参数对应优化 profile,opt是告诉 builder"这个尺寸最常用,请围绕它调优",min和max是允许的边界。如果你只写--shapes不写这三个,那就是静态 shape,engine 只能接受那一个尺寸的输入。

--memPoolSize是工作区大小,构建阶段 TensorRT 需要临时显存放一些候选 kernel 和中间结果。这个值给太小,某些层会因为找不到可行方案而回退到慢速实现,甚至直接构建失败。给太大又浪费显存。我的经验是从 1GB 起步,看 verbose 日志里有没有 "skipping" 之类的提示,有就往上加。旧版本里这个参数叫--workspace,单位是 MB,新版本改成了带单位的写法,抄网上代码的时候注意对一下。

跑完之后看输出里的Throughput和GPU Compute Time两行,这就是你的性能基线。把这个数字记下来,后面用 Python API 构建的 engine 应该和它在一个量级,差太多说明参数没对齐。

3.3 动态 shape 的 profile 到底怎么算

优化 profile 是新手最容易糊弄过去、然后在推理时疯狂报错的地方。三个 shape 的取值不是随便填的,opt应该填你线上流量里出现频率最高的那个尺寸,因为 TensorRT 会围绕它挑 kernel,其他尺寸走的是次优实现。

举个例子,做文本分类,序列长度分布在 16 到 512 之间,大部分请求集中在 128 左右。那min填 1×16,opt填 8×128,max填 16×512,就是合理的。如果你把opt随便填成最大值,那大部分请求都会跑在非最优 kernel 上,性能反而不如静态 shape。

还有一个细节:推理时的实际 shape 必须落在 min 和 max 之间,且维度数必须和声明一致。少一维、多一维、超上界,都会在建 context 或者 set input shape 的时候报错。我习惯在推理代码里加一层 shape 断言,把这种错误挡在业务逻辑外面,而不是等 TensorRT 抛出难懂的堆栈。

3.4 Python API 构建 engine 的完整流程

trtexec验证通过之后,就可以把这套逻辑搬到代码里,方便做参数化和批量化。

import tensorrt as trt TRT_LOGGER = trt.Logger(trt.Logger.INFO) def build_engine(onnx_path, plan_path, fp16=True): 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: if not parser.parse(f.read()): for i in range(parser.num_errors): print(parser.get_error(i)) raise RuntimeError("onnx parse failed") config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30) profile = builder.create_optimization_profile() profile.set_shape( "input", (1, 3, 224, 224), (8, 3, 224, 224), (16, 3, 224, 224), ) config.add_optimization_profile(profile) if fp16 and builder.platform_has_fast_fp16: config.set_flag(trt.BuilderFlag.FP16) serialized = builder.build_serialized_network(network, config) if serialized is None: raise RuntimeError("build engine failed") with open(plan_path, "wb") as f: f.write(serialized) return serialized build_engine("model.onnx", "model.plan")

这段代码有几个地方值得强调。EXPLICIT_BATCH标志在新版本里基本是默认行为,但写上不会有坏处,老版本必须写。解析失败的时候一定要把parser.get_error(i)打印出来,这是定位"哪一层不支持"的唯一线索,不打印就只能看到一个False,完全没法排查。

build_serialized_network返回的是序列化后的字节流,直接写文件就是.plan或者.engine。这个文件绑定了构建时的 GPU 架构和 TensorRT 版本,换一张卡、换一个版本,反序列化要么失败要么跑出错误结果。所以生产环境里我不建议把一个 engine 文件到处拷,正确做法是每台机器上构建一次,或者按 GPU 型号分别存。

还有一种情况是构建耗时太长。大模型构建十几分钟很正常,因为要把所有候选 kernel 跑一遍。这种场景可以开BuilderFlag.TIMING_CACHE,把 kernel 选择结果缓存下来,下次构建同一个模型能快很多。缓存文件同样绑定硬件,换机器要重新生成。

3.5 FP16 和 INT8:什么时候值得多花这一道工序

FP16 基本是默认选项,绝大多数视觉模型在 FP16 下精度损失可以忽略,速度能提升接近一倍。但不是所有层都适合转半精度,某些做归一化或者数值范围很大的层,半精度会溢出成 NaN。遇到这种情况,可以用层的精度约束把特定层钉在 FP32:

for i in range(network.num_layers): layer = network.get_layer(i) if layer.type == trt.LayerType.REDUCE or layer.name.startswith("norm"): layer.precision = trt.DataType.FLOAT for j in range(layer.num_outputs): layer.set_output_type(j, trt.DataType.FLOAT)

INT8 的收益更大,但工序也重得多,需要一份校准数据集让 TensorRT 统计每层激活值的动态范围,然后据此确定量化比例。校准集不用太大,几百张有代表性的样本就够,但分布必须贴近线上真实数据,用训练集随手抽几百张往往效果不好。校准器一般用熵校准,实现一个IInt8EntropyCalibrator2子类,把预处理后的数据按批次喂进去。

我的判断标准很直接:如果 FP16 已经能满足延迟要求,就别碰 INT8。INT8 的调试成本高,精度掉了之后定位是哪一层的问题很费时间,而且换一个模型就得重新校一遍。真要做,先在离线评测集上把精度对齐工作做扎实,别等上线了才发现某些样本的结果明显跑偏。

4. 推理 Demo 编写:从加载 engine 到跑通一次前向

4.1 加载与反序列化的正确姿势

推理侧第一步是反序列化。这里有一个常被忽略的细节:反序列化出来的 engine 是只读的,可以被多个 context 共享;context 才是保存推理中间状态的对象,不是线程安全的。所以正确的做法是全局持有一个 engine,每个线程或者每个并发槽位各自持有一个 context。

import tensorrt as trt TRT_LOGGER = trt.Logger(trt.Logger.WARNING) class EngineWrapper: def __init__(self, plan_path): with open(plan_path, "rb") as f: serialized = f.read() runtime = trt.Runtime(TRT_LOGGER) self.engine = runtime.deserialize_cuda_engine(serialized) if self.engine is None: raise RuntimeError("deserialize failed") self.context = self.engine.create_execution_context()

反序列化失败最常见的原因是版本或硬件不匹配,报错信息一般是空指针或者断言失败。排查顺序是:先确认 engine 是在同一版本的 TensorRT 上构建的,再确认 GPU 型号一致。如果是容器环境,还要确认容器里挂载的 GPU 和构建时一致。

4.2 输入输出绑定:两个大版本的写法差异

这里是整篇文章里我最想强调的部分,因为网上大量代码是旧版本写的,直接抄到新版本上一行都跑不通。两个大版本的内存绑定方式完全不同。

旧版本的写法是靠 binding 索引:

bindings = [] for i in range(engine.num_bindings): name = engine.get_binding_name(i) size = trt.volume(engine.get_binding_shape(i)) * np.dtype(np.float32).itemsize d_ptr = cuda.mem_alloc(size) bindings.append(int(d_ptr))

新版本改成了名字寻址:

for i in range(engine.num_io_tensors): name = engine.get_tensor_name(i) mode = engine.get_tensor_mode(name) dtype = trt.nptype(engine.get_tensor_dtype(name)) shape = engine.get_tensor_shape(name) # 动态 shape 下需要先 set_input_shape 再计算实际大小

怎么快速判断自己该用哪套?看有没有num_io_tensors这个属性,有就是新版本,只有num_bindings就是旧版本。这个问题我见过太多次,有人把两套代码混着写,一会儿get_binding_shape一会儿get_tensor_name,不报错才怪。

动态 shape 的模型还有一步不能漏:推理前必须先调context.set_input_shape("input", (batch, 3, 224, 224)),然后再查询该层的实际分配大小。因为显存大小是随输入尺寸变的,先分配再设 shape,分配的就是错的尺寸。

4.3 一份能直接跑的 Python 推理脚本

把上面的东西串起来,一个完整的前向流程长这样:

import numpy as np import pycuda.driver as cuda import pycuda.autoinit import tensorrt as trt class TRTInfer: def __init__(self, plan_path): logger = trt.Logger(trt.Logger.WARNING) with open(plan_path, "rb") as f: self.engine = trt.Runtime(logger).deserialize_cuda_engine(f.read()) self.context = self.engine.create_execution_context() self.stream = cuda.Stream() self.buffers = {} self.outputs = [] 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)) if mode == trt.TensorIOMode.INPUT: shape = self.engine.get_tensor_shape(name) if -1 in shape: shape = tuple(self.engine.get_tensor_profile_shape(name, 0)[1]) self.context.set_input_shape(name, shape) size = int(np.prod(shape)) host = np.empty(size, dtype=dtype) device = cuda.mem_alloc(host.nbytes) self.buffers[name] = (host, device, shape, dtype) self.context.set_tensor_address(name, int(device)) else: shape = tuple(self.context.get_tensor_shape(name)) size = int(np.prod(shape)) host = np.empty(size, dtype=dtype) device = cuda.mem_alloc(host.nbytes) self.buffers[name] = (host, device, shape, dtype) self.context.set_tensor_address(name, int(device)) self.outputs.append(name) def infer(self, x): in_name = [n for n in self.buffers if n not in self.outputs][0] host, device, shape, dtype = self.buffers[in_name] np.copyto(host, x.ravel().astype(dtype)) cuda.memcpy_htod_async(device, host, self.stream) self.context.execute_async_v3(stream_handle=self.stream.handle) results = {} for name in self.outputs: h, d, s, dt = self.buffers[name] cuda.memcpy_dtoh_async(h, d, self.stream) results[name] = (h, s, dt) self.stream.synchronize() return {k: v[0].reshape(v[1]) for k, v in results.items()}

这段代码的核心思路是所有显存只分配一次,推理时反复复用。输入数据通过np.copyto写进预先分配好的 host 缓冲,再异步拷到 device,执行完再异步拷回来,中间没有任何一次动态分配。如果每帧都cuda.mem_alloc,性能会被显存分配吃掉一大半。

还有一个优化点:如果输入尺寸是固定的,可以在初始化时把 host 到 device 的拷贝也预先绑定好,用cuda.memcpy_htod_async的时候直接传同一块内存,减少 Python 层的对象创建开销。我在一个视频流场景里做过这个优化,单帧端到端延迟从 4.2 毫秒降到 3.1 毫秒,主要省掉的就是 Python 侧的内存管理开销。

4.4 异步与多流:什么时候需要,什么时候是自找麻烦

单路推理用上面那份脚本就够了。多路并发的时候要考虑两件事:context 复用和多流调度。

context 不能跨线程共享,所以并发数 N 就要 N 个 context,但可以共享同一个 engine。写法上是维护一个 context 池,用的时候借,用完还。别每个请求都create_execution_context(),创建销毁本身是有开销的,而且容易把显存撑爆。

多流是用来让多个 context 的计算重叠执行的。给每个 context 配一个独立的 CUDA stream,execute_async_v3里传各自的 handle,多个 kernel 就能在 GPU 上并发调度。但要注意 GPU 的并发能力是有限的,流开太多反而会因为资源竞争导致整体延迟上升。我一般按 GPU 的 SM 数量和单次推理的显存占用估一个上限,实际压测下来 4 到 8 路是比较常见的甜点区。

还有一个容易被忽视的点:首次推理特别慢。因为 kernel 需要做一次 JIT 加载和显存预热,第一帧耗时可能是稳定状态的好几倍。生产环境里必须做 warmup,启动之后先跑 10 到 20 次空推理,把状态预热好再对外提供服务,否则第一批请求会明显超时。

4.5 C++ 侧的骨架与差异点

Python 版跑通之后,如果有低延迟或者嵌入式的需求,会转到 C++。骨架的逻辑是一样的,差别在资源管理上更严格。

std::ifstream file(plan_path, std::ios::binary); file.seekg(0, file.end); size_t size = file.tellg(); file.seekg(0, file.beg); std::vector<char> buffer(size); file.read(buffer.data(), size); auto runtime = nvinfer1::createInferRuntime(logger); auto engine = runtime->deserializeCudaEngine(buffer.data(), size); auto context = engine->createExecutionContext(); void* d_input = nullptr; cudaMalloc(&d_input, input_bytes); context->setTensorAddress("input", d_input); context->setInputShape("input", nvinfer1::Dims4{batch, 3, 224, 224});

C++ 侧最容易出错的是内存生命周期。cudaMalloc出来的指针必须在 engine 销毁之前释放,顺序反了会崩。另外deserializeCudaEngine用的 buffer 在函数返回后就可以释放了,engine 内部已经拷贝了一份,不需要一直留着。这一点很多人会按 Python 的思维理解错,以为要保住原始 buffer。

编译的时候链接要带上nvinfer、nvinfer_plugin、cudart,头文件路径指向解压出来的 include 目录。如果链接时报未定义符号,八成是库目录版本和头文件版本不一致,回去核对一下LD_LIBRARY_PATH里的库版本号。

5. 常见问题与排查实录

5.1 报错速查表

现象大概率原因处理方式
import tensorrt报找不到 so库路径没进环境变量配LD_LIBRARY_PATH并 source
报undefined symbolPython 包与底层库版本不一致统一到同一版本重装
ONNX 解析返回 False算子不支持或 opset 过高打印 parser 错误,降 opset 或加插件
构建时报 out of memoryworkspace 太大或模型太大调小 memPoolSize,或分批构建
推理时 shape 报错超出 profile 范围或维度不符核对 min/opt/max 与实际输入
输出全是 NaN某层半精度溢出该层钉 FP32 重构建
推理结果与原生框架偏差大导出时未 eval、或动态轴设置错重导出并逐层比对
首次推理极慢未做 warmup启动后空跑若干次
多线程下结果错乱context 被跨线程共享每线程一个 context
换机器反序列化失败engine 绑定硬件与版本在目标机器重新构建

这张表我建议直接贴到项目 wiki 上,团队里谁遇到问题先对一遍,能省掉大量重复沟通。

5.2 精度对齐怎么做才有效率

精度问题是最难查的,因为报错信息不会告诉你哪一层出了问题。我的做法是逐层导出中间结果做比对。

先把 ONNX 用推理框架跑一遍,把每一层的输出 dump 下来。再用polygraphy或者trtexec的--dumpLayerOutput把 TensorRT 每一层的输出也 dump 下来。然后按层名对齐,算最大绝对误差和相对误差。正常情况下误差应该在 1e-3 量级以内,如果某一层突然跳到 1e0 甚至出 NaN,问题就定位到那一层了。

常见的罪魁祸首有三类:一是归一化层在半精度下数值不稳定,二是大范围的激活值(比如 softmax 前的 logits)超出 FP16 表示范围,三是某个自定义算子的 ONNX 实现和原框架语义不完全一致。前两类用精度约束解决,第三类要回到导出环节,检查那个算子的导出实现。

还有一个小技巧:先用 FP32 构建一版 engine,和原生框架比对。如果 FP32 就偏了,说明问题在导出或者算子语义,跟精度无关;如果 FP32 正常、FP16 偏了,那就是数值精度问题。这一步能把排查范围砍掉一半。

5.3 性能不达预期的排查顺序

engine 建好了但速度不理想,我一般按这个顺序查。

先看是不是卡在数据搬运上。用 profiler 看时间分布,如果 host 到 device 的拷贝占了三分之一以上,说明瓶颈根本不在计算。解决办法是减少拷贝次数、用 pinned memory、或者把预处理也搬到 GPU 上做。

再看是不是没吃到精度红利。确认构建日志里 FP16 标志真的生效了,trtexec --verbose的输出里会明确写哪些层用了半精度。有时候模型里有几层不支持半精度,整个网络就被拖回混合精度模式,收益大打折扣。

然后看shape 是不是太怪。比如输入是 1×3×225×225 这种非 2 的幂尺寸,某些卷积实现就用不上,kernel 会退化。把尺寸对齐到 32 的倍数,往往能有明显提升。

最后看是不是并发没打满。单路延迟低不代表吞吐高,如果 GPU 利用率只有 30%,说明请求之间没有重叠。这时候加并发、加流,比继续抠单路延迟更有效。

6. 几个踩过才知道的细节

先说 engine 文件的管理。我一直坚持engine 不进代码仓库,因为它绑硬件绑版本,提交上去只会让其他人拉到一堆用不了的文件。正确做法是仓库里放 ONNX 和构建脚本,CI 里按目标机器的配置现场构建,构建产物按 GPU 型号和版本号命名存到对象存储。这样换机器、升级版本都不会乱。

再说一个关于 profile 的实战教训。有一次线上流量里突然混进了一批长文本,长度超过了当初设的 max,整个请求直接抛异常。后来我把 max 调大了一档,同时在前置环节加了长度截断,两个措施一起上才稳。这件事让我意识到,profile 的 max 不能只按"设计上限"设,得按"可能出现的异常上限"留一点余量,然后在业务层再做一层兜底。

最后是一个关于 context 数量的小发现。我原本以为并发数开得越多吞吐越高,实测下来到 8 路之后就基本平了,再往上加只是把延迟拉长。原因是 GPU 的计算单元被打满了,多出来的 context 只是在排队。所以并发数这个东西要压测出来,不能拍脑袋定。

还有一个容易被忽略的点是日志级别。开发阶段把 logger 设成 VERBOSE,能看到每一层的融合情况和精度选择;上线之后一定要调回 WARNING,因为 VERBOSE 模式下构建和推理的日志量非常大,会拖慢启动速度,还可能把磁盘写满。这个切换我一般做成配置项,通过环境变量控制,不用改代码。

关于版本升级,我的态度是除非有明确收益,否则不轻易动。TensorRT 大版本之间的 API 变动不小,一次升级可能要改推理代码、重新验证精度、重新压测性能,成本比想象中高。真要用到新版本的某个特性,比如新的量化方式或者对某个算子的支持,那就先在独立环境里把整条链路跑通、精度和性能都对齐了,再动生产环境。中间这个过程我一般会留出至少两周的验证窗口,不赶时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进