4、TensorRT引擎构建与序列化:Builder配置、网络定义、优化策略(INT8/FP16/FP32)、引擎序列化与反序列化
好,咱们进入正题。引擎构建与序列化,说白了就是TensorRT的“编译”和“打包”过程。你写好了网络结构,喂好了权重,TensorRT得把它变成一张能在GPU上飞速跑起来的“蓝图”——这就是引擎。我个人习惯把这一步叫做“炼丹的最后一步”,因为搞不好,前面所有努力都白费。
4.1 Builder配置:给引擎定调子
Builder是TensorRT的“总指挥”。你得告诉它:用哪个GPU?最大显存吃多少?精度要啥档次?
先看一段最基础的配置代码:
// 创建Builder
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
// 配置网络定义标志位
const auto explicitBatch = 1U << static_cast<uint32_t>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
// 创建Builder配置
nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
// 设置最大工作空间(显存吃紧时要注意)
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
// 设置精度(后面细讲)
config->setFlag(nvinfer1::BuilderFlag::kFP16);
这里有个坑,我踩过好几次。那个explicitBatch标志位,在TensorRT 8.x之后是必须加的。如果你忘了,后面推理时batch size会变得很诡异。嗯,这里要注意,显式batch模式能让你的网络定义更清晰,也方便后面做动态shape。
setMemoryPoolLimit,结果模型在低显存卡上直接OOM。建议根据目标显卡的显存大小,预留30%给系统和其他进程,剩下的给workspace。
4.2 网络定义:搭积木的艺术
网络定义这一步,说白了就是把你的模型结构“翻译”成TensorRT能懂的语言。你可以用Parser(比如ONNX Parser)直接解析,也可以手撸每一层。
我个人更推荐用ONNX Parser,省时省力。但要注意,ONNX模型里有些算子TensorRT不支持,这时候就得手动替换或回退。
// 用ONNX Parser解析模型
nvinfer1::IONNXParser* parser = nvonnxparser::createParser(*network, logger);
if (!parser->parseFromFile("model.onnx", static_cast<int>(nvinfer1::ILogger::Severity::kWARNING))) {
// 解析失败,检查ONNX版本和算子支持情况
std::cerr << "ONNX解析失败,请检查模型" << std::endl;
}
你想想看,为什么有时候解析成功但推理结果不对?大概率是动态shape没处理好。TensorRT要求你在网络定义阶段就明确输入输出的维度范围。
network->getInput(0)->getName()来验证。
4.3 优化策略:INT8/FP16/FP32怎么选?
这是整个课程里最“值钱”的部分。精度选择直接影响推理速度和精度损失。我直接给结论:
| 精度模式 | 速度提升(相对FP32) | 精度损失 | 适用场景 |
|---|---|---|---|
| FP32 | 1x(基准) | 无 | 精度敏感、小模型 |
| FP16 | 1.5x - 2x | 极小(通常<0.5%) | 大多数生产场景 |
| INT8 | 3x - 4x | 1% - 3%(需校准) | 高吞吐、低延迟要求 |
FP16是我用得最多的。大部分模型(ResNet、BERT、YOLO)在FP16下几乎不掉点,但速度能翻倍。你只需要在config里加一行:
config->setFlag(nvinfer1::BuilderFlag::kFP16);
就这么简单?嗯,大部分情况是的。但有些层(比如某些自定义激活函数)在FP16下会溢出,这时候TensorRT会自动回退到FP32。你可以在构建日志里看到类似[W] Layer xxx fallback to FP32的警告。
INT8就复杂多了。它需要校准数据集来统计激活值的分布。我踩过最大的坑就是校准数据分布和实际推理数据不一致,导致精度崩了。
// INT8校准示例
class MyInt8Calibrator : public nvinfer1::IInt8EntropyCalibrator2 {
// 实现getBatch()、getBatchSize()等接口
// 返回校准数据(通常500-1000张图)
};
config->setFlag(nvinfer1::BuilderFlag::kINT8);
config->setInt8Calibrator(new MyInt8Calibrator());
4.4 引擎序列化与反序列化:存下来,下次直接用
构建引擎是个耗时的过程(尤其是INT8,可能要跑几十分钟)。所以,我们通常把构建好的引擎序列化成文件,下次直接加载,省去重新构建的时间。
序列化代码很简单:
// 构建引擎
nvinfer1::IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);
if (!serializedModel) {
std::cerr << "引擎构建失败" << std::endl;
return;
}
// 保存到文件
std::ofstream file("model.engine", std::ios::binary);
file.write(reinterpret_cast<const char*>(serializedModel->data()), serializedModel->size());
file.close();
serializedModel->destroy();
反序列化就是反过来:
// 从文件读取
std::ifstream file("model.engine", std::ios::binary);
file.seekg(0, std::ios::end);
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<char> buffer(size);
file.read(buffer.data(), size);
file.close();
// 创建runtime并反序列化
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(buffer.data(), size);
另外,序列化文件里包含了网络权重和优化后的策略,所以不要随意分发。如果你需要跨平台部署,建议在目标平台上重新构建。
4.5 实战中的避坑指南
- 动态shape的序列化:如果你的网络支持动态batch或动态分辨率,序列化时会把所有优化配置都打包进去。反序列化后,你可以在运行时指定具体shape。但要注意,优化配置越多,引擎文件越大,构建时间也越长。
- 多流推理时的引擎复用:同一个引擎可以被多个CUDA流共享。你只需要反序列化一次,然后创建多个
IExecutionContext(每个流一个)。我习惯把引擎做成单例,避免重复加载。 - 日志的重要性:构建引擎时,TensorRT会输出大量日志。我建议把日志级别设为
kWARNING或kINFO,这样能及时发现回退、精度问题。我曾经因为忽略了一个kWARNING,排查了整整两天。
trtexec工具快速验证一下ONNX模型能否成功构建。命令很简单:trtexec --onnx=model.onnx --fp16 --saveEngine=model.engine。如果这一步都过不了,那代码里肯定也有问题。
好了,引擎构建与序列化这块就讲到这里。说白了,就是三步:配好Builder、搭好网络、选对精度,然后存盘。下次用的时候直接加载,省时省力。下一章咱们聊聊多流推理的具体实现,那才是真正发挥GPU性能的地方。