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);
        }

プロセス実行モード

カーネル実行モード で説明したよう、3 つの実行モードがあります。これらのモードは、関数の戻り値でカーネルに割り当てられているブロック プロトコルにより判断されます。ブロック プロトコルは、#pragma HLS INTERFACE を使用して指定できます。次に、モードとそれらをイネーブルにするブロック プロトコルを示します。

パイプライン
ap_ctrl_chain のデフォルト ブロック プロトコルによりイネーブルになります。
シーケンシャル
ap_ctrl_hs によりイネーブルになりるシリアル アクセス モード。
フリーランニング
ap_ctrl_none によりイネーブルになります。

これらの実行モードの XRT でのサポートについては、サポートされるカーネル実行モデルを参照してください。

パイプライン モード

時間的なデータ並列処理: ホストからカーネルへのデータフロー で説明したように、カーネルが前のトランザクションからのデータを処理中にさらにデータを受信できる場合、XRT は次のデータ バッチを送信できます。パイプライン モードでは、複数のカーネル エンキューをオーバーラップさせることができ、全体的なスループットが向上します。

パイプライン モードをサポートするには、カーネルで ap_ctrl_chain プロトコル (HLS で使用されるデフォルト プロトコル) を使用する必要があります。このプロトコルは、次の例に示すように、関数の戻り値に #pragma HLS INTERFACE を指定することによりイネーブルにすることもできます。

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

パイプライン モードが正しく動作するようにするには、カーネルのキューのレイテンシが長いことが必要です。そうでないとカーネルで各データ バッチを処理するのに十分な時間がなく、パイプラインの利点を活かすことができません。

重要: ホストからカーネルへのデータフローの利点を活かすには、ループのパイプライン処理 で説明するようにループ レベルでパイプライン処理、または データフロー最適化 で説明するようにタスク レベルでパイプライン処理するなどして、データが段階的に処理されるようカーネルを記述する必要もあります。

以前の技術との互換性のため、カーネルではシーケンシャル モードもサポートされています。これは、#pragma HLS INTERFACE で関数の戻り値に ap_ctrl_hs ブロック プロトコルを使用することにより設定できます。パイプラインに設定されたカーネルでデータをパイプライン方式で処理できない場合は、シーケンシャル モードに戻ります。

フリーランニング モード

デフォルトでは、Vitis HLS で同期化がホスト アプリケーションで制御されるカーネルが生成されます。ホストでカーネルの開始と終了が制御および監視されます。ただし、プロセスまたはデータ ストリームを連続して実行する場合など、カーネルをホストで制御する必要がないこともあります。これは、制御ハンドシェイクがないので、フリーランニング カーネルと呼ばれます。次の例に示すように、Vitis ツールでは、これは #pragma HLS INTERFACEap_ctrl_none ブロック プロトコルを使用することによりサポートされます。

void kernel_top(hls::stream<ap_axiu >& input, hls::stream<ap_axiu >& output) 
{ 
#pragma HLS interface axis port=input 
#pragma HLS interface axis port=output 
#pragma HLS interface ap_ctrl_none port=return // Special pragma for Free running kernel 
#pragma HLS DATAFLOW 
// The kernel is using DATAFLOW optimization 
while(1) { ... } 
}

カーネルに AXI4-Stream インターフェイスを使用した場合は、カーネルは入力にデータがある場合にのみ実行されます。入力にデータがない場合は、カーネルは停止し、データが入力されるまで待機します。詳細は、ストリーミング データ転送 を参照してください。

データ型

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 型を使用して、符号付き 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 進小数点が自動的に揃えられます。固定小数点データ型の詳細は、『Vitis 高位合成ユーザー ガイド』 (UG1399) の「C++ の任意精度固定小数点型」を参照してください。

インターフェイス

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

Vitis HLS ツールは、Vitis コア開発キットの一部であり、C/C++ カーネル関数のパラメーターにインターフェイス ポートを自動的に割り当てます。これらのポートは、v++ コンパイル プロセス中に割り当てられます。次のセクションに、これらのインターフェイス ポートの追加の詳細、手動で割り当てる方法、および INTERFACE プラグマを使用してデフォルト割り当てを無効にする方法を説明します。コードに INTERFACE プラグマがない場合は、Vitis ツールにより次のインターフェイス プロトコルが割り当てられます。

  • C/C++ 関数のポインター引数には、AXI4 マスター インターフェイス (m_axi) が割り当てられます。
  • スカラー引数、配列の制御信号、グローバル変数、およびソフトウェア関数の戻り値には、AXI4-Lite インターフェイス (s_axilite) が割り当てられます。
  • Vitis HLS では、スループット帯域幅を最大にし、レイテンシを最短にするため、バースト トランザクションが推論され、メモリ アクセスがまとめられます。バースト転送の詳細は、Vitis HLS フローバースト転送の最適化 を参照してください。
  • パラメーターのデータ型を定義するのに hls::stream が使用されているので、Vitis HLS ツールで axis インターフェイスが推論されます。

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

