このセクションでは、CP 用に記述されたプログラムが FPGA ベースのアクセラレーション用に記述されたアプリケーションに進化する様子を示します。このセクションでは、詳細を設定せずにアプリケーションを構築する方法を主に紹介します。ここでは、新しい用語をいくつか紹介しますが、いくつかの定義については 用語 を参照してください。
次の図は、Vitis アプリケーション アクセラレーション環境の実行フローを示しています。アプリケーション プログラムは、CPU 上で実行されているアプリケーション (ホスト プログラムと呼ばれる) と、FPGA 上で実行されるハードウェア アクセラレーション カーネル間で分割され、それらの間には通信チャネルがあります。ホスト プログラム (C/C++ で記述して XRT API を使用) は、x86 ベースのホスト プロセッサで実行される実行ファイルにコンパイルされます。ハードウェア アクセラレーションされたカーネルは実行可能なデバイス バイナリ (.xclbin) にコンパイルされ、Alveo アクセラレータ カードの AMD デバイスのプログラマブル ロジック (PL) 領域内で実行されます。
ホスト プログラムとハードウェア アクセラレータの間のトランザクションを処理するには、XRT で制御される API 呼び出しが使用されます。制御およびデータ転送を含むホストとカーネル間の通信には、PCIe バスが使用されます。Vitis アプリケーションの実行モデルは、次の手順に分けられます。
- ホスト プログラムが、Alveo データセンター アクセラレータ カード上の PCIe インターフェイスを介して、カーネルで必要なデータを接続されたデバイスのグローバル メモリに書き込みます。
- ホスト プログラムが、入力パラメーターを使用してカーネルを設定します。
- ホスト プログラムが FPGA のカーネル関数の実行をトリガーします。
- カーネルが、必要に応じてグローバル メモリからのデータを読み出しながら、計算を実行します。
- カーネルがグローバル メモリにデータを書き込み、ホストにタスクが終了したことを通知します。
- ホスト プログラムがグローバル メモリからホスト メモリにデータを読み出し、必要に応じて処理を続けます。
次は、CPU 上での実行のために C++ で記述された簡単なプログラムです。このプログラムには、Alveo アクセラレータカード上でカーネルとしてアクセラレーションされる compute()
関数が含まれます。
#include <vector>
#include <iostream>
#include <ap_int.h>
#include "hls_vector.h"
#define totalNumWords 512
unsigned char data_t;
int main(int, char**) {
// initialize input vector arrays on CPU
for (int i = 0; i < totalNumWords; i++) {
in[i] = i;
}
compute(data_t in[totalNumWords], data_t Out[totalNumWords]);
check_results();
}
void compute (data_t in[totalNumWords ], data_t Out[totalNumWords ]) {
data_t tmp1[totalNumWords], tmp2[totalNumWords];
A: for (int i = 0; i < totalNumWords ; ++i) {
tmp1[i] = in[i] * 3;
tmp2[i] = in[i] * 3;
}
B: for (int i = 0; i < totalNumWords ; ++i) {
tmp1[i] = tmp1[i] + 25;
}
C: for (int i = 0; i < totalNumWords ; ++i) {
tmp2[i] = tmp2[i] * 2;
}
D: for (int i = 0; i < totalNumWords ; ++i) {
out[i] = tmp1[i] + tmp2[i] * 2;
}
}
このプログラムは、メイン関数が計算関数を呼び出し、計算関数に送信するデータを設定し、計算関数が完了した後に結果をゴールデン結果でチェックするほかの C++ プログラムと非常によく似ています。このプログラムは、CPU で順次に実行されます。このプログラムは、FPGA で順次に実行することも可能で、CPU と比較すると、パフォーマンスが向上することはありませんが、正しい結果を取得できます。アプリケーションを FPGA でより高いパフォーマンスで実行するには、プログラムを再構築して、さまざまなレベルで並列処理を可能にする必要があります。次は、並列処理の例です。
- 計算関数が、すべてのデータがホストから計算関数に転送される前に開始できます
- 複数の計算関数をオーバーラップさせて実行できます。たとえば、for ループは、前の反復が完了する前に次の反復を開始できます。
- for ループ内の演算は複数のワードで同時に実行できるので、ワード単位で実行する必要はありません
アクセラレーションされたカーネルとして FPGA 上に存在する計算関数と、CPU 上で動作し、アクセラレーション カーネルと通信するホスト アプリケーションは再構築する必要があります。
カーネル コードの再構築
前の例の場合、FPGA ベースのアクセラレーション用に再構築する必要があるのは compute()
関数です。
compute()
関数では、Loop A が入力に 3 を乗算し、B と C という 2 つの個別のパスを作成します。Loop B および C は、演算を実行してデータを D に送ります。これは、次の図に示すように、複数のタスクを 1 つずつ実行し、これらのタスクがネットワークとして相互に接続される現実的なケースを単純に表したものです。
カーネル コードを再構築する際の重要なポイントは次のとおりです。
- タスクレベルの並列処理は、関数レベルでインプリメントされます。タスクレベルの並列処理をインプリメントするため、ループは別々の関数にプッシュされます。元の
compute()
関数は複数のサブ関数に分割されます。経験則として、順次関数は同時に実行できますが、順次ループは順次で実行されます。 - これらのタスク (またはサブ関数) は、FIFO チャネルとして機能する
hls::stream
を使用して互いに通信します。hls::stream
クラスは、関数間のストリーム動作をモデリングする C++ テンプレート クラスです。 - 命令レベルの並列処理は、16 個の 32 ビット ワードをメモリ (または 512 ビットのデータ) から読み出すとインプリメントされます。計算は、これらのすべてのワードで並列に実行できます。
hls::vector
クラスは、複数のサンプルで同時にベクター演算を実行する C++ テンプレート クラスです。 -
compute()
関数は、次の例に示すように、load-compute-store サブ関数に構築し直す必要があります。load 関数と store 関数はデータ アクセスをカプセル化し、さまざまな計算関数によって実行される計算を隔離します。 - また、順次コードを並列実行に変換できる
#pragma
で始まるコンパイラ指示子があります。
#include "diamond.h"
#define NUM_WORDS 16
extern "C" {
void diamond(vecOf16Words* vecIn, vecOf16Words* vecOut, int size)
{
hls::stream<vecOf16Words> c0, c1, c2, c3, c4, c5;
assert(size % 16 == 0);
#pragma HLS dataflow
load(vecIn, c0, size);
compute_A(c0, c1, c2, size);
compute_B(c1, c3, size);
compute_C(c2, c4, size);
compute_D(c3, c4,c5, size);
store(c5, vecOut, size);
}
}
void load(vecOf16Words *in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in[i]);
}
}
void compute_A(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out1, hls::stream<vecOf16Words >& out2, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
vecOf16Words t = in.read();
out1.write(t * 3);
out2.write(t * 3);
}
}
void compute_B(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in.read() + 25);
}
}
void compute_C(hls::stream<vecOf16Words >& in, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (data_t i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in.read() * 2);
}
}
void compute_D(hls::stream<vecOf16Words >& in1, hls::stream<vecOf16Words >& in2, hls::stream<vecOf16Words >& out, int size)
{
Loop0:
for (data_t i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out.write(in1.read() + in2.read());
}
}
void store(hls::stream<vecOf16Words >& in, vecOf16Words *out, int size)
{
Loop0:
for (int i = 0; i < size; i++)
{
#pragma HLS performance target_ti=32
#pragma HLS LOOP_TRIPCOUNT max=32
out[i] = in.read();
}
}
ホスト アプリケーションの再構築
元のプログラムのメイン関数は、データの設定、計算関数の呼び出し、結果の確認などを担当します。アクセラレーションされたアプリケーションの場合、ホスト コードは PCIe® バス経由でデバイス メモリに送受信されるデータの初期化を担当します。また、メイン関数は計算関数を呼び出す方法と同じように、カーネル関数の引数も設定します。ホスト プログラムとハードウェア アクセラレータの間のトランザクションを処理するには、XRT で制御される API 呼び出しが使用されます。
通常、ホスト アプリケーションの構造は次の 3 つの手順に分けることができます。
- 生成された .xclbin をプログラムにロードします。
- グローバル メモリでバッファーを割り当てます。
- 入力テスト データを作成し、バッファーをホスト メモリにマップします。
- カーネルとカーネル引数を設定します。
- ホストとカーネル間でバッファーを転送します。
- カーネルを実行します。
- 出力結果をホストに戻し、出力バッファーに送信します。
前述の compute()
関数用に記述し直されたホスト アプリケーションが、XRT ネイティブ API を使用して Alveo アクセラレータ カード上で実行されます。
// XRT includes
#include "experimental/xrt_bo.h"
#include "experimental/xrt_device.h"
#include "experimental/xrt_kernel.h"
#include "types.h"
int main(int argc, char** argv) {
unsigned int device_index = 0;
auto uuid = device.load_xclbin("diamond.hw.xclbin");
size_t vector_size_bytes = sizeof(int) * totalNumWords;
auto krnl = xrt::kernel(device, uuid, "diamond");
std::cout << "Allocate Buffer in Global Memory\n";
auto bufIn = xrt::bo(device, vector_size_bytes, krnl.group_id(0));
auto bufOut = xrt::bo(device, vector_size_bytes, krnl.group_id(1));
// Map the contents of the buffer object into host memory
auto bufIn_map = bufIn.map<int*>();
auto bufOut_map = bufOut.map<int*>();
std::fill(bufIn_map, bufIn_map + totalNumWords, 0);
std::fill(bufOut_map, bufOut_map + totalNumWords, 0);
// Create the input data
for (int i = 0; i < totalNumWords; i++)
bufIn_map[i] = (uint32_t)i;
// Create the output golden data
int bufReference[totalNumWords];
for (int i = 0; i < totalNumWords; ++i) {
bufReference[i] = ((i*3)+25)+((i*3)*2);
}
// Synchronize buffer content with device side
bufIn.sync(XCL_BO_SYNC_BO_TO_DEVICE);
std::cout << "Execution of the kernel\n";
auto run = krnl(bufIn,bufOut,totalNumWords/16);
run.wait();
// Get the output;
std::cout << "Get the output data from the device" << std::endl;
bufOut.sync(XCL_BO_SYNC_BO_FROM_DEVICE);
for (int i = 0; i < totalNumWords; i++)
{
std::cout << "Referece " << bufReference[i] << std::endl;
std::cout << "Out " << bufOut_map[i] << std::endl;
}
// Validate our results
if (std::memcmp(bufOut_map, bufReference, totalNumWords))
throw std::runtime_error("Value read back does not match reference");
std::cout << "TEST PASSED\n";
アプリケーション実行のタイムライン
Alveo アクセラレータ カードで実行すると、アプリケーション タイムラインは次のようになります。
上の図では、並列処理の種類がいくつかあるため、FPGA 上でのアプリケーションの実行は CPU 上での実行とは大きく異なります。カーネル コードは、各ループごとにサブ関数を作成することで、タスクレベルの並列処理を利用するように記述されています。その結果、compute_A
、compute_B
、compute_C
、および compute_D
がオーバーラップして実行されます。実際、compute_A
、compute_B
、compute_C
、および compute_D
は、計算関数内のサブ関数呼び出しです。同様の実行オーバーラップは、複数のカーネルに対しても達成できます。
ハードウェア デバイスおよびそのカーネルは並列処理できるようになっていますが、この利点を活かすには、ソフトウェア アプリケーションを設計する必要があります。タスクレベルの並列処理は、ホストからデバイスへのデータ転送と計算関数の実行のオーバーラップによってさらに有効になります。