1. 先说清楚:TensorRT到底在解决什么问题
1.1 它不是框架,是个“编译部署工具”
很多刚接触TensorRT的开发者第一反应是:TensorRT是不是又一个深度学习框架,像PyTorch一样用来训练模型?不是。TensorRT是NVIDIA推出的推理优化引擎和运行时库,它的工作对象不是训练,而是“把已经训练好的模型编译成一串能在GPU上跑得更快的部署程序”。
我这么说可能还是有点抽象,换个方式理解:训练好的模型就像一个“设计图纸”,里面画满了算子(Conv、ReLU、Pooling、MatMul等)。PyTorch或者ONNX Runtime拿到这张图纸,是一边看图纸一边按顺序找对应工具去干活,这种方式灵活,但每道工序都有调度开销。TensorRT拿到图纸后,会把很多工序合并成一道,把该提前算好的提前算好,还能给每条流水线挑最熟练的工人,然后把整套流程打包成一个高度定制化的“专用设备”。这个“打包”动作,就是我们常说的构建Engine(模型编译)。
所以这张图景就很清晰了:
- 平时说的“部署模型”,第一步通常是把PyTorch导出成ONNX;
- 第二步,就是用TensorRT去解析ONNX、优化图结构、生成推理计划;
- 第三步,拿到编译好的Engine文件(.engine/.plan),写一个轻量级的推理程序调用它。
标题里说的“第一个trt程序,实现模型编译的过程”,聚焦的就是第二步。你不需要自己手写CUDA算子,但要能把这套编译流程跑通,并且知道每一步在干什么。
1.2 模型编译 vs 直接推理:“编译”到底编了什么
直接跑ONNX(比如用onnxruntime)和先编译成TensorRT Engine再跑,区别在哪?我用一个很实际的项目场景说。
之前我给一个分类模型做部署初期,直接用ONNX Runtime推理,一张图的耗时大概在2ms出头。后来换成TensorRT编译后再推理,耗时压到了1.1ms左右,且这个差异会随着模型变大、算子变多变得更明显。原因不在于TensorRT能“学会更好的算法”,而在于它把大量优化工作放到了编译期提前完成。
编译期内,TensorRT至少干了这几件事:
张量内存复用:神经网络中间会产生一堆残留特征图,如果不做规划,每个算子都临时要显存、用完再释放,碎片化会吃掉很多空间和调度时间。TensorRT会把整张计算图的内存需求整体做一次静态分析,像行李打包一样,把生命周期错开的张量放到同一块显存里。这点对生产环境特别重要,显存占用经常会降下来30%甚至更多。
层融合(Kernel Fusion):最常见的是把Conv + BN + ReLU这类固定组合融合成一个算子,减少显存读写和内核启动次数。做图像模型部署的朋友应该都听过“Conv+BN+ReLU融合”这个经典操作,TensorRT在编译期会自动找这种能合并的模式,不用你手工改模型结构。
算子选择(Tactic selection):GPU上同一个卷积有很多种实现方法,比如直接卷积、im2col+GEMM、Winograd、FFT等,每种在不同shape、不同GPU上表现不一样。TensorRT会在编译期利用一个“策略扫描”的机制,在目标卡上实测一批候选实现,挑耗时最短的写进Engine。这就是为什么编译时通常很慢,但推理时特别快。
精度校准:如果开FP16或INT8,还需要在编译期做精度缩放和校准,把原本FP32的权重和激活值转成更低精度,同时尽量把精度损失控制在可接受范围。
这些动作叠加在一起,最后生成的Engine文件已经不是一个传统意义上的“模型参数文件”,而是一组针对特定GPU架构、特定输入shape、特定精度写好的可执行推理计划。这也是为什么Engine文件换一张不同计算能力的卡,就可能加载失败,需要针对目标卡重新编译。
2. 环境准备:这一步踩的坑比后面所有代码加起来都多
2.1 版本匹配第一原则
写第一个trt程序之前,环境能不能配对,基本决定了你后面是顺畅还是折腾。我见过太多人在这一步卡了两三天。TensorRT对版本极其敏感,需要同时对齐至少三件事:
- CUDA版本
- cuDNN版本
- TensorRT工具包版本
它们不是“越大越好”,而是配套关系。比如你的GPU驱动支持CUDA 12.x,那你装TensorRT 10.x通常没问题;如果系统里是CUDA 11.x,却硬装TensorRT 10.x,大概率会在加载库的时候直接报undefined symbol一类的错误。
我的建议是:先确定你要用的TensorRT版本,去NVIDIA官网看它对应的CUDA、cuDNN最低版本要求,然后照着配。不要先随便装个最新CUDA再去碰运气。
如果你用的是官方容器或者镜像,那更省事,基础环境基本已经给你组合好了,只需要确认自己的GPU架构能被支持。如果是自己装,强烈建议全程记录版本号,哪个环节出问题,第一件事就是排查版本组合。
2.2 两种安装方式与验证方法
TensorRT的安装有几种形态,我最常用的是tar包解压安装,因为能清楚看到目录结构,也方便在部署环境里拷贝运行库。
核心步骤大概是:
- 下载TensorRT tar包,解压到某个目录,比如
/opt/tensorrt。 - 把
/opt/tensorrt/lib加入LD_LIBRARY_PATH。 - Python端的话,进入TensorRT的python目录,安装对应的whl包,例如:
pip install tensorrt-10.0.1/python/tensorrt-10.0.1-cpXX-cpXX-linux_x86_64.whl - 验证是否装好:
如果能正常打印版本号,说明库已经能被找到。python -c "import tensorrt; print(tensorrt.__version__)"
这里有个很容易踩的坑:whl包里只是Python接口,底层还是要动态链接TensorRT的运行库。如果你解压了tar包但没有设置LD_LIBRARY_PATH,import tensorrt 可能报错,提示找不到libnvinfer.so。这时候不要怀疑是pip安装坏了,多半就是环境变量没指过去。
另外,如果你想在编译期跑ONNX解析器,还需要确认Python的onnx包也装了,并且版本不要太老。TensorRT的OnnxParser是基于标准ONNX定义的,onnx版本太老会导致解析某些新格式时奇奇怪怪地失败。我自己一般装最新的稳定onnx版本,基本不会和TensorRT冲突。
3. 第一个trt程序:从ONNX到engine的完整实现
3.1 一个可以直接抄作业的编译脚本
环境准备好之后,我们就可以写第一个trt程序了。下面这个脚本是我在自己项目里最常用的一套骨架,它负责把一个ONNX文件编译成TensorRT Engine文件,并且支持选择FP32或FP16。
import tensorrt as trt # Logger是必须的,控制TensorRT日志输出 TRT_LOGGER = trt.Logger(trt.Logger.WARNING) def build_engine(onnx_file_path, engine_file_path, half=False): # 1. 创建 Builder builder = trt.Builder(TRT_LOGGER) # 2. 创建网络定义,EXPLICIT_BATCH表示显式指定动态/静态batch network = builder.create_network( 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) ) # 3. 创建 BuilderConfig,所有编译配置都挂在它上面 config = builder.create_builder_config() # 4. 创建 ONNX Parser,把onnx文件解析进network parser = trt.OnnxParser(network, TRT_LOGGER) # 5. 读取并解析ONNX with open(onnx_file_path, "rb") as f: if not parser.parse(f.read()): print("ONNX解析失败,错误信息:") for i in range(parser.num_errors): print(parser.get_error(i)) return None print("ONNX解析完成,网络输入数量:", network.num_inputs) print("ONNX解析完成,网络输出数量:", network.num_outputs) # 6. 配置常用选项 # 这里给workspace设置1GB,编译期显存规划空间 config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) if half: config.set_flag(trt.BuilderFlag.FP16) # 7. 编译,返回序列化的engine字节流 serialized_engine = builder.build_serialized_network(network, config) if serialized_engine is not None: with open(engine_file_path, "wb") as f: f.write(serialized_engine) print("Engine已保存到:", engine_file_path) return serialized_engine else: print("编译失败") return None if __name__ == "__main__": build_engine("model.onnx", "model.engine", half=True)这段代码是TensorRT 8.x之后、10.x之前的常用写法。如果你是特别老的版本,可能会看到builder.build_engine返回一个ICudaEngine对象,再通过engine.serialize()拿到字节流,但新版本推荐直接用build_serialized_network,一步到位返回序列化结果,代码更简洁。
3.2 逐行拆解几个关键对象
上面这段代码看起来不长,但背后每个对象都有讲究,我一次性讲清楚。
Logger
trt.Logger(trt.Logger.WARNING)是TensorRT打印日志用的对象,任何TensorRT程序都躲不开它。日志级别有VERBOSE、INFO、WARNING、ERROR。平时日常编译用WARNING就够了,输出干净;如果排查问题,可以临时改成VERBOSE,能看到每个算子融合、每个tactic实测的详细信息,但编译速度会明显变慢、日志量大到刷屏。
Builder、Network、Config
这三个对象是铁三角。Builder是总指挥,Network描述的是“从ONNX解析出来的原始网络结构”,Config记录的是所有编译策略。为什么要把Network和Config分开?因为同一个网络结构,可以用不同配置编译出不同的Engine,比如一个FP32的、一个FP16的、一个INT8的。这种设计让模型结构定义和优化策略解耦,我们在迭代精度时非常方便。
EXPLICIT_BATCH标志
这里必须用1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)这个写法。如果不用它,TensorRT默认走隐式batch模式,很多动态shape操作会受限。现在是2024年了,我建议一律使用显式batch,应对固定batch和动态batch都灵活。
OnnxParser
trt.OnnxParser(network, TRT_LOGGER)负责把ONNX文件按顺序读出来,转成TensorRT的Network定义。它本质上是做一层“翻译”:把ONNX里的OP映射成TensorRT支持的Layer。如果遇到TensorRT不支持的算子,parser就会报错,并记录到parser.num_errors和parser.get_error(i)里,通常错误信息会明确指出是哪个节点、哪个算子出了问题。
我第一次跑通这个脚本时,最大的感受是:编译的核心流程其实就这么几个对象,API设计得很直接,真正让新手头疼的反而是各种配置项的含义。
3.3 动态shape配置:三行代码决定你的batch灵活性
如果你的模型输入shape是固定的,上面那段脚本直接用就好。但如果想用一个Engine同时支持不同batch大小,或者支持长宽可变的图像输入,就必须配置Optimization Profile。
Optimization Profile说白了就是告诉Builder:我未来可能用哪些shape范围,你按这个范围去优化。下面是一个典型的动态batch配置示例,假设输入张量名是"input":
profile = builder.create_optimization_profile() # 这里假设network的第0个输入名是"input" input_tensor = network.get_input(0) # 分别指定 min shape / opt shape / max shape profile.set_shape("input", (1, 3, 224, 224), (4, 3, 224, 224), (16, 3, 224, 224)) config.add_optimization_profile(profile)这三个shape的作用分别是:
- min shape:允许的最小shape,低于这个范围,运行时直接报错;
- opt shape:优化基准shape,Builder会重点按这个shape调优,实际推理shape越接近它,性能越好;
- max shape:允许的最大shape,用于显存分配上限规划。
需要注意的是,profile一次只能覆盖一个shape范围,如果有多个差异很大的范围,可以创建多个profile并全部添加到config里,运行时再按需求选择。但profile越多,编译时间和显存占用也越高,不要贪多。我一般在生产环境只放一到两个最常用的profile,覆盖训练场景和上线场景就够了。
4. 模型编译过程中真正影响性能的四个选项
4.1 精度选择:FP32、FP16、INT8怎么选
很多人在第一次编译时就面临选择:到底是FP32还是FP16?要不要试INT8?我的判断标准很简单:先看精度要求,再看硬件支持,最后看显存和延迟瓶颈。
- FP32:最稳,和训练精度基本一致,适合第一个版本先跑通流程;
- FP16:半精度,绝大多数现代GPU都支持,速度通常比FP32快一倍上下,显存占用缩半,是部署最主流的默认选项;
- INT8:需要校准数据,量化后精度会有一定下降,但延迟和显存又进一步压缩,适合对吞吐有极高要求、精度损失能接受的场景。
在BuilderConfig里开启FP16,只需要加一行config.set_flag(trt.BuilderFlag.FP16)。但如果你的GPU不支持FP16计算,编译时会直接失败或者TensorRT日志里出现明确提示。现在市面上主流N卡基本都支持,除非你是特别老的架构。
下面这个表格是我在实际部署中比较不同精度的直观感受,具体数值和模型有关,但趋势是稳定的:
| 精度 | 相对耗时 | 显存占用 | 精度风险 | 适用场景 |
|---|---|---|---|---|
| FP32 | 基准 | 基准 | 极低 | 首次上线、对比验证 |
| FP16 | 约50%~70% | 约一半 | 一般可忽略 | 绝大多数生产场景 |
| INT8 | 约25%~40% | 可进一步降低 | 需要校准验证 | 高吞吐、视频流处理 |
我个人建议:不要一上来就追求INT8。先FP32把整条链路跑顺,然后切FP16看精度,如果精度还在容忍范围就用FP16。INT8是后面性能死活上不去的时候再考虑的选项。
4.2 workspace与tactic:为什么编译期那么慢
第一次跑编译脚本的人都会问一个问题:编译一个模型怎么要几分钟?这不像是在“转换模型”,更像是在跑一个优化算法。
答案是:TensorRT要对每个算子做tactic扫描。tactic可以理解为某一层在目标GPU上“候选内核实现策略”,不同tactic对应不同的拼接内存方式、计算指令、并行划分。编译时Builder会实际调度这些候选kernel跑一遍基准测试,选出最快的那个。
这里就涉及到workspace配置。我在脚本里写的是:
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30)1 << 30就是1GB字节。这是指Builder在编译期间和最终Engine运行时,能用于临时计算和算法候选的显存上限。给得越大,Builder越有可能尝试更多tactic,找到更优实现,但编译时间和显存压力也会上升;给得太小,一些耗显存的算子就只能退而求其次,性能可能打折扣。
所以这是一个平衡问题。我的经验是:先给1GB跑一版,然后看日志里是否有显存不足的警告;如果模型很大,可以给2GB或者4GB;如果服务器显存紧张,就压缩workspace,别让它和运行时显存打架。
4.3 engine的复用:编译一次,到处加载
生成.engine文件只是第一步。实际部署时,我们不会在每次程序启动时都重新编译模型,那样启动时间会特别长。正确做法是把编译好的engine文件保存下来,部署时直接加载反序列化。
加载代码很简单:
def load_engine(engine_file_path): with open(engine_file_path, "rb") as f: serialized_engine = f.read() runtime = trt.Runtime(TRT_LOGGER) engine = runtime.deserialize_cuda_engine(serialized_engine) return engine重点在于理解:engine文件是和特定GPU绑定的。你在A卡上编译出来的engine,拿到B卡上反序列化,很可能要么报错,要么性能不是最优。原因是tactic选择和GPU架构强相关。正确流程是:拿到目标部署机或者同规格GPU的机器上,重新编译一遍。我之前吃过亏,把一台测试机上编译好的engine拷贝到生产服务器,结果直接抛错,后来老老实实在生产机上建了个编译任务。
4.4 批处理:一个stream跑起来
Engine编译完成后,推理阶段的性能除了受算子和精度影响,还极大依赖批处理大小和CUDA Stream的使用方式。
很多入门教程只演示了batch=1的推理,这没错,但生产中如果要追求吞吐,一定要充分利用batch维度。TensorRT的Engine在编译时已经考虑了batch大小的优化,特别是动态shape场景下,batch=8或16的推理吞吐通常远高于batch=1的8倍。
另外,推理时一般用独立CUDA stream执行,避免和默认stream冲突。代码层面需要:
import pycuda.driver as cuda import cuda这一步需要装上合适的CUDA Python绑定(比如pycuda或cuda-python)。不过这里我不展开推理细节,否则内容就偏离“模型编译”这个主题了。你只需要知道:编译阶段决定性能上限,推理阶段的任务是如何把上限发挥出来。
5. 实测中常见的问题与排查技巧实录
5.1 最常见的两类报错
我在帮很多朋友排查第一个trt程序时,发现报错高度集中在两类。
第一类,ONNX解析失败。特征是最开始那几行日志一片红,然后告诉你某个算子找不到或者opset版本太新/太旧。解决办法是:先看报错里点名的是哪个算子,去查这个算子是不是有第三方实现;如果只是opset版本问题,用onnx-simplifier做一次简化重构,或者从模型导出端调整opset。这里尤其推荐用onnxsim处理一遍再进TensorRT,能省掉很多不必要的麻烦。
第二类,显存不足。这类问题多见于大模型和动态shape。如果报错信息里有out of memory字样,大概率是workspace设得太大,或者profile太多。我的习惯是先把workspace降到512MB左右,再逐步加大,直到找到一个平衡点。另外,如果是动态shape场景,检查一下profile.max_shape是不是设得比实际需要大太多了,max_shape直接决定了预留显存上限。
5.2 怎么判断编译是否成功
这里给一张速查表,方便你往后排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| parser解析时报大量错误 | ONNX算子不兼容或图结构太复杂 | 用onnxsim简化,或检查导出时的opset |
| 编译过程变慢到几十分钟 | workspace设置过大或模型很大 | 降低workspace,或拆分模型 |
| 开了FP16后精度崩了 | 某些层不适合FP16 | 设置精度范围,强制部分层走FP32 |
| Engine在其他机器上加载失败 | GPU架构不匹配 | 在目标卡或同架构卡上重新编译 |
| 编译一直OOM | workspace和profile过大 | 减小workspace、精简约max shape |
| 编译一分钟内完成,推理也没变快 | 可能没找到最优tactic | 增大workspace重新编译 |
5.3 提升编译成功率的小技巧
如果模型结构比较复杂,我强烈建议在进TensorRT之前,先用onnx-simplifier把ONNX模型做一次图简化。很多时候模型导出时带了大量无用节点,比如一堆Identity、Cast、Transpose,这些节点本身不耗时,但会让parser解析变慢,也可能引发奇怪错误。简化之后,模型结构干净不少,TensorRT的层融合也能做得更彻底。
另外,编译环境最好开一个独立终端,不要同时跑多个占用显存的进程。Builder做tactic扫描时需要真实验证候选kernel,显存占用在编译高峰可能超过预期。我遇到过两次因为同时开着可视化工具喂显存,结果编译中途失败的情况。
6. 长辈式经验:编译这一步值得多花时间
我自己用TensorRT做了好几个模型部署项目,回头看,模型编译阶段其实是整个部署链路里最值得花时间的环节。很多在推理阶段怎么调都调不动的问题,根子都在编译阶段没配好参数、没选对精度、没设好workspace。
一个很实际的经验是:先写一个一键编译脚本,把参数全部显式暴露出来,比如half、workspace上限、动态shape范围,全都做成命令行入口。这样每次换模型、换卡、换精度,只需要改参数重跑,不用改代码。我后来所有项目都沿用这套编译脚本,省了太多时间。
还有一点,编译log要养成保存的习惯。比如build_engine(model.onnx, model.engine, half=True)执行完,除了打印结果,我自己会把TensorRT日志重定向到文件里。后面如果推理出现奇怪的精度问题,翻编译日志往往能定位到是哪一层被融合、哪一层走了低精度。这个习惯帮过我很多次。
走到这一步,你已经知道怎么写第一个trt程序,也明白模型编译的过程中Builder、Network、Config、Parser各自是什么角色了。接下来最值得做的一件事,就是拿一个你熟悉的小模型,跑通FP32,再切FP16,对比两次编译出来的engine文件大小和后续推理耗时,亲手感受一下“编译”这两个字的含义。