ハードウェア関数のプログラム

SDSoC™ 環境でハードウェア アクセラレーションの関数をプログラムする方法は、標準的な C/C/C++ 関数を記述する方法と同様でシンプルです。ただし、sds++/sdscc (sds++ と呼ぶ) システム コンパイラを使用してハードウェア アクセラレーションによるパフォーマンス向上を最大限に活用するには、プログラマブル ロジックにインプリメントする関数を記述、または既存のコードを変更する際に注意すべき点がいくつかあります。
  • 関数インターフェイスの定義: 入力および出力のデータ型、およびデータ転送。
  • 関数のアクセスするメモリの種類: DDR とインターフェイスする DMA、または FIFO。
  • データのアクセス方法: 連続または非連続。
  • データの処理方法: ループ、配列。

ハードウェア関数を作成するには、まずアクセラレータに入力および出力されるデータを指定する必要があります。ハードウェア関数の入力および出力を把握しておくと、どのような並列処理を達成できるのかがわかります。ハードウェア/プログラマブル ロジックでアクセラレーションの関数を記述する際は、データ サイズを固定することが重要です。ランタイム中にハードウェア データ型は変更できません。

ライブラリとしてハードウェア関数をエクスポート

ハードウェア関数または関数のライブラリが記述されて、必要に応じて問最適化されたら、ライブラリとしてエクスポートして別のプロジェクトで再利用できます。ライブラリをエクスポートするには、すべての関数定義を正しくグループ分けし、関数をコンパイルする際に sds++/sdscc コマンドに -shared オプション (gcc では -fPIC と解釈される) を使用して共有ライブラリをビルドします。詳細は、GCC 用のライブラリのエクスポートを参照してください。

C 呼び出し可能な IP

アクセラレータは、RTL を使用して作成することもでき、Vivado® Design Suite を介して「C 呼び出し可能な IP」といわれる IP コアとして提供できます。この場合、アプリケーション コードで C 呼び出し可能な IP を使用するには、SDSoC アプリケーション プロジェクトに適切な Arm® プロセッサ用にコンパイルされたスタティック ライブラリ、通常はアーカイブ ファイル (.a)、およびヘッダー ファイル (.h/.hpp) をソース ファイルとして追加します。これらのファイルはアプリケーションのソース ディレクトリに追加するか、ライブラリ検索およびインクルード検索用に指定してから、正しく含めて main 関数にリンクする必要があります。これで、その他のハードウェア関数と同様、通常の C/C++ 関数として呼び出して、データ サイズが関数パラメーターに一致しているかどうかを確認できます。C 呼び出し可能な IP の作成と使用については、SDSoC 環境ユーザー ガイド を参照してください。

コーディング ガイドライン

このセクションでは、sds++ システム コンパイラを使用したアプリケーション プログラミングでの一般的なコーディング ガイドラインを示します。これらのガイドラインは、SDSoC 環境に含まれている GNU ツールチェーンを使用して Zynq®-7000 デバイス内の Arm CPU 用にクロス コンパイルされているアプリケーション コードから開始していることを前提としています。

