アプリケーションのプログラム

SDSoC™ 環境のエンベデッド プロセッサ用にアプリケーションを作成する方法は、その他の SoC またはエンベデッド プラットフォームのアプリケーションを作成する方法と類似していますが、SDK からエンベデッド プロセッサ アプリケーションをアクセラレーションする際には、さらに注意する点があります。SDSoC 環境のプログラミングには、次のタスクが含まれます。
  • プログラマブル ロジック (PL) 領域のプロセッサ アプリケーションでアクセラレーションに適した関数を見つけます。
  • エンベデッド プロセッサ アプリケーション コードとプロセッサ システム (PS) で実行されるソフトウェア関数用、およびデバイスの PL 領域で実行されるアクセラレーションされた関数用にメモリを割り当てます。
  • 同時に実行される複数のアクセラレータを使用してタスクレベルの並列処理を有効にし、システム パフォーマンスを最適化します。
  • ソフトウェアからハードウェアへの関数コード変換を検証して、問題ないかどうかを確認します。

アクセラレータにすべき関数、またはハードウェア関数になる関数を見つけて、どのような計算負荷が必要なのかを決めます。たとえば、大量のデータが計算されたり変更される関数は、ハードウェア関数の候補になります。ただし、典型的なプロセッサ アプリケーション用に記述された関数は SDSoC 環境でハードウェア アクセラレーションをする利点がない可能性もあり、アクセラレーションでパフォーマンスが実際に改善するように再構築する必要があることもあります。

コンパイラで定義されるので、標準的なプロセッサ アプリケーションにメモリを割り当てることはあまりありませんが、ハードウェア アクセラレーション用にプログラムする際は、手動でコードでメモリ割り当てを定義する必要があります。ハードウェア関数に物理的に隣接するメモリがないと、ハードウェアのパフォーマンス要件を満たすことができません。

また、アクセラレーション用に複数のソフトウェア関数を定義した場合は、これらのアクセラレータのスケジューリングを管理して、同時に実行するのか、順次に実行するのかを指定することもできます。このプロセスでは、ハードウェア関数とプロセッサ アプリケーション間のデータフローを理解して管理することが重要です。

ソフトウェア関数をハードウェア関数に変換する際は、ハードウェア関数のアルゴリズム結果を早期に頻繁にチェックする必要があります。ハードウェア関数で返されたデータと元のソフトウェア関数で返された予測結果とを比較検証します。コードをハードウェア関数に再構築すると、パフォーマンスは向上することはありますが、同等の結果をチェックしておく必要があります。

残りのアプリケーション関数に関しては、そのアプリケーションが Linux、FreeRTOS、またはスタンドアロンのどれで実行されるかを指定することが重要です。どれを使用しても良い点と悪い点があります。たとえば、スタンドアロンの場合、アプリケーション ホストを実行するのは Arm® プロセッサだけなので、最も簡単に使用できますが、Linux や FreeRTOS 使用の機能は使用できません。

メモリ割り当て

どのデータがアクセラレータで処理されるのか把握しておくと、使用されるメモリを割り当てるアプリケーション コードが書きやすくなります。通常、main 関数で malloc/free を使用してメモリを割り当てる方法が、全体的なランタイムが改善される可能性があるので推奨されます。これは、アクセラレータ専用の関数以外、どこででも使用できます。ただし、sds_alloc/sds_free を使用してアクセラレータ専用のメモリを割り当てた方が、データが物理的に隣接したメモリに割り当てられて格納されるので、メモリへの読み出しおよび書き込みが速くなり、パフォーマンスを向上できます。通常コンパイラは、割り当てられたデータが物理的に連続していないと安全に推論できない場合は、スキャッター ギャザー方式を使用します。これは、ローカル変数およびバッファーが malloc を使用する場合に発生することがあります。ただし、スキャッター ギャザー データ ムーバーは、スキャッター ギャザー転送に関するソフトウェア オーバーヘッドを最小限にするためにかなり最適化されていますので、ザイリンクスではハードウェア関数に送信されるデータ用にメモリを割り当てるのに sds_alloc を使用することをお勧めしています。

使用されるメモリのタイプは、隣接/非隣接、およびキャッシュ可能/不可能に分けられます。隣接メモリの場合、配列にはそれぞれ物理的に隣接して割り当てられた配列のエレメントすべてが含まれるので、アクセス (順次読み出し/書き込み) 時間が速くなります。キャッシュ不可能なメモリの場合、転送されるデータが PS で使用されるようにはなっていないので、トランザクション速度が速くなります。キャッシュされたメモリ割り当てを使用する場合、キャッシュを一掃するためのパフォーマンス ヒットのほか、CPU アクセス レイテンシがあります。

