ハードウェア関数の最適化

SDSoC™ 環境ではヘテロジニアス クロス コンパイルが導入されており、Zynq®-7000 および Zynq® UltraScale+™ MPSoC プロセッサ CPU の Arm® CPU 用コンパイラと、ハードウェア関数用のプログラマブル ロジック (PL) クロス コンパイラとして Vivado® HLS (高位合成) ツールが含まれています。このセクションでは、デフォルトの動作と、HLS クロス コンパイラに関連する最適化指示子について説明します。

HLS ツールのデフォルトの動作では、ハードウェアが C/C++ コードを正確に反映するように、関数およびループが順次実行されます。最適化指示子を使用すると、パイプライン処理を使用することにより、ハードウェア関数のパフォーマンスが大幅に向上します。この章では、高パフォーマンスを達成するようデザインを最適化するための一般的な手法を示します。

HLS ツールを使用してデザインを最適化する際には、さまざまな目標が考えられます。この設計手法では、クロック サイクルごとに新しい入力データ 1 サンプルを処理するパフォーマンスができるだけ高いデザインを作成することを目標としていると想定されるので、そのための最適化がレイテンシまたはリソースを削減する最適化の前に実行されます。

この章で説明する最適化に関する詳細な説明は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。
注記: ザイリンクスでは、特定の最適化の詳細を確認する前に、設計手法を確認し、ハードウェア関数の最適化をグローバルな観点で理解することをお勧めします。

ハードウェア関数の最適化手法

ハードウェア関数は Vivado HLS ツール コンパイラにより PL に合成されます。このコンパイラでは、C/C++ コードが FPGA ハードウェア インプリメンテーションに自動的に変換されますが、ほかのコンパイラと同様、コンパイラ デフォルトが使用されます。

HLS ツールには、コンパイラ デフォルトだけでなく、コード内にプラグマを使用して C/C++ コードに適用できる最適化が多数あります。この章では、適用可能な最適化と、推奨される適用手法を説明します。

ハードウェア関数を最適化するには、次の 2 つのフローがあります。

トップダウン フロー
SDSoC 環境内でプログラムがトップダウンでハードウェア関数に分解され、自動的にデータフロー モードで動作する関数のパイプラインがシステムのクロスコンパイラで作成されるようにします。各ハードウェア関数のマイクロアーキテクチャは、HLS ツールを使用して最適化します。
ボトムアップ フロー
Vivado Design Suite に含まれる HLS ツールのコンパイラを使用してハードウェア関数をシステムとは別に最適化します。ハードウェア関数を解析して、最適化指示子を使用してデフォルトとは異なるインプリメンテーションを作成し、その結果のハードウェア関数を SDSoC 環境に組み込みます。

ボトムアップ フローは、ソフトウェアとハードウェアが別のチームによって最適化される場合や、ソフトウェア プログラマが自社またはパートナーからの既存のハードウェア インプリメンテーションを利用する場合などによく使用されます。どちらのフローもサポートされており、同じ最適化手法を使用できます。どちらのワークフローでも同じ高パフォーマンスのシステムを作成できます。この選択は各チームおよび組織によるワークフロー決定事項であり、ザイリンクス ではどちらのフローを使用するかは特に推奨しません。

次の図は、ハードウェア関数の最適化手法を示しています。

図: ハードウェア関数の最適化手法

この図は、この手法のすべての手順を示しています。次のセクションで、最適化の詳細を説明します。

注記: コードを最適化する際は、-O 最適化のため、ハードウェア用にビルドするときに [Debug] ではなく [Release] を使用するようにしてください。[Debug] は関数およびシステムが正しく機能することを確認するために使用し、[Release] はパフォーマンス最適化を確認するために使用します。
重要: デザインは、手順 3 の後に最適なパフォーマンスになります。
  • 手順 1: メトリクスの最適化。最適化前にこの章のトピックを確認してください。
  • 手順 2: パフォーマンスのためのパイプライン処理
  • 手順 3: パフォーマンスのための構造最適化
  • 手順 4: レイテンシの削減。この手順はデザイン全体のレイテンシを最小限に抑えるか、特定の制御をするために使用し、レイテンシが問題となるようなアプリケーションでのみ必須です。
  • 手順 5: エリアの削減。このトピックでは、ハードウェア インプリメンテーションに必要なリソースを削減します。これは、通常大型のハードウェア関数が使用可能なリソースにインプリメントできなかった場合にのみ用します。FPGA のリソース数は決まっているので、パフォーマンス目標が達成されていれば、より小型のインプリメンテーションを作成する利点は通常ありません。

ハードウェア関数のベースライン

ハードウェア関数の最適化の前に、既存コードとコンパイラのデフォルトを使用して達成されるパフォーマンスを理解し、パフォーマンスがどのように測定されるか把握することが重要です。ハードウェアにインプリメントする関数を選択して、プロジェクトを構築します。

プロジェクトをビルドすると、レポートが IDE 「Hardware Reports」セクションに表示されます。レポートは、<project name>/<build_config>/_sds/vhls/<hw_function>/solution/syn/report/<hw_function>.rpt にも含まれています。このレポートには、パフォーマンスの見積もりと使用率の見積もりが表示されます。

パフォーマンス見積もりでは、タイミング、間隔 (ループの開始間隔など)、レイテンシの順番で重要になります。

  • タイミング サマリには、ターゲットとクロック周期の見積もりが表示されます。クロック周波数の見積もりがターゲットよりも大きい場合、ハードウェアはこのクロック周期では機能しません。クロック周期は、[Project Settings] > [Data Motion Network Clock Frequency] オプションを使用すると削減できます。ただし、これはフローのこの段階ではあくまでも見積もりにすぎないので、見積もりがターゲットを 20% しか超えていない場合などは、変更しなくても残りのフローを進めることが可能なこともあります。ビットストリームが生成されるときにさらに最適化が適用され、タイミング要件を満たす可能性はまだありますので、これはあくまでも、ハードウェア関数でタイミングが満たされない可能性があることを示しているだけです。
  • 関数の開始間隔 (II) は、関数で新しい入力を受信できるようになるまでのクロック サイクル数で、通常システムの最も重要なパフォーマンス メトリクスです。理想的なハードウェア関数では、ハードウェアでクロック サイクルごとに 1 サンプルのレートでデータが処理されます。ハードウェアに渡される最大容量のデータ セットがサイズ N (例: my_array[<N>]) の場合、最適な II は <N> + 1 になります。つまり、ハードウェア関数では <N> 個のデータ サンプルが <N> クロック サイクルで処理され、<N> 個のサンプルすべてが処理された 1 クロック サイクル後に新しいデータを受信できます。II <N> のハードウェア関数を作成することはできますが、利点はほとんどなく、必要なプログラマブル ロジック (PL) のリソースが増加します。このハードウェア関数は、残りのシステムよりも速いレートでデータを生成して消費するので理想的なものになります。
  • ループ開始間隔とは、ループの次の繰り返しでデータの処理が開始するまでのクロック サイクル数です。このメトリクスは、詳細な解析でパフォーマンスのボトルネックを発見して削除する際に重要となります。
  • レイテンシとは、関数がすべての出力値を計算するのに必要なクロック サイクル数で、データが適用されてから使用可能になるまでの時間です。ほとんどのアプリケーションでは、ハードウェア関数のレイテンシがソフトウェア関数またはシステム関数 (DMA など) のレイテンシを大きく上回る場合などは特に、これが問題となることはほとんどありませんが、アプリケーションで問題とならないことを確認しておく必要があります。
  • ループ繰り返しレイテンシはループ 1 回分を終了するのにかかるクロック サイクル数であり、ループ レイテンシはループのすべての繰り返しを実行するのにかかるサイクル数です。詳細は、メトリクスの最適化を参照してください。