一般的なハードウェア関数のガイドライン

  • ハードウェア関数は、マスター スレッドで制御することにより同時に実行できます。複数のマスター スレッドがサポートされています。
  • 最上位ハードウェア関数は、クラス メソッドではなくグローバル関数にする必要があり、オーバーロードさせることはできません。
  • ハードウェア関数には、例外処理のサポートはありません。
  • ハードウェア関数またはそのサブ関数内のグローバル変数がソフトウェアで実行中のほかの関数でも参照されている場合、そのグローバル変数を参照することはできません。
  • ハードウェア関数では、double、long、packed structs などを含め、最大 1024 ビットのスカラー型がサポートされています。
  • ハードウェア関数には、少なくとも 1 つの引数が含まれている必要があります。
  • ハードウェア関数への出力または入力スカラー引数は、複数回指定できますが、関数が終了する際には最後に記述された値のみが読み出されます。
  • #ifdef および #ifndef プリプロセッサ文を含むコードを保護するため、定義済みマクロを使用します。マクロ名の前後にはアンダースコアを 2 つずつ付けます。例はSDx コマンドおよびユーティリティ リファレンス ガイド の「SDSCC/SDS++ コンパイラのコマンド」を参照してください。
    • __SDSCC__ マクロは、sds++ を使用してソース ファイルをコンパイルするたびに定義されて -D オプションとしてサブツールに渡されます。また、コードが sds++ でコンパイルされるか GNU ホスト コンパイラなどの別のコンパイラでコンパイルされるかに基づいてコードを保護するために使用できます。
    • sds++Vivado HLS を使用したハードウェア アクセラレーション用にソース ファイルをコンパイルする場合は、__SDSVHLS__ マクロが定義され、-D オプションとして渡されるので、このマクロを使用して高位合成が実行されるかされないかに基づいてコードを保護できます。
    • Vivado HLS では、ホスト マシンに関係なく 32 ビットのライブラリが使用されます。さらに、ツールではクロス コンパイルがサポートされません。

Arm CPU のオブジェクト コードはすべて GNU ツールチェーンを使用して生成されますが、sds++ コンパイラは Clang/LLVM フレームワークに基づいてビルドされているので、通常 GNU コンパイラよりも C/C++ 言語違反に対する寛容性が低くなっています。この結果、sds++ を使用すると、アプリケーションに必要なライブラリの一部によりフロントエンド コンパイラ エラーが発生することがあります。この場合、ソース ファイルをコンパイルするのに sds++ ではなく GNU ツールチェーンを直接使用してください。これには、makefile に入力するか、コンパイラを arm-linux-gnueabihf-g++ に設定します。コンパイラを設定するには、[Project Explorer] タブでファイル (またはフォルダー) を右クリックして、[C/C++ Build > Settings] > [SDSCC/SDS++ Compiler] をクリックします。詳細は、SDSoC 環境ユーザー ガイド の「Arm プロセッサでのアプリケーションのコンパイルと実行」を参照してください。

ハードウェア関数の引数型

sds++ では、C99 基本演算型の単一または配列、メンバーが単一または配列にフラット化される C99 基本演算型 struct または class (階層構造体をサポート)、メンバーが単一の C99 基本演算型にフラット化される struct の配列などになるような型のハードウェア関数の引数がサポートされます。スカラー引数は、1024 ビットのコンテナーに収まる必要があります。SDSoC 環境では、引数型と次のプラグマに基づいて、各ハードウェア インターフェイス タイプが自動的に推論されます。
#pragma SDS data copy|zero_copy
#pragma SDS data access_pattern

インターフェイスの互換性が損なわれないようにするため、Vivado HLS interface 指示子およびプラグマをソース コードに含めるのは、sds++ で適切なハードウェア インターフェイス指示子が生成されないときのみにしてください。

  • Vivado HLS では、任意精度型の ap_fixed<int>ap_int<int>、および hls::stream クラスが提供されています。SDSoC 環境 では、ap_fixed<int> 型を 7 より大きく 1025 より小さい幅 (7 < 幅 < 1025) として指定する必要があります。hls::stream データ型はハードウェア関数への関数引数としてはサポートされません。
  • デフォルトでは、ハードウェア関数への配列引数はデータをコピーすると転送されます。これは、#pragma SDS data copy を使用するのと同等です。このため、配列引数は入力として使用するか、出力として生成する必要があり、両方には使用しないようにします。ハードウェア関数で読み出しおよび書き込みされる配列の場合は、#pragma SDS data zero_copy を使用して、コンパイラにその配列は共有メモリ内に保持する必要があり、コピーされないように指示する必要があります。
  • ハードウェア/ソフトウェア インターフェイスでのアライメントを確実にするため、bool 配列のハードウェア関数の引数は使用しないでください。

ポインター

