C/C ++ カーネル

Vitis コア開発キットでは、カーネル コードはアルゴリズムの計算負荷の高い部分であり、FPGA でアクセラレーションすることが意図されています。Vitis コア開発キットでは、の「C+C、OpenCL、および RTL で記述されたカーネル コードがサポートされます。このガイドでは、主に C カーネルのコーディング スタイルを説明します。

ランタイム中は、C/C++ カーネル実行ファイルがホスト コード実行ファイルから呼び出されます。

重要: ホスト コードとカーネル コードをその他々に開発およびコンパイルすると、片方が C で、もう片方が C++ で記述された場合に、命名方法に違いがでる可能性があります。この問題を回避するには、ヘッダー ファイルでカーネル関数宣言を extern "C" リンケージで囲むか、カーネル コードでこの関数全体を囲むことをお勧めします。
extern "C" {
           void kernel_function(int *in, int *out, int size);
        }

データ型

intfloatdouble などのネイティブでサポートされている C データ型を使用した方がコードを短時間で記述および検証できるので、最初にコードを記述する際はこれらのデータ型を使用するのが一般的です。ただし、コードはハードウェアにインプリメントされるので、ハードウェアで使用されるすべての演算子のサイズはアクセラレータ コードで使用されるデータ型によります。そのため、デフォルトのネイティブ C/C++ データ型を使用すると、ハードウェア リソースが大きく低速なものとなり、カーネルのパフォーマンスが制限される可能性があります。その代わりにビット精度のデータ型を使用して、コードがハードウェアのインプリメンテーション用に最適化されるようにします。ビット精度または任意精度のデータ型を使用すると、小型で高速なハードウェア演算子が得られます。これにより、より多くのロジックをプログラマブル ロジックに配置できるようになり、また消費電力を抑えつつロジックをより高いクロック周波数で実行できるようになります。

コードでネイティブの C/C++ データ型ではなくビット精度のデータ型を使用することを考慮してください。

注記: コードでネイティブの C/C++ データ型ではなくビット精度のデータ型を使用することを考慮してください。

次のセクションで、Vitis コンパイラによりサポートされる最も一般的な任意精度データ型 (任意精度整数型および任意精度固定小数点型) について説明します。

注記: これらのデータ型は C/C++ のカーネルにのみ使用し、OpenCL カーネル (またはホスト コード内) には使用しないようにしてください。

任意精度整数型

任意精度の整数データ型は、ヘッダー ファイル ap_int.h で符号付きの整数場合は ap_int、符号なし整数の場合は ap_uint と定義されています。任意精度の整数データ型を使用するには、次の手順に従います。

  • ソース コードにヘッダー ファイル ap_int.h を追加します。
  • ビット型を、ap_int<N> または ap_uint<N> (N はビット サイズを表す 1 ~ 1024 の値) に変更します。
次の例に、ヘッダー ファイルの追加方法と、2 つの変数を 9 ビット整数および 10 ビットの符号なし整数を使用してインプリメントする方法を示します。
#include “ap_int.h” 
ap_int<9> var1 // 9 bit signed integer
ap_uint<10> var2 // 10 bit unsigned integer

任意精度固定小数点データ型

既存アプリケーションの中には、ほかのハードウェア アーキテクチャ用に記述されているために、浮動小数点データ型が使用されているものもあります。ただし、固定小数点型の方が、終了するのに多くのクロック サイクルを必要とする浮動小数点型よりも向いていることもあります。アプリケーションおよびアクセラレータに浮動小数点型を使用するのか、固定小数点型を使用するのか選択する際には、消費電力、コスト、生産性、および精度などのトレードオフに注意してください。

『浮動小数点から固定小数点への変換による消費電力およびコストの削減』 (WP491: 英語版日本語版) で説明するように、機械学習のようなアプリケーションに浮動小数点ではなく固定小数点演算を使用すると、消費電力効率が上がり、必要な消費電力合計を削減できます。浮動小数点型の範囲がすべて必要な場合を除き、固定小数点型で同じ精度をインプリメントでき、より小型で高速なハードウェアにできることがよくあります。

