Vitis HLSで簡単に高性能な回路を設計するノウハウ集

## はじめに

この記事をご覧の皆様は、RTLを書きたくなくて、どうしてもHLSで高い性能(≧500MHz)を達成する回路を記述できないか日々試行錯誤しているに違いありません。私もその一人です。いつかHLSが主流になると信じて止まないのです。（決してRTLが苦手なわけではないです..）そんな私がHLSを記述する中で身に付けた個人的な経験に基づくノウハウをいくつか紹介します。間違っていたり、もっと良い方法があればコメントで教えて下さい！

## 1. AXI Liteを使わないでください

Vitis HLSにおいて、より高い動作周波数を実現するためには、AXI Liteインターフェイスが妨げになります。AXI Liteは小さなパラメータを用意するのに非常に便利なのですが、リソースと速度の面で不利です。関数のインターフェイスには`ap_ctrl_none`を指定しましょう。これにより`ap_start`などの制御が生成されなくなり、無限ループの様に動作します。すべてのインターフェイスはAXI Streamとして設計しましょう。場合によっては`m_axi`インターフェイスが適切な場合がありますが、最高のパフォーマンスの実装は少し難易度が高めです。おっと、outputのAXISではLAST信号線を追加することを忘れずに！

```c++
void kernel_axis_interface(hls::stream<int> &input, &output){
#pragma HLS INTERFACE ap_ctrl_none port=return
#pragma HLS INTERFACE axis port=input
#pragma HLS INTERFACE axis port=output
}
```

## 2. 関数レベルでパイプライン化しよう