ハードウェア関数のポインター引数には、特別な注意が必要です。ハードウェア関数は物理アドレスで動作します。これは、通常ユーザー空間プログラムでは使用できないので、ポインターはハードウェア関数に渡されるデータ構造に埋め込むことはできません。

プラグマがない場合、ポインター引数は C/C++ ではポインターが 1 次元配列型を示す可能性もありますが、デフォルトではスカラー パラメーターとして処理されます。次に、使用可能なインターフェイス プラグマを示します。
  • DATA ZERO_COPY プラグマは、共有メモリを使用したポインター セマンティクスを提供します。
    #pragma SDS data zero_copy
  • DATA COPY および DATA ACCESS_PATTERN プラグマ ペアは、引数をストリームにマップします。配列要素は順次インデックス順にアクセスされる必要があります。
    #pragma SDS data copy(p[0:<p_size>]) 
    #pragma SDS data access_pattern(p:SEQUENTIAL)

    DATA COPY プラグマは、sds++ システム コンパイラがデータ転送サイズを決定できず、エラーが発生した場合にのみ必要です。

ヒント: ハードウェア関数の配列に対して非シーケンシャル アクセスが必要な場合は、ポインター引数を A[1024] などのように次元を明示的に宣言した配列に変更する必要があります。

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

アクセラレーションに必要な関数を定義したら、コンパイルを有効化にするための注意点がいくつかあります。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 データ型に変更するようにしてください。

ハードウェア関数呼び出しのガイドライン

  • SDSoC 環境で生成されたスタブ関数は、ハードウェア関数宣言内の対応する引数のコンパイル時に特定可能な配列範囲によって、正確なバイト数を転送します。ハードウェア関数で可変データ サイズを使用できる場合、次のプラグマを使用して、演算式で定義されたサイズのデータを転送するコードが生成されるように SDSoC 環境で指定できます。
    #pragma SDS data copy(arg[0:<C_size_expr>])
    #pragma SDS data zero_copy(arg[0:<C_size_expr>])

    <C_size_expr> は関数宣言のスコープでコンパイルする必要があります。

    zero_copy プラグマは、SDSoC 環境で引数を共有メモリにマップするよう指定します。

    重要: 意図するデータ転送サイズと実際のデータ転送サイズが異なると、システムがランタイム時に停止し、面倒なハードウェア デバッグでしか解決できない状況が発生することがあるので注意してください。詳細は、SDSoC 環境デバッグ ガイド を参照してください。
  • DMA で転送された配列をキャッシュ ラインの境界 (L1 および L2 キャッシュ) に揃えます。これらの配列を割り当てるには、malloc() ではなく、SDSoC 環境で提供されている sds_alloc() API を使用してください。
  • 配列をページ境界に揃え、スキャッター ギャザー DMA で転送されるページ数を最小限に抑えます (malloc で割り当てられる配列など)。
  • 次の場合は、sds_alloc を使用して配列を割り当てる必要があります。
    1. 配列に zero-copy プラグマを使用している。
    2. シンプル DMA を使用するようにシステム コンパイラに明示的に指示するプラグマを使用している。
    注記: sds_lib.h から sds_alloc() を使用するには、sds_lib.h を含める前に stdlib.h を含める必要があります。stdlib.h は、size_t タイプを提供するために含めます。

データ ムーバー

データ ムーバーは、ハードウェア アクセラレータにデータを入力および出力するために sds++ コンパイラで追加されます。通常、データ ムーバーは、プロセッサおよびプログラマブル ロジック間の FIFO またはダイレクト メモリ アクセス (DMA) です。データ ムーバーは、転送されるデータの量、転送されるメモリの特性、ハードウェア関数でデータを送受信するアクセラレータのアクセス パターン予測に基づいてコンパイラで推論されます。また、ハードウェア関数とのデータ転送をサポートするため、PL 領域にインプリメントされます。システム コンパイラは、データ ムーバー、自動化された制御信号、および割り込みを含め、1 つまたは複数のアクセラレータをプログラマブル領域にインプリメントします。

