メモリ アクセスの最適化

効率的なメモリ アクセスは、FPGA 上で実行されるハードウェア関数のパフォーマンスに重要です。ハードウェア関数に適用できる最適化の最初のカテゴリは、PS と PL 間のメモリ アクセスと転送レートを向上することです。

通常データは外部メモリに格納されますが、オフチップにアクセスするのには時間がかかかり、システム パフォーマンスが低下します。高パフォーマンス システムでメモリ アクセス時間を削減して実行時間を短縮するため、データをローカル キャッシュに格納できます。

FPGA には、これらのメモリに加え、データ ブロックを格納して効率的にアクセスするための小型から中型のローカル メモリがあります。FPGA を使用してパフォーマンスを向上するシステムのメモリ アーキテクチャは、最も頻繁なメモリ アクセスに最もローカルのメモリを使用する CPU+GPU または CPU+DSP システムのメモリ アーキテクチャに似ています。

次の手法を使用すると、ローカル メモリを多用してデータのアクセスおよび格納のレイテンシの影響を最小限に抑えることにより、ハードウェア関数のデザインを向上できます。

データ モーションの最適化
PS と PL コンポーネントの間のデータ転送を含みます。SDSoC™ 環境では、PL に選択された関数の引数に基づいてデフォルトのデータ モーション アーキテクチャがインプリメントされますが、データを連続したメモリに格納するために最適化指示子を使用できます。これにより、データ転送の効率が向上します。非常に大型のデータ セットをより効率的に転送するため、スキャッター ギャザー転送も使用できます。
データ アクセス パターン
FPGA では、データを同時に高速処理することが可能です。不適切なデータ アクセス パターンを使用すると、データフローが途切れ、FPGA が計算機能を発揮できません。適切なデータ アクセス パターンを使用すると、データの再読み出しを最小限に抑え、条件分岐を使用して 1 つのサンプルをさまざまな方法で処理できます。
オンチップ メモリ
オンチップ メモリ (プログラマブル ロジック RAM (PLRAM)) は、FPGA のブロック RAM を使用し、物理的に計算の近くに配置されます。1 サイクルの読み出しおよび書き込みが可能であり、メモリ アクセス パフォーマンスを大幅に向上します。最適なデータ アクセス パターンを使用した DDR からこれらのローカル メモリへのデータのコピーは、バースト トランザクションを使用することにより効率的に実行でき、パフォーマンスを向上できます。バースト トランザクションなどの最適なデータ アクセス パターンを使用してデータを DDR からこれらのローカル メモリに効率的にコピーすることにより、パフォーマンスを向上できます。

データ モーションの最適化

データ モーション ネットワークは、PS で実行されるプログラムを PL ファブリックにインプリメントされたハードウェア関数に接続するために使用されるハードウェアです。SDSoC 環境では、データ型に基づいてデータ モーション ネットワークが自動的に作成されますが、指示子を使用すると、速度およびハードウェア リソースの両方を目的としてインプリメンテーションを最適化できます。各データ型に対して作成されるデフォルトのデータ モーション ネットワークは、次のとおりです。

スカラー
スカラー データ型は、常に AXI_LITE データ ムーバーで転送されます。これは、インプリメントするのに必要なハードウェア リソースが非常に少ないメモリ マップド インターフェイスで、ハンドシェイク付きの 1 ワード読み込み/書き出し転送を実行します。このため、スカラー データ型は、起動ごとに 1 回しか転送されない関数引数に理想的です。システムを最適化する際、これらのデータ型をアドレス指定する必要はほとんどありません。
配列
配列には複数のデータ値が含まれ、高速 DMA 転送を使用すると、より効率的に転送されます。SDSoC 環境には、ハードウェアの効率とハードウェアをインプリメントするのに使用されるリソースを制御するためのオプションが多数あり、特定のデータ ムーバーを選択することもできます。
ポインター
プラグマを使用しない場合、C/C++ で 1 次元配列型が示されている場合でも、ポインター引数はデフォルトではスカラーとして処理されます。ポインターの書き込みまたは読み出しが複数回実行される場合は、プラグマを使用してデータが効率的に転送されるようにする必要があります。
構造体
1 つの struct はフラット化され、使用されるデータ ムーバーはスカラーか配列かによってデータ メンバーごとに異なります。struct の配列の場合、データ モーション ネットワークは前述の array (配列) と同じです。

SDSoC 環境のデフォルトの動作を理解しておくと、要件に基づいて最適なデータ モーション ネットワークを選択することが可能です。