データは、ハードウェア関数を呼び出す前に割り当てる必要があります。ランタイムは、メモリの割り当て方法を考慮してデータ ムーバーを設定します。たとえば、1024 エレメント (32 x 32) を含む行列乗算の場合、main 関数にハードウェア関数用のメモリを明示的に割り当てる必要があります。次のコード例では、コンパイラは物理的に隣接してキャッシュ可能な方法でヒープにメモリを割り当てます。
int MatA[1024] = (int*)sds_alloc(1024*sizeof(int));
int MatB[1024] = (int*)sds_alloc(1024*sizeof(int));
int MatC[1024] = (int*)sds_alloc(1024*sizeof(int));

メモリをヒープに割り当てると、さらに多くのデータが処理できるようになり、パフォーマンスが向上します。このコードの実行が終了したら、sds_free を使用してメモリを解放できます。

メモリ割り当ての例は、ザイリンクス GitHub リポジトリの「SDSoC 例」を参照してください。次のコードは、<install_dir>/SDx/<version>/samples フォルダーの mmultadd 例のものです。このコードは、main 関数でメモリを割り当て、それが問題なく割り当てられているかどうかのクイック チェックを実行し、問題があれば割り当てられたメモリを解放します。
int main(int argc, char* argv[]){
     int test_passed = 0;
     float *A, *B, *C, *D, *D_sw;

     A = (float *)sds_alloc(N * N * sizeof(float));
     B = (float *)sds_alloc(N * N * sizeof(float));
     C = (float *)sds_alloc(N * N * sizeof(float));
     D = (float *)sds_alloc(N * N * sizeof(float));
     D_sw = (float *)malloc(N * N * sizeof(float));
     
     if (!A || !B || !C || !D || !D_sw) {
          if (A) sds_free(A);
          if (B) sds_free(B);
          if (C) sds_free(C);
          if (D) sds_free(D);
          if (D_sw) free(D_sw);
          return 2;
     }
...
}

上記の例では、物理的に隣接するメモリが割り当てられるように、sds_alloc 関数を使用してハードウェア関数で使用される変数が割り当てられ、ソフトウェアのみの変数 (D_sw) が malloc を使用して割り当てられます。

main() 関数の終わりに、sds_free または free を使用し、割り当てられたメモリすべてが解放されます。
sds_free(A);
sds_free(B);
sds_free(C);
sds_free(D);
free(D_sw);

sds_alloc 関数、およびメモリ割り当て/メモリ割り当て解除用のその他の SDSoC 特有の関数は、sds_lib.h に含まれます。これらの API の詳細は、SDSoC 環境の APIを参照してください。

アクセラレータの順次/並列実行

アクセラレータに必要なメモリ割り当てを定義したら、アプリケーション コードからアクセラレータを呼び出す方法を決定します。アクセラレータを main アプリケーションのコンテキストで動作させるには、複数の方法があります。たとえば、アクセラレータが 1 つしかないアプリケーションでは、ハードウェア関数をほかの関数と同様に呼び出せば、必要な順次データフローの結果が得られますが、複数のアクセラレータがある場合は、アクセラレータ間でデータが共有されるかどうか、どのように共有されるかによって、次の 2 つのデータフローのいずれかを選択します。

順次 (同期)
アクセラレータが順次に動作します。この場合、1 つのアクセラレータの実行後に次のアクセラレータが実行されるので、ハードウェア インプリメンテーションでアクセラレーションを使用する利点があります。
並列 (非同期)
両方のアクセラレータが同時に動作するので、アプリケーションのタスクレベルの並列化が実行されて、パフォーマンスをかなり改善できます。

ここで説明するプラグマの詳細は、 を参照してください。

非同期データフローをインプリメントするには、エンベデッド プロセッサ アプリケーションで #pragma SDS async(id) および #pragma SDS wait(id) を指定する必要があります。これらのプラグマは、次の例に示すように、アプリケーション コードのハードウェア関数呼び出しの前後に含める必要があります。
#pragma SDS async(1)
mmult(A, B, C);
#pragma SDS async(2)
madd(D, E, F);

// Do other SW functions

#pragma SDS wait(1)
#pragma SDS wait(2)
ヒント: async/wait を使用すると、ハードウェア関数の実行中にアプリケーションがその他の動作を実行できるようになり、適切なポイントでハードウェア関数が戻るまでアプリケーションを待機状態にできます。

前述のコード例は、典型的な非同期手法を示しています。この場合、それぞれ該当する関数に ID が割り振られます (mmult の場合は id = 1、madd の場合は id = 2)。mmult 関数に入力値 A と B が読み込まれ、処理されます。この場合、アクセラレータはデータ依存 (データはアクセラレータ間で共有されない) なので、非同期実行を使用する利点があります。アクセラレータのデータが CPU または別のアクセラレータのいずれかのほかの関数には必要ない場合、キャッシュできない物理的に隣接したデータの非同期実行により、最適なパフォーマンスを達成できます。

