かずの不定期便ブログ

備忘録代わりに書きます

高位合成ブロックを手書きのRTLから制御する


目次

高位合成について

xilinx社のVitis HLSではC言語からRTLを生成する高位合成が可能です。
#2020.1版の場合です。これまで僕が使ってきた2016.4版ではvivado HLSで可能です。
ちょっと複雑な演算を行う場合には、RTLを手書きするより、C言語で記載した方が簡単ですし、検証が非常に楽ちんになります。

 しかし、良い面ばかりではなくどのような回路になるかを想像しないでCプログラミング♪って感じで記述すると、出来上がりRTLが思っていたより回路サイズが大きい、パイプライン段数が深いとか、そもそも合成できないという羽目になります。ただこの辺りはちょっと慣れればほぼ解消出来ると思います。

 Vitis HLSを使った設計では、ハードウェアをCの関数として作成します。このハード化された関数を簡単に呼び出せる仕組みをXilinx社は用意してくれています。今回はこのフローは使わないで、高位合成エンジン部分だけを拝借して、出来上がったRTLと直接インターフェースする方法を記載したいと思います。

高位合成ブロックのインターフェースについて

「vitis 高位合成ユーザー ガイド ug1399」 (ug1399で検索するとヒットします)
本ドキュメントを読めば分かるようになってはいるのですが、実際に作ってSIMした波形を見てからドキュメントを読む方がよくわかりました。
簡単なものを例に挙げてSIM波形と共に記載します。

 インターフェースは、いくつかの種類が用意されています。C言語の段階でdirectiveとしてどのようなインターフェースで繋ぐかを指示します。実際に合成し、SIMを行う事で波形を観測してプロトコル仕様を確認しました。
 尚、ug1399ドキュメント以外にFPGAプログラム大全に代表的な動作が記載されていて大変参考になりました。

 インターフェースは、ブロック制御用の信号とポート制御用の信号に分かれます。前者は回路全体の動き(開始、終了)を制御、後者はポートに対する制御になります。

 プロトコルは有効区間を示すvalid信号が存在するもの(標準)、有効区間が特に示されないもの(無手順)、SRAMとのインターフェースがあるようです。ここでは標準のインターフェースについて記載します。(手設計のRTLとの結合には標準を使うのがやりやすそうだったためです。)

 まずは最もシンプルなIOになるように、ポートに対しdirectiveを施さない場合の波形を載せます。入力ポートは構造体St_AffineMAT型とap_unit<16>型です。出力ポートはap_int<32>型のポインタです。出力側は、sx_ap_vldやsy_ap_vld信号で出力有効期間を示す信号がありますが、入力側のdxなどの信号にはvld信号はありません。
 Block-level IO Handshakeを見るとap_startに同期して、dx信号が入力されているのが分かります。ポート(引数)に対しなんのdirectiveを与えない場合には回路全体の信号であるBlock-level信号でやり取りされています。テストベンチはCのコードですが、vitisが論理SIMを行えるように勝手にテストベンチを作成してくれます。なので、ここでは"入力されている"という書き方をしています。実際に手設計のRTLと繋ぐ際にはdx信号を作らないといけません。

高位合成ブロックのインターフェースのSIM

 Cソースコードと波形を示します。テスト階層は(長いので)省略します。テスト階層の方がずっと複雑です。

// ソース1
void affine_transform (

    St_AffineMAT affine_mat,
    ap_uint<16> dx,     // dest x
    ap_uint<16> dy,     // dest y
    
    // source座標
    ap_int<32> *sx,
    ap_int<32> *sy
    )
{
#pragma HLS PIPELINE

    *sx = (affine_mat.mat_a * dx + affine_mat.mat_b * dy + affine_mat.mat_c );
    *sy = (affine_mat.mat_d * dx + affine_mat.mat_e * dy + affine_mat.mat_f );
}
f:id:spend-carefree:20210708234058p:plain
directive無しの場合のポート波形

 ap_start="H"入力で入力ポートの信号が確定されています。同時にap_idleが"L"出力になります。(組み合わせ回路のみでap_idle信号は作られているようなので,RTLで駆動する事になるap_startはFF出力で作成する方がタイミングviolationに悩まされないと思います)本回路はlatency=4cycで作成されたので4cyc後にap_doneが出力され、出力信号sx,syが確定しています。
 なので、後段側のRTLを作る際には(ap_done&sx_ap_vld)="H"時にデータを取り込めばよさそうです。
 またdirectiveでPIPELINE指示をしています(ソースコードのpragma行を参照)ので、毎サイクルデータの入力が可能で、ap_doneが一度"H"になれば毎サイクル演算結果が得られます。
 本回路を組み込む際に気を付けないといけないのは、毎サイクル有効データが出力されるというところです。後段のRTL側の都合で、受け取れないタイミングの時には本回路(HLS)を停止させないと演算結果を取りこぼしますし、また毎サイクル入力信号を供給できるとも限らないので、やはり本回路(HLS)を停止させたいです。
 Block-level信号のap_start="0"入力にすれば回路は停止しそうです。実際にSIMして動作を確認します。ap_startを任意のタイミングで信号を入力したいので、今度はvitisは使用しないで、テスト階層のRTLを作成し、HLS合成されたverilogを読み込んで、vivadoでSIMを行います。

