Vitis コンパイラでは、大型の配列は PL 領域にあるブロック RAM メモリにマップされます。これらのブロック RAM のアクセス ポイント (またはポート) は最大 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 分割では、すべての配列エレメントが個別レジスタにマップされます。これにより、すべてのレジスタに同じサイクルで同時にアクセスできるので、カーネル パフォーマンスが改善します。
分割する次元の選択
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;
}
}
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;
}
#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;
}