レポートの「Area Estimates」セクションには、ハードウェア関数をインプリメントするのに PL で必要となるリソース数、および使用可能なリソース数が示されます。重要なメトリクスは使用率 (Utilization (%)) で、100% を超えないようにする必要があります。100% を超えている場合は、そのハードウェア関数をインプリメントするのに十分なリソースがないことを示しており、より大型の FPGA デバイスに移行することが必要な可能性があります。これは、タイミングと同様、フローのこの段階ではあくまでも見積もりです。使用率が 100% を少しだけ超えている場合は、ビットストリーム作成中にハードウェアが最適化される可能性があります。

システムに必要なパフォーマンスおよびハードウェア関数で必要なメトリクスは既にわかっていたはずですが、クロック サイクルなどのハードウェアの概念を熟知していなくても、最高パフォーマンスのハードウェア関数の開始間隔は II = <N> + 1 (N はその関数で処理される最大データ セット) であることがわかるようになりました。現在のデザイン パフォーマンスと基本的なパフォーマンス メトリクスについて理解したら、次はハードウェア関数に最適化指示子を適用します。

メトリクスの最適化

次の表に、デザインに追加するかどうかを最初に考慮する必要のある指示子をリストします。

表 1. 最適化ストラテジの手順 1: メトリクスのための最適化
指示子およびコンフィギュレーション 説明
LOOP_TRIPCOUNT 可変範囲のループに使用されます。ループの繰り返し回数の見積もりを指定します。これは合成には影響がなく、レポートにのみ影響します。

ハードウェア関数を最初にコンパイルすると、レポート ファイルにレイテンシと開始間隔 (II) が数値ではなくクエスチョン マーク (?) として表示されることがよくあります。デザインに可変範囲のループがある場合は、コンパイラでレイテンシまたは II を判断できず、この状況を示すためにクエスチョン マーク (?) が使用されます。可変範囲のループでは、ループの繰り返し回数の上限が可変の高さ、幅、深さのパラメーターなどのようにハードウェア関数への入力引数であるため、ループの繰り返し回数の上限をコンパイル時に決定できません。

この状況を解消するには、ハードウェア関数のレポートで数値が示されていない最下位ループを見つけ、LOOP_TRIPCOUNT 指示子を使用して tripcount の見積もり値を指定します。tripcount は、見積もられる繰り返し回数の最小値、平均値、最大値です。これにより、レイテンシと II の値がレポートされるようになり、さまざまな最適化を適用したインプリメンテーションを比較できるようになります。

LOOP_TRIPCOUNT 値はレポートにしか使用されず、結果のハードウェア インプリメンテーションには影響がないので、任意の値を使用できますが、より正確な値を使用した方が有益なレポートが得られます。

パフォーマンスのためのパイプライン処理

高パフォーマンス デザインを作成する次の段階では、関数、ループ、および演算をパイプライン処理します。パイプライン処理により、同時処理が最大限に実行されるようになり非常に高いパフォーマンスを得ることができます。次の表に、パイプライン処理のために使用する指示子を示します。

表 2. 最適化ストラテジの手順 2: パフォーマンスのためのパイプライン処理
指示子およびコンフィギュレーション 説明
PIPELINE ループまたは関数内で演算を同時に実行できるようにして開始間隔を削減します。
DATAFLOW タスク レベルのパイプライン処理を有効にし、関数およびループが同時に実行されるようにします。開始間隔を最小にするために使用します。
RESOURCE 変数 (配列、算術演算) をインプリメントするために使用されるハードウェア リソースのパイプライン処理を指定します。
Config Compile ボトムアップ フローを使用する場合に、繰り返し回数に基づいてループが自動的にパイプライン処理されるようにします。

最適化プロセスのこの段階では、できるだけ多くの同時処理演算が作成されます。PIPELINE 指示子は関数およびループに適用できます。DATAFLOW 指示子を関数およびループを含むレベルで使用すると、それらを並列実行できます。RESOURCE 指示子が必要なことはまれですが、最高レベルのパフォーマンスを達成できるようにするために使用可能です。

推奨されるのはボトムアップ方式で、次の点に注意する必要があります。

  • 関数およびループの中には、サブ関数が含まれるものがあります。サブ関数がパイプライン処理されていないと、それより上位の関数がパイプライン処理されたときにあまり改善が見られないことがあります。これは、サブ関数がパイプライン処理されていないことが原因です。
  • 関数およびループの中には、下位ループが含まれるものがあります。PIPELINE 指示子を使用すると、それより下の階層のループすべてが自動的に展開され、かなり多くのロジックが作成される可能性があります。このため、下位階層のループをパイプライン処理することを推奨します。
  • 上位階層をパイプライン処理してその階層より下のループを展開した方が良い場合、可変範囲のループは展開できず、その上の階層のループおよび関数はパイプライン処理できません。この問題を回避するには、可変範囲のループをパイプライン処理し、DATAFLOW 最適化を使用してパイプライン処理されたループが同時に実行されるようにして、ループを含む関数のパフォーマンスを向上します。または、可変範囲を削除するようループを記述し直します。条件付きブレークを使用して最大の上限を適用します。

最適化プロセスのこの段階での基本的なストラテジは、タスク (関数およびループ) をできるだけパイプライン処理することです。どの関数およびループをパイプライン処理するかについての詳細は、ハードウェア関数のパイプライン ストラテジを参照してください。

あまり一般的ではありませんが、演算子レベルでパイプライン処理を適用することもできます。たとえば、FPGA のワイヤ配線により予期しない大きな遅延が発生し、デザインを必要なクロック周波数でインプリメントすることが困難な場合があります。このような場合、RESOURCE 指示子を使用して乗算器、加算器、およびブロック RAM などの特定の演算をパイプライン処理してロジック レベルにパイプライン レジスタ段を追加し、ハードウェア関数でデータが再帰の必要なしでできるだけ高いパフォーマンス レベルで処理されるようにします。

注記: コンフィギュレーション コマンドを使用すると、最適化のデフォルト設定を変更できます。これらのコマンドは、ボトムアップ フローを使用した場合に Vivado® HLS ツール内からのみ使用できます。詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。

ハードウェア関数のパイプライン ストラテジ