メモリ マップド インターフェイスは、ポインター パラメーターから推論されます。このインターフェイスでは、カーネルがグローバル メモリのデータを読み出し/書き込みでき、グローバル メモリはカーネルとホスト アプリケーションで共有されます。そのため、メモリ マップド インターフェイスはアクセラレーション アプリケーションの異なるエレメント間でデータを共有するのに便利な方法ですが、カーネル実行モード に説明されているように、シーケンシャルおよびパイプライン カーネル実行モードでのみ使用可能です。

コンパイル中に Vitis ツールで割り当てられるデフォルトのインターフェイスをカスタマイズするには、INTERFACE プラグマを使用できます。ザイリンクスでは、最大のパフォーマンスを得るため、AXI プロトコルの転送ごとの上限である 4 KB までのバースト転送をお勧めします。

カーネル インターフェイス

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

上記の例では、カーネル関数に pixelweightsout というの 3 つのポインターが含まれます。デフォルトでは、Vitis コンパイラによりこれら 3 つのパラメーターは同じ AXI4 インターフェイス (m_axi) にマップされます。

コンパイラで推論されるデフォルトのインターフェイス マップは、次の INTERFACE プラグマと同等です。

#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
ヒント: 推論されたプラグマがツールによりコードに追加されるわけではありません。これは、インターフェイス ポートに割り当てられるデフォルト設定を表すために示されています。

INTERFACE プラグマの bundle キーワードは、ポート名を定義します。システム コンパイラにより各バンドル名に対してポートが作成され、1 つの AXI インターフェイス (m_axi_gmem) を使用するカーネル オブジェクト (XO) ファイルにコンパイルされます。同じ bundle 名を別のインスタンスに使用した場合は、それらのインターフェイスが同じポートにマップされます。

ヒント: gmem はグローバル メモリを表しますが、これはキーワードではなく、一貫性を保つために使用されています。バンドルには、任意の名前を使用できます。

ポートを共有すると、AXI インターフェイスを取り除くことにより FPGA リソースを節約できますが、すべてのメモリ転送が 1 つのポートを介して実行されるので、カーネルのパフォーマンスが制限される可能性があります。m_axi ポートには独立した読み出しチャネルと書き込みチャネルがあるので、1 つの m_axi ポートで読み出しと書き込みを同時に実行できます。カーネルの帯域幅とスループットは、複数のポートを作成し、異なるバンドル名を使用して、複数のメモリ バンクに接続することにより増加できます。pragma HLS interface に説明されているように、INTERFACE の設定用に多数のオプションがあります。コードで INTERFACE プラグマを手動で定義するのには、次のような理由が考えられます。

  • INTERFACE プラグマのバンドルを指定して、AXI 信号を別のバンドルに分ける。
  • インターフェイスの幅をデフォルトの int = 64 バイト (512 ビット) 以外に設定する。
  • バースト トランザクション用に AXI プロパティを指定する。
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 オプションを使用して特定のメモリ バンクに割り当てることができるように、すべて小文字を使用してください。

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

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

グローバル メモリとカーネルの間の最大データ幅は 512 ビットです。データ転送レートを最大にするには、この最大データ幅を使用することをお勧めします。Vitis カーネル フローでは、バースト アクセスを向上するため、Vitis HLS ツールによりデフォルトでカーネル インターフェイスのサイズが 512 ビットに変更されます。詳細は、Vitis HLS フローポート幅の自動変更を参照してください。

ヒント: Vitis HLS の合成サマリ レポートには、ポートの拡張に関する情報が含まれます。ただし、このレポートを確認するには、ツールを起動する必要があります。

ポート幅の自動変更機能を使用する際は、次の利点と問題点を考慮する必要があります。

  • ツールがデータ型のサイズではなく大きなベクターを読み出すので、メモリからの読み出しレイテンシが向上します。
  • 大きなベクターをバッファリングして、データをデータパス サイズにシフトする必要があるので、リソースが増加します。
  • 自動ポート幅変更では標準 C データ型のみがサポートされ、ap_intap_uint、構造体、配列などの集合体でないデータ型はサポートされません。