メモリ割り当て

sds++/sdscc (sds++ と表記) コンパイラは、プログラムを解析し、ペイロード サイズ、アクセラレータのハードウェア インターフェイス、および関数引数のプロパティに基づいて、ソフトウェアとハードウェア間の各ハードウェア関数呼び出しの要件を満たすデータ ムーバーを選択します。コンパイラで配列引数を物理的に隣接したメモリに確実に配置できる場合、最も効率の高いデータ ムーバーを使用できます。次の sds_lib ライブラリ関数を使用して配列を割り当てまたはメモリ マップすると、メモリが物理的に隣接していることをコンパイラに通知できます。

// guarantees physically contiguous memory
sds_alloc(size_t size); 

// paddr must point to contiguous memory
sds_mmap(void *paddr, size_t size, void *vaddr); 

// assumes physically contiguous memory
sds_register_dmabuf(void *vaddr, int fd); 

プログラム構造が原因で sds++ コンパイラでメモリが隣接していることを推測できない場合は、次のような警告メッセージが表示されます。

WARNING: [DMAnalysis 83-4492] Unable to determine the memory attributes passed to foo_arg_A of function foo at foo.cpp:102

次のプラグマを関数宣言の直前に挿入すると、データが物理的に隣接するメモリに割り当てられていることをコンパイラに通知できます。

注記: このプラグマは、物理的に隣接したメモリに割り当てることを指定するものではありません。このようなメモリを割り当てるには、sds_alloc を使用する必要があります。
#pragma SDS data mem_attribute (A:PHYSICAL_CONTIGUOUS) // default is NON_PHYSICAL_CONTIGUOUS

データがどのように使用されるかを理解することは、使用するデータ ムーバーを決定するのに役立つので重要です。これは、連続割り当てが必要なものとそうでないものがあるからです。たとえば、データがランダムに使用される場合、スキャッター ギャザー DMA が使用され、次のタイプのオーバーヘッドが発生します。

  • ページ テーブルのウォーク
  • ページのピン留め
  • ディスクリプターの作成
  • アクセラレータが完了したときのクリーンアップ

コピーおよび共有メモリ セマンティクス

デフォルトでは、ハードウェア関数呼び出しには関数引数のコピー インおよびコピー アウト セマンティクスが関係します。ハードウェア関数引数の共有メモリ モデルを強制することもできますが、バースト転送のスループットが良い一方で、プログラマブル ロジックから外部 DDR へのレイテンシが CPU と比較して大幅に長くなることに注意する必要があります。変数転送で共有メモリ セマンティクスを使用することを宣言するには、次のプラグマを関数宣言の直前に挿入します。

#pragma SDS data zero_copy(A[0:<array_size>]) // array_size = number of elements

合成可能なハードウェア関数内では、共有メモリから 1 語を読み書き (ZERO_COPY プラグマを使用) するのは通常非効率です。パイプライン処理された for-loop を使用してメモリからデータをバーストで読み書きし、ローカル メモリに格納する方が効率的です。

copy および zero_copy メモリ セマンティクスを使用するのも、プログラマブル ロジックと外部 DDR の間でデータをストリーミングしてメモリ効率を最大化できるので効率的です。これにより、変数に対して非シーケンシャル アクセスおよび繰り返しアクセスを実行する必要がある場合に、ハードウェア関数内のローカル メモリにデータが格納されます。たとえば、ビデオ アプリケーションでは通常データがピクセル ストリームとして入力され、FPGA メモリにライン バッファーがインプリメントされてピクセル ストリーム データへの複数アクセスがサポートされます。

ハードウェア関数で配列データ転送にストリーミング アクセスが許容される (各エレメントがインデックス順に 1 回だけアクセスされる) ことを sds++/sdscc (sds++ と表記) コンパイラ コマンドに対して宣言するには、次のプラグマを関数プロトタイプの直前に挿入します。

#pragma SDS data access_pattern(A:SEQUENTIAL) // access pattern = SEQUENTIAL | RANDOM

ポインター型引数としてハードウェア関数に渡された配列では、コンパイラが転送サイズを推論できる場合もありますが、できない場合は次のようなメッセージが表示されます。

WARNING: [DMAnalysis 83-4439] Cannot determine data size for argument p of function foo

次を使用して転送するデータのサイズを指定します。

#pragma SDS data copy(p[0:<array_size>]) // for example, int *p

