动态输入

Core::compile_model 中,有些模型支持在模型编译之前更改输入形态。这一点我们在更改输入形态一文中进行了演示。重塑模型能够定制模型输入形态,以达到最终应用所要求的精确尺寸。本文解释了模型的重塑能力在更多动态场景中如何进一步得到利用。

何时应用动态输入

当可以根据许多具有相同输入的模型推理调用完成一次时,传统的“静态”模型重塑效果很好。然而,如果输入张量形态在每次推理调用时都发生变化,那么这种方法的执行效率就不高:每次新的尺寸出现时调用 reshape()compile_model() 非常耗时。一个流行的示例就是用来自用户的任意大小的输入序列来推理自然语言处理模型(如 BERT)。在这种情况下,序列长度无法预测,而且可能在您每次需要调用推理时发生改变。下面这些可以频繁改变的维度称为动态维度。当输入的实际形态在 compile_model 未知时,这时候就应该考虑动态输入。

以下是几个自然动态维度的示例:

  • 各种序列处理模型(如 BERT)的序列长度维度

  • 分割和风格迁移模型中的空间维度

  • 批处理维度

  • 对象检测模型输出中任意数量的检测

通过组合多个预变形模型和输入数据填充,有多种技巧可以解决输入动态维度问题。这些技巧对模型内部很敏感,并不总能实现最佳性能,而且很麻烦。您可以在此处找到这些方法的简短概述。仅当以下部分中描述的本机动态输入 API 对您不起作用或不能实现所需的性能时,才应用这些方法。

是否使用动态输入应取决于以真实数据对真实应用进行的适当基准测试。这是因为与静态模型不同,动态模型的推理需要不同的推理时间,具体取决于输入数据形态或输入张量内容。此外,使用动态输入还会增加每次推理调用的内存和运行时间开销,具体取决于所用的硬件插件和模型。

不使用技巧的动态输入

本节介绍如何在本机使用 OpenVINO 运行时 API 2022.1 及更高版本处理动态输入的模型。流程中有三大部分不同于静态输入:

  • 配置模型

  • 准备推理数据

  • 推理后读取生成的数据

配置模型

为了避免使用上一节中提到的技巧,有一种方法可以直接在动态模型输入中指定一个或多个维度。这是通过与用于交替静态输入相同的重构方法实现的。 -1ov::Dimension(),而不是用于静态维度的正数:

ov::Core core;
auto model = core.read_model("model.xml");

// Set one static dimension (= 1) and another dynamic dimension (= Dimension())
model->reshape({{1, ov::Dimension()}});  // {1,?}

// The same as above
model->reshape({{1, -1}}); // {1,?}

// Or set both dimensions as dynamic if both are going to be changed dynamically
model->reshape({{ov::Dimension(), ov::Dimension()}});  // {?,?}

// The same as above
model->reshape({{-1, -1}});  // {?,?}
core = ov.Core()
model = core.read_model("model.xml")

# Set one static dimension (= 1) and another dynamic dimension (= Dimension())
model.reshape([1, ov.Dimension()])

# The same as above
model.reshape([1, -1])

# The same as above
model.reshape("1, ?")

# Or set both dimensions as dynamic if both are going to be changed dynamically
model.reshape([ov.Dimension(), ov.Dimension()])

# The same as above
model.reshape([-1, -1])

# The same as above
model.reshape("?, ?")

为了简化代码,这些示例假设模型只有单个输入和单个输出。然而,应用动态输入没有输入和输出数量限制。

未定义维度“开箱即用”

动态维度可以出现在输入模型中,而无需调用重塑。许多深度学习框架支持未定义维度。如果用模型优化器转换这样的模型,或者由 Core::read_model 直接读取,则未定义维度会被保留下来。此类维度自动被视为动态维度。如果已在原始模型或 IR 文件中配置了未定义维度,则无需调用重塑。

如果输入模型有您不打算在推理过程中改变的未定义维度,建议您使用与该模型相同的 reshape 方法将它们设置为静态值。从 API 角度来看,可以对任何动态和静态维度的组合进行配置。

模型优化器提供了相同的功能,可以在转换过程中重塑模型,包括指定动态维度。使用此功能可以节省在最终应用中调用 reshape 方法的时间。如需获取有关使用模型优化器设置输入形态的信息,请参考设置输入形态

维度界限范围

除了将维度标记为动态外,您还可以指定能够明确维度允许值范围的下限和/或上限。界限范围被编码为 ov::Dimension 的参数:

