ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

TensorRT加速GPU推理:从计算图优化到部署实战

TensorRT加速GPU推理:从计算图优化到部署实战

接到一个模型上线任务,第一版部署后单帧推理延迟高得离谱,GPU占用率却连一半都不到——这是很多做部署的同行都撞过的墙。模型本身的精度没问题,性能瓶颈就卡在推理引擎对算子的调度和显存管理上。TensorRT解决的核心问题正在于此:它是目前业界主流的GPU推理加速方案,专门对训练好的模型做计算图优化、算子内核挑选和精度切换,在GPU上把推理延迟压下去、把吞吐提上来。这篇文章写给两类人:一类是刚接触部署、想搞懂TensorRT到底是什么的小白;另一类是已经装过但卡在模型转换和性能调优阶段的工程师。我会从它的核心加速原理讲起,把安装前的环境对齐全讲透,再给出一套可复现的转换和测试流程,最后附上实战中踩过的坑。

1. TensorRT到底改了什么:一张图、一卷层,凭什么快那么多

很多刚接触TensorRT的人会问:PyTorch、ONNX Runtime在GPU上跑得好好的,为什么换成TensorRT之后能快一截?差距不是一两倍的问题,有些模型在FP16下能把延迟砍到原来的三分之一甚至更多。这不是魔法,而是它换了一套完全不同的推理执行逻辑。

1.1 计算图优化与层融合:把每次算子调用都省下来

先看最核心的一点——层融合。普通推理框架执行一个卷积模型,通常会按照计算图逐层调用算子的kernel:读取输入、算卷积、写结果、读结果、加偏置、再写结果、再读结果、算激活函数……每一次“读”和“写”都要经过显存,而显存带宽恰恰是GPU推理的常见瓶颈。

TensorRT的思路是:把能够合并的层揉成一个融合kernel,一次读取、一次计算、一次写回。比如Conv + Bias + ReLU这种组合,几乎每一个部署模型里都存在,TensorRT会把它改写成单个融合kernel执行。在计算图中,这些相邻节点的边界就被消除了。你可以把普通推理框架想象成坐公交:每到一个站都要停一下、下人、上人;TensorRT则是上了一辆直达专车,中途不停站。

层融合不是只融合一两个算子。更常见的还有Conv + BN + ReLU、Conv + ReLU、FC + BN + ReLU,以及残差网络里的Add + ReLU,遇到合适的结构它会整段吃掉。很多人第一次跑通TensorRT,看到日志里写着“Fusing……”就知道自己的网络在优化阶段被扫描了一遍。

1.2 Kernel自动选择与显存复用:在同一个算子上抠出性能

除了层融合,TensorRT还会对网络里剩下的每个算子单独「选内核」。同一个卷积,GPU上有几十种不同算法实现,分别适配不同的输入尺寸、通道数、batch大小和Tensor Core特性。选哪种inner kernel,直接影响延迟和显存占用。

TensorRT build引擎时会逐个算子做一遍基准测试,在它认为合理的耗时范围内挑最优实现,这个过程就是日志里常见的“Tactic”选择。代价是build时间会拉长,好处是推理时那个算子已经被调到了一个相对最优状态。显存方面,它会在构建时做内存复用分析,把多个算子的中间结果分配到同一个显存块上,降低运行时显存峰值。这一手优化在小batch模型上效果特别明显。

1.3 精度下调:FP16与INT8的真正价值

TensorRT另一个常被提及的卖点是低精度推理。GPU上部分硬件单元对半精度和整型精度的计算吞吐远高于FP32。把模型权重和激活从FP32换成FP16,在支持Tensor Core的GPU上可以获得显著加速,显存占用也减半。

INT8量化走得更远,是把激活和权重映射到8位整型。这个要谨慎使用,需要校准数据来统计每层激活值的动态范围,选不好很容易掉点。所以我通常的建议是:先上FP16,速度快、改动小、精度损失可控;只有当FP16还不够、且模型冗余度足够高时,再考虑INT8。每往下降一个精度档位,收益和风险都在同步提高,调试成本完全不是一个量级。