データ転送サイズは関数呼び出しごとに変更可能で、プラグマ定義で <array_size> を関数呼び出しのスコープ内で設定することにより (サイズ設定のすべての変数はその関数へのスカラー引数)、ハードウェア関数に不要なデータ転送を回避できます。

#pragma SDS data copy(A[0:L+2*T/3]) // scalar arguments L, T to same function

sds++ コンパイラは、特定の同期化コード (cf_wait() など) を含むスタブ関数を作成して関数呼び出しインターフェイスをインプリメントし、プログラムの動作を維持して一貫性を保ちます。

データ キャッシュ コヒーレンシ

sds++/sdscc (sds++ と表記) コンパイラでは、システムで必要なデータ ムーバーに対して自動的にソフトウェア コンフィギュレーション コードが生成されます。このコードには、必要に応じて下位デバイス ドライバーへのインターフェイスも含まれます。デフォルトでは、システム コンパイラで、CPU とハードウェア関数の間で転送される配列に割り当てられたメモリのキャッシュ コヒーレンシが保持されると想定されます。このため、ハードウェア関数にデータを転送する前にキャッシュをフラッシュし、ハードウェア関数からメモリにデータを転送する前にキャッシュを無効にするコードがコンパイラで生成される場合があります。どちらの処理も正確性を保つために必要ですが、パフォーマンスに影響します。Zynq® デバイスの HP ポートなどを使用する場合は、CPU がメモリにアクセスせず、アプリケーションの正確性がキャッシュ コヒーレンシに依存しないことが確実であれば、デフォルトを変更できます。不要なキャッシュのフラッシュのオーバーヘッドを回避するには、次の API を使用してメモリを割り当てます。

void *sds_alloc_non_cacheable(size_t size)

キャッシュ不可能なメモリの典型的なユース ケースとしては、一部のフレーム バッファーがプログラマブル ロジックからアクセスされるが CPU からはアクセスされないビデオ アプリケーションが挙げられます。

データ アクセス パターン

FPGA では大量の並列アーキテクチャによりプロセッサのシーケンシャル演算よりもかなり高速に演算を実行できるので、そのパフォーマンスを活用するため、C コードのインプリメントに FPGA が選択されます。

ここでは、C コードのアクセス パターンが結果にどう影響するかについて説明します。最も注意が必要なアクセス パターンはハードウェア関数への入力および出力のパターンですが、ハードウェア関数内にボトルネックがあると、関数への入力および出力の転送レートに悪影響を及ぼすので、関数内のアクセス パターンを考慮することをお勧めします。

このセクションでは画像のたたみ込みアルゴリズムについて説明し、データ アクセス パターンがパフォーマンスに悪影響を与え、適切なパターンを使用することにより FPGA の並列化およびパフォーマンス機能を活用できることを示します。

  • まずアルゴリズムを確認し、FPGA でのパフォーマンスの制限となるデータ アクセスについて説明します。
  • 次に、最高のパフォーマンスを達成するためのアルゴリズムの記述方法について説明します。

不適切なデータ アクセス パターンを使用したアルゴリズム

ここでは画像に適用される標準的なたたみ込み関数を使用して、FPGA で可能なパフォーマンスが C コードによりどのように劣化するかを説明します。この例では、データに対してまず水平たたみ込みが実行された後、垂直たたみ込みが実行されます。画像のエッジのデータはたたみ込み範囲外にあるので、最後に境界周辺のデータが処理されます。

アルゴリズムでの処理は、次の順で実行されます。

  • 水平たたみ込み。
  • 垂直たたみ込み。
  • 境界ピクセル処理。
static void convolution_orig(
  int width,
  int height,
  const T *src,
  T *dst,
  const T *hcoeff,
  const T *vcoeff) {
  T local[MAX_IMG_ROWS*MAX_IMG_COLS];

// Horizontal convolution
  HconvH:for(int row = 0; row < height; row++){
    HconvWfor(int col = border_width; col < width - border_width; col++){
      Hconv:for(int i = - border_width; i <= border_width; i++){
      ...
      }
    }
  }

// Vertical convolution
  VconvH:for(int row = border_width; row < height - border_width; row++){
    VconvW:for(int col = 0; col < width; col++){
      Vconv:for(int i = - border_width; i <= border_width; i++){
      }
    }
  }

// Border pixels
  Top_Border:for(int col = 0; col < border_width; col++){
  }
  Side_Border:for(int row = border_width; row < height - border_width; row++){
  }
  Bottom_Border:for(int = height - border_width; row < height; row++){
  }
}

標準水平たたみ込み