f:id:spend-carefree:20210710231743p:plain
SIM結果1 ap_start="0"入力

この様にap_start="0"を入力すると4cyc(latency cyc)後にap_doneが"L"へ変化し、HLS回路は停止します(出力が止まります)。

f:id:spend-carefree:20210710231829p:plain
SIM結果2 再度ap_start="1"入力

再びap_start="1"入力すると、4cyc後にap_doneは"H"出力へ変化し、有効データが出力されました。

 これらの波形から分かることはSIM結果1に示すようにap_start="0"入力させることで停止はしますが、即停止ではなく、レイテンシ4cyc遅延の後に停止しています。つまり、後段側の回路から停止要求(ap_start=0)を出しても,4cyc分は出力値をロストしないようにfifoのような受け皿が必要です。そして後段回路は何らかの理由(例えばAXIのreadyがネゲートされたなどの理由)で処理が続行ができない為に停止指示を出しているので、処理が再開できるようになった時点で、ここで受けたfifoに入っているデータをまず処理しないといけません(fifoの処理を行った後で、ようやく前段HLS部に再開指示が出せます)。
 次に再開時の下段側の波形を見ると、ap_start="1"入力後、レイテンシ4cyc遅延後に演算結果が出力されます。つまり後段回路側からHLS部へ停止指示を出すたびにレイテンシcyc分、HLSブロックを遊ばせることになるので注意が必要です。たとえば、停止指示はレイテンシより十分長い時間発生しないというようにし、レイテンシ分の停止時間が全体の処理時間に対し無視できるように設計する事が望ましいです。
 まとめると、以下の3点の注意が必要だと思います。

  1. ap_start=0入力した際に、レイテンシcyc後まで、演算結果が出力されるので、演算結果をロストしないように最低、レイテンシサイクル数分バッファ受けする。
  2. HLSブロックの再開は、上記バッファ受けしたデータを処理した後で行う。
  3. "HLSレイテンシサイクル" x "後段回路からHLSブロックへの停止指示回数サイクル分"の時間、HLSブロックから処理結果を受けられないので、この時間が全体処理時間と比べて十分小さい事、もしくは長くても問題のない性質の処理に適用すること。

高位合成ブロックのインターフェースの改善

 ちゃんと作るには割と面倒な気がしますが、実は上記で上げた注意点を気にしなくても良いインターフェースも用意されています。
#最初から説明しろと思うかと思いますが、シンプルなところから記載したかったのです。
 ap_ctrl_chainというブロックレベルインターフェースです。ug1399ドキュメントによると「ap_ctrl_hs と似ていますが、バック プレッシャーを適用するために入力信号ap_continue が追加されている」とあります。上記で挙げた3点の注意を気にしなくてもよくなりそうです。
 下記図のようにDirectiveのタブで関数名で右クリック→Insert Directive...でDirectiveをINTERFACEとしOptionsのmode(optional):でap_ctrl_chainを指定します。
f:id:spend-carefree:20210711231837p:plain
ソースコードは以下の様に「#pragma HLS INTERFACE ap_ctrl_chain port=return」が追加されます。

// ソース2
void affine_transform (

    St_AffineMAT affine_mat,
    ap_uint<16> dx,     // dest x
    ap_uint<16> dy,     // dest y
    
    // source座標
    ap_int<32> *sx,
    ap_int<32> *sy
    )
{
#pragma HLS INTERFACE ap_ctrl_chain port=return  // ←追加
#pragma HLS PIPELINE

    *sx = (affine_mat.mat_a * dx + affine_mat.mat_b * dy + affine_mat.mat_c );
    *sy = (affine_mat.mat_d * dx + affine_mat.mat_e * dy + affine_mat.mat_f );
}

これでvitisによるCOSIMで生成される波形を見るとBlock-level信号にap_continueが追加されています。

f:id:spend-carefree:20210711232735p:plain
SIM結果3 ap_continue付き

ap_doneのタイミングでap_continue="1"が入力されています。これを任意のタイミングで"0"入力をしてみてどのようなタイミングで出力が変化するか確認します。(vivadoへもっていきます)

f:id:spend-carefree:20210712215055p:plain
SIM結果4 ap_continue=0入力によるバックプレッシャ

