2、动态形状的核心概念:Binding、Profile、Optimization Profile、Execution Tensor
好,咱们进入正题。动态形状这块,说白了就是让模型能处理不同尺寸的输入。你想想看,实际部署时哪能保证每张图片都是 224x224?用户上传的图千奇百怪,模型得能接得住才行。
TensorRT 处理动态形状,靠的是四个核心概念:Binding、Profile、Optimization Profile、Execution Tensor。我刚开始接触时也被绕晕过,后来在项目中踩了几次坑才彻底搞明白。今天咱们一个一个说清楚。
2.1 Binding —— 输入输出的“接口”
Binding 是 TensorRT 网络中的输入输出节点。你可以把它理解成模型的“接口”。每个 Binding 都有一个名字,对应 ONNX 模型里的输入输出张量。
我个人习惯把 Binding 分成两类:
- 输入 Binding:数据从这儿进去
- 输出 Binding:结果从这儿出来
在动态形状场景下,Binding 本身不固定尺寸。它只告诉你“我是谁”,不告诉你“我多大”。尺寸信息由 Profile 来管。
关键点:Binding 的数量和名字在构建引擎时就固定了。你不能在推理时增删 Binding。
举个例子,我有个分类模型,输入叫 "input",输出叫 "output"。那引擎里就有两个 Binding。代码里怎么查?
// 获取 Binding 数量
int nbBindings = engine->getNbBindings();
// 遍历所有 Binding
for (int i = 0; i < nbBindings; i++) {
const char* name = engine->getBindingName(i);
bool isInput = engine->bindingIsInput(i);
std::cout << "Binding " << i << ": " << name
<< " (" << (isInput ? "输入" : "输出") << ")" << std::endl;
}
小技巧:我建议用名字而不是索引来访问 Binding。因为不同版本的 TensorRT 可能改变索引顺序,但名字是稳定的。
2.2 Profile —— 形状的“约束范围”
Profile 是动态形状的核心。它定义了每个动态维度允许的最小、最大和最优尺寸。
一个 Profile 包含三个部分:
- kMIN:最小形状。推理时不能小于这个值。
- kMAX:最大形状。推理时不能大于这个值。
- kOPT:最优形状。TensorRT 会针对这个尺寸做最大程度的优化。
为什么会这样?因为 TensorRT 需要在编译时做很多优化,比如内存分配、算子选择。它得知道你的输入大概在什么范围,才能做出最优决策。
我曾经在项目中犯过一个错误:把 kMIN 设得太小,kMAX 设得太大。结果引擎编译时间暴涨,推理性能也差。后来我学乖了,尽量让 kMIN、kOPT、kMAX 贴近实际使用场景。
注意:kOPT 不一定是中间值。比如你的输入大部分是 640x640,偶尔有 320x320 和 1280x1280,那 kOPT 应该设成 640x640,而不是 (320+1280)/2。
代码里怎么设置?看这个例子:
// 创建网络构建配置
IBuilderConfig* config = builder->createBuilderConfig();
// 创建优化配置
IOptimizationProfile* profile = builder->createOptimizationProfile();
// 设置输入 "input" 的动态范围
// 假设输入是 [batch, 3, height, width]
profile->setDimensions("input", OptProfileSelector::kMIN, Dims4{1, 3, 224, 224});
profile->setDimensions("input", OptProfileSelector::kOPT, Dims4{4, 3, 640, 640});
profile->setDimensions("input", OptProfileSelector::kMAX, Dims4{8, 3, 1280, 1280});
// 将 profile 添加到配置中
config->addOptimizationProfile(profile);
2.3 Optimization Profile —— 多场景的“组合拳”
一个引擎可以包含多个 Optimization Profile。每个 Profile 对应一组形状约束。
为什么要多个?因为实际场景可能很复杂。比如:
- 白天处理小图(640x480)
- 晚上处理大图(1920x1080)
- 两种场景的优化方向不同
你可以为每个场景创建一个 Profile。推理时动态切换,TensorRT 会自动选择最合适的优化版本。
核心思想:多个 Profile 让你能在同一个引擎里支持多种形状范围,而不需要为每种尺寸单独编译引擎。
我记得有个项目需要同时处理手机拍照和监控摄像头。手机图小,监控图大。我建了两个 Profile:
// Profile 1: 小图场景
IOptimizationProfile* profile1 = builder->createOptimizationProfile();
profile1->setDimensions("input", OptProfileSelector::kMIN, Dims4{1, 3, 320, 240});
profile1->setDimensions("input", OptProfileSelector::kOPT, Dims4{1, 3, 640, 480});
profile1->setDimensions("input", OptProfileSelector::kMAX, Dims4{1, 3, 1280, 720});
config->addOptimizationProfile(profile1);
// Profile 2: 大图场景
IOptimizationProfile* profile2 = builder->createOptimizationProfile();
profile2->setDimensions("input", OptProfileSelector::kMIN, Dims4{1, 3, 1080, 720});
profile2->setDimensions("input", OptProfileSelector::kOPT, Dims4{1, 3, 1920, 1080});
profile2->setDimensions("input", OptProfileSelector::kMAX, Dims4{1, 3, 3840, 2160});
config->addOptimizationProfile(profile2);
推理时,根据输入尺寸选择 Profile:
// 假设输入是 640x480
int profileIdx = 0; // 小图 Profile
context->setOptimizationProfile(profileIdx);
context->setBindingDimensions(0, Dims4{1, 3, 480, 640});
经验之谈:Profile 数量不是越多越好。每个 Profile 都会增加引擎大小和编译时间。我一般控制在 2-3 个,覆盖主要场景就够了。
2.4 Execution Tensor —— 运行时的“活数据”
Execution Tensor 是推理时实际存在的张量。它和 Binding 不同:Binding 是静态的接口定义,Execution Tensor 是动态的运行时数据。
每个 Execution Tensor 都有:
- 形状:运行时确定的实际尺寸
- 数据类型:float32、float16、int8 等
- 数据指针:指向 GPU 显存中的实际数据
在动态形状场景下,Execution Tensor 的形状在每次推理时都可能变化。你需要:
- 设置输入 Binding 的形状
- 验证形状是否在 Profile 范围内
- 分配足够的内存
- 执行推理
- 读取输出形状
代码示例:
// 设置输入形状
context->setBindingDimensions(0, Dims4{batchSize, 3, height, width});
// 验证形状是否有效
if (!context->allInputDimensionsSpecified()) {
std::cerr << "输入形状未完全指定" << std::endl;
return;
}
// 获取输出形状(动态的!)
Dims outputDims = context->getBindingDimensions(1);
int outputSize = outputDims.d[0] * outputDims.d[1] * outputDims.d[2] * outputDims.d[3];
// 分配内存(根据实际输出大小)
float* outputBuffer;
cudaMalloc(&outputBuffer, outputSize * sizeof(float));
// 执行推理
context->enqueueV2(buffers.data(), stream, nullptr);
坑点提醒:输出形状只有在推理完成后才能确定。你不能提前假设输出大小。我曾经在代码里写死了输出 buffer 大小,结果输入尺寸一变就崩了。
2.5 四个概念的关系总结
说了这么多,咱们理一下它们的关系:
| 概念 | 阶段 | 作用 | 是否可变 |
|---|---|---|---|
| Binding | 构建时 | 定义输入输出接口 | 固定 |
| Profile | 构建时 | 定义形状约束范围 | 固定 |
| Optimization Profile | 构建时 | 组合多个形状场景 | 固定 |
| Execution Tensor | 运行时 | 实际数据载体 | 每次推理可变 |
简单来说:Binding 是门,Profile 是门框,Optimization Profile 是多个门框,Execution Tensor 是实际进出的人。门和门框在盖房子时就定好了,但每天进出的人高矮胖瘦都不一样。
一句话总结:动态形状不是让模型“什么尺寸都能处理”,而是让模型在“你规划好的尺寸范围内”灵活变化。规划得越好,性能越稳。
下一章咱们会深入实际代码,看看怎么用这些概念构建一个完整的动态形状推理引擎。到时候我会拿一个真实项目来演示,包括那些我踩过的坑。