まず、次の図に示すように水平方向のたたみ込みを実行します。

図: 水平方向のたたみ込み

たたみ込みは、K 個のデータ サンプルと K 個のたたみ込み係数を使用して実行されます。上の図では K の値は 5 ですが、この値はコードで定義されます。たたみ込みを実行するには、K 個以上のデータ サンプルが必要です。たたみ込みウィンドウは、画像外にあるピクセルを含む必要があるため、最初のピクセルでは開始できません。

対称たたみ込みを実行すると、src 入力からの最初の K 個のデータ サンプルが水平係数でたたみ込まれ、最初の出力が計算されます。2 つ目の出力を計算するには、次の K 個のデータ サンプルが使用されます。この計算は、最後の出力が書き込まれるまで各行に対して実行されます。

この操作を実行する C コードは次のとおりです。

const int conv_size = K;

const int border_width = int(conv_size / 2);



#ifndef __SYNTHESIS__

T * const local = new T[MAX_IMG_ROWS*MAX_IMG_COLS];

#else // Static storage allocation for HLS, dynamic otherwise

T local[MAX_IMG_ROWS*MAX_IMG_COLS];

#endif



Clear_Local:for(int i = 0; i < height * width; i++){

  local[i]=0;

}



// Horizontal convolution

HconvH:for(int col = 0; col < height; col++){

  HconvWfor(int row = border_width; row < width - border_width; row++){

    int pixel = col * width + row;

    Hconv:for(int i = - border_width; i <= border_width; i++){

      local[pixel] += src[pixel + i] * hcoeff[i + border_width];

    }

  }

}

コードは簡単ですが、ハードウェアの結果の質に悪影響を及ぼす問題がいくつかあります。

1 つ目の問題は、C コンパイル中に大型ストレージが必要であるということです。アルゴリズムの中間結果は内部 local 配列に格納されます。HEIGHT*WIDTH の配列が必要で、1920*1080 の標準ビデオ画像では 2,073,600 の値が保持されます。

  • Zynq®-7000 SoC または Zynq® UltraScale+™ MPSoC デバイスをターゲットとするコンパイラおよび多くのホスト システムでは、この量のローカル ストレージのためランタイムでスタック オーバーフローが発生します (ターゲット デバイス上での実行、Vivado® HLS (高位合成) ツール内での協調シミュレーションの実行など)。local 配列のデータはスタックに配置され、OS で管理されるヒープには含まれません。arm-linux-gnueabihf-g++ でクロス コンパイルする際は、-Wl,"-z stacksize=4194304" リンカー オプションを使用して十分なスタック空間を割り当てます。
    注記: このオプションの構文は、リンカーによって異なります。
  • 関数がハードウェアでのみ実行される場合は、__SYNTHESIS__ マクロを使用するとこのような問題を回避できます。このマクロは、ハードウェア関数がハードウェアに合成されるときにシステム コンパイラにより自動的に定義されます。上記のコードでは、C シミュレーション中にダイナミック メモリ割り当てを使用してコンパイルの問題を回避しており、合成中はスタティック ストレージのみが使用されます。このマクロを使用する短所は、C シミュレーションで検証されたコードが合成されるコードとは異なるものになることです。この例の場合はコードは複雑ではないので、動作は同じになります。
  • この local 配列の主な問題は、FPGA インプリメンテーションの質です。これは配列なので、内部 FPGA ブロック RAM を使用してインプリメントされます。これは、FPGA 内にインプリメントするにはかなり大きいメモリであり、より大型でコストのかかる FPGA デバイスが必要となる可能性があります。DATAFLOW 最適化を使用して、小型で効率的な FIFO を介してデータをストリーミングすると、ブロック RAM の使用を最小限に抑えることはできますが、データがストリーミングでシーケンシャルに使用されるようにする必要があります。現在のところ、そのような要件はありません。

2 つ目の問題は、パフォーマンスと local 配列の初期化に関するものです。Clear_Local ループは local 配列の値を 0 に設定するために使用されます。高パフォーマンスで実行するためにこのループをハードウェアでパイプライン処理しても、この操作をインプリメントするのに約 2 百万クロック サイクル (HEIGHT*WIDTH) 必要です。このメモリの初期化中、システムで画像処理を実行することはできません。同じデータの初期化は、HConv ループ内の一時的な変数を使用して、書き出し前に累積を初期化することにより実行できます。