2. 装机之前必须对齐的三个版本关系

TensorRT安装本身不复杂,慢慢看、按官方仓库的包名取件即可。真正让新手栽跟头的,是装之前没对齐环境版本:装完了导入库报错、运行引擎报错、或者干脆检测不到GPU架构。

2.1 显卡驱动、CUDA和cuDNN:一个都不能错位

在装TensorRT之前,至少先确认四样东西:GPU型号、驱动版本、CUDA runtime版本、cuDNN版本。TensorRT是依赖CUDA和cuDNN的,它编译出的引擎里大量算子调用了cuDNN的卷积实现和CUDA的基础库。

可以在命令行里快速查:

nvidia-smi # 查看GPU型号和驱动版本 nvcc -V # 查看系统CUDA版本 python -c "import torch; print(torch.version.cuda)" # 查看PyTorch自带的CUDA版本

这里有个常见的坑:系统里用nvcc查到的CUDA版本,和PyTorch里报出来的CUDA版本可能不一样,甚至和TensorRT打包版本要求的CUDA版本也可能不一样。因为PyTorch经常自带CUDA runtime,它不一定走系统CUDA。如果你用pip安装TensorRT的方式,一定要确保它对应的CUDA版本和实际运行环境能对上,否则会出现“库已装上但加载引擎报错”的情况。

2.2 芯片架构与TensorRT版本的关系

GPU不同的芯片架构,支持的TensorRT能力不完全一致。老一些的Maxwell/Pascal架构,对INT8量化的支持、Tensor Core的调用能力都受到限制;比较新的Volta、Turing、Ampere以及后续架构,FP16和INT8的加速效果才真正发挥出来。如果你的GPU架构老一些,写代码时不要强行使用Tensor Core特性,否则要么运行报错,要么精度异常。

不同TensorRT主版本对CUDA版本的支持矩阵也在动态变化。装之前要对照你用的CUDA主版本来选TensorRT包。不能用高版本TensorRT硬配很老的CUDA,也不要拿新版本CUDA库去兼容非常旧的TensorRT,字段对不上是常有的事。最简单的做法是:直接看官方文档里的支持矩阵表,找到自己环境对应的那个安装命令。

2.3 安装后的自检清单

装完之后,可以按下面几步确认安装没问题:

trtexec --help # 能输出帮助信息,说明可执行文件已可用

然后在Python里尝试导入:

import tensorrt as trt print(trt.__version__)

如果打印出版本号,说明Python绑定已经就位。如果只是想验证GPU环境下基础功能,可以用一个最简单的模型文件跑一遍:

trtexec --onnx=model.onnx --saveEngine=model.engine

一旦这条命令成功,说明环境链路基本是通的。很多刚接触的人不知道怎么验证安装,其实只要能成功build一个简单的onnx模型,安装这关就稳了。

3. 从普通模型到TensorRT引擎,完整转换流程

安装完TensorRT之后,真正的核心工作是模型转换。这一步不像装包那样点两下就完事,里面牵扯到ONNX导出的细节、TensorRT构建参数的选择、以及一连串经典的“为什么我的模型转换失败”问题。

3.1 第一步:把PyTorch模型导出为ONNX

TensorRT本身不直接用PyTorch的权重文件,中间普遍通过ONNX格式过渡。导出ONNX时要注意的几个点,直接决定了后面TensorRT转换顺不顺利。

用PyTorch导出ONNX的标准流程大致长这样:

