
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.xnvcc却是 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. 模型转换从框架权重到可执行的 engine3.1 ONNX 导出这一步参数比想象中重要导出 ONNX 是整条链路里最容易埋雷的一步。我总结下来有四个参数必须显式指定opset 版本、输入输出名字、动态轴、模型是否处于推理模式。import torch model.eval() dummy torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, model.onnx, opset_version17, input_names[input], output_names[output], dynamic_axes{ input: {0: batch}, output: {0: batch}, }, do_constant_foldingTrue, )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 \ --onnxmodel.onnx \ --saveEnginemodel_fp16.plan \ --fp16 \ --minShapesinput:1x3x224x224 \ --optShapesinput8x3x224x224 \ --maxShapesinput16x3x224x224 \ --memPoolSizeworkspace:4096MiB \ --verbose这里有几个点值得展开。--fp16是开启半精度前提是前面自检里platform_has_fast_fp16为 True。三个 shape 参数对应优化 profileopt是告诉 builder这个尺寸最常用请围绕它调优min和max是允许的边界。如果你只写--shapes不写这三个那就是静态 shapeengine 只能接受那一个尺寸的输入。--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×16opt填 8×128max填 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, fp16True): 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。遇到这种情况可以用层的精度约束把特定层钉在 FP32for 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, dtypedtype) 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, dtypedtype) 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_handleself.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 streamexecute_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::vectorchar 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 变动不小一次升级可能要改推理代码、重新验证精度、重新压测性能成本比想象中高。真要用到新版本的某个特性比如新的量化方式或者对某个算子的支持那就先在独立环境里把整条链路跑通、精度和性能都对齐了再动生产环境。中间这个过程我一般会留出至少两周的验证窗口不赶时间。