资讯动态

TensorRT模型部署全链路:环境、ONNX转换与推理调优

发布时间:2026/10/27 16:03:42 来源:尧图企业网站定制
做深度学习部署这行绕不开的一件事就是把训练好的模型真正跑快起来。很多同行的经历都差不多PyTorch 里推理一轮要几十毫秒业务方要求压到十毫秒以内于是开始接触 TensorRT。但 TensorRT 安装、模型转换、推理 demo 编写这条链路坑的密度远比想象中高——版本对不上、ONNX 转过去算子不支持、转换成功了但输出和原模型差一大截、demo 跑起来显存泄漏。我前后在几个项目里反复趟过这条链路中间经历过“装了两天环境”“转换脚本改了十几版”“推理结果对不上排查一整晚”这些事。这篇文章就把 TensorRT 从装环境、转模型到写出一个能长期用的推理骨架完整讲一遍重点放在那些文档里不写、但实际会卡住你的地方。适合已经能跑通 PyTorch 推理、准备上生产加速的读者也适合刚接手部署工作、对着官方样例不知从哪下手的朋友。1. 版本这张网先理清楚否则后面全是无用功1.1 一次“装完就报错”的现场还原我最开始接触 TensorRT 的时候思路很朴素装个包导入跑。结果import tensorrt直接抛ImportError: libnvinfer.so.8: cannot open shared object file。查了半天才发现Python 包只是外壳真正干活的是一堆 C 动态库而这些库对 CUDA 版本有硬性要求。你把 CUDA 12 的驱动环境和一个只编了 CUDA 11 的 TensorRT 放一起它不会告诉你“版本不匹配”只会告诉你“找不到某个 .so”。这件事的本质是TensorRT 不是孤立的一个库它处在一条链条的末端。显卡驱动决定能装哪个 CUDA 运行时CUDA 运行时决定能装哪个 cuDNNcuDNN 和 CUDA 一起决定能装哪个 TensorRTTensorRT 又决定你的 ONNX Parser 支持哪些算子。任何一环错位表现出来往往是最后那一步报错让人误以为是 TensorRT 自己的问题。我后来养成的习惯是动手之前先把四个数字写在一张纸上驱动版本、CUDA 版本、cuDNN 版本、TensorRT 版本。这四个数字确定了后面所有事才有讨论的基础。如果你还没有开始先跑一遍nvidia-smi看驱动再跑nvcc -V看 CUDA 运行时两个数字可能不一致这很正常——驱动是向下兼容的但 TensorRT 认的是运行时版本。1.2 四个版本号之间的绑定关系这里有一张我在实际项目里反复对照的对应关系表需要说明的是具体的小版本组合一定要以官方 release notes 为准下表只是帮你建立大致印象避免犯方向性错误。组件作用选错的典型症状判断依据显卡驱动提供 CUDA 运行时基础容器内nvidia-smi无输出nvidia-smi返回的驱动号CUDA 运行时TensorRT 编译目标平台libcudart.so找不到或版本冲突nvcc -V与ls /usr/local/cuDNN部分算子底层依赖推理时崩在卷积相关调用cuDNN 版本与 CUDA 大版本匹配TensorRT图优化与执行引擎Parser 不支持某算子、精度异常官方 release notes 的 CUDA 列比较容易踩的一个坑是“驱动够新但 CUDA 装了两个版本”。系统里/usr/local/cuda是个软链接指向哪个版本编译器就用哪个版本但 TensorRT 的 pip 包是按 CUDA 大版本分发的。你装的是tensorrt-cu12系列系统nvcc却是 11.8编译自定义 plugin 的时候就会报一堆找不到符号。这种情况我遇到过两次一次是镜像里预装了旧 CUDA一次是有人手动又装了一遍新 CUDA 但没改软链接。提示不要在同一个环境里装多个 CUDA 大版本再指望“让它自己挑”。编译期和运行期用不同版本是很常见的翻车源头能一个就一个多版本用容器隔离。1.3 三条安装路线各自适合谁TensorRT 官方给的安装方式大致三类pip 包、tar 压缩包、容器镜像。这三条不是并列关系而是对应三种不同的使用场景选错了会很别扭。pip 包适合纯 Python 推理、不想碰 C 的场景。优点是安装干净卸载也干净配合虚拟环境可以做到版本互不干扰。缺点是它自带的头文件和库文件路径比较深如果你要写 C 或者编译自定义插件找路径会花时间。另外 pip 包通常只覆盖主流的 TensorRT 组件一些额外工具比如某些版本的trtexec可能不含在包里。tar 压缩包适合需要 C 接口、需要完整工具链的场景。解压后是一个独立目录里面有bin、lib、include、python结构清晰环境变量一设就能用。好处是可以把整个目录塞进自己的构建系统缺点是需要自己管环境变量和动态库路径卸载时容易留下脏东西。容器镜像适合需要环境完全可复现的场景尤其是团队协作。镜像里已经配好了驱动之外的所有东西换台机器一样能跑。缺点是镜像体积大而且容器内不能用比宿主机驱动更新的 CUDA 特性这个限制经常被人忽略。我的建议是如果你的工作只是“把 ONNX 转成 plan 再用 Python 跑”直接 pip如果要写 C 服务或者调 plugin用 tar 包如果是多人协作、要交付给运维用容器。1.4 一个五行的自检脚本装完先跑它不管用哪种方式装完我都会先跑一段自检代码确认库真的能加载而不是等写了几百行推理代码才发现环境有问题。import tensorrt as trt import torch logger trt.Logger(trt.Logger.WARNING) print(TensorRT:, trt.__version__) print(CUDA available:, torch.cuda.is_available()) print(Device:, torch.cuda.get_device_name(0)) print(Capability:, torch.cuda.get_device_capability(0)) print(Has onnx parser:, hasattr(trt, OnnxParser))这段代码的价值在于提前暴露两类问题一是动态库加载失败import就挂了二是 ONNX Parser 缺失。后者在一些精简安装包里很常见import tensorrt成功但trt.OnnxParser是None等到转换模型时才报错容易误判成模型问题。同时打印计算能力Capability也很关键因为不同架构的显卡支持的精度模式不同比如某些老卡没有高效的低精度张量核心你在配置里开了 FP16 也未必有加速甚至可能更慢。2. 安装落地三条路径的实操细节和踩坑点2.1 pip 安装的边界与虚拟环境策略pip 安装看起来最简单但有两个细节不注意会很难受。第一是包名变化。早期版本拆成tensorrt和tensorrt-libs两个包后来新版本对 CUDA 大版本做了区分包名里带cu12之类的后缀。如果你照着两年前的教程敲命令很可能装出一个和系统 CUDA 不匹配的组合。我的做法是先看官方文档里的安装章节当前给的是哪条命令不要凭记忆。第二是虚拟环境。TensorRT 的 pip 包体积不小而且不同项目可能需要不同大版本老项目还在用 8.x新项目上 10.x 是很常见的现实。用 conda 或 venv 隔离是必须的。我见过有人在系统 Python 里装了 8.x又装了一遍 10.x最后import tensorrt加载到哪个全看路径顺序排查起来极其痛苦。安装命令大致长这样具体包名以你环境的 CUDA 版本为准python -m venv trt-env source trt-env/bin/activate pip install --upgrade pip pip install tensorrt pip install cuda-pythoncuda-python不是必须的但我建议装上。写推理 demo 时需要申请设备显存、做主机到设备的内存拷贝早期大家用pycuda后来官方推荐cuda-python。两者的差别在于cuda-python由官方维护和 CUDA 版本的对应关系更清晰pycuda编译期依赖比较重环境一换就要重新编。2.2 tar 包安装环境变量里最容易漏的两行tar 包的安装动作就是解压加设环境变量。解压到一个固定路径比如/opt/TensorRT然后在~/.bashrc里加export TRT_HOME/opt/TensorRT export LD_LIBRARY_PATH$TRT_HOME/lib:$LD_LIBRARY_PATH export PATH$TRT_HOME/bin:$PATH export PYTHONPATH$TRT_HOME/python:$PYTHONPATH这四行里最容易漏的是PYTHONPATH和LD_LIBRARY_PATH。前者决定了你import tensorrt能不能找到模块后者决定了运行时能不能找到libnvinfer.so。我遇到过一种情况编译能过因为链接时指定了完整路径但一运行就报找不到库原因就是LD_LIBRARY_PATH只在当前 shell 生效跑服务的那个进程没继承到。凡是遇到“编译通过、运行报错”的先查动态库路径。还有一个细节如果用ldconfig做全局注册记得把 TensorRT 的 lib 目录写进/etc/ld.so.conf.d/下的配置文件然后执行sudo ldconfig。这样比LD_LIBRARY_PATH更稳因为它对所有进程都生效不受启动方式影响。但反过来说它也更“脏”改完记得记录否则以后换版本会互相干扰。2.3 容器方式镜像内自检清单容器方式最省心但有两个点必须确认。第一是宿主驱动版本。容器内的 CUDA 运行时版本不能超过宿主驱动支持的上限否则会报CUDA driver version is insufficient for CUDA runtime version。这个错信息很直白但新手常误以为是镜像坏了。第二是容器内是否真的能看到 GPU跑一次nvidia-smi就清楚了。镜像里我会固定跑三件事trtexec --help看工具在不在python -c import tensorrt看 Python 绑定在不在以及跑一次最小 ONNX 转换确认整条链路通。第三件事最容易被跳过但它能一次性暴露 Parser 缺失、插件库路径不对、权限不足等问题。花五分钟做这件事比后面排查两小时强。2.4 装完之后该确认的三件事环境装好不等于可用我通常会确认三件事。第一trtexec能跑起来并且能正常输出帮助信息这说明主程序链路没问题。第二Python 侧能用trt.Builder创建一个空 builder 而不崩这说明核心库加载正常。第三能成功解析一个极小的 ONNX 模型比如一个只有一次卷积的模型这说明 Parser 和 ONNX 前端都正常。这三件事都过了才算环境真的就绪。我见过太多次“环境和模型一起调试”的情况出问题时根本分不清是模型的问题还是环境的问题最后两边都改越改越乱。先把环境用最小模型验证干净后面调试模型时心里才有底。3. 模型转换从 ONNX 到 plan成败在细节3.1 导 ONNX 的时候就要替 TensorRT 想很多人把 ONNX 当成一个中性中转格式随手torch.onnx.export一导就往里塞然后抱怨 TensorRT 转换各种报错。实际上 TensorRT 只认 ONNX 的一个子集而且对算子属性、输入输出结构有自己的一套预期。导出阶段多做一点转换阶段能省很多事。第一个要点是opset版本。太低的 opset 可能缺少某些算子定义太高的 opset 某些 TensorRT 版本还没跟上。我的经验是选一个“主流偏新”的值比如 13 到 17 之间具体看你的 TensorRT 大版本支持到哪。如果你不确定可以先导一个中等版本转换失败时再调整这比一开始就选极端值靠谱。第二个要点是输入输出名。TensorRT 转换后会按名字查找输入输出如果你的模型导出时输入名是一串自动生成的东西后面写推理代码会很别扭。导出时显式指定input_names和output_names既方便调试也方便你写 profile。torch.onnx.export( model, dummy_input, model.onnx, input_names[images], output_names[logits], opset_version17, dynamic_axes{images: {0: batch, 2: h, 3: w}}, do_constant_foldingTrue, )第三个要点是动态维度。如果你需要变 batch 或者变分辨率导出时必须声明dynamic_axes否则导出来的是固定 shapeTensorRT 转换时也就只能按固定 shape 构引擎。这一点后面讲动态 profile 时还会展开但根子在导出这里。还有一个容易被忽略的点do_constant_foldingTrue能让一些常量运算在导出阶段就折叠掉减少图规模。但它偶尔会和某些自定义算子配合出问题如果你的模型里有非标准算子先关掉它试试排除变量。3.2 trtexec最快的可行性验证手段在写任何 Python 转换脚本之前我都会先用trtexec跑一遍。它的作用类似“试纸”能快速告诉你这个 ONNX 到底能不能转、转出来的引擎大概多快。这一步的价值在于把“模型能不能转”和“我代码写得对不对”两件事分开。trtexec \ --onnxmodel.onnx \ --saveEnginemodel.plan \ --fp16 \ --minShapesimages:1x3x224x224 \ --optShapesimages:8x3x224x224 \ --maxShapesimages:16x3x224x224 \ --verbose几个参数值得说明。--fp16是开启半精度大多数卷积网络开了之后速度有明显提升精度损失通常很小但这不是绝对的后面单独讲。--minShapes/--optShapes/--maxShapes这三个参数就是动态 profileoptShapes是优化时的目标形状TensorRT 会针对这个形状做 kernel 选择所以它应该设成你线上最常见的那个 batch 和分辨率而不是最大值。这一点我踩过坑一开始把optShapes设成最大 batch结果线上大部分请求是小 batch性能反而比不设 profile 还差。--verbose会打印大量日志第一次转换建议开因为你能看到哪些层被融合了、哪些层退化成了别的实现、有没有警告说某算子被放到 CPU 上执行。这些信息在排查精度和性能问题时是金矿。注意trtexec报“成功”不代表结果正确。它不做数值校验只保证引擎构建完成。精度对齐一定要自己拿原始框架的输出做对比。3.3 Python API 精细构建从 Parser 到序列化trtexec验证通过后正式项目里我会用 Python API 构建因为可以插入自定义逻辑比如自定义插件注册、profile 动态设置、构建期回调。import tensorrt as trt TRT_LOGGER trt.Logger(trt.Logger.WARNING) 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, 1 30) 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(engine build failed) with open(plan_path, wb) as f: f.write(serialized)这段代码有几个地方值得停下来解释。EXPLICIT_BATCH这个标志在新版本里已经是默认行为但老代码里到处都有如果你照着旧教程写会遇到“flag 已废弃”的警告。set_memory_pool_limit替代了老版本的max_workspace_size单位是字节这里给 1GB。工作空间太小会导致某些层无法用最优实现构建可能失败或者性能打折给太大又浪费显存1GB 对多数视觉模型够用大模型要往上加。build_serialized_network是新版本推荐的构建方式直接返回序列化后的字节流省掉了先建 engine 再 serialize 这一步。老版本是builder.build_engine(network, config)然后engine.serialize()。如果你在网上看到的代码和这里不一致多半是版本差异不用慌逻辑是一样的。解析失败时的错误打印非常关键。parser.num_errors通常不止一条第一条往往不是根因要全打出来看。我遇到过的最迷惑的一次是第一条报某个算子不支持实际根因是更早的一个 shape 推断失败导致后续层全部推断不出来。3.4 动态 shape 与 profile 的配置逻辑动态 shape 是 TensorRT 里最容易被低估的部分。很多人以为只要导出 ONNX 时声明了dynamic_axesTensorRT 就自动处理好实际上你必须在构建时显式给 profile否则构建会失败提示输入维度是-1。profile builder.create_optimization_profile() profile.set_shape( images, min(1, 3, 224, 224), opt(8, 3, 224, 224), max(32, 3, 224, 224), ) config.add_optimization_profile(profile)这里三个 shape 的含义要分清min和max定义了合法范围超出范围的输入在推理时会报错opt是优化目标TensorRT 会为这个形状挑选 kernel。所以opt要贴近真实分布这一点比很多人想的更重要。如果你的请求 batch 在 1 到 32 之间波动且没有明显峰值可以考虑给多个 profile让运行时按实际输入选择一个。多个 profile 的写法是再create_optimization_profile一次然后add_optimization_profile。代价是构建时间和引擎体积都会增加因为每个 profile 都要单独做 kernel 选择。我一般最多给两个 profile一个覆盖小 batch 低延迟场景一个覆盖大 batch 吞吐场景再多收益就不明显了。还有一个隐藏细节profile 里的维度顺序必须和网络输入的维度顺序一致。如果你的输入是 NCHWmin给的却是(3, 224, 224, 1)构建时不一定报错但推理结果会完全错乱。这种错最难查因为引擎构建成功、推理也能跑就是数值不对。我现在的习惯是在构建完成后打印一遍engine.get_tensor_shape(name)和人预期对一遍。3.5 FP16 与 INT8精度换速度的取舍FP16 基本是默认选项绝大多数卷积网络开 FP16 后精度损失在小数点后三四位肉眼不可见。但有两类模型要小心一类是输出范围很大的回归模型半精度的表示范围有限容易出现溢出另一类是有大量累加操作的模型误差会累积。判断方法很直接拿一批真实数据分别用原始框架和 FP16 引擎跑算最大绝对误差和相对误差。如果相对误差在千分之一以内基本可以放心。如果超了先看看是不是某几层特别敏感TensorRT 支持按层设置精度可以把敏感层强制回 FP32。INT8 就是另一个量级的事了。它需要校准也就是拿一批有代表性的数据跑一遍统计每层激活值的分布算出量化缩放系数。校准集的质量直接决定量化后的精度这一步没有捷径。class Calibrator(trt.IInt8EntropyCalibrator2): def __init__(self, data_loader, cache_file): super().__init__() self.data_loader data_loader self.cache_file cache_file self.batch next(iter(data_loader)) self.device_input None def get_batch_size(self): return self.batch.shape[0] def get_batch(self, names): # 返回设备指针列表由调用方负责拷贝 ... def read_calibration_cache(self): ... def write_calibration_cache(self, cache): ...校准集的经验是500 到 1000 张覆盖真实分布的样本不要只用某一类样本。我见过有人用 ImageNet 的一小撮图去校准一个工业质检模型结果量化后精度掉了十几个点后来换成产线实际图片精度只掉了一个多点。校准的本质是估计激活范围样本分布不对估出来的范围就是错的。另外校准缓存一定要保存下来。同样的模型和校准集缓存在的话构建时间能从几分钟降到几秒而且能保证多次构建结果一致。不保存缓存的话每次构建出来的量化参数可能有细微差异给复现问题添麻烦。4. 推理 demo写一个能长期用的最小骨架4.1 对象生命周期与显存绑定的正确顺序推理代码看着简单实际上对象之间的依赖顺序很讲究。我总结的顺序是先加载引擎再创建执行上下文然后分配设备显存最后绑定地址。顺序错了就会出现“绑定了但推理没数据”或者“显存分配了但绑错位置”。import tensorrt as trt import numpy as np import pycuda.driver as cuda import pycuda.autoinit class TRTEngine: def __init__(self, plan_path): self.logger trt.Logger(trt.Logger.WARNING) with open(plan_path, rb) as f: runtime trt.Runtime(self.logger) self.engine runtime.deserialize_cuda_engine(f.read()) self.context self.engine.create_execution_context() self.stream cuda.Stream() self.buffers {} self._allocate() def _allocate(self): for i in range(self.engine.num_io_tensors): name self.engine.get_tensor_name(i) shape self.engine.get_tensor_shape(name) dtype trt.nptype(self.engine.get_tensor_dtype(name)) size int(np.prod(shape)) * np.dtype(dtype).itemsize device_mem cuda.mem_alloc(size) host_mem cuda.pagelocked_empty(int(np.prod(shape)), dtype) self.buffers[name] { device: device_mem, host: host_mem, shape: shape, dtype: dtype, }这里用的是新版本的张量 APInum_io_tensors、get_tensor_name老版本是num_bindings、get_binding_name。两者的思路一样只是命名从“绑定索引”变成了“张量名”。如果引擎带动态 shapeget_tensor_shape返回的可能是-1这种情况下不能直接用来算大小必须先调context.set_input_shape再用context.get_tensor_shape获取运行时实际形状。这里有一个我必须强调的点pagelocked_empty和普通的np.empty差别很大。前者是页锁定内存主机到设备的拷贝可以用 DMA 直接传输速度明显快后者是普通分页内存拷贝时要经过一次中转。对于小 batch 高频调用的场景这个差别能占到总耗时的不小比例。代价是页锁定内存不能随便创建太多会占用系统物理内存所以要在初始化时一次分配好而不是每次推理都新建。4.2 主机与设备之间的搬运以及异步流的用法推理的本质是三段把输入搬到设备上、在设备上执行、把输出搬回主机。这三段如果全用同步调用GPU 在等待拷贝时是闲着的。用execute_async_v3配合 CUDA stream可以让拷贝和计算部分重叠。def infer(self, input_array): inp self.buffers[images] np.copyto(inp[host], input_array.ravel()) cuda.memcpy_htod_async(inp[device], inp[host], self.stream) self.context.set_tensor_address(images, int(inp[device])) for name, buf in self.buffers.items(): if name ! images: self.context.set_tensor_address(name, int(buf[device])) self.context.execute_async_v3(stream_handleself.stream.handle) out self.buffers[logits] cuda.memcpy_dtoh_async(out[host], out[device], self.stream) self.stream.synchronize() return out[host].reshape(out[shape])几个细节值得说。set_tensor_address每次推理都调一遍看着啰嗦但在动态 shape 场景下确实需要每次设因为地址可能随形状变化。如果你的形状固定可以在初始化时设一次推理时省掉这几行能省下一点点开销。execute_async_v3是新版本的接口老版本是execute_async_v2(bindings, stream, input_consumed_event)传的是一个绑定指针列表。这两个接口的差别不只是参数形式v3依赖事先用set_tensor_address设置地址v2是直接传数组。迁移代码时要注意这个区别混用会报地址未设置的错。stream.synchronize()是同步点调用它才算真正等到了结果。如果你想做流水线可以把多次推理排进同一个 stream最后统一同步一次这样拷贝和计算能重叠起来。我做过一个批量处理的场景把 8 个 batch 排进同一个 stream整体吞吐比逐个同步高了将近三成。4.3 动态 shape 在推理侧的落地带动态 shape 的引擎推理侧要多做一步。前面_allocate里如果遇到-1需要先在上下文里设好输入形状再按实际的输出形状分配显存。def infer_dynamic(self, input_array): self.context.set_input_shape(images, input_array.shape) out_shape self.context.get_tensor_shape(logits) out_size int(np.prod(out_shape)) if self.buffers[logits][device].size() out_size * 4: self.buffers[logits][device] cuda.mem_alloc(out_size * 4) self.buffers[logits][host] cuda.pagelocked_empty(out_size, np.float32) ...这里有个显存管理的取舍。每次输入 shape 变了就重新分配显存会有分配开销而且频繁分配容易造成碎片。更稳的做法是按max形状一次性分配到位推理时只用前面一部分。代价是显存占用按最大值算如果你的max设得很大而实际用得小就浪费了。我一般按opt形状分配遇到超出再扩兼顾两者。还有一个坑set_input_shape之后输出形状可能变但如果你不重新调get_tensor_shape用的还是上一次的形状导致数据截断或者读取越界。这种错误在小 shape 换大 shape 时特别隐蔽因为读出来的数据一部分是旧值看起来像“推理结果部分正确”很容易往模型精度上想。4.4 前后处理要和引擎对齐别在边界上翻车推理代码里最容易出错的往往不是引擎部分而是前后处理。TensorRT 只负责算输入的数据布局、归一化方式、颜色通道顺序全都要你自己保证和训练时一致。我踩过的典型坑是通道顺序。训练时用的是 RGB部署时读图用的某个库默认给 BGR模型照样能跑出结果但精度掉得莫名其妙查了半天才发现在读图那一步就错了。另一个坑是归一化参数训练用 ImageNet 均值方差部署时忘了减均值输出分布完全变了。我的做法是在推理类里把前后处理也封进去输入原始数据输出结构化结果中间不允许外部插手。这样任何布局或归一化的问题都收敛在一个地方出问题好定位。同时我会写一个对比脚本用同一张图分别走 PyTorch 和 TensorRT逐元素比对误差在可接受范围内才算真正跑通。def compare(torch_out, trt_out, atol1e-3, rtol1e-3): diff np.abs(torch_out - trt_out) print(max abs diff:, diff.max()) print(mean abs diff:, diff.mean()) ok np.allclose(torch_out, trt_out, atolatol, rtolrtol) print(aligned:, ok)这个对比要一次性比对十张以上的图不能只看一张。单张图可能刚好误差小批量比对才能暴露系统性问题。5. 排查链路转换成功但结果不对怎么办5.1 精度异常的二分定位法“引擎构建成功、推理能跑、结果就是不对”是我遇到最多也最头疼的问题。我的排查方法是从后往前二分先确认输入数据是否真的进到了引擎里再确认输出层之前的某一层是否已经偏离。具体做法是用trtexec的--dumpLayerOutput或者在 Python 里用调试接口把中间层输出导出来和原始框架对应层的输出比。如果最后一层偏了但倒数第三层是准的问题就在这中间两层。这样能把几十层的网络缩小到几层的范围再逐个看是不是某个算子被替换成了近似实现或者某个精度标志没生效。常见的几个原因我列一下供对照排查现象可能原因验证方式输出全为同一值输入没拷进去或地址绑错打印输入 buffer 前后内容误差随 batch 增大profile 的 opt 形状与实际不符改成实际形状重建某类样本错得离谱归一化参数不一致检查前后处理FP16 后精度骤降某层溢出该层强制 FP32输出形状对但顺序乱维度布局理解错打印张量 shape 逐个对二分定位的关键是“先证明输入是对的”。我现在的习惯是在推理代码里加一段可开关的调试逻辑把输入 buffer 里的数据拷回主机打印几个数和原始输入比。这一步看着笨但它能排除掉一半的可能原因剩下的一半才好查。5.2 算子不支持与 shape 推断失败另一类常见问题是解析阶段就挂了。报错信息通常是onnx2trt_utils.cpp: xxx: Your ONNX model has been generated with INT64 weights, while TensorRT does not natively support INT64或者是某个具体算子名加一句 not supported。INT64 这个错误特别常见因为 PyTorch 里很多索引和张量形状相关操作用的是 int64。解决方法是导出前把用不到的 int64 张量转成 int32或者在导出时用torch.onnx.export的常量折叠把它消掉。如果实在消不掉可以尝试在 ONNX 层做一次改写用工具把相关节点类型转换掉。算子不支持的处理分三条路一是换实现比如用一个等效的算子组合替代二是升级 TensorRT 版本新版通常补了不少算子三是写自定义插件。第三条路成本最高只有在别无选择时才走。写插件需要实现IPluginV3系列接口编译成动态库运行时注册一套流程下来至少半天而且容易和版本绑死。shape 推断失败往往比算子不支持更难查因为报错位置和真实原因隔得很远。我的经验是打开--verbose从第一条警告开始看很多时候第一条警告会提示某个维度是动态的但没给 profile后面的一连串错误都是它的连锁反应。6. 性能实测与调优别只看转换成功6.1 用 trtexec 建立性能基线引擎转出来之后第一件事是测性能而不是直接塞进业务代码。trtexec自带压测功能能给出延迟和吞吐还能分项显示各部分耗时。trtexec \ --loadEnginemodel.plan \ --shapesimages:8x3x224x224 \ --warmUp1000 \ --duration10 \ --avgRuns100--warmUp是预热次数GPU 在初始几次运行时会做频率调整和缓存预热不预热直接测会得到偏高的数字。--duration是持续时间--avgRuns是取平均的次数。这套参数跑出来的结果是我判断“这个引擎值不值得用”的依据。跑完之后重点看三个数GPU Compute Time、Host to Device、Device to Host。如果 Compute Time 占比很高说明瓶颈在计算优化空间在精度和层融合如果拷贝时间占了不少说明瓶颈在数据搬运要考虑用页锁定内存、减少拷贝次数、或者把多次推理合并。6.2 那些比换精度更有效的优化很多人一提优化就想到 INT8但实际上有些更简单的动作收益更大。第一个是减少不必要的拷贝。我见过一段代码每次推理都重新cudaMemcpy整个输入包括那些没变的常量部分白白浪费带宽。第二个是合理设置 batch。GPU 在 batch 太小时利用率很低但 batch 太大又会让延迟变高找到那个拐点很关键。第三个是层融合的检查。--verbose日志里会显示哪些层被融合了如果发现某个本该融合的结构没融合比如卷积后面跟的激活函数可能是导出方式导致的。把这类结构改写成能融合的形式性能会有明显变化。第四个是显存复用如果你的服务同时跑多个引擎显存分配策略会显著影响稳定性和吞吐。6.3 一个真实场景的调优顺序我在一个图像分类服务上的调优顺序是这样的先跑基线得到延迟然后把 FP16 打开延迟降了一截接着检查 profile 的opt形状发现设成了 32 而线上多是 4改回去又降了一截最后把前后处理里的图像缩放从 CPU 挪到 GPU 上做整体又降了一点。整个过程里 INT8 反而是最后才试的因为校准集不好搞而且精度验证成本高。这个顺序的价值在于先用零成本的手段改配置、改形状拿收益再用有成本的手段量化、换实现。很多团队一上来就搞 INT8花了两周做校准最后发现 profile 配错导致的性能损失更大等于白忙。注意每次改完配置都要重新跑精度验证。性能和精度是跷跷板优化幅度越大越要确认结果没崩。6.4 序列化与版本兼容的那些事最后说一个容易被忽略但很致命的问题plan 文件不是跨版本兼容的。用 8.x 构建的引擎在 10.x 的运行时里加载通常直接失败。这意味着你的部署包里必须锁定运行时版本或者随包带上构建时的运行时。我的做法是在 CI 里固定 TensorRT 版本plan 文件在构建阶段生成并打进镜像运行时用同一个镜像版本。这样构建和运行是同一套环境不会出现版本错位。如果做不到那就退而求其次在服务启动时检查 plan 的版本号不匹配就直接用 ONNX 重建宁可多花几分钟启动时间也不要带着一个加载不了的引擎上线。另一个细节是构建的确定性。同样的 ONNX、同样的配置不同机器构建出来的引擎性能可能有细微差别因为 kernel 选择可能受硬件影响。所以性能基线要在目标机器上测不要拿开发机的结果去推算线上表现。我个人在实际操作中的体会是TensorRT 这条链路的难点从来不在某一个 API 上而在“版本、形状、精度、数据流”这四件事的协同。装环境时把版本钉死转模型时把形状和精度想清楚写推理时把数据流和生命周期理顺剩下的问题基本都是可以用二分法定位的常规故障。真正花时间的往往不是技术本身而是过程中的不确定性——所以每次动手前多花十分钟确认前提条件比事后熬夜排查要划算得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