最後の問題は、データのスループット、つまりシステム パフォーマンスがデータ アクセス パターンにより制限されることです。

  • 最初のたたみ込み出力を作成するため、最初の K 個の値が入力から読み出されます。
  • 2 つ目の出力の計算には、新しい値が読み出され、その後同じ K-1 個の値が再度読み出されます。

高パフォーマンスの FPGA にするには、PS へのアクセスを最小限に抑えることが重要です。前に既にフェッチされたデータに再度アクセスすることは、システムのパフォーマンスに悪影響を与えます。FPGA では多数の計算を同時に実行して高パフォーマンスを達成することが可能ですが、データのフローが値を再読み出しするために頻繁に中断されると高パフォーマンスは得られません。

注記: 高パフォーマンスを達成するには、PS からのデータにアクセスするのは 1 回のみにし、小型のローカル ストレージ (小型から中型のサイズの配列) に格納して再利用する必要があります。

上記のコードではデータを何度も読み出す必要があるので、操作を使用してプロセッサから直接ストリーミングできません。

標準垂直たたみ込み

次の段階では、次の図に示す垂直たたみ込みを実行します。

図: 垂直たたみ込み

垂直たたみ込みのプロセスは、水平たたみ込みと似ています。たたみ込み係数 (この場合は Vcoeff) を使用したたたみ込みには、K 個のデータ サンプルが必要です。垂直方向の最初の K 個のサンプルを使用して最初の出力が作成された後、次の K 個の値を使用して 2 つ目の出力が作成されます。この処理は、最後の出力が作成されるまで各列に対して実行されます。

水平および垂直の境界効果により、垂直たたみ込み後の画像はソース画像 src よりも小さくなります。

これらを実行するコードは次のとおりです。

Clear_Dst:for(int i = 0; i < height * width; i++){
  dst[i]=0;
}
// Vertical convolution
VconvH:for(int col = border_width; col < height - border_width; col++){
  VconvW:for(int row = 0; row < width; row++){
    int pixel = col * width + row;
    Vconv:for(int i = - border_width; i <= border_width; i++){
      int offset = i * width;
      dst[pixel] += local[pixel + offset] * vcoeff[i + border_width];
    }
  }
}

このコードには、水平たたみ込みコードを使用して既に説明した問題と同様の問題があります。

  • 出力画像 dst の値を 0 に設定するのに、多数のクロック サイクルが費やされます。この場合、1920*1080 画像サイズに対してさらに約 2 百万サイクル必要です。
  • local 配列に格納されたデータを再度読み出すために、各ピクセルが複数回アクセスされます。
  • 出力配列/ポート dst に対しても、各ピクセルが複数回書き込まれます。

上記のコードのアクセス パターンでは、大型の local 配列が必要となります。アルゴリズムでは、最初の計算を実行するために行 K のデータが使用可能になっていることが必要です。次の列に進む前に各行のデータを処理するため、画像全体がローカルに格納されている必要があります。そのためすべての値が格納されるので、FPGA に大型のローカル ストレージが作成されます。

さらに、local 配列からデータがストリーミング出力されないので、コンパイラ指示子を使用してハードウェア関数のパフォーマンスを最適化する段階に達したときに、水平および垂直ループ間のデータのフローを FIFO (高パフォーマンスで低リソースのユニット) を使用して制御することができません。FIFO は、シーケンシャル アクセス パターンでのみ使用可能です。このコードでは任意/ランダム アクセスが必要なため、パフォーマンスを向上するにはピンポン ブロック RAM が必要です。ローカル配列のインプリメンテーションに必要なメモリが 2 倍の約 4 百万個のデータ サンプルになり、1 つの FPGA には大きすぎます。

標準境界ピクセルたたみ込み

たたみ込みの最後の段階では、境界周辺のデータを作成します。これらのピクセルは、たたみ込み出力の最も近いピクセルを再利用することにより作成されます。次の図に、これをどのように達成するかを示します。

図: 標準境界ピクセルたたみ込み

境界領域は、最も近い有効な値を使用して作成されます。前の図に示す操作は、次のコードで実行されます。

int border_width_offset = border_width * width;
int border_height_offset = (height - border_width - 1) * width;

// Border pixels

Top_Border:for(int col = 0; col < border_width; col++){
  int offset = col * width;
  for(int row = 0; row < border_width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_width_offset + border_width];
  }
  for(int row = border_width; row < width - border_width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_width_offset + row];
  }
  for(int row = width - border_width; row < width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_width_offset + width - border_width - 1];
  }
}