import torch import torch.nn as nn class SimpleModel(nn.Module): def __init__(self): super().__init__() self.conv = nn.Conv2d(3, 16, 3, padding=1) self.relu = nn.ReLU() self.fc = nn.Linear(16 * 8 * 8, 10) def forward(self, x): x = self.relu(self.conv(x)) x = x.view(x.size(0), -1) return self.fc(x) model = SimpleModel().eval() dummy_input = torch.randn(1, 3, 8, 8) torch.onnx.export( model, dummy_input, "simple_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=17, )

这段代码里,dynamic_axes值得特别留意:如果你需要支持动态batch,就把它标出来;如果不需要,这里可以直接删掉,TensorRT构建时也会简单很多。导出的ONNX可以在 [netron] 这种工具里打开看一眼结构和输入输出,能够避免很多低级错误。

3.2 第二步:用trtexec快速验证ONNX能不能转

换了TensorRT之后,我最推荐的第一个动作是先拿trtexec命令行探路,而不是直接写Python代码。因为它不用写构建脚本,一条命令就能告诉你:这个ONNX能不能被TensorRT解析,有哪些算子是不支持的,默认精度下性能大概什么样。

trtexec --onnx=simple_model.onnx \ --saveEngine=simple_model.engine \ --fp16

--fp16表示构建FP16精度引擎。不加这个选项时,默认按FP32构建。命令跑完之后,注意观察日志里有没有UNSUPPORTED_NODE或者WARNING: Unused Input这种字样。前者说明有算子没被支持,后者说明ONNX输入可能没有被网络用到,容易导致后续运行时报输入维度不匹配。

如果你只需要快速确认性能和构建可行性,trtexec完全够用。但如果你想在代码里高度定制引擎,比如动态shape、并行推理、多路设备管理,那就得用Python API手动搭构建流程。

3.3 第三步:用Python API构建引擎

如果工程中需要精细控制,通常会舍弃trtexec,改用Python脚本。构建逻辑不算复杂,基本是创建builder、构建网络、配置参数、build,四步走:

import tensorrt as trt 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("simple_model.onnx", "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.max_workspace_size = 1 << 30 # 1GB config.set_flag(trt.BuilderFlag.FP16) engine = builder.build_engine(network, config) with open("simple_model.engine", "wb") as f: f.write(engine.serialize())

这个示例里还有两个事值得展开:EXPLICIT_BATCH和max_workspace_size。

EXPLICIT_BATCH是为了配合动态batch,它把batch作为网络输入的一个显式维度,而不是隐式的固定值。如果你觉得“我不需要动态batch,就固定batch=1”,那也必须加这个flag,因为现在ONNX导出的模型几乎都带有显式batch维度。

max_workspace_size是给TensorRT构建时使用的临时显存上限。设得越大,builder就越能放开手脚去尝试更多分析算法;设得太小,构建可能会失败或者选到次优实现。惯常做法是设成1GB到2GB,显存紧张时再往下压。

3.4 经典转换期报错与定位思路

转换过程中出现过的几个高频问题,我按典型程度列一张表:

报错/现象常见原因排查思路
UNSUPPORTED_NODE: xxx某个算子没有在TensorRT支持列表里换用新版TensorRT或用plugin实现;也可以改写模型结构,用等价算子替代
ONNX parser卡死/内存暴涨模型里循环或递归结构过于复杂给ONNX导出设置onnx简化,消除常量和多余结构
构建失败:cannot find implementation某种精度下找不到对应kernel实现换成FP32尝试,或者降低workspace限制,再用FP16重试
动态shape运行报错OptProfile设置不正确或没设置在builder config里显式添加optimization profile,并设置min/opt/max三个档位
性能远低于预期算子融合没生效,执行图过于碎片化检查网络是否是标准的Conv+BN+ReLU结构,若是自定义结构较复杂的层,考虑重写模型

遇到问题,不要第一反应就是怀疑TensorRT有bug。绝大多数情况是模型结构或精度设置的适配问题。上面那几条占了我实际踩坑的八成以上。

4. 性能实测:同样跑推理,延迟和吞吐差在哪

环境装好了、引擎也构建出来了,这时候最让人兴奋的事就是跑一次性能测试。性能测试不只是看秒数,还得学会看trtexec输出的那堆指标,并且要和原有推理框架做公平对比。

4.1 trtexec输出怎么读

跑测试时可以再加几个参数,模拟真实部署场景:

trtexec --loadEngine=simple_model.engine \ --shapes=input:1x3x8x8 \ --fp16 \ --warmUp=1000 \ --iterations=100

这里--shapes指定输入的实际shape;--warmUp是预热时间,跳过显存初始化带来的性能抖动;--iterations是测试迭代次数。trtexec跑完会输出不少指标,重点关注这几个:

  • Latency:平均延迟、p50、p90甚至p99,部署看p90/p99比看平均更合理。
  • Throughput:QPS,表示每秒能处理多少帧/多少次推理。
  • Host Latency和Device Latency:前者包含拷贝和调度时间,后者是纯GPU计算时间。如果两者差距很大,说明瓶颈在CPU侧或数据搬运上。

那行GPU Compute Time超过均值是不是正常?当然正常,因为GPU可能同时在处理多个并发请求,计算时间和端到端延迟并不完全一致。实际部署中要同时关注“客户端拿到的端到端延迟”和“GPU计算吞吐”,两者结合才能定位性能瓶颈。

4.2 和PyTorch、ONNX Runtime的对比实验设计

我的建议是采用「同一模型、同一输入、同一GPU、同一精度档位」的对比原则。把模型分别用以下方式跑:

  • PyTorch FP32:作为baseline,代表“训练时的那套逻辑”。
  • ONNX Runtime GPU FP32:代表“不经过特殊优化、但用了GPU推理框架”的情况。
  • TensorRT FP32:评估TensorRT层融合和kernel选择带来的收益。
  • TensorRT FP16:评估精度下调带来的额外收益。

测试脚本的框架并不复杂:先创建输入,然后分别预热几次,再用循环计时。注意要保证每次推理是真正被GPU执行完的,而不是异步提交后没等结果。最简单可靠的办法是用cuDNN推理时同步等待,比如在PyTorch里推理后调用torch.cuda.synchronize(),在ONNX Runtime里确保session.run执行完再计时。

下面是我常用的一张模拟汇总表,具体数字会随GPU和模型差异浮动,但趋势通常一致:

推理方式FP32延迟(ms)相对baseline延迟说明
PyTorch GPU6.201.00xbaseline
ONNX Runtime GPU5.100.82x已有一定优化
TensorRT FP323.800.61x层融合和kernel选择效果
TensorRT FP162.400.39x精度下调+Tensor Core

这张表能直观回答“TensorRT到底快多少”的问题,但要注意几点:模型越小、结构越简单,TensorRT的优势可能越不明显;模型越大、层数越深、融合空间越大,优势才越明显。而且如果模型里batch=1且算子碎片化严重,TensorRT调度开销占比反而高,这时候不如把精力放在模型剪枝上。

4.3 测速最容易忽略的三个细节

第一,一定要预热。GPU在第一次推理时要加载kernel、分配显存,这类开销不算正常稳态延迟。不预热测出来的数,通常比实际稳定状态慢二到三倍。

第二,使用相同输入shape。不同shape会影响融合效果和kernel选择,别拿一个batch=1的TensorRT去对比PyTorch batch=32,那个对比既不公平也没意义。

第三,避免GPU降频。长时间烤机或者笔记本功耗限制都会让GPU自动降频。对比实验最好分两次交替跑,保证每个推理方式都在相似的频率状态下。

5. 部署阶段绕不开的坑:显存、动态shape、引擎序列化

引擎构建成功、性能测试通过,只代表实验室环境通了。正式部署到服务里还会遇到一批更“脏”的问题,这里挑几个高频场景拆开讲。

5.1 显存策略:别再默认整卡加载

用TensorRT跑推理时,默认行为是把引擎需要的显存一次性划出来,即使模型没那么大也会预留整个工作区。多路并发服务时,每个实例各占一块,很快就爆显存。部署场景下有两个调节点:

  • 构建设置max_workspace_size:控制构建阶段可用的临时显存。实际推理时的显存占用也和这个值相关。
  • 运行时设置显存池上限:通过trt.create_execution_context后显式分配设备内存,可以选用固定的一小块显存复用,而不是每次请求都开新的。这能明显降低峰值显存。

还有一种做法是把多个模型共享一个context。如果你部署多个小模型,代码逻辑允许,尽量让它们复用同一块builder,否则每个模型独立构建,显存碎片会很严重。

5.2 动态shape和Optimization Profile

动态batch是个很典型的部署需求:线上流量可能同时来1个请求、8个请求、甚至32个请求。如果你在构建时固定死了batch,到了线上很容易遇到“shape不匹配”的运行时报错。

动态shape的正确玩法,是在builder config里添加optimization profile:

profile = builder.create_optimization_profile() profile.set_shape("input", (1, 3, 8, 8), (4, 3, 8, 8), (16, 3, 8, 8)) config.add_optimization_profile(profile)

这里三个输入分别代表:min shape、opt shape和max shape。TensorRT在运行时只保证在这三个尺寸区间内能正常工作;如果你设置的输入超出max,运行时会直接报错。实际线上部署时,我会把opt shape设成线上流量的平均并发数,这样引擎的核心tactic选择会优先照顾日常出现最多的那个shape。

5.3 引擎序列化和反序列化的陷阱

构建一个engine可能需要几分钟,甚至大型模型加INT8校准要半小时以上。如果每次服务启动都重新build,那等待时间会让人崩溃。所以要把build好的引擎保存为文件,运行时直接load,这个想法是对的,但有一个隐藏前提。

TensorRT引擎和CUDA设备是绑定关系。同一份engine文件,在A卡上build,直接copy到B卡上load,大概率会失败或者性能异常,因为kernel权重和tactic选择都是针对特定GPU型号来的。正确的部署姿势是:在同一GPU型号的机器上构建并保存引擎,然后只把这个文件分发给同样卡的机器。如果服务器型号混杂,尽量为每种型号各保存一份engine,或者直接在每台机器上重新build。

5.4 推理上下文和并发:一个engine不等于一个线程安全

TensorRT的engine对象本身是线程安全的,可以多个线程共享同一个engine、分别创建各自的execution context。实际使用中常见错误是:多个线程同时用一个context做推理,结果出现输出错乱或者崩溃。

正确写法是每个线程持有独立的IExecutionContext,再配合CPU侧的输入输出buffer复用。这样既避免了线程竞争,又能在多路并发场景下顶住压力。

6. 精度对比工具与调参有效经验

再说一个很多人问了很久的问题:FP16/INT8到底掉点多少?怎么确认精度还能接受?

6.1 用逐层对比定位精度掉点

碰过三次精度掉点之后,我的习惯是不再只看最后输出误差,而是定位到具体是哪一段网络掉得厉害。对比工具可以选择某开源调试工具(常见的是Polygraphy),它可以把ONNX Runtime的逐层输出和TensorRT引擎的逐层输出对齐,逐个算子比较差异。

操作上可以理解为:把同一个输入分别喂给ONNX Runtime和TensorRT引擎,程序会自动跑一轮完整推理,然后把每一层的输出张量算一遍平均绝对误差或余弦相似度。误差大的那一层就是精度风险的源头。常见的处理方式有三种:把该层强制设置成FP32,或者调整智能缩放策略降低量化误差,再或者干脆用精度校准数据集重新统计激活分布。

6.2 我的部署工作流建议

踩了这么多坑之后,我现在项目里固定用一套工作流:先在本地小模型上用trtexec验证onnx可转换性,确认没有问题后再用Python API构建正式引擎;构建完先跑几次性能对比测试,确认延迟达标后再接业务代码;业务上引入动态shape时,一定要测试最小/最优/最大三个尺寸下的稳定性;最后做一次精度对比测试,输出报告后再上线。整条链路里,最花时间的不是构建本身,而是每遇到一个报错时的定位过程。

这套流程走下来,绝大多数TensorRT相关的问题都能被提前拦住,而不是留到线上爆炸。

RELATED READING

延伸阅读

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