// Both dimensions are dynamic, first has a size within 1..10 and the second has a size within 8..512
model->reshape({{ov::Dimension(1, 10), ov::Dimension(8, 512)}});  // {1..10,8..512}

// Both dimensions are dynamic, first doesn't have bounds, the second is in the range of 8..512
model->reshape({{-1, ov::Dimension(8, 512)}});   // {?,8..512}
# Both dimensions are dynamic, first has a size within 1..10 and the second has a size within 8..512
model.reshape([ov.Dimension(1, 10), ov.Dimension(8, 512)])

# The same as above
model.reshape([(1, 10), (8, 512)])

# The same as above
model.reshape("1..10, 8..512")

# Both dimensions are dynamic, first doesn't have bounds, the second is in the range of 8..512
model.reshape([-1, (8, 512)])

界限范围相关信息为推理插件应用额外优化提供了机会。使用动态输入需要插件在模型编译期间应用更松散的优化技术。这可能需要更多的时间/内存进行模型编译和推理。因此,提供界限范围等任何额外信息都是有益的。出于同样的原因,除非真正需要,否则不建议将维度设为未定义。

在指定界限范围时,下限不如上限重要,因为知道上限能够让推理设备更精确地为用于推理的中间张量分配内存,并为不同尺寸使用较少数量的调优核心。准确地说,指定下限或上限的好处取决于设备。根据插件的不同,可以要求指定上限。有关不同设备上动态输入支持的信息,请参阅功能支持表

如果用户已知维度的下限和上限,建议即使在插件不需要界限范围即可执行模型时也指定上限和下限。

设置输入张量

用重塑方法准备模型是第一步。第二步是传递一个恰当形态的张量来推理请求。这与常规步骤相似,但现在我们可以为相同的可执行模型甚至相同的推理请求传递不同形态的张量:

// The first inference call

// Create tensor compatible with the model input
// Shape {1, 128} is compatible with any reshape statements made in previous examples
auto input_tensor_1 = ov::Tensor(model->input().get_element_type(), {1, 128});
// ... write values to input_tensor_1

// Set the tensor as an input for the infer request
infer_request.set_input_tensor(input_tensor_1);

// Do the inference
infer_request.infer();

// Retrieve a tensor representing the output data
ov::Tensor output_tensor = infer_request.get_output_tensor();

// For dynamic models output shape usually depends on input shape,
// that means shape of output tensor is initialized after the first inference only
// and has to be queried after every infer request
auto output_shape_1 = output_tensor.get_shape();

// Take a pointer of an appropriate type to tensor data and read elements according to the shape
// Assuming model output is f32 data type
auto data_1 = output_tensor.data<float>();
// ... read values

// The second inference call, repeat steps:

// Create another tensor (if the previous one cannot be utilized)
// Notice, the shape is different from input_tensor_1
auto input_tensor_2 = ov::Tensor(model->input().get_element_type(), {1, 200});
// ... write values to input_tensor_2

infer_request.set_input_tensor(input_tensor_2);

infer_request.infer();

// No need to call infer_request.get_output_tensor() again
// output_tensor queried after the first inference call above is valid here.
// But it may not be true for the memory underneath as shape changed, so re-take a pointer:
auto data_2 = output_tensor.data<float>();

// and new shape as well
auto output_shape_2 = output_tensor.get_shape();

// ... read values in data_2 according to the shape output_shape_2
# The first inference call

# Create tensor compatible to the model input
# Shape {1, 128} is compatible with any reshape statements made in previous examples
input_tensor1 = ov.Tensor(model.input().element_type, [1, 128])
# ... write values to input_tensor_1

# Set the tensor as an input for the infer request
infer_request.set_input_tensor(input_tensor1)

# Do the inference
infer_request.infer()

# Or pass a tensor in infer to set the tensor as a model input and make the inference
infer_request.infer([input_tensor1])

# Or pass the numpy array to set inputs of the infer request
input_data = np.ones(shape=[1, 128])
infer_request.infer([input_data])

# Retrieve a tensor representing the output data
output_tensor = infer_request.get_output_tensor()

# Copy data from tensor to numpy array
data1 = output_tensor.data[:]

# The second inference call, repeat steps:

# Create another tensor (if the previous one cannot be utilized)
# Notice, the shape is different from input_tensor_1
input_tensor2 = ov.Tensor(model.input().element_type, [1, 200])
# ... write values to input_tensor_2