重要: 1 つのアクセラレータからのデータが 2 つ目のアクセラレータで必要とされる場合は、async/wait は使用しないでください。1 つのアクセラレータが別のアクセラレータの開始前に同期する必要がある場合、async プラグマによりコンパイラ ドリブンの同期ができないために、正しい結果にならないことがあります。

直接接続の例は、ザイリンクス GitHub リポジトリの「SDSoC 例」を参照してください。parallel_accel コードには、行列加算と行列乗算の 2 つの単純なハードウェア関数の例が含まれ、システム並列処理と同時処理でパフォーマンスを向上するのに役立つ async および wait を使用方法を示しています。

parallel_accel の例には、2 つのアクセラレータの順次データフロー形式と、2 つのアクセラレータの並列データフロー形式の両方が示され、含まれている sds_utils.h からのパフォーマンス モニター関数 (seq_hw_ctrpar_hw_ctr) を使用してパフォーマンスの違いを測定しています。次に、関連するコードを示します。
//Two hw functions are called back to back. First the 
//vadd_accel is executed, then vmul_accel is executed.
//The execution of both accelerators is sequential here.
//To prevent automatic dataflow between calls to the two
//hw functions, async and wait pragma is used here so as
//to ensure that the two hw functions will be running sequentially.
seq_hw_ctr.start();
// Launch Hardware Solution
for(int itr = 0; itr < MAX_NUM_TIMES; itr++)
{
  #pragma SDS async(1)        
  vadd_accel(source_in1, source_in2, source_vadd_hw_results, size);
  #pragma SDS wait(1)
  #pragma SDS async(2)                        
  vmul_accel(source_in1, source_in2, source_vmul_hw_results, size);
  #pragma SDS wait(2)
}
seq_hw_ctr.stop();

//Two hw functions are called back to back.
//The program running on the hardware first transfers in1 and in2 
//to the vadd_accel hardware and returns immediately. Then the program 
//transfers in1  and in2 to the vmul_accel hardware and returns
//immediately. When the program later executes to the point of 
//#pragma SDS wait(id), it waits for the particular output to be ready.
par_hw_ctr.start();
// Launch Hardware Solution
#pragma SDS async(1)
vadd_accel(source_in1, source_in2, source_vadd_hw_results, size);
#pragma SDS async(2)
vmul_accel(source_in1, source_in2, source_vmul_hw_results, size);
for(int itr = 0; itr < MAX_NUM_TIMES; itr++)
{
  #pragma SDS wait(1)
  #pragma SDS async(1)
  vadd_accel(source_in1, source_in2, source_vadd_hw_results, size);
  #pragma SDS wait(2)
  #pragma SDS async(2)
  vmul_accel(source_in1, source_in2, source_vmul_hw_results, size);
}
#pragma SDS wait(1)
#pragma SDS wait(2)
par_hw_ctr.stop(); 

順次データフローの例では、async および wait プラグマを使用して 2 つのハードウェア関数が順次に実行されるようになっています。乗算関数 vmul_accel を呼び出す前に wait プラグマを使用することで、行列乗算が始まる前に加算関数 vadd_accel が終了するようにしています。また、async(2) および wait(2) プラグマを使用して、アプリケーションが vmul_accel ハードウェア関数の終了を待つようにしています。

ヒント: 前述の例の場合、コンパイラがこれらの関数を記述した方法で自動的に同期するので、async/wait プラグマは実際には必要ありません。

並列データフローの例では、vadd_accel および vmul_accel 関数は 1 つのハードウェア関数が終了して次を呼び出すのを待たず、順次に開始されます。これにより、2 つのハードウェア関数がほぼ並列で実行されるようになります。これらの関数呼び出しには、async(1) および async(2) というラベルが付きます。この後、for ループが呼び出されて MAX_NUM_TIMES で指定した回数分関数が繰り返されますが、wait(1) および wait(2) が使用され、関数を再び呼び出す前に前の実行が終了するのを待ちます。

並列コードと同様に、関数呼び出しを明示的に同期して、関数を終了するためにアプリケーションにデータが提供されるようにしておく必要があります。これを正しくプログラムしないと、デッドロック状態が発生したり、動作が不定になることがあります。ただし、同時処理アクセラレータを実行するインスタンスの中には、ほかの方法と比べて最適なパフォーマンスになるとは限らないものもあります。たとえば、互いにデータ依存である同時処理アクセラレータをパイプライン処理する場合などです。これには、アクセラレータがデータを処理し始める前にパイプライン段階で同期されるようにする必要があります。

ソフトウェアからハードウェアへの変換の検証