ハードウェア関数のソース コードで、データ ムーバーの動作に影響する SDS DATA COPY または DATA ZERO_COPY プラグマを指定します。DATA COPY プラグマでは、転送に適切なデータ ムーバーを使用してメモリとハードウェア関数間でデータが明示的にコピーされます。DATA ZERO_COPY を使用すると、ハードウェア関数が AXI4 マスター バス インターフェイスを介して共有メモリからデータに直接アクセスします。

データ サイズの範囲がわかっている場合は、データの転送方法を最適化できます。たとえば、大きな画像 (1920 x 1080) のようなデータにアクセスする場合、DDR に物理的に隣接させて保存すると効率的です。データを取り出す場合は、DDR に直接アクセスすることにより実行し、順次アクセスが必要かどうかはそのときに判断します。デフォルトでは sds++ コンパイラでデータ ムーバーが推論されますが、転送されるデータが大きすぎる場合は、使用するデータ ムーバーを指定する必要があります。データ モーション ネットワークの設定および最適化の詳細は、SDSoC プロファイリングおよび最適化ガイド を参照してください。たとえば、関数インターフェイスで大きなデータ幅 (64 ビット以上) が使用される場合、AXIDMA_SIMPLEFASTDMA を適用すると、帯域幅を広くして、スループットを速くできることがあります。

このようなタイプのデータ ムーバーを組み込むには、プラグマを使用して、アクセラレータをシステムの残りの部分にインターフェイスする方法をコンパイラに指示します。たとえば、次の SDS プラグマを使用すると、画像を DDR に保存して読み出すことができます。

#pragma SDS DATA COPY(out[0:size])
#pragma DATA ACCESS_PATTERN(data:SEQUENTIAL, out:SEQUENTIAL)
void accelerator(float *data, int *out, int size);

データがアクセラレータからどのようにアクセスされるかを指定するには、DATA ACCESS_PATTERN プラグマを使用します。このプラグマは、データがランタイムで決定される際に必要ですが、コンパイル時には未知の状態です。SEQUENTIAL データ アクセスの場合、SDSoC でストリーミング インターフェイスが作成され、RANDOM データ アクセス パターンにより RAM インターフェイスが作成されます (デフォルト)。SEQUENTIAL の場合は、配列からの要素に一度のみアクセスし、RANDOM の場合は、配列値にどの順序ででもアクセスできます。ただし、ランダム アクセス パターンを使用しても、アクセスされる量に関係なくデータすべてが転送されたままになる点に注意してください。

ヒント: アクセラレータがどのように記述されるかによって、sds++ コンパイラで自動的にデータがどのようにアクセスされるか決定されるので、ACCESS_PATTERN を使用する必要がないこともあります。コード例からは、アクセラレータのメモリ アクセスが簡単に決定されず、コンパイラがデータを適切に処理するようプラグマが必要であることがわかります。

SDS プラグマの DATA MEM_ATTRIBUTE をコンパイラへのヒントとして使用しても、そのメモリが物理的に隣接しているかどうか、キャッシュ可能かどうかを指定できます。

データがどのように割り当てられるかを理解しておくと、アクセラレータによって、システム パフォーマンスを調整しやすくなります。たとえば、コンパイラは物理的に隣接したメモリに対して AXI_DMA_SG よりも小さくて高速な simple DMA 転送を使用できます。物理的に隣接したメモリの場合、sds_alloc を使用する必要があります。物理的に隣接していないメモリの場合は malloc を使用します。DATA MEM_ATTRIBUTE を使用すると、コンパイラでどのデータ ムーバーを使用するかを指定できます。

SDS プラグマおよび例の詳細は、 を参照してください。

プラグマを C 呼び出し可能な IP に関連付けられたヘッダー ファイルで定義されるソフトウェア関数シグネチャに適用すると、データ ムーバーをパッケージされた C 呼び出し可能な IP の要素として作成することもできます。詳細は、SDSoC 環境ユーザー ガイド を参照してください。