高パフォーマンスのデザインを得るのに重要な最適化指示子は、PIPELINE および DATAFLOW 指示子です。このセクションでは、さまざまな C コード アーキテクチャにこれらの指示子を適用する方法を説明します。

C/C++ 関数には、フレーム ベースとサンプル ベースの 2 種類のコード形式があります。どちらのコーディング スタイルを使用しても、ハードウェア関数は同じパフォーマンスでインプリメントできます。違いは、最適化指示子の適用方法のみです。

フレーム ベースの C コード

フレーム ベースのコーディング スタイルの主な特徴は、各トランザクションで関数により複数のデータ サンプル (1 データ フレーム) が処理されることです。データ フレームは、ポインター演算を使用してアクセスされる、データを含む配列またはポインターとして供給されます。トランザクションは、C 関数の完全な実行 1 回と考えられます。このコーディング スタイルでは、データは通常連続したループまたは入れ子のループで処理されます。

次に、フレーム ベースの C コード例を示します。

void foo(
  data_t in1[HEIGHT][WIDTH],
  data_t in2[HEIGHT][WIDTH],
  data_t out[HEIGHT][WIDTH] {
  Loop1: for(int i = 0; i < HEIGHT; i++) {
    Loop2: for(int j = 0; j < WIDTH; j++) {
      out[i][j] = in1[i][j] * in2[i][j];
        Loop3: for(int k = 0; k < NUM_BITS; k++) {
          . . . .
        }
    }
 }

C/C++ コードをパイプライン処理してハードウェアでのパフォーマンスを向上する場合は、データのサンプルが処理されるレベルに PIPELINE 最適化指示子を配置します。

上記の例は、画像またはビデオ フレームを処理するのに使用されるコードで、ハードウェア関数を効率的にパイプライン処理する方法を示しています。2 つの入力セットがデータ フレームとして関数に供給され、出力もデータ フレームです。この関数をパイプライン処理できる箇所は、次のように複数あります。

  • foo 関数のレベル。
  • Loop1 ループのレベル。
  • Loop2 ループのレベル。
  • Loop3 ループのレベル。

PIPELINE 指示子をこれらの箇所に配置するのには、利点と欠点があります。これらを理解しておくと、PIPELINE 指示子をコードのどこに配置するのが最適化を判断するのに役立ちます。

関数レベル
関数は、データ フレーム (in1 および in2) を入力として受信します。関数が II = 1 (各クロック サイクルごとに新しい入力セットを読み込み) でパイプライン処理されると、in1in2 のすべての HEIGHT*WIDTH 値が 1 つのクロック サイクルで読み込まれるようコンパイルされます。これは 1 サイクルで読み込むには大量のデータであり、このようなデザインは望ましくないことがほとんどです。

PIPELINE 指示子を関数 foo に適用する場合、この階層の下位にあるループすべてを展開する必要があります。これは、パイプライン内に順序ロジックを存在させることはできないというパイプライン処理の要件です。このため、ロジックのコピーが HEIGHT*WIDTH*NUM_ELEMENT 個作成され、デザインが大きくなります。

データは順番にアクセスされるので、ハードウェア関数へのインターフェイスの配列は、次のような複数のハードウェア インターフェイスのタイプとしてインプリメントできます。

  • ブロック RAM インターフェイス
  • AXI4 インターフェイス
  • AXI4-Lite インターフェイス
  • AXI4-Stream インターフェイス
  • FIFO インターフェイス

ブロック RAM インターフェイスは、クロックごとに 2 サンプルを供給可能なデュアル ポート インターフェイスとしてインプリメントできます。その他のインターフェイス タイプでは、クロックごとに 1 サンプルしか供給できないので、これがボトルネックとなり、高度に並列化された大型ハードウェア デザインですべてのデータを並列処理できず、ハードウェア リソースが無駄に使用される結果となります。

Loop1 レベル
Loop1 内のロジックでは、2 次元行列の行全体が処理されます。ここに PIPELINE 指示子を配置すると、クロック サイクルごとに 1 行を処理するデザインが作成されます。これにより、これより下のループは展開されるので、追加のロジックが作成されます。この追加ロジックを利用するには、クロック サイクルごとにデータの行全体 (各ワードが WIDTH* <number of bits in data_t> ビット幅になる HEIGHT データ ワードの配列) を転送します。

PS で実行されるホスト コードがこのような大型データ ワードを処理できることはまれなので、この場合も高度に並列化されたハードウェア リソースで、帯域幅の制限のため並列実行できないという結果になる可能性があります。

Loop2 レベル
Loop2 内のロジックでは、配列からの 1 サンプルが処理されます。画像アルゴリズムの場合は、これが 1 つのピクセルのレベルです。デザインでクロック サイクルごとに 1 サンプル処理される場合は、これがパイプライン処理を実行するレベルです。これは、インターフェイスが PS に入出力されるデータを消費して生成するレートでもあります。

これにより Loop3 が完全に展開されますが、クロックごとに 1 サンプル処理されるようになります。Loop3 の演算がすべて並列処理されることが要件です。典型的なデザインでは、Loop3 のロジックはシフト レジスタで、1 ワード内のビットを処理します。クロックごとに 1 サンプル処理されるようにするには、ループを展開してこれらの処理が並列実行されるようにしてください。Loop2 をパイプライン処理することにより作成されるハードウェア関数では、クロックごとに 1 データ サンプル処理され、必要なデータ スループットを達成するのに必要な場合にのみ並列ロジックが作成されます。

Loop3 レベル
前述のように Loop2 が各データ サンプルまたはピクセルを演算をする場合、Loop3 のロジックでは通常ビット レベル タスクまたはデータ シフト タスクが実行されるので、このレベルではピクセルごとに複数の演算が実行されます。このレベルでパイプライン処理を実行すると、クロックごとにこのループの各演算が 1 回実行されるので、ピクセルごとに NUM_BITS クロックとなり、各ピクセルまたはデータ サンプルが複数クロックのレートで処理されます。

たとえば、Loop3 にウィンドウィングまたはたたみ込みアルゴリズム用に前のピクセルを保持するシフト レジスタが含まれているとします。このレベルに PIPELINE 指示子を追加すると、各クロック サイクルでデータ値が 1 つシフトされるようになります。デザインは Loop2 のロジックに戻って NUM_BITS 回の繰り返し後に次の入力を読み込むので、データ処理レートはかなり遅くなります。

この例の場合、パイプライン処理する理想的な箇所は Loop2 です。

フレーム ベースのコードの場合、ループ レベルでパイプライン処理し、通常はサンプルのレベルで演算を実行するループをパイプライン処理することをお勧めします。確信がない場合は、C コードに print コマンドを記述し、これが各クロック サイクルで実行するレベルであるかどうかを確認してください。

上記の例では入れ子のループのセットが 1 つしかありませんが、階層の同レベルに複数のループがある場合は、ループごとに PIPELINE 指示子を配置するのに最適な場所を決定してから、PIPELINE 指示子を関数に適用して、各ループが同時に実行されるようにします。

サンプル ベースの C コード

次にサンプル ベースの C コードの例を示します。このコーディング スタイルの主な特徴は、トランザクションごとに関数で 1 つのデータ サンプルが処理されることです。

void foo (data_t *in, data_t *out) {
  static data_t acc;
  Loop1: for (int i=N-1;i>=0;i--) {
    acc+= ..some calculation..;
  }
  *out=acc>>N;
}

サンプル ベースのコーディング スタイルでは、関数にスタティック変数が含まれることがよくあります。スタティック変数の値は、アキュムレータやサンプル カウンターなど、関数呼び出し間で保持される必要があります。

サンプル ベースのコードを使用すると、PIPELINE 指示子の位置が明確になり、II = 1 を達成して、クロック サイクルごとに 1 つのデータ値が処理されるようにできます。このためには、関数のパイプライン処理が必要です。

パイプライン処理により関数内のループがすべて展開され、追加ロジックが作成されますが、これを回避する方法はありません。Loop1 がパイプライン処理されない場合、完了するのに最低でも N クロック サイクルかかります。この後にのみ、関数は次の x 入力値を読み込むことができます。

サンプル レベルで動作する C コードを使用する際は、常に関数をパイプライン処理するようにします。

このタイプのコーディング スタイルでは、ループは通常配列に対して実行され、シフト レジスタまたはライン バッファー関数が実行されます。パフォーマンスのための構造最適化に示すように、これらの配列を個別のエレメントに分割し、すべてのサンプルが 1 クロック サイクルでシフトされるようにするのが一般的な方法です。配列がブロック RAM にインプリメントされる場合、各クロック サイクルで読み込みまたは書き出しできるのは最大 2 サンプルだけなので、これがデータ処理のボトルネックとなります。

この例でのソリューションは、関数 foo をパイプライン処理することです。これにより、クロックごとに 1 サンプル処理するデザインが得られます。

パフォーマンスのための構造最適化

C コードに、必要なパフォーマンスを達成するための関数またはループのパイプライン処理を妨げるような記述が含まれていることがあります。これは通常、C コードの構造または PL をインプリメントするのに使用されるデフォルトのロジック構造からわかります。この場合、コードを変更する必要のあることもありますが、ほとんどの場合は追加の最適化指示子を使用することによりこれらの問題を解決できます。

次に、最適化指示子を使用してインプリメンテーションの構造およびパイプライン処理のパフォーマンスを改善する例を示します。最初の例では、ループに PIPELINE 指示子を追加して、ループのパフォーマンスを改善しています。このコード例は、関数内で使用されているループを示しています。

#include "bottleneck.h"
dout_t bottleneck(...) {
  ...
  SUM_LOOP: for(i=3;i<N;i=i+4) {
#pragma HLS PIPELINE
    sum += mem[i] + mem[i-1] + mem[i-2] + mem[i-3];
  }
  ...
}

上記のコードがハードウェアにコンパイルされると、次のメッセージが表示されます。

INFO: [SCHED 61] Pipelining loop 'SUM_LOOP'.
WARNING: [SCHED 69] Unable to schedule 'load' operation ('mem_load_2', bottleneck.c:62) on array 'mem' due to limited memory ports.
INFO: [SCHED 61] Pipelining result: Target II: 1, Final II: 2, Depth: 3.
I

この例の問題は、配列が PL の効率的なブロック RAM リソースを使用してインプリメントされていることです。これにより、小型で、コスト パフォーマンスの高い、高速デザインが得られます。ただし、ブロック RAM では、DDR や SRAM などのメモリと同様、データ ポート数が通常 2 までに制限されます。

上記のコードでは、sum の値を計算するのに mem からの 4 つのデータ値が必要です。mem は配列であり、データ ポート数が 2 つのみのブロック RAM にインプリメントされるので、各クロック サイクルで読み出しまたは書き込みできるのは 2 つの値のみです。この構成では、sum の値を 1 クロック サイクルで計算することは不可能であり、データの消費または生成の開始間隔 (II) は 1 です (クロックごとに 1 つのデータ サンプルを処理)。

メモリ ポートの制限による問題は、mem 配列に ARRAY_PARTITION 指示子を使用すると解決できます。この指示子を使用すると、配列が複数の小型の配列に分割され、データ ポート数が増加し、高パフォーマンスのパイプライン処理が可能になります。

次に示すように指示子を追加すると、配列 mem が 2 つのデュアル ポート メモリに分割され、4 つの読み出しすべてを 1 クロック サイクルで実行できるようになります。配列を分割するには、複数のオプションがあります。この例では、係数 2 でのサイクリック分割により、最初のパーティションに元の配列からの要素 0、2、4 およびなどが含まれ、2 つ目のパーティションに要素 1、3、5 などが含まれます。分割することにより 2 つのデュアル ポート ブロック RAM (合計 4 つのデータ ポート) が使用されるようになるので、要素 0、1、2、3 を 1 つのクロック サイクルで読み出すことができます。

注記: アクセラレータとして選択された関数の引数である配列に対しては、ARRAY_PARTITION 指示子は使用できません。
#include "bottleneck.h"
dout_t bottleneck(...) {
#pragma HLS ARRAY_PARTITION variable=mem cyclic factor=2 dim=1
  ...
  SUM_LOOP: for(i=3;i<N;i=i+4) {
#pragma HLS PIPELINE
    sum += mem[i] + mem[i-1] + mem[i-2] + mem[i-3];
  }
  ...
}

ループおよび関数をパイプライン処理する際には、ほかにも問題が発生する可能性があります。次の表に、これらの問題に対処するのに有益な、データ構造のボトルネックを削減する指示子をリストします。

表 3. 最適化ストラテジの手順 3: パフォーマンスのための構造最適化
指示子およびコンフィギュレーション 説明
ARRAY_PARTITION 大型の配列を複数の配列または個別のレジスタに分割し、データへのアクセスを改善してブロック RAM のボトルネックを削除します。
DEPENDENCE ループ キャリー依存性を克服し、ループをパイプライン処理できるようにする (またはより短い間隔でパイプラインできるようにする) 追加情報を提供します。
INLINE 関数をインライン展開し、関数の階層をすべて削除します。関数の境界を超えたロジック最適化をイネーブルにし、関数呼び出しのオーバーヘッドを削減することによりレイテンシ/間隔を改善します。
UNROLL for ループを展開し、複数の演算を 1 つにまとめたものではなく、複数の個別の演算を作成して、ハードウェアの並列化を向上します。これにより、ループの部分展開が可能になります。
Config Array Partition グローバル配列を含めた配列の分割方法と、分割が配列ポートに影響するかどうかを指定します。
Config Compile 自動ループ パイプラインおよび浮動小数点の math 最適化など、合成特有の最適化を制御します。
Config Schedule 合成のスケジューリング段階で使用するエフォート レベル、出力メッセージの詳細度、およびタイミングを満たすためにパイプライン処理されたタスクの開始間隔 (II) を緩和するかどうかを指定します。
Config Unroll 指定したループ繰り返し数以下のすべてのループを展開します。

配列の自動分割には、ARRAY_PARTITION 指示子だけでなく、配列分割のコンフィギュレーション (Config Array Partition) も使用できます。

ループをパイプライン処理する際に暗示される依存性を削除するため、DEPENDENCE 指示子が必要な場合があります。このような依存性は、SCHED-68 メッセージでレポートされます。

@W [SCHED-68] Target II not met due to carried dependence(s)

INLINE 指示子を使用すると、関数の境界が削除されます。これは、ロジックまたはループを 1 レベル上の階層に移動するために使用できます。ロジックをその上の関数に含め、ループを上の階層に統合すると、上の階層では中間サブ関数呼び出しのオーバーヘッドなしですべてのループを同時実行する DATAFLOW 最適化を使用できるので、関数内のロジックをより効率的にパイプライン処理できるようになる場合があります。これにより、デザインのパフォーマンスを向上できる可能性があります。

ループを必要な開始間隔 (II) でパイプライン処理できない場合は、UNROLL 指示子が必要である可能性があります。ループをパイプライン処理しても II = 4 しか達成できない場合、システム内のその他のループおよび関数も II = 4 に制約されます。ループを展開するとさらにロジックが作成されますが、ボトルネックは削除されるので、場合によってはループを展開または部分展開すると有益です。ループをパイプライン処理しても II = 4 しか達成できない場合、係数 4 を使用してループを展開すると、ループの 4 つの繰り返しを並列処理するロジックが作成され、II = 1 を達成できます。

コンフィギュレーション コマンドを使用すると、最適化のデフォルト設定を変更できます。これらのコマンドは、ボトムアップ フローを使用した場合に Vivado HLS ツール内からのみ使用できます。詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。開始間隔 (II) を改善するために最適化指示子を使用できない場合、コードの変更が必要となる可能性があります。具体例については、同じガイドを参照してください。

レイテンシの削減

コンパイラで開始間隔 (II) を最小限に抑える処理が終了すると、レイテンシを最小限に抑えるための処理が実行されます。次の表にリストされる最適化指示子を使用すると、特定のレイテンシを指定したり、生成されたレイテンシよりも短いレイテンシを達成するように指定して、結果の II が大きくなってもレイテンシ指示子を満たすようにされるようにできます。これにより、デザインのパフォーマンスが低下する可能性があります。

ほとんどのアプリケーションにはスループット要件はありますが、レイテンシ要件はないので、レイテンシ指示子は通常必要ありません。ハードウェア関数がプロセッサと統合される場合、プロセッサのレイテンシが通常システムの制限要因となります。

ループおよび関数がパイプライン処理されない場合、現在のタスクが完了するまで次の入力セットを読み込むことはできないので、スループットがレイテンシにより制限されます。

表 4. 最適化ストラテジの手順 4: レイテンシの削減
指示子 説明
LATENCY 最小および最大レイテンシ制約を指定します。
LOOP_FLATTEN 入れ子のループを 1 つのループに展開して、ループ遷移のオーバーヘッドを削減し、レイテンシを改善します。入れ子のループは、PIPELINE 指示子を適用すると、自動的にフラットになります。
LOOP_MERGE 連続するループを結合して、全体的なレイテンシを削減し、ロジック リソースの共有を増やして最適化を向上します。

ループ最適化指示子は、ループ階層をフラットにしたり、連続するループを結合するために使用できます。レイテンシを向上できるのは、通常ループで作成されたロジックに入って出るまでに制御ロジックで 1 クロック サイクル費やされるからです。ループ間の遷移数が少ないほど、デザインが完了するまでにかかるクロック数も少なくなります。

エリアの削減

ハードウェアでは、ロジック関数をインプリメントするのに必要なリソース数はデザイン エリアと呼ばれます。デザイン エリアは、決まったサイズの PL ファブリックでどれだけのリソースが使用されるかも意味します。エリアは、ハードウェアがターゲット デバイスにインプリメントするには大きすぎる場合、およびハードウェア関数が使用可能なエリアをかなり高い割合 (> 90%) で消費している場合などに重要となります。この場合、ハードウェア ロジックどうしをワイヤ接続しようとすると、ワイヤ自体がリソースを必要とするので、接続が困難になります。

必要なパフォーマンス ターゲットまたは開始間隔 (II) が満たされたら、次は同じパフォーマンスを維持しながらエリアを削減します。ただし、ハードウェア関数が必要なパフォーマンスで動作していて、残りの PL のスペースにインプリメントされるハードウェア関数がほかにない場合は、エリアを削減しても意味がないので、この段階はオプションです。

最もよく使用されるエリア最適化は、データフロー メモリ チャネルの最適化で、ハードウェア関数をインプリメントするのに必要なブロック RAM リソース数を削減できます。各デバイスのブロック RAM リソースの数には制限があります。

DATAFLOW 最適化を使用している場合に、デザインのタスクがストリーミング データであるかどうかをコンパイラで判断できない場合は、ピンポン バッファーを使用してデータフロー タスク間にメモリ チャネルがインプリメントされます。これには、それぞれサイズ <N> (<N> はタスク間を転送されるサンプル数で、通常はタスク間で渡される配列のサイズ) の 2 つのブロックが必要です。デザインがパイプライン処理されて、データが 1 つのタスクから次のタスクにストリーミングされて値がシーケンシャルに生成および消費される場合は、STREAM 指示子を使用して配列がストリーミング方式 (深さを指定可能な単純な FIFO を使用) でインプリメントされるように指定すると、エリアを大幅に削減できます。レジスタを使用して浅い FIFO がインプリメントされます。PL ファブリックには、多数のレジスタが含まれています。

ほとんどのアプリケーションでは、深さを 1 に指定すると、メモリ チャネルが単純なレジスタとしてインプリメントされます。アルゴリズムでデータ圧縮または外挿がインプリメントされ、生成されるよりも多くのデータが消費されたり、消費されるよりも多くのデータが生成されるようなタスクがある場合は、一部の配列の深さを大きくする必要があります。

  • 同じレートでデータを生成および消費するタスクでは、それらの間に配列を指定して深さ 1 でストリーミングされるようにします。
  • データ レートを X:1 で削減するタスクでは、タスクの入力に配列を指定して、深さ X でストリーミングされるようにします。関数内のこれより前の配列の深さもすべて X にして、FIFO がフルになったためにハードウェア関数が停止することがないようにします。
  • データ レートを 1:Y で増加するタスクでは、タスクの出力に配列を指定して、深さ Y でストリーミングされるようにします。関数内のこれより後の配列の深さもすべて Y にして、FIFO がフルになったためにハードウェア関数が停止することがないようにします。
注記: 設定した深さが小さすぎると、FIFO がフルになって残りのシステムが待機状態になるため、ハードウェア エミュレーション中にハードウェア関数が停止してパフォーマンスが低下したり、場合によってはデッドロック状態になることがあります。

次の表に、デザインをインプリメントするために使用されるリソースを最小限に抑える場合に考慮すべきその他の指示子およびコンフィギュレーションを示します。

表 5. 最適化ストラテジの手順 5: エリアの削減
指示子およびコンフィギュレーション 説明
ALLOCATION 使用される演算、ハードウェア リソース、または関数の数を制限します。これによりハードウェア リソースが強制的に共有されますが、レイテンシは増加する可能性があります。
ARRAY_MAP 複数の小型の配列を 1 つの大型の配列に結合し、ブロック RAM リソース数を削減します。
ARRAY_RESHAPE 配列を多数の要素を含むものからワード幅の広いものに変更します。ブロック RAM 数を増やさずにブロック RAM アクセスを向上するのに有益です。
DATA_PACK 内部構造体のデータ フィールドをワード幅の広い 1 つのスカラーにパックして、1 つの制御信号ですべてのフィールドを制御できるようにします。
LOOP_MERGE 連続するループを結合して、全体的なレイテンシを削減し、共有を増やして最適化を向上します。
OCCURRENCE 関数またはループをパイプライン処理する際に、あるロケーションのコードがそれを含む関数またはループのコードよりも低速で実行されるように指定します。
RESOURCE 変数 (配列、算術演算) をインプリメントするために使用される特定のハードウェア リソース (コア) を指定します。
STREAM 特定のメモリ チャネルを FIFO (オプションで深さを指定) としてインプリメントするよう指定します。
Config Bind 合成のバインド段階で使用するエフォート レベルを指定します。使用される演算数をグローバルに最小限に抑えるために使用します。
Config Dataflow DATAFLOW 最適化でのデフォルトのメモリ チャネルと FIFO の深さを指定します。

演算数を制限し、演算をインプリメントするのに使用するコア (ハードウェア リソース) を選択するには、ALLOCATION と RESOURCE 指示子を使用します。たとえば、関数またはループに乗算器が 1 つだけ使用されるように制限し、パイプライン乗算器を使用してインプリメントされるよう指定できます。

開始間隔を向上するために ARRAY_PARITION 指示子を使用する場合は、その代わりに ARRAY_RESHAPE 指示子を使用することも考慮してみてください。ARRAY_RESHAPE 最適化では、配列の分割と同様のタスクが実行されますが、分割により作成された要素がより幅の広いデータ ポートを持つ 1 つのブロック RAM に再結合され、必要な RAM リソース数が増加しないようにすることができる可能性があります。

C コードに類似のインデックスを持つ一連のループが含まれる場合、LOOP_MERGE 指示子を使用してループを結合することにより実行できるようになる最適化もあります。最後に、パイプライン領域のコードの一部が、領域の残りの部分よりも小さい開始間隔で動作する場合は、OCCURENCE 指示子を使用して、このロジックが低いレートで実行されるよう最適化できます。

注記: コンフィギュレーション コマンドを使用すると、最適化のデフォルト設定を変更できます。これらのコマンドは、ボトムアップ フローを使用した場合に Vivado HLS ツール内からのみ使用できます。詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。

デザイン最適化のワークフロー

最適化を実行する前に、プロジェクト内に新しいビルド コンフィギュレーションを作成することをお勧めします。別のビルド コンフィギュレーションを使用すると、結果のセットを異なる結果のセットと比較できます。[Project Settings] > [Manage Build Configurations for the Project] ツールバー ボタンを使用すると、標準の Debug および Release コンフィギュレーションに加えて、よりわかりやすい名前 (Opt_ver1UnOpt_ver など) のカスタム コンフィギュレーションをウィンドウ内に作成できます。

異なるビルド コンフィギュレーションを使用すると、結果だけでなく、ログ ファイルや FPGA のインプリメントに使用される出力 RTL ファイルも比較できます (RTL ファイルはハードウェア デザインを熟知したユーザーにのみ推奨)。

高パフォーマンス デザインを得るための基本的な最適化ストラテジは、次のとおりです。

  • 初期またはベースライン デザインを作成します。
  • ループおよび関数をパイプライン処理します。DATAFLOW 最適化を適用してループおよび関数が同時に実行されるようにします。
  • 配列のボトルネックやループの依存性など、パイプラインを制限する問題を解決します (ARRAY_PARTITION および DEPENDENCE 指示子を使用)。
  • 特定のレイテンシを指定するか、データフロー メモリ チャネルのサイズを削減し、ALLOCATION および RESOURCES 指示子を使用してさらにエリアを削減します。
注記: パフォーマンスを満たすため、必要に応じてコードを変更します。

エリアを削減するよりも前に、まずパフォーマンスを満たすようにします。できるだけ少ないリソースでデザインを作成することがストラテジである場合は、パフォーマンスを改善する手順を実行しないようにします。ただし、ベースラインの結果は最小のデザインに近いものとなります。

最適化プロセス中は、コンパイル後にコンソールへの出力 (またはログ ファイル) を確認することをお勧めします。コンパイラで指定した最適化のパフォーマンス目標を達成できなかった場合、目標が自動的に緩和され (クロック周波数は例外)、達成できる目標でデザインが作成されます。このため、コンパイルのログ ファイルとレポートを確認して、どのような最適化が実行されたのか理解することが重要です。

最適化適用の詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。

最適化ガイドライン

このセクションでは、Vivado HLS ツールを使用してハードウェア関数のパフォーマンスを向上させる基本的な最適化手法について説明します。これらの手法は、関数のインライン展開、ループおよび関数のパイプライン処理、ループ展開、ローカル メモリの帯域幅の増加、およびループと関数間のデータフローのストリーミングです。

関数のインライン展開

ソフトウェア関数のインライン展開と同様、ハードウェア関数のインライン展開にも利点があります。

関数をインライン展開すると、実際の引数と仮引数が解決された後に、関数呼び出しが関数本体のコピーに置き換えられます。インライン展開された関数は、別の階層として表示されなくなります。関数のインライン展開では、インライン関数内の演算が周辺の演算と一緒に効率的に最適化されるので、ループの全体的なレイテンシまたは開始間隔を向上できます。

関数をインライン展開するには、インライン展開する関数の本体の最初に「#pragma HLS inline」と入力します。次のコードでは、mmult_kernel 関数をインライン展開するように Vivado HLS ツールに指示しています。

void mmult_kernel(float in_A[A_NROWS][A_NCOLS],
                  float in_B[A_NCOLS][B_NCOLS],
                  float out_C[A_NROWS][B_NCOLS])
{
#pragma HLS INLINE
     int index_a, index_b, index_d;
     // rest of code body omitted
}

ループのパイプライン処理とループ展開

ループのパイプライン処理とループ展開は、どちらもループの繰り返し間の並列処理を可能にすることで、ハードウェア関数のパフォーマンスを改善する手法です。ここでは、ループのパイプライン処理とループ展開の基本的な概念とこれらの手法を使用するコード例を示し、これらの手法を使用して最適なパフォーマンスを達成する際に制限となる要因について説明します。

ループのパイプライン処理

C/C++ のような逐次言語の場合、ループの演算は順番に実行され、ループの次の繰り返しは、現在の繰り返しの最後の演算が終了してから開始されます。ループのパイプライン処理を使用すると、次の図に示すようにループ内の演算が並列方式でインプリメントできるようになります。

図: ループのパイプライン処理



前の図に示すように、パイプライン処理しない場合、2 つの RD 演算間に 3 クロック サイクルあるので、ループ全体が終了するのに 6 クロック サイクル必要となります。パイプライン処理を使用すると、2 つの RD 演算間は 1 クロック サイクルなので、ループ全体が終了するのに 4 クロック サイクルしか必要となりません。ループの次の繰り返しは現在の繰り返しが終了する前に開始できます。

開始間隔 (II) はループのパイプライン処理における重要な用語で、ループの連続する繰り返しの開始時間の差をクロック サイクル数で示します。上記の図では、連続するループの繰り返しの開始時間の差は 1 クロック サイクルしかないので、開始間隔 (II) は 1 です。

ループをパイプライン処理するには、次に示すように、ループ本体の開始部分に #pragma HLS pipeline と記述します。Vivado HLS ツールで、最小限の開始間隔でループのパイプライン処理が試みられます。

for (index_a = 0; index_a < A_NROWS; index_a++) {
     for (index_b = 0; index_b < B_NCOLS; index_b++) {
#pragma HLS PIPELINE II=1
         float result = 0;
         for (index_d = 0; index_d < A_NCOLS; index_d++) {
             float product_term = in_A[index_a][index_d] * in_B[index_d][index_b];
             result += product_term;
         }
         out_C[index_a * B_NCOLS + index_b] = result;
     }
} 

ループ展開

ループ展開は、ループの繰り返し間を並列処理するための別の手法で、ループ本体の複数コピーを作成して、ループの繰り返しカウンターをそれに合わせて調整します。次のコードは、展開されていないループを示しています。

int sum = 0;
for(int i = 0; i < 10; i++) {
    sum += a[i];
}

ループを係数 2 で展開すると、次のようになります。

int sum = 0;
for(int i = 0; i < 10; i+=2) {
    sum += a[i];
    sum += a[i+1];
}

係数 <N> でループを展開すると、ループ本体の <N> 個のコピーが作成され、各コピーで参照されるループ変数 (前述の例の場合は a[i+1]) がそれに合わせてアップデートされ、ループの繰り返しカウンター (前述の例の場合は i+=2) もそれに合わせてアップデートされます。

ループ展開では、ループの各繰り返しにより多くの演算が作成されるので、Vivado HLS ツールでこれらの演算を並列処理できるようになります。並列処理が増えると、スループットが増加し、システム パフォーマンスも向上します。

  • 係数 <N> がループの繰り返しの合計 (前述の例の場合は 10) よりも少ない場合、「部分展開」と呼ばれます。
  • 係数 <N> がループの繰り返し数と同じ場合は、「全展開」と呼ばれます。全展開の場合、コンパイル時にループ範囲がわかっている必要がありますが、並列処理は最大限に実行されます。

ループを展開するには、そのループの開始部分に #pragma HLS unroll [factor=N] を挿入します。オプションの factor=N を指定しない場合、ループは全展開されます。

int sum = 0;
for(int i = 0; i < 10; i++) {
#pragma HLS unroll factor=2
    sum += a[i];
}

ループのパイプライン処理とループ展開で達成される並列処理を制限する要因

ループのパイプライン処理とループ展開は、どちらもループの繰り返し間の並列処理を可能にしますが、ループの反復間の並列処理は、次の 2 つの主な要因により制限されます。

  • ループの反復間のデータ依存性。
  • 使用可能なハードウェア リソース数。

連続する繰り返しにおける 1 つの繰り返しの演算から次の繰り返しの演算へのデータ依存性は「ループ キャリー依存性」と呼ばれ、現在の繰り返しの演算が終了して次の繰り返しの演算用のデータ入力が計算されるまで、次の繰り返しの演算を開始できないことを意味します。ループ キャリー依存性があると、ループのパイプライン処理を使用して達成可能な開始間隔とループ展開を使用して実行可能な並列処理が制限されます。

次の例は、変数 ab を出力する演算と入力として使用する演算間でのループ キャリー依存性を示しています。

while (a != b) {
    if (a > b) 
        a –= b;
    else 
        b –= a;
}

演算このループの次の反復は、次に示すように、現在の反復が計算されて、a および b の値がアップデートされるまで開始されません。次の例に示すように、ループ キャリー依存性は配列アクセスによって発生することがよくあります。

for (i = 1; i < N; i++)
    mem[i] = mem[i-1] + i;

この例の場合、現在の繰り返しが配列の内容をアップデートするまでループの次の繰り返しを開始できません。ループのパイプライン処理の場合、最小の開始間隔はメモリ読み出し、加算、メモリ書き込みに必要な合計クロック サイクル数です。

使用可能なハードウェア リソース数もループのパイプライン処理およびループ展開のパフォーマンスを制限する要因です。次の図は、リソースの制限により発生する問題の例を示しています。この場合、ループを開始間隔 1 でパイプライン処理することはできません。

図: リソースの競合



この例の場合、ループが開始間隔 1 でパイプライン処理されると、2 つの読み出しが実行されることになります。メモリにシングル ポートしかない場合、この 2 つの読み出しは同時に実行できず、2 サイクルで実行する必要があります。このため、最小の開始間隔は図の (B) に示すように 2 になります。同じことは、その他のハードウェア リソースでも発生します。たとえば、op_compute が DSP コアを使用してインプリメントされ、それが各サイクルごとに新しい入力を受信できず、このような DSP コアが 1 つしかない場合、op_compute はサイクルごとに DSP に出力できないので、開始間隔 1 は使用できません。

ローカル メモリ帯域幅の増加

このセクションでは、Vivado HLS ツールで提供されているローカル メモリ帯域幅を増加するいくつかの方法を示します。これらの方法をループのパイプライン処理およびループ展開と共に使用して、システム パフォーマンスを向上できます。

C/C++ プログラムでは、配列は理解しやすく便利なコンストラクトです。これにより、アルゴリズムを簡単にキャプチャして理解できます。HLS ツールでは、各配列はデフォルトでは 1 つのポート メモリ リソースを使用してインプリメントされますが、このようなメモリ インプリメンテーションは、パフォーマンス指向のプログラムにとっては最適なメモリ アーキテクチャでないことがあります。制限されたメモリ ポートにより発生するリソース競合の例は、ループのパイプライン処理とループ展開を参照してください。

配列の分割

配列は、より小型の配列に分割できます。メモリの物理的なインプリメンテーションでは、読み出しポートと書き込みポートの数に制限があり、ロード/ストアが多用されるアルゴリズムではスループットが制限されます。元の配列 (1 つのメモリ リソースとしてインプリメント) を複数の小型の配列 (複数のメモリとしてインプリメント) に分割してロード/ストア ポートの有効数を増加させることにより、メモリ帯域幅を向上できる場合があります。

Vivado HLS ツールでは、次の図に示すように、3 つの配列分割方法があります。

block
元の配列の連続した要素が同じサイズのに分割されます。
cyclic
元の配列の要素がインターリーブされて同じサイズのブロックに分割されます。
complete
デフォルトでは配列が個別の要素に分割されます。これは、配列をメモリとしてではなく複数のレジスタとしてインプリメントすることに対応します。

図: 多次元配列の分割



HLS ツールで配列を分割するには、これをハードウェア関数ソース コードに挿入します。

#pragma HLS array_partition variable=<variable> <block, cyclic, complete> factor=<int> dim=<int>

block および cyclic 分割では、factor オプションを使用して作成する配列の数を指定できます。上記の図では、係数として 2 が使用され、2 つの配列に分割されています。配列に含まれる要素の数が指定された係数の整数倍でない場合、最後の配列に含まれる要素の数はほかの配列よりも少なくなります。

多次元配列を分割する場合は、dim オプションを使用してどの次元を分割するかを指定できます。次の図に、多次元配列の異なる次元を分割した例を示します。

図: 多次元配列の分割



配列の再形成

配列を再形成して、メモリ帯域幅を増加できます。再形成では、元の配列の 1 つの次元から異なる要素を取り出して、1 つの幅の広い要素に結合します。配列の再形成は配列の分割に似ていますが、複数の配列に分割するのではなく、配列の要素の幅を広くします。次の図に、配列の再形成の概念を示します。

図: 配列の再形成



Vivado HLS ツールで配列の形状を変更するには、これをハードウェア関数ソース コードに挿入します。

#pragma HLS array_reshape variable=<variable> <block, cyclic, complete> factor=<int> dim=<int>

オプションは、配列分割プラグマと同じです。

データフローのパイプライン処理

これまでに説明した最適化手法はすべて、乗算、加算、メモリのロード/ストアなどの演算子レベルでの細粒度の並列処理最適化でした。これらの最適化では、これらの演算子間が並列処理されます。一方データフローのパイプライン処理では、関数およびループのレベルで粗粒度の並列処理が実行されます。データフロー パイプラン処理により、関数およびループの同時処理が増加します。

関数のデータフローのパイプライン処理

Vivado HLS ツールの一連の関数呼び出しは、デフォルトでは 1 つの関数が完了してから次の関数が開始します。次の図の (A) は、関数のデータフ ロー パイプライン処理を実行しない場合のレイテンシを示しています。3 つの関数に 8 クロック サイクルかかると想定した場合、このコードでは func_A で新しい入力を処理できるようになるまでに 8 サイクルかかり、func_C で出力が書き込まれる (func_C の最後に出力が書き込まれると想定) までに 8 サイクルかかります。

図: 関数のデータフローのパイプライン処理



上記の図の (B) は、データフロー パイプライン処理を使用した例を示しています。func_A の実行に 3 サイクルかかるとすると、func_A はこの 3 つの関数すべてが完了するまで待たずに、3 クロック サイクルごとに新しい入力の処理を開始できるので、スループットが増加します。最終的な値は 5 サイクルで出力されるようになり、全体的なレイテンシが短くなります。

HLS ツールでは、関数のデータフロー パイプライン処理は関数間にチャネルを挿入することにより実行されます。これらのチャネルは、データのプロデューサーおよびコンシューマーのアクセス パターンによって、ピンポン バッファーまたは FIFO としてインプリメントされます。

  • 関数パラメーター (プロデューサーまたはコンシューマー) が配列の場合は、該当するチャネルがマルチバッファーとして標準メモリ アクセス (関連のアドレスおよび制御信号を使用) を使用してインプリメントされます。
  • スカラー、ポインター、参照パラメーター、および関数の戻り値の場合は、チャネルは FIFO としてインプリメンテーションされます。この場合、アドレス生成は不要なので使用されるハードウェア リソースは少なくなりますが、データに順次アクセスする必要があります。

関数のデータフロー パイプライン処理を使用するには、データフロー最適化が必要な部分に #pragma HLS dataflow を挿入します。次に、コード例を示します。

void top(a, b, c, d) {
#pragma HLS dataflow
    func_A(a, b, i1);
    func_B(c, i1, i2);
    func_C(i2, d);
}

ループのデータフロー パイプライン処理

データフロー パイプライン処理は、関数に適用するのと同様の方法でループにも適用できます。これにより、ループのシーケンス (通常は順次処理) がイネーブルになり、同時処理されるようになります。データフロー パイプライン処理は、関数、ループ、またはすべて関数かすべてループを含む領域に適用する必要があります。ループと関数が混合したスコープに適用しないでください。

データフロー パイプライン処理をループに適用した場合の利点については、次の図を参照してください。データフロー パイプライン処理を実行しない場合、ループ M を開始する前にループ N のすべての繰り返しを実行し、完了する必要があります。ループ M とループ P にも同様の関係があります。この例では、ループ N で新しい値を処理できるようになるまでに 8 サイクルかかり、出力が書き込まれる (ループ P が終了したときに出力が書き込まれると想定) までに 8 サイクルかかります。

図: ループのデータフローのパイプライン処理



データフロー パイプラインを使用すると、これらのループが同時に処理されるようにできます。上記の図の (B) は、データフロー パイプライン処理を使用した例を示しています。ループ M の実行に 3 サイクルかかるとすると、このコードでは 3 サイクルごとに新しい入力を受信できます。同様に、同じハードウェア リソースを使用して 5 サイクルごとに出力値を生成できます。Vivado HLS ツールではループ間に自動的にチャネルが挿入され、データが 1 つのループから次のループに非同期に流れるようになります。データフロー パイプラインを使用した場合と同様、ループ間のチャネルはマルチバッファーか FIFO のいずれかとしてインプリメントされます。

ループのデータフロー パイプライン処理を使用するには、データフロー最適な部分に #pragma HLS dataflow を挿入します。

ハードウェア関数のインターフェイス

アクセラレーションに必要な関数を定義したら、コンパイルを有効化にするための注意点がいくつかあります。Vivado HLS ツールのデータ型 (ap_intap_uintap_fixed など) は、アプリケーション呼び出しの関数パラメーター リストに含めることができません。これらのデータ型は HLS 独自のものなので、関連するツールやコンパイラ以外には影響がありません。

たとえば、次の関数が HLS で記述された場合、パラメーター リストを調整して、関数本体で HLS からのデータがさらに汎用のデータ型に移行されるようにしておく必要があります。

void foo(ap_int *a, ap_int *b, ap_int *c) { /* Function body */ }

ローカル変数を使用する場合は、これを次のように変更する必要があります。

void foo(int *a, int *b, int *c) {
	ap_int *local_a = a;
	ap_int *local_b = b;
	ap_int *local_c = c;
	// Remaining function body
}
重要: 入力データを使用してローカル変数を初期化すると、アクセラレータ内のメモリがかなり使用されてしまうことがあります。このため、入力データ型を適切な HLS データ型に変更するようにしてください。