動的形状

入力形状の変更で説明したように、Core::compile_model でモデルをコンパイルする前に入力形状の変更をサポートするモデルがあります。モデルの再形状により、最終アプリケーションの正確なサイズに合わせてモデルの入力形状をカスタマイズする機能が提供されます。ここでは、モデルの再形状機能をより動的なシナリオでさらに活用する方法について説明します。

動的形状の適用

従来の “静的” モデルの再形状は、同じ形状の多数のモデル推論呼び出しごとに 1 回実行できる場合に機能します。ただし、入力テンソル形状が推論呼び出しごとに変化する場合、このアプローチは効率よく実行されません。サイズが変わるたびに reshape() メソッドと compile_model() メソッドを呼び出すのは時間がかかります。一般的な例として、任意のサイズのユーザー入力シーケンスによる自然言語処理モデル (BERT など) の推論があります。この場合、シーケンス長は予測できず、推論が呼び出されるたびに変化する可能性があります。頻繁に変更される次元は動的次元と呼ばれます。実際の入力形状が、compile_model() メソッドの呼び出し時に不明である場合、動的な形状を考慮する必要があります。

以下に、自然な流れで動的になる次元の例をいくつか示します。

  • BERT などのさまざまなシーケンス処理モデルのシーケンス長次元

  • セグメント化およびスタイル転送モデルの空間次元

  • バッチ次元

  • 物体検出モデル出力における任意の数の検出

事前に再形成された複数のモデルと入力データのパディングを組み合わせることにより、入力の動的次元に対処する方法があります。この方法はモデル内部の影響を受けやすいため、常に最適なパフォーマンスが得られるとは限らず、また複雑です。メソッドの概要については、動的形状 API が適用できない場合を参照してください。これらのメソッドは、次のセクションで説明するネイティブの動的形状 API が機能しない場合、または期待どおりに動作しない場合にのみ適用してください。

動的形状を使用するかどうかは、実際のデータを使用したアプリケーションの適切なベンチマークに基づいて決定する必要があります。静的形状モデルとは異なり、動的形状モデルは、入力データの形状または入力テンソルの内容に応じて、異なる推論時間をもたらします。さらに、動的形状を使用すると、使用するハードウェア・プラグインとモデルよっては、メモリーのオーバーヘッドが増加し、推論呼び出しの実行時間が増加する可能性があります。

動的形状の処理

ここでは、OpenVINO ランタイム API バージョン 2022.1 以降を使用して動的形状モデルを処理する方法について説明します。動的形状を使用する場合、静的形状と比べてワークフローに 3 つの違いがあります。

  • モデルの構成

  • 動的データの準備と推論

  • 出力の動的形状

モデルの構成

モデルの入力次元は、model.reshape メソッドを使用して動的に指定できます。動的次元を設定するには、その次元の値として -1ov::Dimension() (C++)、または ov.Dimension() (Python) を使用します。

一部のモデルには、すぐに使用できる動的形状がすでに備わっていることがあり、その場合、追加の構成は必要ありません。これは、ソース・フレームワークから動的形状を使用して生成されたか、動的形状を使用するようにモデル変換 API で変換されたことが原因であると考えられます。詳細については、動的次元の “アウトオブボックス” を参照してください。

次の例は、静的な [1, 3, 224, 224] 入力形状を持つモデルで動的次元を設定する方法を示しています。最初の例は、最初の次元 (バッチサイズ) を動的に変更する方法を示しています。2 番目の例では、3 番目と 4 番目の次元 (高さと幅) が動的に設定されます。

core = ov.Core()

# Set first dimension to be dynamic while keeping others static
model.reshape([-1, 3, 224, 224])

# Or, set third and fourth dimensions as dynamic
model.reshape([1, 3, -1, -1])

Python では、すべての次元を文字列として渡し、動的次元には ? を使用することもできます (例: model.reshape(“1, 3, ?, ?”))。

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

// Set first dimension as dynamic (ov::Dimension()) and remaining dimensions as static
model->reshape({{ov::Dimension(), 3, 224, 224}});  // {?,3,224,224}

// Or, set third and fourth dimensions as dynamic
model->reshape({{1, 3, ov::Dimension(), ov::Dimension()}});  // {1,3,?,?}
ov_core_t* core = NULL;
ov_core_create(&core);

ov_model_t* model = NULL;
ov_core_read_model(core, "model.xml", NULL, &model);

