入れ子のループの使用 - 2023.2 日本語

Vitis 高位合成ユーザー ガイド (UG1399)

Document ID
UG1399
Release Date
2023-12-18
Version
2023.2 日本語

入れ子のループを使用する際に最高のパフォーマンス (最短レイテンシ) を達成するには、完全な入れ子のループを作成することが重要です。完全な入れ子ループでは、ループの境界は定数で、最も内側のループにのみ機能が含まれます (次を参照)。

Perfect_nested_loop_1: for (int i = 0; i < N; ++i) {
    Perfect_nested_loop_2: for (int j = 0; j < M; ++j) {
        // Perfect Nested Loop Code goes here and no where else
    }
}
 
Imperfect_nested_loop_1: for (int i = 0; i < N; ++i) {
    // Imperfect Nested Loop Code contains code here
    Imperfect_nested_loop_2: for (int j = 0; j < M; ++j) {
        // Imperfect Nested Loop Code goes here
    }
    // Imperfect Nested Loop Code may contain code here as well
}
完全ループ ネスト
一番内側のループのみにループ ボディがあり、ループ文の間にロジックは指定されておらず、すべてのループ範囲が定数。
半完全ループ ネスト
一番内側のループのみにループ ボディがあり、ループ文の間にロジックは指定されていないが、最外側ループの範囲が変数。
不完全ループ ネスト
内側のループの範囲が変数であったり、ループ本体が内側のループにのみ含まれているとは限らない。この場合、コードの構造を変更するか、ループ本体内のループを展開して、完全ループの入れ子を作成してみてください。

また、展開されていない入れ子のループ間を移動するには、さらにクロック サイクルが必要になります。外側のループから内側のループに、または内側のループから外側のループに移動するのに 1 クロック サイクルかかります。ここに示す小さい例の場合、これはループ Outer を実行するのに余分のクロック サイクルが 200 かかることを意味します。

void foo_top { a, b, c, d} {
    ...
    Outer: while(j<100)
        Inner: while(i<6) // 1 cycle to enter inner
            ...
            LOOP_BODY
            ...
        } // 1 cycle to exit inner
    }
 ...
}

LOOP_FLATTEN プラグマまたは指示子を使用すると、ラベル付きの完全な入れ子のループとほぼ完全な入れ子のループをフラットにできるので、最適なハードウェア パフォーマンスにするためにコードを記述し直さなくても、ループ内の演算を実行するのにかかるサイクル数を削減できます。入れ子のループに LOOP_FLATTEN 最適化を適用する際は、ループ ボディを含む一番内側のループに適用する必要があります。ループのフラット化は、個々のループに設定できるほか、関数レベルで指示子を適用して関数に含まれるすべてのループに設定することもできます。

入れ子のループをパイプライン処理する際は、通常一番内部のループをパイプライン処理すると、エリアとパフォーマンスの最適なバランスがわかります。これにより、実行時間も短縮されます。次のコード例は GitHub にあり (pipelined_loop)、ループおよび関数をパイプライン処理した場合のトレードオフを示しています。

#include "loop_pipeline.h"
 
dout_t loop_pipeline(din_t A[N]) { 
    int i,j;
    static dout_t acc;
   
    LOOP_I:for(i=0; i < 20; i++){
        LOOP_J: for(j=0; j < 20; j++){
            acc += A[j] * i;
        }
    }
    return acc;
}

上記の例では、最も内側のループ (LOOP_J) がパイプライン処理されると、ハードウェア (単一の乗算器) にはLOOP_J のコピーが 1 つ作成されます。Vitis HLS では、ループができるだけフラットにされるので、この場合は反復数が 20 x 20 の 1 つの新しいループ (現在は LOOP_I_LOOP_J と呼ばれる) が作成されます。スケジューリングする必要があるのは、乗算器演算 1 つと配列アクセス 1 回のみです。これらをスケジューリングしておくと、ループの繰り返しを 1 つのループ本体のエンティティ (20X20 のループ繰り返し) としてスケジューリングできます。

ヒント: ループまたは関数がパイプライン処理される場合は、そのループまたは関数の下の階層にあるループを展開する必要があります。

外側のループ (LOOP_I) がパイプライン処理されると、内側のループ (LOOP_J) が展開され、ループ本体のコピーが 20 個作成されるので、乗算器 20 個と配列 1 個のアクセスをスケジューリングする必要があります。LOOP_I の各反復は 1 つの要素としてスケジューリングできます。

最上位関数がパイプライン処理されると、両方のループを展開する必要があるので、乗算器 400 個と配列 20 個のアクセスをスケジューリングする必要があります。ただし、ほとんどのデザインでは、通常データ依存性のため最大限に並列処理できないので、Vitis HLS で 400 個の乗算器が作成されることはほぼありません。たとえば、デュアル ポート RAM が A に使用されても、1 つのクロック サイクルでアクセスできるのは A の 2 つの値のみです。そうでない場合は、配列を 400 個のレジスタにパーティションする必要があり、すべてのレジスタを 1 クロック サイクルで読み出して、ハードウェア コストが非常に高くなります。

パイプライン処理する階層レベルを選択すると、たとえば一番内側のループをパイプライン処理したときに、ほとんどのアプリケーションで一般的に許容されるスループットで最小のハードウェアが提供されます。階層の上位をパイプライン処理すると、すべてのサブループが展開されるので、スケジューリングするためにさらに多くの演算が作成されますが (コンパイル時間とメモリ容量に影響する可能性あり)、スループットとレイテンシの観点から、通常はパフォーマンスの最も高いデザインになります。データ アクセス帯域幅は、並列で実行されることが想定される操作の要件と一致する必要があります。このため、配列 A を分割することが必要な場合があります。

上記のオプションをまとめると、次のようになります。

  • パイプライン LOOP_J: レイテンシは約 400 サイクル (20 x 20) になり、250 個未満の LUT およびレジスタが必要になります (I/O 制御および FSM は常に存在)。

    図 1. [Performance & Resource Estimates]
  • パイプライン LOOP_I: レイテンシは約 13 サイクルになりますが、数百個の LUT およびレジスタが必要になります。ロジック数は、最初のオプションの約 2 倍の数からロジック最適化で処理されるロジックを引いた数になります。

    図 2. [Performance & Resource Estimates]
  • パイプライン関数 loop_pipeline: レイテンシは 3 サイクルになりますが (20 回の並列レジスタ アクセスによる)、ロジック数は、2 番目のオプションの約 2 倍 (最初のオプションの約 4 倍) からロジック最適化で処理されるロジックを引いた数になります。

    図 3. [Performance & Resource Estimates]

Vitis HLS では不完全なループ ネストをフラットにできません。このため、ループの入出のためにクロック サイクルが追加されることがあります。デザインに入れ子のループが含まれる場合は、結果を解析して、なるべく多くのループがフラット化されるようにします。ログ ファイルまたは上記のような場合は合成レポートで、ループ ラベルが統合されたかどうかを確認してください (LOOP_I および LOOP_JLOOP_I_LOOP_J としてレポートされる)。