固定小数点データ型では、整数ビットおよび小数ビットとしてデータが記述されます。固定小数点データ型には、ap_fixed ヘッダーが必要で、符号付きと符号なしの両方がサポートされます。

  • ヘッダー ファイル: ap_fixed.h
  • 符号付き固定小数点: ap_fixed<W,I,Q,O,N>
  • 符号なし固定小数点: ap_ufixed<W,I,Q,O,N>
    • W = 合計幅 <1024 ビット
    • I = 整数ビット幅。I の値は幅 (W) と等しいかそれ以下である必要があります。小数部を表すためのビット数は W から I を差し引いた値です。整数幅を指定するには、定数の整数式のみを使用します。
    • Q = 量子化モード。Q の指定には、あらかじめ定義されている列挙値のみを使用できます。使用できる値は、次のとおりです。
      • AP_RND: 正の無限大への丸め。
      • AP_RND_ZERO: 0 への丸め。
      • AP_RND_MIN_INF: 負の無限大への丸め。
      • AP_RND_INF: 無限大への丸め。
      • AP_RND_CONV: 収束丸め。
      • AP_TRN: 切り捨て。Q を指定しない場合、これがデフォルト値です。
      • AP_TRN_ZERO: 0 への切り捨て。
    • O = オーバーフロー モード。Q の指定には、あらかじめ定義されている列挙値のみを使用できます。使用できる値は、次のとおりです。
      • AP_SAT: 飽和。
      • AP_SAT_ZERO: 0 への飽和。
      • AP_SAT_SYM: 対称飽和。
      • AP_WRAP: 折り返し。O を指定しない場合、これがデフォルト値です。
      • AP_WRAP_SM: 符号絶対値の折り返し。
    • N = オーバーフロー WRAP モードでの飽和ビット数。このパラメーター値には定数の整数式のみを使用します。デフォルト値は 0 です。
    ヒント: ap_fixed および ap_ufixed データ型には省略定義を使用でき、W および I だけが必須で、その他のパラメーターはデフォルト値に割り当てられます。ただし、Q または N を定義する場合は、デフォルト値だけを指定した場合でも、これらの前にパラメーターを指定する必要があります。

次のコード例では、ap_fixed 型を使用して、符号付き 18 ビット変数 (6 ビットが 2 進小数点より上位の整数部で、12 ビットが 2 進小数点より下位の小数部) を定義しています。量子化モードは正の無限大 (AP_RND) へ丸めらるように設定されています。オーバーフロー モードと飽和ビットが指定されていないので、デフォルトの AP_WRAP と 0 が使用されます。

#include <ap_fixed.h>
...
  ap_fixed<18,6,AP_RND> my_type;
...

変数に異なるビット数 (W) や精度 (I) が含まれるような計算を実行する場合は、2 進小数点が自動的に揃えられます。固定小数点データ型の詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) の「C++ の任意精度固定小数点型」を参照してください。

インターフェイス

ホスト マシンと FPGA 上のカーネルの間のデータ転送には、2 つのタイプがあります。データ ポインターは、グローバル メモリ バンクをを介してホスト CPU とアクセラレータ間で転送されます。スカラー データは、ホストからカーネルに直接渡されます。

メモリマップドのインターフェイス

カーネルで処理されるメインのデータは通常大量であり、FPGA ボード上のグローバル メモリ バンクを介して転送する必要があります。ホスト マシンは、データの大きな塊を 1 つまたは複数のグローバル メモリ バンクに転送します。カーネルは、これらのグローバル メモリ バンクからデータに、理想的にはバーストでアクセスします。カーネルが計算を終了すると、結果のデータがグローバル メモリ バンクを介してホスト マシンに戻されます。

カーネル インターフェイスを記述する際、グローバル メモリ バンクに対するインターフェイスはプラグマを使用して記述します。

カーネル インターフェイスおよびメモリ バンク

