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要求你在网络定义阶段就明确输入输出的维度范围。

小技巧: 定义网络时,给每个输入输出起个有意义的名字(比如"input", "output"),后面序列化和反序列化时能省不少事。我习惯用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());
核心建议: 如果你刚接触TensorRT,先从FP32开始,验证精度没问题后切FP16。INT8留给那些对延迟极度敏感的场景(比如实时视频流)。别一上来就INT8,否则排查精度问题会让你怀疑人生。

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);
注意: 序列化后的引擎文件是硬件和驱动版本相关的。你在A100上构建的引擎,拿到T4上可能跑不了。我遇到过最离谱的一次,同一个引擎在CUDA 11.4和11.8上表现都不一样。所以,尽量在目标机器上构建引擎,或者至少保证CUDA和TensorRT版本一致。

另外,序列化文件里包含了网络权重和优化后的策略,所以不要随意分发。如果你需要跨平台部署,建议在目标平台上重新构建。

4.5 实战中的避坑指南

  • 动态shape的序列化:如果你的网络支持动态batch或动态分辨率,序列化时会把所有优化配置都打包进去。反序列化后,你可以在运行时指定具体shape。但要注意,优化配置越多,引擎文件越大,构建时间也越长。
  • 多流推理时的引擎复用:同一个引擎可以被多个CUDA流共享。你只需要反序列化一次,然后创建多个IExecutionContext(每个流一个)。我习惯把引擎做成单例,避免重复加载。
  • 日志的重要性:构建引擎时,TensorRT会输出大量日志。我建议把日志级别设为kWARNINGkINFO,这样能及时发现回退、精度问题。我曾经因为忽略了一个kWARNING,排查了整整两天。
个人经验: 在构建引擎前,先用trtexec工具快速验证一下ONNX模型能否成功构建。命令很简单:trtexec --onnx=model.onnx --fp16 --saveEngine=model.engine。如果这一步都过不了,那代码里肯定也有问题。

好了,引擎构建与序列化这块就讲到这里。说白了,就是三步:配好Builder、搭好网络、选对精度,然后存盘。下次用的时候直接加载,省时省力。下一章咱们聊聊多流推理的具体实现,那才是真正发挥GPU性能的地方。