Side_Border:for(int col = border_width; col < height - border_width; col++){
  int offset = col * width;
  for(int row = 0; row < border_width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[offset + border_width];
  }
  for(int row = width - border_width; row < width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[offset + width - border_width - 1];
  }
}

Bottom_Border:for(int col = height - border_width; col < height; col++){
  int offset = col * width;
  for(int row = 0; row < border_width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_height_offset + border_width];
  }
  for(int row = border_width; row < width - border_width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_height_offset + row];
  }
  for(int row = width - border_width; row < width; row++){
    int pixel = offset + row;
    dst[pixel] = dst[border_height_offset + width - border_width - 1];
  }
}

このコードには、データに繰り返しアクセスするという同じ問題があります。FPGA 外の dst 配列に格納されたデータは、入力データとして複数回読み出されることが可能になっている必要があります。最初のループでも、dst[border_width_offset + border_width] が複数回読み出されますが、border_width_offset および border_width の値は変更されません。

このコードは、読み出しと書き込みのどちらも非常にわかりやすいものです。SDSoC 環境でインプリメントすると約 120M クロック サイクルであり、CPU のパフォーマンスよりも少し長くなります。ただし、次のセクションで示すように最適なデータ アクセス パターンを使用すると、同じアルゴリズムをクロック サイクルごとに 1 ピクセルのレート (約 2M クロック サイクル) で FPGA にインプリメントできます。

次のような不適切なデータ アクセス パターンを使用すると、パフォーマンスが低下し、FPGA インプリメンテーションのサイズが大きくなります。

  • データを繰り返し読み出すために複数回アクセスします。可能な場合は、ローカル ストレージを使用してください。
  • データに任意またはランダムにアクセスします。データをローカルに配列として格納する必要があり、リソースが多く必要になります。
  • 配列のデフォルト値を設定すると、クロック サイクル数が多くなり、パフォーマンスが低下します。

最適なデータ アクセス パターンを使用したアルゴリズム

前のセクションで説明したたたみ込みの例 (リソース使用量が最小の高パフォーマンス デザイン) をインプリメントするには、次を実行します。

  • システム中のデータフローを最大限にします。データフローが途切れてしまうようなコーディング手法やアルゴリズム処理は避けてください。
  • データの再利用を最大限にします。ローカル キャッシュを使用して、同じデータを何回も読み出す必要がないようにし、入力データのフローが途切れないようにします。
  • 条件分岐を使用します。これは CPU、GPU、または DSP ではコストがかかりますが、FPGA では最適な方法です。

最初の段階では、システムから FPGA、FPGA からシステムにデータがどのように流れるかを理解します。たたみ込みアルゴリズムは、画像に対して実行されます。画像からのデータは、次の図に示すような標準のラスター走査順序で転送されます。

図: ラスター走査



データが FPGA にストリーミング方式で転送された場合、FPGA ではそれをストリーミング方式で処理し、同じ方式で転送し戻す必要があります。

次に示すたたみ込みアルゴリズムでは、このコード形式を使用しています。この抽象レベルでは、コードが簡潔に示されますが、各ループ間に中間バッファー hconv および vconv があります。これらはストリーミング方式でアクセスされるので、最終的なインプリメンテーションでは 1 つのレジスタに最適化されます。