// Set first dimension as dynamic ({-1, -1}) and remaining dimensions as static
{
ov_partial_shape_t partial_shape;
ov_dimension_t dims[4] = {{-1, -1}, {3, 3}, {224, 224}, {224, 224}};
ov_partial_shape_create(4, dims, &partial_shape);
ov_model_reshape_single_input(model, partial_shape); // {?,3,224,224}
ov_partial_shape_free(&partial_shape);
}

// Or, set third and fourth dimensions as dynamic
{
ov_partial_shape_t partial_shape;
ov_dimension_t dims[4] = {{1, 1}, {3, 3}, {-1, -1}, {-1, -1}};
ov_partial_shape_create(4, dims, &partial_shape);
ov_model_reshape_single_input(model, partial_shape); // {1,3,?,?}
ov_partial_shape_free(&partial_shape);
}

上記の例では、モデルに単一の入力レイヤーがあることを前提としています。複数の入力レイヤーを持つモデル (NLP モデルなど) を変更するには、すべての入力レイヤーを反復処理し、レイヤーごとの形状を更新し、model.reshape メソッドを適用します。例えば、次のコードは、すべての入力レイヤーで 2 番目の次元を動的に設定します。

# Assign dynamic shapes to second dimension in every input layer
shapes = {}
for input_layer in model.inputs:
    shapes[input_layer] = input_layer.partial_shape
    shapes[input_layer][1] = -1
model.reshape(shapes)
// Assign dynamic shapes to second dimension in every input layer
std::map<ov::Output<ov::Node>, ov::PartialShape> port_to_shape;
for (const ov::Output<ov::Node>& input : model->inputs()) {
    ov::PartialShape shape = input.get_partial_shape();
    shape[1] = -1;
    port_to_shape[input] = shape;
}
model->reshape(port_to_shape);

複数の入力レイヤーを変更する方法の例については、入力形状の変更を参照してください。

未定義の次元 “アウトオブボックス”

多くの DL フレームワークは、動的な (または未定義の) 次元を持つモデルの生成をサポートしています。このようなモデルが convert_model() で変換されるか、Core::read_model で直接読み取られる場合、その動的次元は保持されます。これらのモデルを動的形状で使用するのに追加の構成は必要ありません。

モデルに動的次元があるか確認するには、read_model() メソッドを使用してモデルをロードし、各レイヤーの partial_shape プロパティーを確認します。モデルに動的次元ある場合、それらは ? として報告されます。例えば、次のコードは各入力レイヤーの名前と次元を出力します。

# Print model input layer info
for input_layer in model.inputs:
    print(input_layer.names, input_layer.partial_shape)
ov::Core core;
auto model = core.read_model("model.xml");

// Print info of first input layer
std::cout << model->input(0).get_partial_shape() << "\n";

// Print info of second input layer
std::cout << model->input(1).get_partial_shape() << "\n";

//etc

入力モデルにすでに動的次元がある場合、それは推論中に変更されることはありません。入力が動的ではない場合、アプリケーションのメモリーを節約し、推論速度を向上させる可能性があるため、reshape メソッドを使用して入力を静的な値に設定することを推奨します。OpenVINO API は、静的次元と動的次元の任意の組み合わせをサポートします。

静的および動的次元は、convert_model() でモデルを変換する際に設定することもできます。これは reshape メソッドと同じ機能を備えているため、アプリケーション・コード内ではなく、事前に動的形状にモデルを変換することで時間を節約できます。convert_model() を使用して入力形状を設定する方法については、入力形状の設定を参照してください。

次元境界

動的次元の下限および/または上限も指定できます。これには、次元に許可される値の範囲を定義します。次元境界は、以下に示すオプションを使用して reshape メソッドに下限と上限を渡すことで設定できます。

これらの各オプションは同等です。

  • 下限と上限を reshape メソッドに直接渡します。例: model.reshape([1, 10), (8,512)])

  • ov.Dimension を使用して下限と上限を渡します。例: model.reshape([ov.Dimension(1, 10), (8, 512)])

  • 次元範囲を文字列として渡します。例: model.reshape(“1..10, 8..512”)

以下の例は、デフォルトの静的形状 [1,3,224,224] を使用して mobilenet-v2 モデルの動的な次元境界を設定する方法を示しています。

# Example 1 - set first dimension as dynamic (no bounds) and third and fourth dimensions to range of 112..448
model.reshape([-1, 3, (112, 448), (112, 448)])