infer_request.infer([input_tensor2])

# No need to call infer_request.get_output_tensor() again
# output_tensor queried after the first inference call above is valid here.
# But it may not be true for the memory underneath as shape changed, so re-take an output data:
data2 = output_tensor.data[:]

在上面的示例中,set_input_tensor 用于指定输入张量。张量的实际维度总是静态的,因为它是一个具体的张量,而且与模型输入相比,它没有任何维度变化。

与静态输入类似,可以使用 get_input_tensor 而不是 set_input_tensor。与静态输入形态相反,当使用 get_input_tensor 进行动态输入时,应调用返回张量的 set_shape 方法来定义形态和分配内存。如果不这样做,get_input_tensor 返回的张量就是一个空张量,其形态没有被初始化,也没有为其分配内存,因为推理请求没有您要输入的真实形态的信息。不管有没有界限范围相关信息,当相应的输入至少有一个动态维度时,就需要为输入张量设置形态。以下示例生成的两个推理请求序列与上一示例相同,但使用的是 get_input_tensor 而不是 set_input_tensor

// The first inference call

// Get the tensor; shape is not initialized
auto input_tensor = infer_request.get_input_tensor();

// Set shape is required
input_tensor.set_shape({1, 128});
// ... write values to input_tensor

infer_request.infer();
ov::Tensor output_tensor = infer_request.get_output_tensor();
auto output_shape_1 = output_tensor.get_shape();
auto data_1 = output_tensor.data<float>();
// ... read values

// The second inference call, repeat steps:

// Set a new shape, may reallocate tensor memory
input_tensor.set_shape({1, 200});
// ... write values to input_tensor memory

infer_request.infer();
auto data_2 = output_tensor.data<float>();
auto output_shape_2 = output_tensor.get_shape();
// ... read values in data_2 according to the shape output_shape_2
# Get the tensor, shape is not initialized
input_tensor = infer_request.get_input_tensor()

# Set shape is required
input_tensor.shape = [1, 128]
# ... write values to input_tensor

infer_request.infer()
output_tensor = infer_request.get_output_tensor()
data1 = output_tensor.data[:]

# The second inference call, repeat steps:

# Set a new shape, may reallocate tensor memory
input_tensor.shape = [1, 200]
# ... write values to input_tensor

infer_request.infer()
data2 = output_tensor.data[:]

输出结果中的动态输入

以上示例正确处理了输出中的动态维度可能被输入的动态维度传播所隐含的情况。例如,输入形态中的批处理维度通常通过整个模型进行传播,并出现在输出形态中。对于通过整个网络传播的其他维度也是如此,如 NLP 模型的序列长度或分割模型的空间维度。

输出是否具有动态维度,可以在模型读取或重塑后通过查询输出的部分形态进行检查。这也适用于输入。例如:

// Print output partial shape
std::cout << model->output().get_partial_shape() << "\n";

// Print input partial shape
std::cout << model->input().get_partial_shape() << "\n";
# Print output partial shape
print(model.output().partial_shape)

# Print input partial shape
print(model.input().partial_shape)

显示 ? 或者像 1..10 这样的范围,则表示相应的输入或输出中存在动态维度。

或者用更程序化的方式表达:

auto model = core.read_model("model.xml");

if (model->input(0).get_partial_shape().is_dynamic()) {
    // input is dynamic
}

if (model->output(0).get_partial_shape().is_dynamic()) {
    // output is dynamic
}

if (model->output(0).get_partial_shape()[1].is_dynamic()) {
    // 1-st dimension of output is dynamic
}
model = core.read_model("model.xml")

if model.input(0).partial_shape.is_dynamic():
    # input is dynamic
    pass

if model.output(0).partial_shape.is_dynamic():
    # output is dynamic
    pass

if model.output(0).partial_shape[1].is_dynamic():
    # 1-st dimension of output is dynamic
    pass

如果模型的输出中存在至少一个动态维度,则相应输出张量的形态将被设置为推理调用的结果。在第一次推理之前,没有为这样一个张量分配内存,它的形态为 [0]。如果用户用预先分配的张量调用 set_output_tensor,推理就会在内部调用 set_shape,而且初始形态会被实际计算出来的形态替换。因此,在这种情况下,设置输出张量的形态只有在您想要为输出张量预分配足额内存时才起作用,因为 Tensorset_shape 方法只有在新形态需要更多存储时才会重新分配内存。