関数本体

関数インターフェイスおよびデータ転送メカニズムを決定したら、あとは関数本体を記述するだけです。関数本体の記述はプロセッサの関数記述とはあまり変わりませんが、アクセラレータおよび全体的なアプリケーションのパフォーマンスを改善できる可能性がある点に注意してください。これは、ハードウェア関数の構造を検証して記述し直して、命令レベルまたはタスク レベルの並列処理を増加したり、ビット精度のデータ型を使用したり、ループ展開およびパイプライン処理、全体的なデータフローを管理することで達成できる可能性があります。

データ型

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

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

注記: ビット精度のデータ型は、最上位インターフェイスでなく、アクセラレータ関数内で使用する必要があります。

任意精度整数型

任意精度の整数データ型は、ヘッダー ファイル 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

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

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

『ザイリンクス デバイスでの INT8 に最適化した深層学習の実装』 (WP486) で説明するように、機械学習のようなアプリケーションに浮動小数点ではなく固定小数点演算を使用すると、消費電力効率が上がり、必要な消費電力合計を削減できます。浮動小数点型の範囲がすべて必要な場合を除き、固定小数点型で同じ精度をインプリメントでき、より小型で高速なハードウェアにできることがよくあります。この変換方法の例は、『浮動小数点から固定小数点への変換による消費電力およびコストの削減』 (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++ の任意精度固定小数点型」を参照してください。

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

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

重要: 次の配列コンフィギュレーションを関数パラメーターではなく、アクセラレータ内のローカル バッファー変数に使用してください。使用しないと、正しいランタイム ビヘイビアーになりません。
パフォーマンス要件によっては、配列の一部またはすべての要素に同じクロック サイクルでアクセスすることが必要な場合があります。これには、#pragma HLS ARRAY_PARTITION を使用して、コンパイラで配列の要素が分割され、より小さな配列や個別レジスタにマップされるようにします。コンパイラには、次の図に示すように、3 つの配列分割方法があります。3 つの分割方法は、次のとおりです。
  • block: 元の配列の連続した要素が同じサイズのに分割されます。
  • cyclic: 元の配列の要素がインターリーブされて同じサイズのブロックに分割されます。
  • complete: 配列が個別要素に分割されます。これは、メモリの個別レジスタへの分解に相当します。これは ARRAY_PARTITION プラグマの場合デフォルトです。

図: 配列の分割

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];
  ...   
}

図: 配列の次元の分割

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) で適用されています。

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

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

次のコード例では、配列へのアクセスにより最終インプリメンテーションのパフォーマンスが制限されます。この例では、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 回だけにしています。これにより、パフォーマンスを達成するためには、シングル ポート BRAM だけが必要となります。
#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;
}
注記: アルゴリズムによって、パイプライン パフォーマンスを改善するため、ローカル レジスタにキャッシュして配列アクセスを最小限に抑えることを考慮してください。

配列のコンフィギュレーションに関する詳細は、『Vivado Design Suite ユーザー ガイド: 高位合成』 (UG902: 英語版日本語版) の「配列」セクションを参照してください。

ループ

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

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

(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 回の反復すべてが並列実行されます。

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

部分展開されたループ

ループを完全に展開するには、ループの範囲が定数である必要があります (上記の例では 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<2;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;
}

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

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

入れ子のループ

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

HLS PIPELINE プラグマが別のループ内で入れ子になったループに適用されると、sds++ コンパイラはループを平坦化して 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 つの条件を満たすことが、sds++ コンパイラでループを平坦化し、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 ループは、内部ストレージからデータを読み出してメモリに書き込みます。

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

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

データフロー最適化

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

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

図: データフロー最適化

データフローのコード例

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

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

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

次のような場合、sds++ コンパイラで DATAFLOW 最適化が実行されないことがあります。

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

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