# Example 2 - Set first dimension to a range of 1..8 and third and fourth dimensions to range of 112..448
model.reshape([(1, 8), 3, (112, 448), (112, 448)])

以下に示すように、次元境界は 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}

以下に示すように、次元境界は ov_dimension の引数として記述できます。

// Both dimensions are dynamic, first has a size within 1..10 and the second has a size within 8..512
{
ov_partial_shape_t partial_shape;
ov_dimension_t dims[2] = {{1, 10}, {8, 512}};
ov_partial_shape_create(2, dims, &partial_shape);
ov_model_reshape_single_input(model, partial_shape); // {1..10,8..512}
ov_partial_shape_free(&partial_shape);
}

// Both dimensions are dynamic, first doesn't have bounds, the second is in the range of 8..512
{
ov_partial_shape_t partial_shape;
ov_dimension_t dims[2] = {{-1, -1}, {8, 512}};
ov_partial_shape_create(2, dims, &partial_shape);
ov_model_reshape_single_input(model, partial_shape); // {?,8..512}
ov_partial_shape_free(&partial_shape);
}

境界に関する情報は、推論プラグインに最適化の可能性を与えます。動的形状では、プラグインがモデルのコンパイル中により柔軟な最適化アプローチを適用することを前提としています。モデルのコンパイルと推論により時間/メモリーが必要になる場合があります。したがって、境界などの追加情報を提供することが有益である場合があります。同様の理由で、実際に必要としない限り、次元を未定義にすることはお勧めできません。

境界を指定する場合、下限は上限ほど重要ではありません。上限を指定することで、推論デバイスが中間テンソルにメモリーをより正確に割り当てることができます。また、異なるサイズに対してより少数の調整されたカーネルを使用することも可能になります。下限または上限を指定する利点はデバイスによって異なります。プラグインによっては、上限の指定が必要な場合があります。各デバイスでの動的形状のサポート状況については、機能サポート表を参照してください。

プラグインが境界なしでモデルを実行できる場合でも、次元の下限と上限が判明している場合は、それらを指定することを推奨します。

動的データの準備と推論

reshape メソッドを使用してモデルを構成した後に、適切なデータ形状を持つテンソルを作成し、それらを推論要求としてモデルに渡します。これは、OpenVINO™ とアプリケーションの統合で説明されている通常の手順と似ています。ただし、テンソルをさまざまな形状のモデルに渡すことができることが異なります。

以下のサンプルは、モデルがさまざまな入力形状をどのように受け入れるかを示しています。最初の例では、モデルは 1x128 入力形状に対して推論を実行し、結果を返します。2 番目の例では、1x200 の入力形状が使用されますが、動的形状であるため、モデルはこれを処理できます。

# For first inference call, prepare an input tensor with 1x128 shape and run inference request
input_data1 = np.ones(shape=[1,128])
infer_request.infer({input_tensor_name: input_data1})

# Get resulting outputs
output_tensor1 = infer_request.get_output_tensor()
output_data1 = output_tensor1.data[:]

# For second inference call, prepare a 1x200 input tensor and run inference request
input_data2 = np.ones(shape=[1,200])
infer_request.infer({input_tensor_name: input_data2})

# Get resulting outputs
output_tensor2 = infer_request.get_output_tensor()
output_data2 = output_tensor2.data[:]
// 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
ov_output_port_t* input_port = NULL;
ov_element_type_e* type = NULL;
ov_shape_t input_shape_1;
ov_tensor_t* input_tensor_1 = NULL;
ov_tensor_t* output_tensor = NULL;
ov_shape_t output_shape_1;
void* data_1 = NULL;
ov_shape_t input_shape_2;
ov_tensor_t* input_tensor_2 = NULL;
ov_shape_t output_shape_2;
void* data_2 = NULL;
// The first inference call

// Create tensor compatible with the model input
// Shape {1, 128} is compatible with any reshape statements made in previous examples
{
ov_model_input(model, &input_port);
ov_port_get_element_type(input_port, type);
int64_t dims[2] = {1, 128};
ov_shape_create(2, dims, &input_shape_1);
ov_tensor_create(type, input_shape_1, &input_tensor_1);
// ... write values to input_tensor
}

// Set the tensor as an input for the infer request
ov_infer_request_set_input_tensor(infer_request, input_tensor_1);

// Do the inference
ov_infer_request_infer(infer_request);

