What is HLS?
ソフトウェアエンジニアのための高位合成入門
普通のソフトウェアエンジニアの感覚でfor文を書くと、とんでもなくデカい回路ができたり、レイテンシが爆発したりする。これはそのことについてのノート。
HLSってなに?
HLSはHigh-Level Synthesis(高位合成)の略。CとかC++(クソ言語)で、高い抽象性を持ったまま回路設計ができるやつ。
HDLでの設計が力仕事なら、HLSは"ハイレベルな"設計ができる。だからまぁ、Verilogでゴリゴリ書くよりかはHLSの方が僕は好き。
HLSの代表的なツールとしてはXilinx(現AMD)のVitis HLSがデファクトで、Intel(旧Altera)側にはi++コンパイラがある。あとはMentorのCatapult HLSなんかもあるけど、エコシステムの充実度でいうとVitis HLSが圧倒的。この記事もVitis HLS前提で書いている。
高位合成の立ち位置
HDLでの設計はRTL(Register Transfer Level)設計と呼ばれていて、これは設計の抽象度を表している。レジスタ間のデータ転送をイメージして設計するという意味。論理回路記号を並べるゲートレベル設計に対して、一つ上の抽象性を持っている。
そしてこれをもっと抽象度を上げたのが、Behavior model。回路の動作や機能に主眼を置いているから、クロック概念を取り外して設計できる。
このBehavior modelから回路を生成するのが高位合成なんだ。CやC++から論理合成可能なHDLを生成する感じ。アセンブリじゃなくてHDLが生成される、特殊なフレーバーのCみたいなもの、という認識でいい。
抽象度を整理するとこう。
- システムレベル — アーキテクチャ全体。どのブロックがどの仕事をするか
- ビヘイビアレベル — 動作・機能の記述。クロック概念なし。← HLSはここ
- RTLレベル — レジスタ間のデータ転送。クロックサイクルごとの動作を記述
- ゲートレベル — AND/OR/NOTなど論理ゲートの接続
- トランジスタレベル — MOSFETレベルの物理設計
上に行くほど人間にとって書きやすくて、下に行くほどハードウェアに近い。HLSはビヘイビアレベルからRTLレベルへの自動変換を担う。ちなみにSystemCという言語もあって、C++にハードウェア記述用のクラスライブラリを追加したもの。HLSツールの中にはSystemCを入力として受け付けるものもある。
データ処理と制御系
論理回路の構造を機能で分類すると、データ処理系と制御系に分かれる。
- データ処理系 — 演算器やデータパスの部分。回路規模がデカくなりやすい。乗算器、加算器、シフタ、MUXなんかがここ
- 制御系 — AXIバスなどのプロトコルに基づいて、ステートマシンで実現する部分。設計難易度がデカくなりやすい。ハンドシェイク、アービトレーション、エラー処理など
AXIバスっていうのは、FPGAの中でCPU(PS側)とロジック(PL側)がデータをやり取りするための標準インターフェースのこと。ARM社が策定したAMBAバス仕様の一部で、Xilinxのエコシステムだとこれが事実上のデファクト。IPコア同士を繋ぐ共通言語みたいなもの。
PS(Processing System)はFPGA内蔵のARMプロセッサで、Linux動かしたりソフトウェア処理を担当する。PL(Programmable Logic)はFPGA本体のロジック部分で、こっちにHLSで作った回路が載る。ZynqシリーズだとPS+PLが1チップに統合されていて、AXIバスでシームレスに連携できる。
両方デカくなると、まぁ大変。HLSの強みは、データ処理系をC/C++で効率よく記述できること。制御系のAXIプロトコル実装はツールが自動生成してくれるから、手でステートマシンを書く地獄から解放される。
スケジューリング
高位合成の基本中の基本がスケジューリング。
HLSはクロックを考慮せずに回路を記述できるけど、ツール側で処理のクロック数を状況に応じて柔軟に設定してくれる。たとえばこういう式があったとする。
dout = a * b + c / d - e;
本来なら、乗除算と加減算の遅延が累積する。つまり1クロックで全部やろうとすると、
これを1クロック回路で実現すると、クリティカルパスが長すぎて高速動作ができない。クリティカルパスとは、組み合わせ回路の中で最も遅延が大きいパスのこと。動作周波数はクリティカルパスの遅延で決まるから、1クロックに詰め込みすぎると周波数が上がらない。
そこで、演算ごとにレジスタ(記憶素子)を配置する。処理クロック数は増えるけど、1クロックあたりの処理が軽くなるから、高い周波数で動作させることができる。
クロック1: ffa(1) = a × b, ffa(2) = c ÷ d, ffa(3) = e
クロック2: ffb(1) = ffa(1) + ffa(2), ffb(2) = ffa(3)
クロック3: dout = ffb(1) - ffb(2)
処理のクロック数と、個々の演算を割り当てるクロックを決めるのがスケジューリング。高位合成ツールは動作周波数などの基本設定に基づいて自動でスケジューリングを行うけど、プラグマで手動指定することもできる。
スケジューリングのアルゴリズムとしては、ASAP(As Soon As Possible)とALAP(As Late As Possible)が基本。ASAPは依存関係が解決した瞬間に演算を配置する。ALAPはレイテンシ制約ぎりぎりまで遅延させる。実際のHLSツールはこの2つの境界の中で、リソース制約やタイミング制約を考慮してスケジュールを決定する。
上のレジスタを挟んだ構成をよく見ると、連続的にデータを流すとめちゃくちゃ強いことが分かると思う。HLSの用語で表すとこうなる。
- レイテンシ — 1つのデータが入ってから結果が出るまで:3クロック
- Initiation Interval(II) — 次のデータを受け付けるまでの間隔:1クロック
そして、最初の結果が出るまでは3クロック待つけど、そのあとは毎クロック結果が出続ける。これがパイプライン。
パイプラインなしだとどうなるか? レイテンシもIIも3クロック。つまり1つのデータを処理し終わるまで、次のデータを入れられない。スループットが3分の1に落ちる。連続データを処理するときの差は歴然。
スループットを定量的に言うと、動作周波数 でII = 1のパイプラインなら、スループットは データ/秒。II = なら データ/秒。200MHzでII = 1なら毎秒2億データを処理できる。これがFPGAの強さの本質。
ループ最適化
HLSでパフォーマンスを引き出すカギはループの書き方にある。ソフトウェアだとfor文は逐次実行だけど、ハードウェアでは並列化の粒度そのもの。ここを雑に書くと、ツールが「えっ、本当にこれ全部直列でいいの?」って顔をする(実際にはWarningが出る)。
まずパイプライン。#pragma HLS PIPELINE をループに付けると、さっき説明したパイプライン実行がループ単位で適用される。
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
out[i] = in[i] * coeff + bias;
}
II = 1を指定すると、毎クロック新しいイテレーションを開始する。N個のデータをレイテンシ + N - 1クロックで処理できる。パイプラインなしだとレイテンシ × Nクロックだから、Nが大きいほど差が歴然になる。
ただしII = 1が常に達成できるとは限らない。厄介なのがループキャリー依存。前のイテレーションの結果を次で使うパターン。
int sum = 0;
for (int i = 0; i < N; i++) {
#pragma HLS PIPELINE II=1
sum += data[i]; // ループキャリー依存
}
sum は前のイテレーションの結果に依存するから、加算のレイテンシ分だけIIが強制的に増える。整数加算なら1クロックで済むからいいけど、浮動小数点加算だと数クロックかかるからII = 1は達成不可能。合成レポートに「II violation」って出たら、だいたいこれが原因。
次にアンロール。#pragma HLS UNROLL はループを展開して並列実行する。
for (int i = 0; i < 4; i++) {
#pragma HLS UNROLL
result[i] = a[i] + b[i];
}
これは4個の加算器が同時に動く回路になる。ソフトウェアエンジニア的には「ループ展開」って最適化テクニックのイメージだけど、HLSだとハードウェアが物理的に増える。完全展開するとイテレーション数ぶんの演算器が生成されるから、ループ回数が大きいとリソースが爆発する。factor=2 で部分展開もできるから、状況に応じて使い分ける。
で、パイプラインやアンロールで並列度を上げると、次のボトルネックはメモリのポート数。BRAMはデュアルポートだから、1クロックで最大2回しか読み書きできない。4並列で配列にアクセスしたいのに、メモリの口が2つしかない。ここで ARRAY_PARTITION の出番。
int arr[1024];
#pragma HLS ARRAY_PARTITION variable=arr cyclic factor=4
cyclic factor=4 は配列を4つのBRAMに巡回的に分割する。これで4並列アクセスが可能になる。complete を指定すると全要素をレジスタに展開するけど、配列がデカいとFF(フリップフロップ)を食い尽くすから注意。1024要素の配列をcompleteしたら32768個のFFが消える。やめとけ。
最後にDATAFLOW。これはループじゃなくて関数レベルの並列化。
void top(int *in, int *out) {
#pragma HLS DATAFLOW
int tmp1[N], tmp2[N];
stage1(in, tmp1);
stage2(tmp1, tmp2);
stage3(tmp2, out);
}
stage1が途中まで書き込んだデータをstage2が読み始める、というストリーミング的な動作になる。関数間のバッファはFIFOかPIPO(Ping-Pong buffer)として合成される。全ステージが同時に動くから、レイテンシは各ステージの最大値で決まる。ソフトウェアのパイプ(cmd1 | cmd2 | cmd3)に近いイメージ。
DATAFLOWが効くのは「プロデューサ→コンシューマ」の関係が明確なとき。同じ配列に複数の関数がランダムアクセスするような構造だと適用できない。ちゃんとデータの流れを一方向に整理してから使うこと。
バインド
スケジューリングが「どの演算をどのクロックでやるか」を決めるものだとしたら、バインドは「その演算をどのハードウェアリソースでやるか」を決めるもの。
たとえばクロック1で乗算、クロック3でも乗算が必要だとする。この2つの乗算は同時には実行されない。だったら、乗算器を1個だけ用意して使い回すことができる。これがリソース共有(リソースシェアリング)。
逆に、スループットを優先して乗算器を2個並べる選択もある。
- リソース共有する — 回路が小さくなる。面積節約。でもスループットは落ちる可能性がある
- リソース共有しない — 回路はデカくなる。でも並列度が上がって速くなる
高位合成ツールはこのバインドも自動でやってくれるけど、プラグマで制御できる。たとえばVitis HLSなら #pragma HLS ALLOCATION で「乗算器は最大2個まで」みたいに指定したり、#pragma HLS BIND_OP で特定の演算をDSPスライスに割り当てるか、LUTで実装するかを選んだりできる。
FPGAにはDSP48ブロックという専用演算器が載っていて、乗算や積和演算はこいつを使うのが効率的。DSP48は ビットの乗算器と48ビットのアキュムレータを内蔵していて、1クロックで積和演算(MAC)ができる。でもDSPブロックの数には限りがあるから(Zynq-7020で220個、UltraScale+で数千個)、使い切ったらLUTで代替するしかない。このあたりのリソース配分を意識するのがバインドの勘所。
FPGAの主要リソースをまとめておくとこう。
- LUT(Look-Up Table) — 任意の論理関数を実現するテーブル。汎用だけど大量に使うと配線が混雑する
- FF(Flip-Flop) — 1ビットの記憶素子。レジスタやパイプラインステージに使う
- BRAM(Block RAM) — オンチップメモリ。18Kbitまたは36Kbitのブロック単位。配列はだいたいこれに載る
- DSP48 — 専用演算ブロック。乗算・積和に使う。数に限りあり
- URAM — UltraScale+以降の大容量メモリブロック。288Kbit/ブロック
合成レポートでこれらの使用率が出てくるから、どれかが90%超えてたら設計を見直すシグナル。
IP化
HLSで設計した回路は、最終的にIPコア(Intellectual Property コア)としてパッケージングする。IPコアというのは、再利用可能なハードウェアモジュールのこと。
HLSで書いたC/C++のコードは、合成するとRTL(VerilogやVHDL)が生成される。これをVivadoのIPカタログに登録すれば、ブロックデザイン上で他のIPと自由に組み合わせて使える。
IP化の流れはこう。
- Vitis HLSでC/C++を書く
- Cシミュレーションでアルゴリズムの正しさを確認する
- 高位合成を実行して、レイテンシ・II・リソース使用量のレポートを確認する
- 協調シミュレーション(C/RTL Co-Simulation)でRTLの動作を検証する
- Export RTL でIPとしてパッケージングする
- Vivadoのブロックデザインに追加して、他のIPやPS(ARM)と接続する
ステップ3の合成レポートは超重要。ここでレイテンシ、II、リソース使用率(LUT/FF/BRAM/DSP)が一覧で出るから、目標スペックを満たしているかを確認する。満たしてなかったらプラグマを調整して再合成、の繰り返し。
インターフェースの設定
IP化するときに超重要なのが、外部とのインターフェース。#pragma HLS INTERFACE で指定する。
- s_axilite — レジスタアクセス。CPUから制御パラメータを渡したり、ステータスを読んだりする用途。低速だけどシンプル。32ビット幅のレジスタ空間にマッピングされる
- m_axi — メモリマップドアクセス。DDRメモリなどに直接読み書きする。大量データの転送に向いている。バーストアクセスで帯域を稼ぐ
- axis — AXI-Stream。データをストリーミングで流す。パケット処理やリアルタイムデータ処理に最適。HFTのネットワークデータ処理なんかはこれ。TLASTシグナルでパケット境界を示す
使い分けの目安として、s_axiliteは少量のパラメータ(数十バイト)、m_axiは大量データ(キロバイト〜メガバイト)のランダムアクセス、axisは連続ストリームデータ。
たとえばこんな感じ。
void my_ip(
int *input, // DDRから読む
int *output, // DDRに書く
int param // CPUからの制御パラメータ
) {
#pragma HLS INTERFACE m_axi port=input depth=1024
#pragma HLS INTERFACE m_axi port=output depth=1024
#pragma HLS INTERFACE s_axilite port=param
#pragma HLS INTERFACE s_axilite port=return
// 処理
}
port=return を s_axilite にしておくと、CPUから関数の開始・完了をポーリングで制御できるようになる。地味に大事。depth はシミュレーション用のバッファサイズで、合成結果には影響しないけど、協調シミュレーションのときに正しいサイズを指定しないとハングする。
m_axiのバースト転送
m_axiでパフォーマンスを出すコツはバースト転送。memcpyのようにシーケンシャルにアクセスすると、ツールがバーストに変換してくれる。
// バースト転送になる(良い)
for (int i = 0; i < N; i++) {
local_buf[i] = input[i];
}
// バーストにならない(悪い)
for (int i = 0; i < N; i++) {
local_buf[i] = input[i * stride]; // 非連続アクセス
}
データをまずローカルバッファ(BRAM)にバースト転送してから処理する、というパターンが定石。DDRへのランダムアクセスはレイテンシが数十〜数百クロックかかるから、直接DDRに対してパイプライン処理しようとしても効率が出ない。
IP化したあとの世界
IP化してしまえば、ブロックデザイン上ではただの箱になる。中身がCで書かれたものか、Verilogで書かれたものかは関係ない。AXI Interconnectで繋いで、アドレスマップを設定して、ビットストリームを生成すれば動く。
ソフトウェア側(PS上のLinuxやベアメタル)からは、Xilinxが自動生成するドライバAPIでIPを操作する。レジスタに値を書いて、スタートビットを叩いて、完了割り込みを待つ。このあたりはVitis(旧SDK)が雛形を生成してくれる。
これがHLSの最大のメリットかもしれない。アルゴリズムの変更はCレベルで修正して再合成すればいい。RTLを手で直す苦行から解放される。早くあの.vファイルを捨てる時が来る。
まぁHLSは「Cを書いてるだけ」じゃなくてハードウェア設計なんだと思う。for文はハードウェアの生成指示だし、配列はBRAMの割り当てだし、関数呼び出しはモジュールのインスタンス化だし、ポインタはインターフェースの定義。mallocは存在しない。
逆に言えば、これさえ意識できれば、RTLを手書きしなくてもかなりのことができると思う。プロトタイプはHLSで素早く作って、ボトルネックだけRTLに落とす。これが最適解なのかもしれない。