本SIM波形から、ap_continue="0"入力で即座に回路が停止していることが分かります。これを利用すれば、上記で挙げた3つの注意は不要になります。

 次に、入力ポートと出力ポートにフリップフロップを付けるように修正します。繋げるRTLとの境界のタイミングが緩くなります。
 ソースコードのDirectiveタブで各ポートに対し右クリック→Insert Directive...でDirectiveをINTERFACE、modeをap_hs、registerのチェックボックスをONにします。
 Latencyが2cyc長くなりますが、ap_continueの動作が変わってないか念のために確認します。pragmaを載せます。

#pragma HLS INTERFACE ap_hs port=sy register
#pragma HLS INTERFACE ap_hs port=sx register
#pragma HLS INTERFACE ap_hs port=dy register
#pragma HLS INTERFACE ap_hs port=dx register
#pragma HLS INTERFACE ap_hs port=affine_mat register
#pragma HLS INTERFACE ap_ctrl_chain port=return
#pragma HLS PIPELINE
f:id:spend-carefree:20210712232817p:plain
SIM5 portにregisterを追加

 先ほどとは異なりap_continue="0"入力後、停止までのサイクル数が1cyc増加しています(タイミングが変わるとは思っていなかった)。バッファレスとはいかないので気を付けないといけないです。
 また、入力側にregisterを追加した為か、入力ポートにインターフェース信号xxx_ap_vld,xxx_ap_ack信号が追加されました。
 xxx_ap_ack信号が"H"出力されるまで、入力信号を受けてくれませんが、リセット解除と同時に"H"アサートされ、ap_continue="0"入力でデアサートされる信号なので、ポート毎に制御する事がないのであれば(ほとんどないと思うが、非同期に動く回路がそれぞれの入力ポートを制御する場合に活用?)、Bloclk-level信号の活用のみで特に問題はなさそうです。xxx_ap_vldはap_startと同じ信号を繋いでおけば問題はないと思います。

構造体

 突然話が変わりますが、本ソースの入力は構造体"St_AffineMAT"を利用しています。高位合成を行ってRTLを生成すると、構造体affine_matは、
input [127:0] affine_mat;
とフラットになってたりするのですが、構造体は以下の様に定義しています。

typedef struct {
    // 固定小数8bit 行列式
    ap_int<16> mat_a;
    ap_int<16> mat_b;
    ap_int<16> mat_d;
    ap_int<16> mat_e;
    ap_int<24> mat_c;
    ap_int<24> mat_f;

} St_AffineMAT;

 不思議ですよね?定義している構造体の合計ビット数は108bitなのに128bit化されているので。。。
 RTLと接続するためには、どのビットにどの信号が割り付けられているか分からないと結線が出来ません。このあたりも「ug1399ドキュメント」しっかり記載されています。基本的に8bit,16bit,32bitに構造体の変数は切り上げられパディングされます。また構造体内の変数定義の上から順にLSBから埋められる様です。更に構造体全体のサイズは32bit単位になります。本例の場合は以下の様なassignになるようです。今回は16bit変数が偶数個だったので128bitにきれいに収まりましたが、もし16bit変数が5個だった場合は32bitの倍数になるようにMSBに16bitが追加されて96bitになるようです。

assign affine_mat = {
    {8'h00,mat_f},  // 8bitパディングして32bit化
    {8'h00,mat_c},
    mat_e,
    mat_d,
    mat_b,
    mat_a
    };

全体ブロック図

 今までの事を踏まえると以下の様な構成にすればよいかと思います。

f:id:spend-carefree:20210718173915p:plain
ブロック図

以上で、HLSブロックにRTLを接続する最低限必要な情報は揃いました。

HLSブロックとRTLの結線

 vitis HLSからExport-RTLでIP化して、vivadoのblock-diagramでIP読み込みをして結線するのが王道ではあると思います。
 しかしながら手書きRTLとは結線が出来ませんでした。IPの方は接続相手もインターフェースであることを期待しているようで、端子むき出しのRTLとは結線が出来なかったのです。もしかしたらAXIインターフェースの様にネーミングルールがあってそれに従ってRTLの端子名を決めれば出来るのかもしれません。
 なので、IP化は行わずHLS合成で生成されたRTLを直接取り扱うという方法を取りました。合成も問題なく出来ているので方法としてはありなのかと思っていますが、本当のところは分かりません。実はFPGAへ焼く段階までは進んでいなくてSIM&合成が可能なところを確認して作業を止めています。
 vitis HLSでIP化した方が、vivadoでの合成時間短縮にはなると思います。

最後に

 一応、これでなんとなくHLSを使えるようになりました。本当は王道でHLSで作ったものだけで、FPGAへ焼いてpythonから制御というやり方が、色々環境が整備されていて便利で高度な事が出来そうですが、このような環境を使うだけのネタがないといいますか。。。AIエンジンネタが巷にあふれているのですが、そもそもAIをよく分かってないので何か試すこともできずでして。AIの勉強をしたいなと思っているところです。
 いきなり最後は説明が雑になった感がしますが、個人備忘録としてはよいのではないかと思っています。


以上です。
久しぶりの更新でした。