// Retrieve a tensor representing the output data
ov_infer_request_get_output_tensor(infer_request, &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
ov_tensor_get_shape(output_tensor, &output_shape_1);

// Take a pointer of an appropriate type to tensor data and read elements according to the shape
// Assuming model output is f32 data type
ov_tensor_data(output_tensor, &data_1);
// ... 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
{
int64_t dims[2] = {1, 200};
ov_shape_create(2, dims, &input_shape_2);
ov_tensor_create(type, input_shape_2, &input_tensor_2);
// ... write values to input_tensor_2
}

ov_infer_request_set_input_tensor(infer_request, input_tensor_2);
ov_infer_request_infer(infer_request);

// 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:
ov_tensor_data(output_tensor, &data_2);

// and new shape as well
ov_tensor_get_shape(output_tensor, &output_shape_2);
// ... read values in data_2 according to the shape output_shape_2

// free resource
ov_output_port_free(input_port);
ov_shape_free(&input_shape_1);
ov_tensor_free(input_tensor_1);
ov_shape_free(&output_shape_1);
ov_shape_free(&input_shape_2);
ov_tensor_free(input_tensor_2);
ov_shape_free(&output_shape_2);
ov_tensor_free(output_tensor);

入力データをモデルに適用して推論を実行する方法の詳細については、OpenVINO™ 推論要求を参照してください。

出力の動的形状

モデルの入力で動的次元を使用する場合、動的入力がモデルにどのように伝播されるかに応じて、出力次元も動的になる場合があります。例えば、入力形状のバッチ次元は通常、モデル全体に伝播され、出力形状に表示されます。また、NLP モデルのシーケンス長やセグメント化モデルの空間次元など、ネットワーク全体に伝播される他の次元にも適用されます。

出力に動的次元があるか確認するには、モデルの読み取りまたは再形状後に、モデルの出力レイヤーの partial_shape プロパティーを照会します。同じプロパティーをモデル入力に対しても照会できます。
例:

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

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

// Print input partial shape
std::cout << model->input().get_partial_shape() << "\n";
ov_output_port_t* output_port = NULL;
ov_output_port_t* input_port = NULL;
ov_partial_shape_t partial_shape;
char * str_partial_shape = NULL;

// Print output partial shape
{
ov_model_output(model, &output_port);
ov_port_get_partial_shape(output_port, &partial_shape);
str_partial_shape = ov_partial_shape_to_string(partial_shape);
printf("The output partial shape: %s", str_partial_shape);
}

// Print input partial shape
{
ov_model_input(model, &input_port);
ov_port_get_partial_shape(input_port, &partial_shape);
str_partial_shape = ov_partial_shape_to_string(partial_shape);
printf("The input partial shape: %s", str_partial_shape);
}

// free allocated resource
ov_free(str_partial_shape);
ov_partial_shape_free(&partial_shape);
ov_output_port_free(output_port);
ov_output_port_free(input_port);

出力に動的次元ある場合、それらは ? または範囲で (例:1..10) として報告されます。

出力レイヤーは、partial_shape.is_dynamic() プロパティーを使用して動的次元をチェックすることもできます。これは、次のように、出力レイヤー全体で使用することも、個々の次元で使用することもできます。


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
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
}
ov_model_t* model = NULL;
ov_output_port_t* input_port = NULL;
ov_output_port_t* output_port = NULL;
ov_partial_shape_t partial_shape;

ov_core_read_model(core, "model.xml", NULL, &model);

// for input
{
ov_model_input_by_index(model, 0, &input_port);
ov_port_get_partial_shape(input_port, &partial_shape);
if (ov_partial_shape_is_dynamic(partial_shape)) {
    // input is dynamic
}
}

// for output
{
ov_model_output_by_index(model, 0, &output_port);
ov_port_get_partial_shape(output_port, &partial_shape);
if (ov_partial_shape_is_dynamic(partial_shape)) {
    // output is dynamic
}
}

// free allocated resource
ov_partial_shape_free(&partial_shape);
ov_output_port_free(input_port);
ov_output_port_free(output_port);

少なくとも 1 つの動的次元がモデルの出力レイヤーに存在する場合、出力テンソルの実際の形状は推論中に決定されます。最初の推論の前に出力テンソルのメモリーは割り当てられず、形状は [0] です。

メモリー内に出力テンソル用のスペースを事前に割り当てるには、出力で予想される形状を指定して set_output_tensor メソッドを使用します。これにより、内部で set_shape メソッドが呼び出され、初期形状が計算された形状に置き換えられます。