2、动态形状的核心概念:Binding、Profile、Optimization Profile、Execution Tensor

好,咱们进入正题。动态形状这块,说白了就是让模型能处理不同尺寸的输入。你想想看,实际部署时哪能保证每张图片都是 224x224?用户上传的图千奇百怪,模型得能接得住才行。

TensorRT 处理动态形状,靠的是四个核心概念:BindingProfileOptimization ProfileExecution 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 的形状在每次推理时都可能变化。你需要:

  1. 设置输入 Binding 的形状
  2. 验证形状是否在 Profile 范围内
  3. 分配足够的内存
  4. 执行推理
  5. 读取输出形状

代码示例:

// 设置输入形状
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 是实际进出的人。门和门框在盖房子时就定好了,但每天进出的人高矮胖瘦都不一样。

一句话总结:动态形状不是让模型“什么尺寸都能处理”,而是让模型在“你规划好的尺寸范围内”灵活变化。规划得越好,性能越稳。

下一章咱们会深入实际代码,看看怎么用这些概念构建一个完整的动态形状推理引擎。到时候我会拿一个真实项目来演示,包括那些我踩过的坑。