4. TensorRT核心概念:Network Definition、Builder、Engine、Context、Binding
好,咱们今天来聊聊TensorRT的五个核心概念。说实话,这五个东西就像TensorRT的"五脏六腑",搞懂了它们,你基本就摸清了TensorRT的运作逻辑。
我记得刚接触TensorRT那会儿,看着这些名词一头雾水——什么Network Definition、Builder、Engine、Context、Binding,每个都像黑话。后来踩了不少坑,才慢慢理清楚它们各自扮演的角色。今天我就用最直白的方式,把这五个概念给你讲透。
4.1 Network Definition —— 你的模型蓝图
Network Definition,说白了就是你对模型的"描述"。它不负责计算,只负责记录——记录你的网络有哪些层、每层的参数是什么、输入输出长什么样。
我个人习惯把Network Definition比作建筑图纸。你盖房子之前,总得先画张图吧?这张图上标明了哪里是承重墙、哪里是窗户、楼梯怎么走。Network Definition干的就是这个活。
关键点:Network Definition只定义网络结构,不包含任何权重数据。权重是在后续步骤中加载的。
在代码里,你通常这样创建它:
// 创建builder
IBuilder* builder = createInferBuilder(gLogger);
// 创建network,指定显式batch模式
INetworkDefinition* network = builder->createNetworkV2(1U <<
static_cast<int>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH));
嗯,这里要注意一点:createNetworkV2的参数。如果你用的是隐式batch模式(老版本),那就要用createNetwork。但我建议你直接用显式batch,因为从TensorRT 8.x开始,隐式batch已经被标记为废弃了。我曾经在一个老项目里看到有人还在用隐式batch,结果升级TensorRT版本后代码直接崩了——这就是个坑。
4.2 Builder —— 那个"编译"你的人
Builder是干什么的?它拿着你给的Network Definition,再加上一堆配置参数,然后吭哧吭哧地帮你把模型"编译"成优化后的Engine。
你想想看,Builder就像是一个建筑队队长。你给他图纸(Network Definition),告诉他"我要用最好的材料"、"工期要快"(配置参数),他就去组织施工了。最后交给你一栋建好的房子(Engine)。
Builder的核心配置包括:
- 最大工作空间:允许Builder使用多少显存来做优化
- 精度设置:FP32、FP16还是INT8
- 动态形状范围:如果你的模型支持动态batch或动态分辨率
代码示例:
// 配置builder
config = builder->createBuilderConfig();
config->setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
config->setFlag(BuilderFlag::kFP16); // 启用FP16
// 开始构建
IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);
小技巧:Builder的构建过程可能很慢,尤其是大模型。我建议你把构建好的Engine序列化保存到磁盘,下次直接加载,省得每次都要重新编译。这在生产环境中几乎是标配做法。
4.3 Engine —— 优化后的"可执行文件"
Engine就是Builder产出的最终成果。它是一个高度优化过的、可执行的推理引擎。说白了,Engine已经不再是原来的模型了——它经过了层融合、内存复用、算子替换等一系列骚操作,变得面目全非,但跑起来飞快。
Engine有几个特点:
- 平台相关:在A100上构建的Engine,不能直接拿到T4上用。因为不同GPU的架构不同,优化策略也不同。
- 不可逆:Engine一旦构建完成,你就没法再改它的结构了。想改?重新构建吧。
- 可序列化:Engine可以保存为二进制文件,下次直接反序列化加载。
加载Engine的代码:
// 从文件读取序列化的Engine
std::ifstream file("model.engine", std::ios::binary);
// ... 读取到内存 ...
IRuntime* runtime = createInferRuntime(gLogger);
ICudaEngine* engine = runtime->deserializeCudaEngine(modelData, size);
注意:反序列化Engine时,需要确保当前GPU和构建Engine时的GPU是同一型号,或者至少架构兼容。我曾经把在V100上构建的Engine拿到T4上跑,结果直接报错——CUDA架构不匹配。这个坑我踩过,你别踩。
4.4 Context —— 推理的执行上下文
Engine是"静态的",它只是描述了一个优化后的计算图。真正要跑推理,你需要一个Context。Context就是Engine的一个"实例",它持有推理过程中的所有中间状态。
打个比方:Engine就像是一本菜谱,Context就是厨师。菜谱写得再好,没有厨师去执行,你也吃不上菜。Context就是那个动手的人。
一个Engine可以创建多个Context,每个Context可以独立执行推理。这在多线程场景下特别有用——你可以为每个线程创建一个Context,共享同一个Engine,互不干扰。
// 创建context
IExecutionContext* context = engine->createExecutionContext();
// 设置输入形状(动态形状时需要)
context->setBindingDimensions(0, Dims4{1, 3, 224, 224});
// 执行推理
context->enqueueV2(buffers, stream, nullptr);
嗯,这里有个细节:enqueueV2是异步的,它会把推理任务提交到CUDA stream上。如果你需要同步等待结果,记得调用cudaStreamSynchronize。
4.5 Binding —— 输入输出的"接口"
Binding,说白了就是Engine的输入输出接口。每个Binding都有一个索引号和一个名字,通过它们你可以把数据送进Engine,或者从Engine取出结果。
Binding有两种类型:
- 输入Binding:你把数据塞进去
- 输出Binding:Engine把结果吐出来
Binding的索引从0开始,顺序和你在Network Definition中定义的顺序一致。我个人习惯用名字来获取索引,这样代码可读性更好:
// 获取binding索引
int inputIndex = engine->getBindingIndex("input");
int outputIndex = engine->getBindingIndex("output");
// 准备设备内存
void* inputBuffer;
void* outputBuffer;
cudaMalloc(&inputBuffer, inputSize);
cudaMalloc(&outputBuffer, outputSize);
// 设置binding
buffers[inputIndex] = inputBuffer;
buffers[outputIndex] = outputBuffer;
// 执行推理
context->enqueueV2(buffers, stream, nullptr);
重要提醒:Binding的维度信息可以通过engine->getBindingDimensions()获取。对于动态形状的模型,你需要在推理前通过context->setBindingDimensions()设置实际的输入尺寸。
4.6 它们之间的关系
最后,我用一张表格总结一下这五个概念的关系:
| 概念 | 角色 | 生命周期 | 是否可序列化 |
|---|---|---|---|
| Network Definition | 模型描述(图纸) | 构建阶段 | 否 |
| Builder | 优化编译器(施工队) | 构建阶段 | 否 |
| Engine | 优化后的模型(成品房) | 推理阶段 | 是 |
| Context | 推理执行者(住户) | 推理阶段 | 否 |
| Binding | 输入输出接口(门窗) | 推理阶段 | 否 |
整个流程走下来就是:你先画图纸(Network Definition),然后找施工队(Builder)按图纸盖房子(Engine),房子盖好后你住进去(Context),通过门窗(Binding)进出东西。就这么简单。
好了,这五个核心概念就讲到这里。下一章我们会深入聊聊如何用这些概念搭建一个完整的推理pipeline,到时候我会带上一些实际项目中的踩坑经验,敬请期待。