template<typename T, int K>
static void convolution_strm(
int width,
int height,
T src[TEST_IMG_ROWS][TEST_IMG_COLS],
T dst[TEST_IMG_ROWS][TEST_IMG_COLS],
const T *hcoeff,
const T *vcoeff)
{    

T hconv_buffer[MAX_IMG_COLS*MAX_IMG_ROWS];
T vconv_buffer[MAX_IMG_COLS*MAX_IMG_ROWS];
T *phconv, *pvconv;

// These assertions let HLS know the upper bounds of loops
assert(height < MAX_IMG_ROWS);
assert(width < MAX_IMG_COLS);
assert(vconv_xlim < MAX_IMG_COLS - (K - 1));
// Horizontal convolution
HConvH:for(int col = 0; col < height; col++) {
  HConvW:for(int row = 0; row < width; row++) {    
    HConv:for(int i = 0; i < K; i++) {
    }
  }
}
// Vertical convolution
VConvH:for(int col = 0; col < height; col++) {
  VConvW:for(int row = 0; row < vconv_xlim; row++) {
    VConv:for(int i = 0; i < K; i++) {
    }
  }
}
Border:for (int i = 0; i < height; i++) {
  for (int j = 0; j < width; j++) {
  }
}

これで 3 つの処理ループに条件分岐が含まれ、データが連続処理されるようになります。

最適な水平たたみ込み

前の K 個のサンプルを使用してたたみ込み結果を計算する必要があるので、サンプルが一時キャッシュ hwin にコピーされます。このようにローカル ストレージを使用すると、PS から値を読み込み直す必要はなく、データのフローは途切れません。最初の計算では、hwin に結果を計算するのに十分な値が含まれていないので、条件により出力値は書き出されません。FPGA インプリメンテーションで効率的な方法で計算を実行するため、水平たたみ込みが計算されます。

アルゴリズムは入力サンプルを読み込み続け、hwin キャッシュに格納します。新しいサンプルが読み込まれるたびに、不要なサンプルが hwin から排出されます。K 番目の入力が読み込まれると、最初の出力値を書き込むことができるようになります。このように、最後のサンプルが読み込まれるまで行ごとに処理されます。この段階で hwin に格納されているのは最後の K 個のサンプルだけであり、そのすべてがたたみ込み計算に必要となります。

次に示すように、この操作を実行するコードでは、ローカル ストレージを使用することにより PL からの再読み込みを回避して最終インプリメンテーションでローカル ストレージからの読み出しを並列に実行できるようにし、さらに条件分岐を多用して各新しいデータ サンプルを異なる方法で処理できるようにしています。

// Horizontal convolution 
phconv=hconv_buffer; // set / reset pointer to start of buffer

// These assertions let HLS know the upper bounds of loops
assert(height < MAX_IMG_ROWS);
assert(width < MAX_IMG_COLS);
assert(vconv_xlim < MAX_IMG_COLS - (K - 1));
HConvH:for(int col = 0; col < height; col++) {
  HConvW:for(int row = 0; row < width; row++) {
#pragma HLS PIPELINE
    T in_val = *src++;
    // Reset pixel value on-the-fly - eliminates an O(height*width) loop
    T out_val = 0;
      HConv:for(int i = 0; i < K; i++) {
        hwin[i] = i < K - 1 ? hwin[i + 1] : in_val;
        out_val += hwin[i] * hcoeff[i];
      }
      if (row >= K - 1) {
        *phconv++=out_val;
    }
  }
}

上記のコードでは、一時変数 out_val を使用してたたみ込み計算が実行されています。この変数は、計算の実行前に 0 に設定されるので、前の例で示したように、値をリセットするために 2 百万クロック サイクルを費やす必要はありません。

プロセス全体を通して、src 入力のサンプルはラスター ストリーミング方式で処理されます。すべてのサンプルが順番に読み込まれます。タスクからの出力は破棄または使用されますが、タスクは常に計算を実行し続けます。この点が、CPU で実行するために記述されたコードと異なります。

最適な垂直たたみ込み

垂直たたみ込みでは、FPGA 向けのストリーミング データ モデルを記述するのが困難です。データには列ごとにアクセスする必要がありますが、画像全体を格納するのは望ましくありません。ソリューションは、次の図に示すようにライン バッファーを使用することです。

図: ライン バッファー

先ほどと同様、サンプルはストリーミング方式で読み込まれますが、この場合はローカル バッファーの hconv から読み込まれます。このアルゴリズムでは、最初のサンプルを処理するのに少なくとも K-1 行のデータが必要です。この前に実行される計算はすべて、条件を使用することにより破棄します。

ライン バッファーには、K-1 行のデータを格納できます。新しいサンプルが読み込まれるたびに、別のサンプルがライン バッファーから排出されます。つまり、最新のサンプルが計算に使用されると、そのサンプルがライン バッファーに格納され、古いサンプルが排出されます。これにより、K-1 行のみをキャッシュすればよく、ローカル ストレージの使用が最小限に抑えられます。ライン バッファーにはローカルで格納するために複数行が必要ですが、たたみ込みのカーネル サイズ K はフル ビデオ画像の 1080 行よりもかなり小さくなります。

最初の計算は、K 行目にある最初のサンプルが読み込まれると実行されます。その後、最後のピクセルが読み込まれるまで値が出力されます。

// Vertical convolution 
phconv=hconv_buffer; // set/reset pointer to start of buffer
pvconv=vconv_buffer; // set/reset pointer to start of buffer
VConvH:for(int col = 0; col < height; col++) {
  VConvW:for(int row = 0; row < vconv_xlim; row++) {
#pragma HLS DEPENDENCE variable=linebuf inter false
#pragma HLS PIPELINE
    T in_val = *phconv++;
    // Reset pixel value on-the-fly - eliminates an O(height*width) loop
    T out_val = 0;
    VConv:for(int i = 0; i < K; i++) {
      T vwin_val = i < K - 1 ? linebuf[i][row] : in_val;
      out_val += vwin_val * vcoeff[i];
      if (i > 0)
        linebuf[i - 1][row] = vwin_val;
    }
    if (col >= K - 1) {
      *pvconv++ = out_val;
  }
}

このコードでは、デザインのサンプルがすべてストリーミング方式で処理されます。タスクは、継続して実行されます。再読み出し (または再書き込み) の回数を最小限に抑えるコーディング スタイルに従う場合、データをローカルでキャッシュする必要があります。これは、FPGA をターゲットにする場合の理想的なストラテジです。

最適な境界ピクセルたたみ込み

このアルゴリズムの最後には、エッジ ピクセルを境界領域に複製します。一定したデータフローおよびデータ再利用を実現するため、ローカル キャッシュが使用されます。次の図に、境界サンプルがどのように画像に組み込まれるかを示します。

  • 各サンプルが垂直たたみ込みからの vconv 出力から読み込まれます。
  • サンプルが 4 つのピクセル タイプのいずれかとしてキャッシュに格納されます。
  • サンプルが出力ストリームに書き出されます。

図: 境界サンプルの組み込み

次に、境界ピクセルの位置を決定するコードを示します。

// Border pixels
pvconv=vconv_buffer; // set/reset pointer to start of buffer
Border:for (int i = 0; i < height; i++) {
  for (int j = 0; j < width; j++) {
    T pix_in, l_edge_pix, r_edge_pix, pix_out;
#pragma HLS PIPELINE
    if (i == 0 || (i > border_width && i < height - border_width)) {
      // read a pixel out of the video stream and cache it for
      // immediate use and later replication purposes
      if (j < width - (K - 1)) {
        pix_in = *pvconv++;
        borderbuf[j] = pix_in;
      }
      if (j == 0) {
        l_edge_pix = pix_in;
      }
      if (j == width - K) {
        r_edge_pix = pix_in;
      }
    }
    // Select output value from the appropriate cache resource
    if (j <= border_width) {
      pix_out = l_edge_pix;
    } else if (j >= width - border_width - 1) {
      pix_out = r_edge_pix;
    } else {
       pix_out = borderbuf[j - border_width];
    }
    *dst++=pix_out;
  }
}

このコードの明らかな違いは、タスク内に条件文が多く使用されている点です。これにより、タスクがパイプライン処理された後、データが継続的に処理されるようになります。条件文の結果はパイプラインの実行には影響しません。結果は出力値に影響しますが、入力サンプルが使用可能である限り、パイプラインは継続的に処理されます。

最適なデータ アクセス パターン

FPGA で最適なパフォーマンスを得るためのデータ アクセス パターンは、次のとおりです。

  • データ入力の読み込みを最小限にします。データがブロックに読み込まれると、多くの並列パスに簡単に供給できますが、ハードウェア関数への入力がパフォーマンスのボトルネックになることがあります。データを読み込んだ後、再利用する必要がある場合は、ローカル キャッシュを使用します。
  • 配列、特に大型の配列へのアクセスを最小限に抑えます。配列はブロック RAM にインプリメントされますが、ブロック RAM では I/O ポートと同様ポート数が限られるので、パフォーマンスのボトルネックになることがあります。配列は小型の配列および個別のレジスタに分割できますが、大型の配列を分割すると使用されるレジスタ数が多くなります。小型のローカル キャッシュを使用して累積などの結果を保持してから、最終結果を配列に書き出すようにします。
  • パイプライン処理されたタスクであっても、タスクを条件で実行するのではなく、パイプライン処理されたタスク内で条件分岐を実行するようにします。条件文は、パイプラインで個別のパスとしてインプリメントされます。データを 1 つのタスクから次のタスクに流し、そのタスク内で条件が実行されるようにすると、システムのパフォーマンスが向上します。
  • 入力の読み込みと同様にポートはボトルネックになるので、出力の書き出しを最小限にします。追加のアクセスを複製すると、問題がシステム内に先送りされるだけです。

データをストリーミング方式で処理する C コードでは、関数引数に対する読み出し/書き込みを一度のみにすると、FPGA に効率的にインプリメントできるようになります。FPGA が必要なパフォーマンスで動作しない理由をデバッグするよりも、高パフォーマンスの FPGA インプリメンテーションが得られる C のアルゴリズムを設計する方が生産的です。