SDSoC 環境のアクセラレータはソフトウェア プラットフォームのその他の関数と同じようにテストできます。通常はテストベンチを記述してアプリケーション コードを実行して検証するか、問題のなかったデータセットを使用してこのテストを main 関数からの関数呼び出しとしてインプリメントし、出力を比較します。ソフトウェア関数の C/C++ コードをハードウェア関数の HDL コードに変換すると、ハードウェア関数の動作が変わってしまうことがあります。このため、変換したハードウェア コードと問題のなかったことがわかっているソフトウェア コード間で検証テストを実行して、アルゴリズムがビルド プロセス全体で維持されているかどうかを確認することをお勧めします。

ヒント: 複数のアクセラレータを含むアプリケーションの場合は、すべてのアクセラレータを一緒にテストするよりも、各アクセラレータを個別にテストするボトムアップ テスト方法を使用することをお勧めします。これにより、デバッグ時間を短縮できます。詳細は、SDSoC 環境デバッグ ガイド を参照してください。
検証コードの例は、ザイリンクス GitHub リポジトリの「SDSoC 例」を参照してください。次のコードは、<install_dir>/SDx/<version>/samples フォルダーの mmultadd 例のものです。main.cpp ファイルでは、行列加算 (madd_golden) および乗算 (mmult_golden) 用に問題のなかったデータ (ゴールデン データ) を計算する方法を定義します。次に mmult_golden のコードを示します。
void mmult_golden(float *A,  float *B, float *C)
{
     for (int row = 0; row < N; row++) {
          for (int col = 0; col < N; col++) {
               float result = 0.0;
               for (int k = 0; k < N; k++) {
                    result += A[row*N+k] * B[k*N+col];
               }
               C[row*N+col] = result;
          }
     }
}

この関数は基本的にハードウェア関数 mmult と同じで、デバイスの PL 領域で行列乗算をアクセラレーションしつつ、配列の分割およびパイプライン処理などの手法をいくつか追加して最適なパフォーマンスを達成します。mmult_golden は、後でアクセラレーションされた関数で返された結果に対して比較できるように、単に予測値をゴールデン データとして計算します。

最後に、mmult_test 関数内で検証プロセスが呼び出されて、ゴールデン データが生成され、アクセラレーションされたハードウェア関数で生成された結果と比較されます。次にコードの該当セクションを示します。
int mmult_test(float *A,  float *B, float *C, float *D, float *D_sw) 
{
    std::cout << "Testing " << NUM_TESTS << " iterations of " << N << "x" 
        << N << " floating point mmultadd..." << std::endl;

    perf_counter hw_ctr, sw_ctr;
     
    for (int i = 0; i < NUM_TESTS; i++) 
    {
        init_arrays(A, B, C, D, D_sw);

        float tmp[N*N], tmp1[N*N];
        sw_ctr.start();
        mmult_golden(A, B, tmp);
        madd_golden(tmp, C, D_sw);
        sw_ctr.stop();

        hw_ctr.start();
        mmult(A, B, tmp1);
        madd(tmp1, C, D);
        hw_ctr.stop();

        if (result_check(D, D_sw))
            return 1;
     }

     //Example performance measurement code removed
}

パフォーマンスの見積もり

ハードウェア関数に変換される可能性のある関数の実時間を知っておくことが必要な場合があります。関数の実行時間は、Arm プロセッサ のフリーランニング クロックに基づいてアクティビティを測定する専用の SDSoC API 呼び出しを使用すると正確に計測できます。API 関数には sds_clock_counter() および sds_clock_frequency() が含まれます。これらの関数は、関数の開始時と終了時を記録するために使用できます。関数 sds_clock_counter() はフリーランニング クロックの値を返し、関数 sds_clock_frequency() は速度を Arm プロセッサの秒ごとのティック数で返します。これらの関数の詳細は、SDSoC 環境の APIを参照してください。

注記: sds_clock_frequency() は高パフォーマンスのカウンターで、精度の高いイベント測定ができます。
パフォーマンス カウンター クラスは、ザイリンクス GitHub リポジトリの「SDSoC の例」sds_util.h に含まれます。perf_counter には、開始と停止クロック時と関数呼び出し数を取り込むメソッドが含まれます。
#include "sds_lib.h"

class perf_counter
{
public:
     uint64_t tot, cnt, calls;
     perf_counter() : tot(0), cnt(0), calls(0) {};
     inline void reset() { tot = cnt = calls = 0; }
     inline void start() { cnt = sds_clock_counter(); calls++; };
     inline void stop() { tot += (sds_clock_counter() - cnt); };
     inline uint64_t avg_cpu_cycles() { return ((tot+(calls>>1)) / calls); };
};

avg_cpu_cycles() メソッドを使用してもタスクを実行するのにかかった CPU サイクル カウントのと同等の平均サイクル数を取得できます。