Vitis HLSにおいて、関数レベルでのパイプライン化はパフォーマンス向上の鍵です。トップレベルの関数に`PIPELINE II=1`の指示を追加することで、1サイクルごとに各ステージが処理を実行するようになります。関数内の全てのループはアンロールを行うことで、処理の並列化と効率化を図ります。状態を保持する必要があるデータには`static`キーワードを付けることで、データの永続性を保証します。 ~~デッドロックが心配な場合は`style=frp`をつけましょう。PIPELINEのスタイルの詳細は[こちら](https://www.xilinx.com/htmldocs/xilinx2021_2/hls-guidance/200-1553.html)。~~ 追記：style=frpはリソース使用量が大きくなるため、最適ではないかも知れません。hls::streamのread_nbを活用して、自分でデッドロックが起こらないように対処するか、style=flpを使うことも検討して下さい。


```c++
void kernel_pipelined(...){
#pragma HLS INTERFACE ap_ctrl_none port=return
#pragma HLS PIPELINE II=1 style=frp
    static int state = 0;
    ...
    // HLSなのにstateを人間が管理するのか...
    state += 1;
}
```

## 3. 関数をインライン化しよう

Vitis HLSでの関数のインライン化は、コードの効率化に役立ちます。インライン指令を使用することで、関数の内容がその呼び出し箇所に直接展開され、関数呼び出しのオーバーヘッドが削減されます。ほとんどの場合でパイプラインのブロック内では自動でインライン化されるのですが、関数の規模によってはインライン化されない場合があるようです。これは、オーバーヘッドが発生し、また関数を跨る最適化が妨げられてしまいます。明示的にプラグマを挿入する癖をつけましょう。

```c++
int inner_function(int a) {
#pragma HLS INLINE
    ...
    return ...;
}
```

## 4. できるだけARRAY_PARTITIONしよう

配列の全展開は、Vitis HLSにおいてパフォーマンスを最適化する重要な手法です。`ARRAY_PARTITION`指令を使用することで、配列をより細かく分割し、並列アクセスを可能にします。一定以上大きな配列はメモリとしてインプリされてしまいます。全展開することで、データアクセスのボトルネックを軽減し、全体的な処理速度を向上させることができます。ただし、場合によっては大きなマルチプレクサが大量に生成されてしまいリソースの問題が発生します。適切なリソースを検討して明示的にBRAMやLUTメモリとしての支持も検討して下さい。

```c++
void kernel_array_partition(...) {
    ...
    int my_array[4][16];
#pragma HLS ARRAY_PARTITION variable=my_array complete dim=0
    ...
}
```

## 5. ループ間依存関係に注意しよう

これは当たり前のことなのですが、ループ間の依存関係がパフォーマンスに大きな影響を与えます。`#pragma HLS DEPENDENCE`指令を使用して遅延を許容できる変数は明示しましょう。ケースによっては、元の変数と遅延版の変数の2つを用意することで、データの流れを管理しやすくなります。ただし、コードの依存関係を無視した回路が出力されるので、正しハザードの処理を自力で書く必要あります。


```c++
void kernel_loop_dependence(...) {
    static int foo;
    static int foo_delay;
    static int in_hazard_control;
#pragma HLS DEPENDENCE variable=foo_delay inter false
    ...
    foo = foo_delay = state_xxx;
    in_hazard_control = true;
    // HLSなのに手動でハザードを管理するのか...
}
```

## 6. カーネル関数を小さくしよう

これはHLSの限界とも言える部分なのですが、HLSが想定する遅延と実際に回路にインプリされる遅延が大きく異なる場合があります。特に回路規模が大きい場合に顕著で、HLSの合成結果では高い性能が発生できる見積が出ても、実際に配置配線すると全くその性能が出ない場合があります。様々な研究が行われていますが、まだまだ課題は山積みです。

しかし、簡単な解決策があります。カーネル関数を小さくすることです。経験則から、LUTの使用量は2000以下、DSPブロックは約30個程度が理想的です。このサイズを超えると、回路の混雑による遅延が発生し、500MHz以上を達成しづらくなります。また、[Double MAC](https://dl.acm.org/doi/pdf/10.5555/3130379.3130591)の使用も検討して下さい。DSPの数を削減できます。BRAMの使用量は問題になりにくいですが、帯域幅が大きい場合は、LUTの使用量も増加して周波数低下の問題になります。


## 7. カーネル関数を分割しよう

少しテクニカルなノウハウになりますが、可能ならばカーネルを分割して下さい。各カーネルを小さく保ちながら、それらをモジュールとして実装し、カーネル間はap_hsインターフェイスを用いて通信します。IPインテグレータを使用してモジュールを接続することで、効率的なデータフローを実現します。接続にFIFOを使用しても良いですが、使用しなくても十分なスループットは確保することができます。

私の開発中のコードには10個のHLSカーネルが含まれ、IPブロックの数は100を超えています。以下の表に予備実験を示します。あるカーネルをそのまま実装した場合と、2つの回路に分割した場合についてリソースと動作周波数の例を紹介します。簡単な命令実行型のカーネルを、デコード部分と実行部分の2つに分割しました。合成の結果、動作周波数は400MHz→500MHzに増加しましたが、リソースの増加は僅かでした。

![Screenshot 2023-12-07 at 10.43.27 PM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68704/1c8d3312-93f1-5edc-9d94-c69cd32f47c3.png)

また、分割のその他の効果として、一部のカーネルの動作周波数を下げたり、PIPELINEのIIを2以上に大きくすることもできます。例えばURAMは動作周波数が低いので、メモリアクセスだけを低い動作周波数で実現することもできます。本当に黒魔術ですね。。

## 8. DATAFLOWを使わないでください

HLSの機能に`#pragma HLS DATAFLOW`があります。これは関数間をFIFOやピンポンバッファで繋げていい感じにデータフロー処理をしてくれる機能で、7.で書いたカーネル分割と同じような挙動を実現することができます。しかし、高性能を求めるなら使わないで下さい。性能が出ません。探索空間が関数単位に閉じて周波数の向上が期待できるので、一見するとカーネルの分割と全く同じ効果が期待できますが、実際に合成してみると経験上全くそんなことはありません。HLS合成では周波数制約を達成しても配置配線でダメになります。同じ処理を関数毎に合成してIPインテグレータで繋げるだけでいいので、別カーネルにしましょう。

## 9. 探索しよう

HLSの目標周波数と、Vivadoの配置配線の目標周波数は個別に探索して下さい。例えば最初に合成した結果3.0nsだったが、それを探索した結果、2.0nsで通るなんてことが平気であります。ここまででカーネルを小さくしたので探索は簡単です。0.1ns刻みで、20通り*20通り=400パターンくらい探索して下さい。可能であればVivadoのストラテジも探索して下さい。これだけで、1.0nsくらい改善できる場合があります。私の作った[探索スクリプト](https://github.com/jin0g/hls-search)も良かったら使って下さい。

## 10. RTLを書こう

そうこうしているうちに、私たちのプロジェクトではHLSにしては性能が高めの、RLTより可読性も性能も低いコードが完成しました。 ~~最初からRTLを書けば良かったですね。めでたしめでたし(?)~~ まあそう言わずに、、同じソースコードを使い回して様々なターゲットや異なる世代のFPGAに簡単に移植できるので、これも一つの正解だと信じています。。(しかしIntel FPGAを使う場合はまた別のノウハウが待っているでしょう。。)

## おわりに

この記事では、Vitis HLSを使用した高性能な回路設計のための私が持っているほとんど全てのノウハウを紹介しました。これらの手法は必ずしも全てのケースで最適とは限りませんが、効率的な設計に向けた一助となることを願っています。皆さんの経験やノウハウもぜひコメントで共有していただければ幸いです。