3. TensorRT深度解析(上)

好,咱们进入 TensorRT 的专题。说实话,在工业界做推理加速,TensorRT 几乎是绕不开的坎。我最早接触它是在 2017 年,那时候版本还比较糙,但已经能看出它的潜力了。今天我们先啃上半部分,把它的核心概念、网络定义、优化器机制、层融合和精度校准这些地基打牢。

3.1 核心概念:从 Builder 到 Engine 的完整链路

TensorRT 的工作流,说白了就是一条流水线:你把训练好的模型喂给它,它给你吐出一个高度优化的推理引擎。这里面有几个关键角色,我习惯把它们叫做「四件套」。

组件 职责 我的理解
Builder 配置参数、创建网络、调用优化器 像个总包工头,定工期、定材料
Network 定义计算图(层、权重、输入输出) 就是设计图纸,告诉你怎么盖
BuilderConfig 设置工作空间、精度、动态形状等 施工规范,比如用多大场地、什么精度
Engine 序列化后的可执行推理实例 盖好的楼,直接住进去跑

流程大致是这样:
Builder → 创建 Network → 填充网络定义 → 配置 BuilderConfig → 调用 buildSerializedNetwork → 得到 Engine

嗯,这里要注意:Engine 是平台相关的。你在 A100 上生成的 Engine,放到 T4 上大概率跑不了。我踩过这个坑,当时图省事在开发机上 build 完直接丢到生产服务器,结果 Inference 直接崩了……后来学乖了,老老实实在目标设备上 build。

3.2 网络定义:两种方式,各有千秋

TensorRT 提供了两种定义网络的方式,我分别说说我的使用感受。

3.2.1 使用 Parser(解析器)

最常见的是 ONNX Parser。你从 PyTorch 导出 ONNX,然后 TensorRT 直接解析。代码大概长这样:

// C++ 示例
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
nvinfer1::INetworkDefinition* network = builder->createNetwork();

// 创建 ONNX 解析器
auto parser = nvonnxparser::createParser(*network, logger);
parser->parseFromFile("model.onnx", 
                      static_cast<int>(nvinfer1::ILogger::Severity::kWARNING));

// 解析完成后,network 里就有了完整的计算图

这种方式的好处是省事,但有个前提:你的 ONNX 导出必须规范。我见过太多人栽在动态轴、自定义算子、或者 PyTorch 版本不匹配上。有一次一个学员的模型怎么都解析不了,最后发现是 ONNX opset 版本太高,TensorRT 不认。

3.2.2 使用 Network API 手动构建

这种方式更底层,也更灵活。适合那些 Parser 搞不定的情况,比如自定义算子、特殊结构。

// 手动添加一个卷积层
auto convLayer = network->addConvolutionNd(
    *inputTensor,           // 输入
    32,                     // 输出通道数
    nvinfer1::DimsHW{3, 3}, // 卷积核大小
    weightMap["conv.weight"],
    weightMap["conv.bias"]
);
convLayer->setStrideNd(nvinfer1::DimsHW{1, 1});
convLayer->setPaddingNd(nvinfer1::DimsHW{1, 1});

// 添加 ReLU
auto reluLayer = network->addActivation(
    *convLayer->getOutput(0),
    nvinfer1::ActivationType::kRELU
);

说实话,手动构建很繁琐,但调试起来特别清晰。我早期做 NLP 模型加速时,Transformer 的某些结构 Parser 解析出来总是不对,最后就是靠 Network API 逐层手写才搞定的。

我的建议:优先用 Parser,遇到问题再降级到 Network API。别一开始就手写,除非你特别清楚自己在做什么。

3.3 优化器:TensorRT 的「炼丹炉」

Builder 调用 buildSerializedNetwork 时,优化器就开始工作了。它内部做了三件大事:图优化、层融合、内存复用。

图优化包括常量折叠、死节点消除、算子替换等。比如你把两个卷积串联,中间没有其他操作,优化器可能会把它们合并成一个更大的卷积。

内存复用这块,TensorRT 会分析每个张量的生命周期,让不同时间点使用的张量共享同一块显存。这能大幅降低峰值显存占用。我记得有个项目,优化前显存占用 8GB,优化后降到 3.5GB,效果非常明显。

3.4 层融合:化繁为简的艺术

层融合是 TensorRT 加速的核心手段之一。它把多个连续的小操作合并成一个大的 kernel,减少 kernel launch 开销和显存带宽消耗。

常见的融合模式有:

  • Conv + BN + ReLU → 融合成一个 CBR 算子
  • Conv + Add + ReLU → 融合成残差块算子
  • Gemm + Bias + ReLU → 融合成全连接激活算子

为什么会这么快?你想想看,原本三个 kernel 要启动三次,每次都要读写显存。融合后,数据在寄存器里就完成了所有操作,只写回一次。这速度能不快吗?

避坑指南:我曾经遇到过一个情况,模型里有个很奇怪的 reshape 操作,把 TensorRT 的融合给打断了。结果推理速度比预期慢了 30%。后来我把那个 reshape 去掉,融合就正常了。所以,尽量保持网络结构的规整性,别搞太多花里胡哨的 shape 变换。

3.5 精度校准:FP16 与 INT8 的抉择

精度校准是 TensorRT 的重头戏。默认是 FP32,但为了速度,我们通常会降到 FP16 甚至 INT8。

3.5.1 FP16 推理

FP16 基本是「无痛降精度」。大部分模型在 FP16 下精度损失可以忽略不计,但速度能提升 1.5-2 倍。配置很简单:

config->setFlag(nvinfer1::BuilderFlag::kFP16);

就这一行,搞定。我几乎所有的线上模型都跑在 FP16 下,没出过问题。

3.5.2 INT8 推理与校准

INT8 就复杂多了。它需要校准数据集来统计激活值的分布,然后找到最佳的量化参数。TensorRT 支持三种校准算法:

算法 原理 适用场景
Entropy 最小化 KL 散度,保留信息熵 通用场景,默认选项
MinMax 直接使用最小/最大值作为阈值 分布均匀的激活值
Entropy 2 改进版,对异常值更鲁棒 激活值有长尾分布时

校准的代码流程:

// 创建 INT8 校准器
auto calibrator = new Int8EntropyCalibrator2(
    calibrationBatchSize, 
    calibrationData, 
    calibrationCacheFile
);
config->setFlag(nvinfer1::BuilderFlag::kINT8);
config->setInt8Calibrator(calibrator);
注意:校准数据集一定要有代表性。我见过有人随便拿几张图做校准,结果 INT8 模型精度掉了 5 个点。后来换成验证集里的 500 张图,精度就只掉了 0.3%。校准数据的选择,直接决定 INT8 模型的生死。

3.6 知识图谱:TensorRT 核心流程一览

下面这张图总结了从模型输入到 Engine 产出的完整链路,以及各阶段的关键操作。

TensorRT 核心流程与优化链路 ONNX / Caffe 模型 Network 定义 Builder 优化器 图优化 层融合 内存复用 精度校准 Serialized Engine Runtime 推理 关键配置项 • 工作空间大小 (workspace) • 精度模式 (FP32/FP16/INT8) • 动态形状范围 (DLA) • 校准器与校准数据

从这张图可以看得很清楚:模型进来后,先经过 Network 定义,然后 Builder 优化器内部并行执行图优化、层融合、内存复用和精度校准,最后输出一个序列化的 Engine,交给 Runtime 执行推理。

一个小技巧:如果你不确定某个优化是否生效,可以开启 TensorRT 的 verbose 日志。它会打印出每一层的融合情况。我经常用这个来排查性能瓶颈。