void cnn( int *pixel, // Input pixel
  int *weights, // Input Weight Matrix
  int *out, // Output pixel
  ... // Other input or Output ports

#pragma HLS INTERFACE m_axi port=pixel offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=weights offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem

上記の例では、3 つの大型データ インターフェイスがあります。pixel および weights という 2 つの入力と、out という 1 つの出力です。グローバル メモリ バンクに接続されたこれらの入力と出力は、HLS INTERFACE m_axi プラグマを使用して C コードで指定されています。

pragma HLS interfacebundle キーワードはポート名を定義します。システム コンパイラは一意のバンドル名ごとにポートを作成するので、1 つの AXI インターフェイス (m_axi_gmem) を持つコンパイル済みのカーネル オブジェクト (.xo) が作成されます。同じ bundle 名を別のインスタンスに使用した場合は、それらのインターフェイスが同じポートにマップされます。

ポートを共有すると、FPGA リソースは節約できますが、すべてのメモリ転送が 1 つのポートを通ることになるので、カーネルのパフォーマンスが制限されてしまうことがあります。カーネルの帯域幅とスループットは、複数のポートを別のバンドル名を使用して作成すると増加でき、複数のメモリ バンクに接続できます。

void cnn( int *pixel, // Input pixel
  int *weights, // Input Weight Matrix
  int *out, // Output pixel
  ... // Other input or Output ports
		   
#pragma HLS INTERFACE m_axi port=pixel offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=weights offset=slave bundle=gmem1
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem

上記の例の場合、2 つの bundle 名が gmemgmem1 の 2 つのポートを作成します。カーネルは gmem ポートを介して pixel および out にアクセスしますが、weights には gmem1 ポートを介してアクセスされます。このため、カーネルが pixelweights に並列にアクセスできるようになるので、カーネルのスループットが改善される可能性があります。

重要: bundle= の名前は小文字で指定しないと、connectivity.sp オプションを使用して特定のメモリ バンクに割り当てられません。

HLS INTERFACE プラグマが v++ コンパイル中に使用されると、2 つの AXI インターフェイス (m_axi_gmem および m_axi_gmem1) を含むコンパイル済みのカーネル オブジェクト (.xo) が作成され、必要に応じてグローバル メモリに接続できます。カーネル ポートのグローバル メモリへのマップ で説明するように、システム コンパイラのリンク中にコンフィギュレーション ファイルで connectivity.sp オプションを使用すると、別のインターフェイスを異なるメモリ バンクにマップできます。

メモリ インターフェイスの幅に関する考慮事項

グローバル メモリとカーネルの間の最大データ幅は 512 ビットです。データ転送レートを最大にするには、この最大データ幅を使用することをお勧めします。最大ビット幅を利用するには、カーネル コードを変更する必要があります。

上記の例では、ネイティブの整数型が使用されているので、データ転送に最大帯域幅は使用されません。データ型に説明されているように、任意精度型 ap_int または ap_uint を使用すると、ビット精度の高いデータ幅を達成できます。

void cnn( ap_uint<512> *pixel, // Input pixel
  int *weights, // Input Weight Matrix 
  ap_uint<512> *out, // Output pixel
  ... // Other input or output ports
		   
#pragma HLS INTERFACE m_axi port=pixel offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=weights offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem

上記の例では、ap_uint データ型を使用した出力 (out) インターフェイスにより、512 ビットの最大転送幅が使用されるようになっています。

メモリ インターフェイスのデータ幅は 2 のべき乗で指定する必要があります。データ幅が 32 未満の場合は、ネイティブ C++ データ型を使用します。32 を超え、2 のべき乗で増加していく場合は、ap_int/ap_uint を使用します。

バースト読み出しおよび書き込み

カーネルからグローバル メモリ バンク インターフェイスへのアクセスには、長いレイテンシがありますので、グローバル メモリ転送はバーストで実行する必要があります。バーストを推論するには、次のパイプライン ループを使用したコーディング スタイルをお勧めします。

hls::stream<datatype_t> str;

INPUT_READ: for(int i=0; i<INPUT_SIZE; i++) {
  #pragma HLS PIPELINE
  str.write(inp[i]); // Reading from Input interface
}

このコード例では、パイプライン処理された for ループが使用され、入力メモリ インターフェイスからデータを読み出し、内部 hls::stream 変数に書き込んでいます。上記のコーディング スタイルにより、データはグローバル メモリ バンクからバーストで読み出されます。

データフロー最適化 で説明したように、上記の for ループを異なる関数内に含め、最上位で dataflow 最適化を適用するコーディング スタイルをお勧めします。次はそのコード例で、コンパイラに読み出し関数、実行関数、書き込み関数間のデータフローを作成させています。

top_function(datatype_t * m_in, // Memory data Input
  datatype_t * m_out, // Memory data Output
  int inp1,     // Other Input
  int inp2) {   // Other Input
#pragma HLS DATAFLOW

hls::stream<datatype_t> in_var1;   // Internal stream to transfer
hls::stream<datatype_t> out_var1;  // data through the dataflow region

read_function(m_in, inp1, in_var1); // Read function contains pipelined for loop 
                           // to infer burst

execute_function(in_var1, out_var1, inp1, inp2); // Core compute function

write_function(out_var1, m_out); // Write function contains pipelined for loop 
                                 // to infer burst
}

スカラー入力

スカラー入力は通常、ホスト マシンから直接読み込まれる制御変数です。これらは、メイン カーネルの計算が実行されるプログラム データまたはパラメーターと考えることができます。これらのカーネル入力は、ホスト側からの書き込みのみです。これらのインターフェイスは、カーネル コードで次のように指定します。
void process_image(int *input, int *output, int width, int height) {
  #pragma HLS INTERFACE s_axilite port=width bundle=control
  #pragma HLS INTERFACE s_axilite port=height bundle=control

この例には、画像の width および height を指定する 2 つのスカラー入力があります。これらの入力は、#pragma HLS INTERFACE s_axilite を使用して指定します。これらのデータ入力は、グローバル メモリ バンクを使用せずに、ホスト マシンから直接カーネルに送信されます。

重要: 現在のところ、Vitis コア開発環境では各カーネルに 1 つの制御インターフェイス バンドルのみがサポートされています。そのため、すべてのスカラー データ入力および return 関数の bundle= 名は同じにする必要があります。前の例では、すべてのスカラー入力に bundle=control が使用されています。

ホストからカーネルのデータフローのイネーブル

時間的なデータ並列処理: ホストからカーネルへのデータフロー で説明したように、カーネルが前のトランザクションからのデータを処理中にさらにデータを受信できる場合、XRT は次のデータ バッチを送信できます。これで、カーネルがアルゴリズムのさまざまな段階で並列で複数のデータ セットを処理するようになるので、パフォーマンスは改善しますが、ホストからカーネルへのデータフローをサポートするには、カーネルがその関数リターンに HLS INTERFACE プラグマを使用した ap_ctrl_chain プロトコルをインプリメントする必要があります。
void kernel_name( int *inputs,
                  ...         )// Other input or Output ports
{
#pragma HLS INTERFACE  .....   // Other interface pragmas
#pragma HLS INTERFACE ap_ctrl_chain port=return bundle=control
重要: ホストからカーネルへのデータフローの利点を生かすには、ループのパイプライン処理 で説明するようにループ レベルでパイプラインしたり、データフロー最適化 で説明するようにタスク レベルでパイプラインしたり、段階的にデータを処理するようにカーネルを記述する必要もあります。

ループ

ループは、高パフォーマンスのアクセラレータには重要です。ループは通常、高度に分散された並列 FPGA アーキテクチャを利用するためパイプライン処理されるか展開され、CPU で実行するよりもパフォーマンスが上がります。

デフォルトでは、ループはパイプライン処理も展開もされません。ハードウェアでは、ループの各反復を実行するのに少なくとも 1 クロック サイクルかかります。ハードウェアの面から考えると、ループの本体ではクロックまで待機することが暗示されます。 ループの次の反復は、前の反復が終了してから開始されます。

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

デフォルトでは、ループの反復は前の反復が終了してから開始されます。たとえば次に示すループの例では、ループの 1 回の反復で 2 つの変数が加算され、結果が 3 つ目の変数に格納されます。ハードウェアでこのループの 1 回の反復を終了するのに 3 サイクルかかるとします。また、ループの変数 len は 20 だとします (カーネルで vadd ループが 20 回反復実行される)。このループのすべての演算を終了するには、合計 60 クロックサイクル (20 反復 * 3 サイクル) かかります。
vadd: for(int i = 0; i < len; i++) {
  c[i] = a[i] + b[i];
}
ヒント: ループには、上記のコード例のように常にラベルを付けることをお勧めします (vadd:…)。このようにしておくと、Vitis コア開発キットでデバッグしやすくなります。ラベルが原因でコンパイル中に警告メッセージが表示されますが、無視しても問題ありません。
ループをパイプライン処理すると、後続の反復はパイプラインで実行されます。つまり、ループの後続の反復は重複させて、ループ本体の別のセクションで同時に実行されます。ループをパイプライン処理するには、HLS PIPELINE プラグマをイネーブルにします。プラグマはループの本体内に記述します。
vadd: for(int i = 0; i < len; i++) {
  #pragma HLS PIPELINE
  c[i] = a[i] + b[i];
}

上記の例では、ループの各反復 (読み込み、追加、書き出し) に 3 サイクルかかっていました。パイプライン処理なしの場合、ループの後続の反復は 3 サイクルごとに開始します。パイプライン処理すると、ループの後続の反復が 2 サイクルごと、または各サイクルで開始するようにできます。

ループの次の反復を開始するまでにかかるサイクル数は、パイプライン ループの開始間隔 (II) と呼ばれます。II=2 はループの連続する反復が 2 サイクルごとに開始され、II=1 (理想的) はループの各反復が各サイクルで開始されることを意味します。pragma HLS PIPELINE を使用すると、コンパイラは常に II=1 のパフォーマンスを達成しようとします。

次の図は、パイプライン処理ありとなしのループの違いを示しています。この図の (A) はデフォルトの順次演算を示しています。各入力は 3 クロック サイクルごとに処理され (II=3)、最後の出力が書き出されるまでに 8 クロック サイクルかかっています。

1: ループのパイプライン処理
(B) に示すパイプライン処理されたループでは、入力サンプルが各クロック サイクルで読み出され、最終的な出力は 4 クロック サイクル後に書き込まれるようになり、同じハードウェア リソースを使用して、開始間隔 (II) とレイテンシの両方を向上できます。
重要: ループをパイプライン処理すると、そのループ内の入れ子のループがすべて展開されます。

ループ内にデータ依存性がある場合、ループ依存性 で説明するように、II=1 を達成できず、開始間隔が 1 より大きな値になることがあります。

ループ展開

コンパイラでは、ループを部分的または完全に展開し、複数のループ反復を並列で実行するようにもできます。これには、HLS UNROLL プラグマ使用します。ループを展開すると、並列処理が多くなるので、デザインをかなり高速にできますが、ループ反復の演算すべてが並列で実行されるので、ハードウェアをインプリメントするのに多くのプログラマブル ロジック リソースが必要になります。この結果、リソース量が多くなって容量の問題が発生し、カーネル コンパイル処理時間が遅くなってしまうことがあります。そのため、ループ本体が小さいループ、または反復回数の少ないループを展開することをお勧めします。
vadd: for(int i = 0; i < 20; i++) {
  #pragma HLS UNROLL
  c[i] = a[i] + b[i];
}

上記の例では、ループ本体に pragma HLS UNROLL が追加されており、コンパイラにループを完全に展開するよう指示しています。データの依存性で許容されれば、ループの 20 回の反復すべてが並列実行されます。

ヒント: ループを完全に展開すると多量のデバイス リソースが使用されますが、部分的に展開するだけの場合、少な目のハードウェア リソースを使用するので、パフォーマンスを向上できることがあります。

Partially Unrolled Loop (部分的に展開されたループ)

ループを完全に展開するには、ループの範囲が定数である必要があります (上記の例では 20)。ただし、範囲が可変のループでも、部分展開は可能です。ループを部分展開するということは、特定数の反復のみが並列実行されるということです。

次のコード例は、部分展開を示しています。
array_sum:for(int i=0;i<4;i++){
  #pragma HLS UNROLL factor=2
  sum += arr[i];
}

上記の例では、UNROLL プラグマに係数 2 が指定されています。これは、ループ本体を手動で複製し、その 2 つのループを半分の反復回数分だけ同時に実行するのと同じことです。次のコードは、その例です。この変換により、上記のループの 2 回の反復が並列に実行されます。

array_sum_unrolled:for(int i=0;i<4;i+=2){
  // Manual unroll by a factor 2
  sum += arr[i];
  sum += arr[i+1];
}

ループ内のデータの依存性がパイプライン処理されたループの開始間隔 (II) に影響するのと同様に、展開されたループでも、データの依存性により可能な場合にのみの演算が並列実行されます。ループの 1 回の反復での演算に前の反復からの結果が必要な場合は並列実行できませんが、1 つの反復からのデータが使用可能になるとすぐに次の反復が実行されます。

注記: まず、PIPELINE でループをパイプライン処理をしてから、小型のループ本体を UNROLL で限られた反復回数分展開して、パフォーマンスを改善していくことをお勧めします。

ループ依存性

ループ内のデータ依存性は、ループのパイプライン処理またはループの展開結果に影響することがあります。これらのループ依存性は、ループの 1 回の反復内またはループ内の異なる反復間で発生します。ループ依存性を理解するには、極端な例を見てみるのとわかりやすいです。次のコード例では、ループの結果がそのループの継続/終了条件として使用されています。次のループを開始するには、前のループの各反復が終了する必要があります。
Minim_Loop: while (a != b) { 
  if (a > b) 
    a -= b; 
  else 
    b -= a;
}

このループはパイプライン処理できません。ループの前の反復が終了するまで次の反復を開始できないからです。

Vitis コンパイラを使用してさまざまなタイプの依存性を処理するには、コンパイラの結果を使用する高位合成について詳細に理解しておく必要があります。Vivado HLS での依存性の詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。

入れ子のループ

入れ子のループはコード記述で一般的に使用されます。入れ子のループ構造内のループがどのようにパイプライン処理されるか理解することが、必要なパフォーマンスの達成につながります。

HLS PIPELINE プラグマが別のループ内で入れ子になったループに適用されると、v++ コンパイラはループを平坦化して 1 つのループを作成し、PIPELINE プラグマをその作成されたループに適用します。ループを平坦化すると、カーネル パフォーマンスを改善しやすくなります。

コンパイラでは、次のタイプの入れ子のループを平坦化できます。
  1. 完全入れ子ループ:
    • 内側のループのみにループ本体が含まれます。
    • ループ宣言間に指定されるロジックまたは演算はありません。
    • すべてのループ範囲は定数です。
  2. 半完全入れ子ループ:
    • 内側のループのみにループ本体が含まれます。
    • ループ宣言間に指定されるロジックまたは演算はありません。
    • 内側のループ範囲は定数にする必要がありますが、外側のループ範囲は変数にできます。
次のコード例は、完全入れ子ループの構造を示しています。
ROW_LOOP: for(int i=0; i< MAX_HEIGHT; i++) {
  COL_LOOP: For(int j=0; j< MAX_WIDTH; j++) {
    #pragma HLS PIPELINE
    // Main computation per pixel
  }
}

上記の例は、入力ピクセル データに対して計算を実行する 2 つのループを含む入れ子のループ構造を示しています。ほとんどの場合、サイクルごとに 1 ピクセル処理するのが望ましいので、PIPELINE は入れ子のループの本体構造に適用されます。この例の場合は完全入れ子ループなので、コンパイラで入れ子ループ構造を平坦化できます。

前の例の入れ子ループには、2 つのループ宣言間にロジックが含まれていません。ROW_LOOPCOL_LOOP の間にロジックはないので、すべての処理ロジックが COL_LOOP に含まれます。また、両方のループの反復数は固定されています。これらの 2 つの条件を満たすことが、v++ コンパイラでループを平坦化し、PIPELINE 制約を適用するのに役立ちます。

注記: 外側のループの境界が変数の場合でも、コンパイラでループを平坦化することは可能です。内側のループの範囲は、定数にするようにしてください。

シーケンシャル ループ

デザインに複数のループがある場合、デフォルトではこれらがオーバーラップせずに順に実行されます。このセクションでは、シーケンシャル ループのデータフロー最適化の概念について説明します。次のようなコードがあるとします。
void adder(unsigned int *in, unsigned int *out, int inc, int size) {

  unsigned int in_internal[MAX_SIZE];
  unsigned int out_internal[MAX_SIZE];
  mem_rd: for (int i = 0 ; i < size ; i++){
    #pragma HLS PIPELINE
    // Reading from the input vector "in" and saving to internal variable
    in_internal[i] = in[i];
  }
  compute: for (int i=0; i<size; i++) {
  #pragma HLS PIPELINE
    out_internal[i] = in_internal[i] + inc;
  } 

  mem_wr: for(int i=0; i<size; i++) {
  #pragma HLS PIPELINE
    out[i] = out_internal[i];
  }
}

上記の例は、mem_rdcompute、および mem_wr の 3 つのシーケンシャル ループを示しています。

  • mem_rd ループはメモリ インターフェイスから入力ベクター データを読み出し、内部ストレージに格納します。
  • compute ループは、内部ストレージからデータを読み出してインクリメント演算を実行し、結果を別の内部ストレージに保存します。
  • mem_wr ループは、内部ストレージからデータを読み出してメモリに書き込みます。

このコード例ではメモリの入力/出力インターフェイスに対する読み出しと書き込みに 2 つの個別のループを使用してバースト読み出し/書き込みを推論しています。

デフォルトでは、これらのループはオーバーラップせずに順に実行されます。mem_rd ループが入力データをすべて読み出すまで、compute ループの処理は開始しません。同様に、compute ループがデータを処理し終わってから mem_wr ループがデータの書き込みを開始します。ただし、これらのループの処理をオーバーラップさせて、mem_rd (または compute) ループがデータを処理し終えるまで待たずに、compute (または mem_wr) ループが処理を実行するのに十分なデータが使用可能になったらすぐに開始されるようにできます。

ループの実行は、データフロー最適化に示すように、データフロー最適化を使用してオーバーラップさせることができます。

データフロー最適化

データフロー最適化は、カーネル ハードウェア関数タスク レベルのパイプラインおよび並列処理をイネーブルにすることで、カーネルのパフォーマンスを改善する優れた手法で、スループットを高めてレイテンシを下げるために、v++ コンパイラで複数のカーネル関数が同時実行されるようスケジュールできるようになります。これは、タスク レベルの並列処理とも呼ばれます。

次の図に、データフロー パイプライン処理の概念を示します。デフォルトでは、func_Afunc_Bfunc_C の順に実行されて終了しますが、HLS DATAFLOW プラグマをイネーブルにすると、データが使用可能になった直後に各関数が実行されるようスケジュールできます。この例の場合、元の top 関数のレイテンシと間隔は 8 クロック サイクルです。データフロー最適化を使用すると、間隔は 3 クロック サイクルに削減されます。

2: データフロー最適化

データフローのコード例

データフローのコード例では、次に注意してください。

  1. コンパイラでデータフロー最適化をイネーブルにするため、HLS DATAFLOW プラグマが適用されています。これは、PS と PL 間のインターフェイスであるデータ ムーバーではなく、データがアクセラレータを介してフローする方法を指定を示します。
  2. データフロー領域内の各関数間のデータ転送チャネルとして stream クラスが使用されています。
    ヒント: stream クラスは、プログラマブル の FIFO メモリ回路を推論します。このメモリ回路はソフトウェア プログラミングのキューとして動作し、関数間をデータ レベルで同期化してパフォーマンスを向上します。hls::stream クラスの詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902) を参照してください。
void compute_kernel(ap_int<256> *inx, ap_int<256> *outx, DTYPE alpha) {
  hls::stream<unsigned int>inFifo;
  #pragma HLS STREAM variable=inFifo depth=32
  hls::stream<unsigned int>outFifo; 
  #pragma HLS STREAM variable=outFifo depth=32

  #pragma HLS DATAFLOW
  read_data(inx, inFifo);
  // Do computation with the acquired data
  compute(inFifo, outFifo, alpha);
  write_data(outx, outFifo);
  return;
}

データフロー最適化の正規形式

ザイリンクスでは、正規形式を使用してデータフロー領域内でコードを記述することをお勧めしています。関数およびループのデータフロー最適化には、正規形式があります。
  • 関数: 関数内のデータフローの正規形式のコーディング ガイドラインは、次のとおりです。
    1. データフロー領域内では、次のタイプの変数のみを使用します。
      1. ローカルの非スタティック スカラー/配列/ポインター変数。
      2. ローカルのスタティック hls::stream 変数。
    2. 関数呼び出しは、データを前方向にのみ送信します。
    3. 配列または hls::stream には、プロデューサー関数 1 つと コンシューマー関数 1 つのみが含まれます。
    4. 関数引数 (データフロー領域外から入ってくる変数) は読み出されるか書き込まれますが、両方が実行されることはありません。読み出しと書き込みを同じ関数引数で実行する場合は、読み出しが書き込みよりも前に発生する必要があります。
    5. ローカル変数 (前方向へのデータ転送する変数) は読み出しよりも前に書き込まれる必要があります。

    次のコード例は、関数内のデータフローの正規形式を示しています。上記のコードでは、最初の関数 (func1) で入力が読み出され、最後の関数 (func3) で出力が書き込まれます。各関数で作成された出力値は、次の関数に入力パラメーターとして渡されます。

    void dataflow(Input0, Input1, Output0, Output1) {
      UserDataType C0, C1, C2;
      #pragma HLS DATAFLOW
      func1(read Input0, read Input1, write C0, write C1);
      func2(read C0, read C1, write C2);
      func3(read C2, write Output0, write Output1);
    }
  • ループ: ループ本体内のデータフローの正規形式のコーディング ガイドラインには、上記で定義される関数のコーディング ガイドラインが含まれるほか、次も指定されます。
    1. 初期値は 0。
    2. ループ条件は、ループ変数と定数、またはループ本体内で変化しない変数との比較結果で決まります。
    3. 1 ずつインクリメント。

    次のコード例は、ループ内のデータフローの正規形式を示しています。

    void dataflow(Input0, Input1, Output0, Output1) {
    	 		UserDataType C0, C1, C2;
    	 		for (int i = 0; i < N; ++i) {
                      #pragma HLS DATAFLOW
    	             func1(read Input0, read Input1, write C0, write C1);
    	             func2(read C0, read C0, read C1, write C2);
    	             func3(read C2, write Output0, write Output1);
    	           }
    }

データフローのトラブルシュート

次のような場合、Vitis コンパイラでデータフロー最適化が実行されないことがあります。

  1. シングル プロデューサー コンシューマー違反。
  2. タスクのバイパス。
  3. タスク間のフィードバック。
  4. タスクの条件付き実行。
  5. 複数の exit 条件またはループ内で定義された条件を持つループ。

これらのいずれかがデータフロー領域内で発生する場合は、データフロー最適化が問題なく実行されるようにするためにコードを記述し直す必要があることもあります。

配列コンフィギュレーション

Vitis コンパイラでは、大型の配列は PL 領域にあるブロック RAM メモリにマップされます。これらのブロック RAM のアクセス ポイント (またはポート) は最大 2 つなので、ハードウェアにインプリメントされたときに配列のすべての要素に並列にアクセスできず、アプリケーションのパフォーマンスが制限されることがあります。

パフォーマンス要件によっては、配列の一部またはすべての要素に同じクロック サイクルでアクセスすることが必要な場合があります。これには、HLS ARRAY_PARTITION プラグマを使用して、コンパイラで配列の要素が分割され、より小さな配列や個別レジスタにマップされるようにします。コンパイラには、次の図に示すように、3 つの配列分割方法があります。3 つの分割方法は、次のとおりです。

  • block: 元の配列の連続した要素が同じサイズのに分割されます。
  • cyclic: 元の配列の要素がインターリーブされて同じサイズのブロックに分割されます。
  • complete: 配列が個別要素に分割されます。これは、メモリの個別レジスタへの分解に相当します。これは ARRAY_PARTITION プラグマの場合デフォルトです。
3: 配列の分割

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

多次元配列を分割する際は、dimension オプションを使用して、分割する次元を指定できます。次の図に、次のコード例を dimension オプションを使用して 3 つの方法で分割した場合を示します。
void foo (...) {
  // my_array[dim=1][dim=2][dim=3] 
  // The following three pragma results are shown in the figure below
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=3 <block|cyclic> factor=2
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=1 <block|cyclic> factor=2
  // #pragma HLS ARRAY_PARTITION variable=my_array dim=0 complete
  int  my_array[10][6][4];
  ...   
}
4: 配列の次元の分割

dimension を 3 に設定すると 4 つの配列に、1 に設定すると 10 個の配列に分割されます。dimension を 0 に設定すると、すべての次元が分割されます。

分割の重要性

配列の complete 分割では、すべての配列エレメントが個別レジスタにマップされます。これにより、すべてのレジスタに同じサイクルで同時にアクセスできるので、カーネル パフォーマンスが改善します。

注意: 大型の配列の complete 分割すると、PL 領域が大量に使用されるので、注意が必要です。場合によっては、コンパイル プロセスの速度が遅くなり、容量が足りなくなることもあります。このため、配列は必要な場合にのみ分割する必要があります。特定の次元を分割するか、block または cycle 分割を実行することを考慮してみてください。

分割する次元の選択

A と B が 2 つの行列を示す 2 次元配列で、次のような行列乗算アルゴリズムがあるとします。
int A[64][64];
int B[64][64];
 
ROW_WISE: for (int i = 0; i < 64; i++) {
  COL_WISE : for (int j = 0; j < 64; j++) {
    #pragma HLS PIPELINE
    int result = 0;
    COMPUTE_LOOP: for (int k = 0; k < 64; k++) {
      result += A[i ][ k] * B[k ][ j];
    }
    C[i][ j] = result;
  }
}
PIPELINE プラグマにより、ROW_WISE および COL_WISE ループが一緒に平坦化され、COMPUTE_LOOP が完全に展開されます。COMPUTE_LOOP の各反復 (k) を同時に実行するには、行列 A の各列と行列 B の各行に並列でアクセスできるようにする必要があります。このため、行列 A は 2 つ目の次元で分割し、行列 B は最初の次元で分割する必要があります。
#pragma HLS ARRAY_PARTITION variable=A dim=2 complete
#pragma HLS ARRAY_PARTITION variable=B dim=1 complete

cyclic および block 分割の選択

ここでは、同じ行列乗算アルゴリズムを使用して、cyclic および block 分割を選択し、下位のアルゴリズムの配列アクセス パターンを理解することで、適切な係数を指定します。

int A[64 * 64];
int B[64 * 64];
#pragma HLS ARRAY_PARTITION variable=A dim=1 cyclic factor=64
#pragma HLS ARRAY_PARTITION variable=B dim=1 block factor=64
 
ROW_WISE: for (int i = 0; i < 64; i++) {
  COL_WISE : for (int j = 0; j < 64; j++) {
    #pragma HLS PIPELINE
    int result = 0;
    COMPUTE_LOOP: for (int k = 0; k < 64; k++) {
      result += A[i * 64 +  k] * B[k * 64 + j];
    }
    C[i* 64 + j] = result;
  }
}

ここではコード A と B は 1 次元配列であるとします。行列 A の各列と行列 B の各行に並列でアクセスするには、cyclic と block 分割を上記の例のように使用します。行列 A の各列に並列でアクセスするため、cyclic 分割が行サイズとして指定した factor (この場合 64) で適用されています。同様に、行列 B の各行に並列でアクセスするため、block 分割が列サイズとして指定した factor (この場合 64) で適用されています。

キャッシュを使用した配列アクセスの削減

配列はアクセス ポート数が制限されたブロック RAM にマップされるので、配列の繰り返しにより、アクセラレータのパフォーマンスが制限されることがあります。アルゴリズムの配列アクセス パターンを理解し、データをローカルでキャッシュして配列アクセスを制限して、カーネルのパフォーマンスを改善するようにしてください。

次のコード例では、配列へのアクセスにより最終インプリメンテーションのパフォーマンスが制限されます。この例では、mem[N] 配列が 3 回アクセスされ、それらを合計して結果が作成されています。
#include "array_mem_bottleneck.h"
dout_t array_mem_bottleneck(din_t mem[N]) {  
  dout_t sum=0;
  int i;
  SUM_LOOP:for(i=2;i<N;++i) 
    sum += mem[i] + mem[i-1] + mem[i-2];    
  return sum;
}
上記のコード例を次のように変更すると、II=1 でパイプライン処理できるようになります。先行読み出しを実行し、データ アクセスを手動でパイプライン処理することで、ループの各反復で指定される配列読み出しを 1 回だけにしています。これにより、パフォーマンスを達成するためには、シングル ポート ブロック RAM だけが必要となります。
#include "array_mem_perform.h"
dout_t array_mem_perform(din_t mem[N]) {  
  din_t tmp0, tmp1, tmp2;
  dout_t sum=0;
  int i;
  tmp0 = mem[0];
  tmp1 = mem[1];
  SUM_LOOP:for (i = 2; i < N; i++) { 
    tmp2 = mem[i];
    sum += tmp2 + tmp1 + tmp0;
    tmp0 = tmp1;
    tmp1 = tmp2;
  }     
  return sum;
}
注記: アルゴリズムによって、パイプライン パフォーマンスを改善するため、ローカル レジスタにキャッシュして配列アクセスを最小限に抑えることを考慮してください。

関数のインライン展開

C コードは通常、複数の関数で構成されます。デフォルトでは、各関数が Vitis コンパイラにより個別にコンパイルおよび最適化されます。関数本体に対して固有のハードウェア モジュールが生成され、必要に応じて再利用されます。

パフォーマンスの面から、一般的には関数をインライン展開するか、関数の階層を解除する方が良い結果が得られます。このようにすると、Vitis コンパイラで関数の境界を越えてグローバルに最適化を実行できるようになります。たとえば、パイプライン処理されたループ内で関数を呼び出す場合、関数をインライン展開するとコンパイラでより積極的に最適化を実行できるようになり、ループのパイプライン パフォーマンスが向上します (開始間隔 (II) を削減)。

次の INLINE プラグマを関数本体に配置すると、関数がインライン展開されます。
foo_sub (p, q) {
  #pragma HLS INLINE
  ....
  ...
}

ただし、関数本体が非常に大きく、メインのカーネル関数で複数回呼び出される場合は、関数をインライン展開すると、使用されるリソース量が多くなりすぎて容量の問題が発生することがあります。このような場合は関数をインライン展開せずに、v++ コンパイラで関数がそのローカル コンテキスト内で個別に最適化されるようにします。

まとめ

前のトピックで説明したように、C/C/C++ を使用した FPGA アクセラレーションのカーネルをコード記述する際には、重要な点がいくつかあります。

  1. 任意精度データ型 ap_int および ap_fixed を使用することを考慮します。
  2. カーネル インターフェイスを理解して、スカラー インターフェイスを使用するかメモリ インターフェイスを使用するかを決定します。リンク段階で別の DDR メモリ バンクを指定する場合は、bundle キーワードを使用して異なる名前を指定します。
  3. メモリ インターフェイスに対する読み出しおよび書き込みにはバースト コーディング スタイルを使用します。
  4. メモリ データのデータ入力および出力入力および出力の幅を選択する際は、データ転送に DDR バンクの全幅を使用することを考慮します。
  5. パイプライン処理およびデータフローを使用して最大限のパフォーマンスを得られるようにします。
  6. v++ コンパイラで平坦化を実行してパイプラインを効果的に適用できるように、完全または半完全な入れ子のループを記述します。
  7. 反復回数が少なく、ループ本体内の演算数が少ないループを展開します。
  8. 配列のアクセス パターンを理解し、配列全体に complete 分割を適用するのではなく、特定の次元に complete 分割を適用するか、block または cyclic 分割を適用します。
  9. カーネルのパフォーマンスを向上するため、ローカル キャッシュを使用して配列へのアクセスを最小限に抑えます。
  10. 関数 (特にパイプライン処理された領域内) をインライン展開することを考慮します。データフロー領域内の関数はインライン展開しないでください。