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 逐层手写才搞定的。
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);
3.6 知识图谱:TensorRT 核心流程一览
下面这张图总结了从模型输入到 Engine 产出的完整链路,以及各阶段的关键操作。
从这张图可以看得很清楚:模型进来后,先经过 Network 定义,然后 Builder 优化器内部并行执行图优化、层融合、内存复用和精度校准,最后输出一个序列化的 Engine,交给 Runtime 执行推理。