3、TensorRT核心概念:Builder、Network、Config、Engine、Context、Buffer与Tensor的概念解析
好,咱们今天来啃一块硬骨头——TensorRT的七大核心概念。说实话,我刚开始接触TensorRT时,也被这些名词绕得晕头转向。Builder是干嘛的?Engine和Context又有什么区别?Buffer到底指什么?
别急,咱们一个一个捋清楚。我保证,看完这一章,你脑子里会有一张清晰的架构图。
3.1 整体架构:从构建到推理的流水线
先给你一个宏观视角。TensorRT的工作流程,说白了就是一条流水线:
- Builder(构建器)—— 负责搭建和编译模型
- Network(网络定义)—— 描述模型的计算图
- Config(配置)—— 告诉Builder怎么编译
- Engine(引擎)—— 编译好的可执行模型
- Context(执行上下文)—— 真正干活的执行器
- Buffer(缓冲区)—— 数据存放的地方
- Tensor(张量)—— 数据本身
嗯,这七个概念,就像工厂里的不同角色。Builder是厂长,Network是设计图纸,Config是生产参数,Engine是造好的机器,Context是操作机器的工人,Buffer是原料仓库,Tensor就是原料本身。
核心要点:这七个对象有严格的创建顺序。你不能跳过Builder直接创建Engine,也不能没有Network就去配置Config。顺序搞错了,程序直接崩给你看。
3.2 Builder(构建器)—— 一切从这里开始
Builder是TensorRT的入口。我习惯叫它「总指挥」。它的职责就两个:创建Network定义,以及编译生成Engine。
// 创建Builder
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
// 创建Network
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(flags);
// 创建Config
nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
// 编译Engine
nvinfer1::IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);
你看,Builder就像一把瑞士军刀,什么都能干。但要注意,Builder本身不参与推理,它只负责「造」。造完了,它的使命就结束了。
我的经验:Builder的创建和销毁一定要配对。我在项目中见过有人忘了释放Builder,结果内存泄漏,跑一次推理内存涨几百兆。嗯,排查了好久才发现是这里的问题。
3.3 Network(网络定义)—— 你的模型长什么样
Network就是你的模型在TensorRT中的「投影」。它描述了每一层的输入输出、权重、连接关系。
你想想看,你从PyTorch或TensorFlow导出的模型,TensorRT不认识。你得通过Network,一层一层告诉它:这里有个卷积,那里有个ReLU,最后是个Softmax。
// 添加输入张量
nvinfer1::ITensor* input = network->addInput("input", nvinfer1::DataType::kFLOAT, nvinfer1::Dims4{1, 3, 224, 224});
// 添加卷积层
nvinfer1::IConvolutionLayer* conv = network->addConvolutionNd(*input, 64, nvinfer1::DimsHW{3, 3}, weightMap["conv.weight"], weightMap["conv.bias"]);
// 添加激活层
nvinfer1::IActivationLayer* relu = network->addActivation(*conv->getOutput(0), nvinfer1::ActivationType::kRELU);
// 标记输出
network->markOutput(*relu->getOutput(0));
这里有个坑,我踩过好几次——Network的构建必须在Builder的生存期内完成。Builder一旦销毁,Network也跟着失效。所以,我建议你把Builder、Network、Config的创建和Engine的编译放在同一个作用域里。
3.4 Config(配置)—— 告诉Builder怎么干活
Config就是给Builder下指令的。你想用FP16还是INT8?最大batch size是多少?工作空间给多大?这些都在Config里设置。
// 设置工作空间大小
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
// 启用FP16
if (builder->platformHasFastFp16()) {
config->setFlag(nvinfer1::BuilderFlag::kFP16);
}
// 设置最大batch size
builder->setMaxBatchSize(32);
注意:不是所有GPU都支持FP16或INT8。我在项目里就遇到过,在T4上跑得好好的FP16模型,换到P4上直接报错。所以,一定要先检查platformHasFastFp16()或platformHasFastInt8()。
3.5 Engine(引擎)—— 编译好的可执行模型
Engine是TensorRT最核心的产物。它把Network定义和Config配置编译成了一个高度优化的、可序列化的二进制对象。
说白了,Engine就是你的「最终武器」。它可以保存到磁盘,下次直接加载,省去重新编译的时间。
// 序列化Engine到内存
nvinfer1::IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);
// 保存到文件
std::ofstream file("model.engine", std::ios::binary);
file.write(reinterpret_cast<const char*>(serializedModel->data()), serializedModel->size());
file.close();
// 从文件反序列化
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);
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(buffer.data(), size);
这里有个关键点:Engine是线程安全的。你可以创建一个Engine,然后在多个线程里同时用它创建Context。但Engine本身不执行推理,它只是个「模板」。
3.6 Context(执行上下文)—— 真正干活的工人
Context才是真正执行推理的对象。它从Engine创建,每个Context都有自己的执行状态和中间结果。
// 从Engine创建Context
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
// 执行推理
context->executeV2(buffers); // 非batch模式
// 或
context->execute(batchSize, buffers); // batch模式
你想想看,为什么需要Context?因为Engine是只读的、共享的。但推理过程中,每一层的中间结果、临时变量,都需要独立的内存空间。Context就是干这个的。
我的建议:如果你需要同时处理多个推理请求,可以创建多个Context共享同一个Engine。这样既节省内存,又能并行处理。我在做视频流推理时就是这么干的,一个Engine配4个Context,吞吐量直接翻倍。
3.7 Buffer(缓冲区)—— 数据的中转站
Buffer就是GPU上的内存区域。输入数据要放到Buffer里,推理结果也要从Buffer里取出来。
TensorRT本身不管理Buffer的分配和释放,它只负责「用」。你需要自己用cudaMalloc分配GPU内存,然后把数据拷进去。
// 分配GPU内存
void* inputBuffer;
void* outputBuffer;
cudaMalloc(&inputBuffer, inputSize);
cudaMalloc(&outputBuffer, outputSize);
// 将输入数据拷贝到GPU
cudaMemcpy(inputBuffer, hostInputData, inputSize, cudaMemcpyHostToDevice);
// 设置Buffer数组
void* buffers[] = {inputBuffer, outputBuffer};
// 执行推理
context->executeV2(buffers);
// 将结果拷回CPU
cudaMemcpy(hostOutputData, outputBuffer, outputSize, cudaMemcpyDeviceToHost);
我曾经踩过的坑:Buffer的大小必须和Engine期望的输入输出尺寸严格匹配。有一次我改了模型输入尺寸,但忘了更新Buffer大小,结果推理出来的数据全是乱的。排查了整整一天,才发现是Buffer没对齐。
3.8 Tensor(张量)—— 数据本身
Tensor就是数据本身,但它不是一块单纯的内存。它包含了数据的形状(Shape)、数据类型(DataType)、以及数据在内存中的布局(Layout)。
在Network定义阶段,Tensor是ITensor对象。在推理阶段,Tensor就是Buffer里的数据,加上Engine中记录的元信息。
// 获取Engine中Tensor的信息
int inputIndex = engine->getBindingIndex("input");
nvinfer1::Dims inputDims = engine->getBindingDimensions(inputIndex);
nvinfer1::DataType inputType = engine->getBindingDataType(inputIndex);
// 输出Tensor信息
std::cout << "Input shape: " << inputDims.d[0] << "x" << inputDims.d[1] << "x" << inputDims.d[2] << "x" << inputDims.d[3] << std::endl;
std::cout << "Input type: " << (inputType == nvinfer1::DataType::kFLOAT ? "FP32" : "FP16") << std::endl;
这里有个容易混淆的地方:Tensor和Buffer不是一回事。Buffer是物理内存,Tensor是逻辑概念。一个Tensor可能对应多个Buffer(比如动态batch),一个Buffer也可能被多个Tensor共享(比如in-place操作)。
3.9 七者关系总结
好,咱们来捋一捋这七个概念的关系:
| 概念 | 角色 | 生命周期 | 线程安全 |
|---|---|---|---|
| Builder | 总指挥,负责构建 | 编译期间 | 否 |
| Network | 设计图纸,描述模型 | 编译期间 | 否 |
| Config | 生产参数,配置编译 | 编译期间 | 否 |
| Engine | 造好的机器,可执行模型 | 推理期间 | 是 |
| Context | 操作机器的工人,执行推理 | 推理期间 | 否 |
| Buffer | 原料仓库,GPU内存 | 推理期间 | 取决于使用方式 |
| Tensor | 原料本身,数据+元信息 | 贯穿始终 | 取决于具体对象 |
你看,这七个概念其实分成了两个阶段:
- 编译阶段:Builder + Network + Config → Engine
- 推理阶段:Engine → Context + Buffer + Tensor
我个人习惯把编译阶段叫做「造车」,推理阶段叫做「开车」。车造好了,你才能开。车是共享的,但每个司机(Context)有自己的驾驶舱。
一句话总结:Builder用Config配置Network,编译出Engine;Engine创建Context,Context操作Buffer中的Tensor,完成推理。
嗯,这七个概念你搞清楚了,TensorRT的骨架就算搭起来了。下一章,咱们会深入Network,看看怎么用代码搭建一个完整的网络。到时候,这些概念会一个个活起来。