サンプル アプリケーション - 2023.2 日本語

Vitis 統合ソフトウェア プラットフォームの資料: アプリケーション アクセラレーション開発 (UG1393)

Document ID
UG1393
Release Date
2023-12-13
Version
2023.2 日本語

このセクションでは、CP 用に記述されたプログラムが FPGA ベースのアクセラレーション用に記述されたアプリケーションに進化する様子を示します。このセクションでは、詳細を設定せずにアプリケーションを構築する方法を主に紹介します。ここでは、新しい用語をいくつか紹介しますが、いくつかの定義については 用語 を参照してください。

次の図は、Vitis アプリケーション アクセラレーション環境の実行フローを示しています。アプリケーション プログラムは、CPU 上で実行されているアプリケーション (ホスト プログラムと呼ばれる) と、FPGA 上で実行されるハードウェア アクセラレーション カーネル間で分割され、それらの間には通信チャネルがあります。ホスト プログラム (C/C++ で記述して XRT API を使用) は、x86 ベースのホスト プロセッサで実行される実行ファイルにコンパイルされます。ハードウェア アクセラレーションされたカーネルは実行可能なデバイス バイナリ (.xclbin) にコンパイルされ、Alveo アクセラレータ カードの AMD デバイスのプログラマブル ロジック (PL) 領域内で実行されます。

図 1. CPU/FPGA のインタラクション

ホスト プログラムとハードウェア アクセラレータの間のトランザクションを処理するには、XRT で制御される API 呼び出しが使用されます。制御およびデータ転送を含むホストとカーネル間の通信には、PCIe バスが使用されます。Vitis アプリケーションの実行モデルは、次の手順に分けられます。

  1. ホスト プログラムが、Alveo データセンター アクセラレータ カード上の PCIe インターフェイスを介して、カーネルで必要なデータを接続されたデバイスのグローバル メモリに書き込みます。
  2. ホスト プログラムが、入力パラメーターを使用してカーネルを設定します。
  3. ホスト プログラムが FPGA のカーネル関数の実行をトリガーします。
  4. カーネルが、必要に応じてグローバル メモリからのデータを読み出しながら、計算を実行します。
  5. カーネルがグローバル メモリにデータを書き込み、ホストにタスクが終了したことを通知します。
  6. ホスト プログラムがグローバル メモリからホスト メモリにデータを読み出し、必要に応じて処理を続けます。

次は、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 つずつ実行し、これらのタスクがネットワークとして相互に接続される現実的なケースを単純に表したものです。

図 2. カーネル アーキテクチャ

カーネル コードを再構築する際の重要なポイントは次のとおりです。

  • タスクレベルの並列処理は、関数レベルでインプリメントされます。タスクレベルの並列処理をインプリメントするため、ループは別々の関数にプッシュされます。元の 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 つの手順に分けることができます。

  1. 生成された .xclbin をプログラムにロードします。
  2. グローバル メモリでバッファーを割り当てます。
  3. 入力テスト データを作成し、バッファーをホスト メモリにマップします。
  4. カーネルとカーネル引数を設定します。
  5. ホストとカーネル間でバッファーを転送します。
  6. カーネルを実行します。
  7. 出力結果をホストに戻し、出力バッファーに送信します。

前述の 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 アクセラレータ カードで実行すると、アプリケーション タイムラインは次のようになります。

図 3. アプリケーション タイムライン

上の図では、並列処理の種類がいくつかあるため、FPGA 上でのアプリケーションの実行は CPU 上での実行とは大きく異なります。カーネル コードは、各ループごとにサブ関数を作成することで、タスクレベルの並列処理を利用するように記述されています。その結果、compute_Acompute_Bcompute_C、および compute_D がオーバーラップして実行されます。実際、compute_Acompute_Bcompute_C、および compute_D は、計算関数内のサブ関数呼び出しです。同様の実行オーバーラップは、複数のカーネルに対しても達成できます。

ハードウェア デバイスおよびそのカーネルは並列処理できるようになっていますが、この利点を活かすには、ソフトウェア アプリケーションを設計する必要があります。タスクレベルの並列処理は、ホストからデバイスへのデータ転送と計算関数の実行のオーバーラップによってさらに有効になります。