ヒント: 自動ポート幅変更をディスエーブルにし、手動でカーネル ポートのサイズを指定することもできます。

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

カーネルからグローバル メモリ バンク インターフェイスへのアクセスには、長いレイテンシがありますので、グローバル メモリ転送はバーストで実行する必要があります。バースト転送の詳細は、Vitis HLS フローバースト転送の最適化 を参照してください。

ヒント: Vitis HLS の 合成サマリ レポートには、カーネルのバースト転送に関する詳細情報が含まれます。ただし、このレポートを確認するには、ツールを起動する必要があります。

バーストを推論するには、次のパイプライン ループを使用したコーディング スタイルをお勧めします。

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
}

スカラー入力

スカラー入力は通常、ホスト マシンから直接読み込まれる制御変数です。これらは、メイン カーネルの計算が実行されるプログラム データまたはパラメーターと考えることができます。これらのカーネル入力は、ホスト側からの書き込みのみです。次の関数には、width および height のスカラー パラメーターがあります。

void process_image(int *input, int *output, int width, int height) {

スカラー引数には、ツールで推論されたデフォルトの INTERFACE プラグマが割り当てられます。

#pragma HLS INTERFACE s_axilite port=width bundle=control
#pragma HLS INTERFACE s_axilite port=height bundle=control

この例には、画像の幅 (width) と高さ (height) を指定する 2 つのスカラー入力があります。これらのデータ入力は、グローバル メモリ バンクを介さずに、ホスト マシンから直接カーネルに送信されます。このプラグマは、ツールによりコードに追加されます。

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

ストリーミング インターフェイス

データのアクセスが順次である場合は、ストリーミング インターフェイスを使用できます。このインターフェイスでは、グローバル メモリを介さずに、ホストからカーネル、カーネルからホストへのデータの直接ストリーミングが可能です。ストリーミング インターフェイスは、1 つのカーネルがデータをストリーミングするプロデューサーで、もう 1 つのカーネルがコンシューマーである場合にも、これらのカーネル間で使用できます。この転送も、グローバル メモリを使用せずに直接実行されます。詳細は、ストリーミング データ転送 を参照してください。

ループ

ループは、高パフォーマンスのアクセラレータには重要です。ループは通常、高度に分散された並列 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 コア開発キットでデバッグしやすくなります。ラベルが原因でコンパイル中に警告メッセージが表示されますが、無視しても問題ありません。
ループをパイプライン処理すると、後続の反復はパイプラインで実行されます。つまり、ループの後続の反復は重複させて、ループ本体の別のセクションで同時に実行されます。ループのパイプライン処理は、pragma 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 より大きな値になることがあります。

ループ展開

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

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

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

部分展開されたループ

ループを完全に展開するには、ループの範囲が定数である必要があります (上記の例では 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 コンパイラを使用してさまざまなタイプの依存性を処理するには、コンパイラの結果を使用する高位合成について詳細に理解しておく必要があります。詳細は、『Vitis 高位合成ユーザー ガイド』 (UG1399) の「Vitis HLS での依存」を参照してください。

入れ子のループ

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

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 の順に実行されて終了します。pragma HLS dataflow をイネーブルにすると、データが使用可能になったらすぐに各関数が実行されるようスケジューリングできます。この例の場合、元の top 関数のレイテンシと間隔は 8 クロック サイクルです。データフロー最適化を使用すると、間隔は 3 クロック サイクルに削減されます。

2: データフロー最適化

データフローのコード例

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

  1. コンパイラでデータフロー最適化をイネーブルにするため、pragma HLS dataflow が適用されています。これは、PS と PL 間のインターフェイスであるデータ ムーバーではなく、データがアクセラレータを介してフローする方法を指定を示します。
  2. データフロー領域内の各関数間のデータ転送チャネルとして stream クラスが使用されています。
    ヒント: stream クラスは、プログラマブルの FIFO メモリ回路を推論します。このメモリ回路はソフトウェア プログラミングのキューとして動作し、関数間をデータ レベルで同期化してパフォーマンスを向上します。
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 つなので、ハードウェアにインプリメントされたときに配列のすべての要素に並列にアクセスできず、アプリケーションのパフォーマンスが制限されることがあります。

パフォーマンス要件によっては、配列の一部またはすべての要素に同じクロック サイクルでアクセスすることが必要な場合があります。これには、pragma 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. 関数 (特にパイプライン処理された領域内) をインライン展開することを考慮します。データフロー領域内の関数はインライン展開しないでください。