1. はじめに:ruleとは
Hardware Description Language (HDL) アドベントカレンダー 17 日目は、rule を用いない Bluespec SystemVerilog (BSV) の設計手法を考えてみます。
さて、BSV における rule とは何でしょうか。最近では面倒なことをなんでも ChatGPT に聞いているので、今回も聞いてみました。
rule は「条件付きで同時に発火しうる、1サイクルでアトミックに実行されるステップ」であり、コンパイラがそれらの並列実行と衝突回避を自動で解決してくれる仕組み
とのことです。これはハードウエアのふるまいをうまく表しており、微小な並行性をいくらでも並べることができます。BSV の利点として、それらの資源競合をコンパイラ任せにできる点が挙げられます。つまり、BSV の基本はこの rule であり、rule によってあらゆる機能、例えば RISC-V CPU に至るまで書かれていると言っても過言ではありません。
ひとつ具体例を挙げます。
interface Fifo_ifc;
(* prefix="" *)
method Action if_write_enable((* port="wr_en" *)Bool wen);
(* prefix="rd" *)
method Action if_read_enable0(Bool en);
(* result="empty" *)
method Bool if_empty();
endinterface
(* synthesize, always_ready, always_enabled *)
module mkOneStage(Fifo_ifc);
Reg#(Bool) in_wen <- mkReg(False);
Reg#(Bool) in_ren <- mkReg(False);
Reg#(Bool) in_empty <- mkReg(True);
rule rule_read (in_ren && !in_empty);
in_empty <= True;
endrule
rule rule_write (in_wen && in_empty);
in_empty <= False;
endrule
method Action if_write_enable(Bool wen);
in_wen <= wen;
endmethod
method Action if_read_enable0(Bool en);
in_ren <= en;
endmethod
method Bool if_empty();
return in_empty;
endmethod
endmodule: mkOneStage
これはセマフォをエミュレーションする 1 ビットのステートマシンを表しています。上流モジュールは、このモジュールが empty なら書き込みを行い、同時にこのモジュールは empty を !empty に更新します。他方で下流モジュールは、!empty なら読み出しを行い、同時にこのモジュールは empty に戻します。
これは rule で書くと非常にきれいに表現できる例です。書き込みと読み出しの並行性を素直に書けています。もっとも、生成された Verilog を見てもほぼこのとおりに生成されており、高位合成と言いながら RTL を手書きしているのと大きくは変わりません。
マイクロアーキテクチャレベルでコンカレント性があるのは RTL の特徴なので、強いて BSV の利点を挙げるなら、BSV コンパイラによる自動スケジューリングと言えるでしょう。
2. ruleのデメリット
一方で、マイクロアーキテクチャレベルでコンカレント性があるということは、人間にはかなり書きにくいということでもあります。このような単純な例なら良いのですが、複数の微小なハードウエアが並行して動作していると、タイミングバグを生みやすいだけでなく、それ以前に人間のシーケンシャルな頭では挙動を想像しにくくなります。
実際に BSV で アプリケーションを書いた経験から、rule は大規模モジュールではむしろ必要ないと感じました。以下に、そのときに採用した手法をまとめます。
3. RuleレスBSV設計法
ChatGPT と議論して整理した手法をまとめます。
3.1 ゴール:ユーザコードから rule を追放する
- 大規模・本質的な機能は Stmt による逐次記述だけで書く
-
ruleキーワードはユーザソースに一切出さない - 並列性は
- モジュール間の並列(インスタンスを複数並べる)
- セマフォや FIFO などの同期モジュール
で表現し、モジュール内部は「単一のシーケンシャル実行器」とみなす
3.2 コーディング規約
規約 1:大きな処理は必ず Stmt で FSM 化する
- 各モジュールごとに 1 本の Stmt を用意する
-
seq / if / while / awaitで、やりたい処理を素直に逐次記述する - その Stmt を
mkAutoFSMで駆動する
import StmtFSM::*;
module mkUnit(IfcUnit);
// ステートレジスタ
Reg#(Bit#(32)) rX <- mkReg(0);
FIFO#(Req) qReq <- mkFIFO();
FIFO#(Resp) qResp <- mkFIFO();
// 外部 I/F
interface inReq = toGet(qReq);
interface outResp = toPut(qResp);
// 本体シーケンス
Stmt fsm = seq
while (True) seq
await(!qReq.isEmpty);
action
let req = qReq.first; qReq.deq;
endaction
// ここに逐次処理を C 的に書く
action
rX <= rX + 1;
endaction
action
qResp.enq(makeResp(req, rX));
endaction
endseq
endseq;
// 実行器
mkAutoFSM(fsm);
endmodule
規約 2:レジスタ更新は Stmt の action からしか行わない
-
Reg#(T)は宣言して良いが、モジュール本体では Stmt 内のaction ... endactionからだけ<=する。 - これにより、「どのタイミングでステートが変わるかをすべて Stmt 上で追える
規約 3:モジュール間は Get/Put や Client/Server のみで接続
- I/F は必ず
Get#(T),Put#(T),Client#(Req,Resp),Server#(Req,Resp)に落とし込む - FIFO は
toGet/toPutで公開し、接続はmkConnectionを基本とする - これにより、並列性を「複数モジュール+キュー」の組み合わせだけとして扱える
規約 4:手動のステート分解をしない
-
Idle/Busy/Doneのようなステート変数に分解したくなっても、 まずはwhile/if/awaitで自然に書く - 必要なステートは「使用中フラグ」「カウンタ」など、本当に必要なものだけ Reg として持つ
3.3 基本テンプレート
3.3.1 サービス型モジュール(Client/Server型)
interface IfcCalc;
interface Server#(Req, Resp) srv;
endinterface
module mkCalc(IfcCalc);
FIFO#(Req) qReq <- mkFIFO();
FIFO#(Resp) qResp <- mkFIFO();
interface srv.request = toPut(qReq);
interface srv.response = toGet(qResp);
Reg#(Bool) busy <- mkReg(False);
function Stmt doOneJob(Req r);
return (seq
// ここに r を処理する逐次コード
action
qResp.enq(makeResp(/*...*/));
endaction
endseq);
endfunction
Stmt main = seq
while (True) seq
await(!qReq.isEmpty && !busy);
action
let r = qReq.first; qReq.deq;
busy <= True;
endaction
doOneJob(r);
action
busy <= False;
endaction
endseq
endseq;
mkAutoFSM(main);
endmodule
3.3.2 ストリーム変換モジュール
interface IfcFilter;
interface Get#(InT) inIfc;
interface Put#(OutT) outIfc;
endinterface
module mkFilter(IfcFilter);
FIFO#(InT) inQ <- mkFIFO();
FIFO#(OutT) outQ <- mkFIFO();
interface inIfc = toGet(inQ);
interface outIfc = toPut(outQ);
Stmt loop = seq
while (True) seq
await(!inQ.isEmpty && !outQ.isFull);
action
let x = inQ.first; inQ.deq;
let y = filterFunc(x);
outQ.enq(y);
endaction
endseq
endseq;
mkAutoFSM(loop);
endmodule
3.4 並列性と同期の扱い
- 並列性は、モジュールインスタンスを複数作ることで表現する
- 例:ゲーム本体モジュール、サウンドモジュール、描画モジュールを別々に作る
- それらの同期は
- FIFO
- セマフォ(
mkOneStage等)
を介して行う
- 大規模なゲーム・描画・サウンドロジックはすべて各モジュール内部の Stmt に書き、モジュール内部に複数の「実行コンテキスト」を持たない
3.5 例外:小さな同期プリミティブ
- セマフォや 1 段 FIFO など、数行で収まる同期プリミティブは、必要なら別モジュールとして rule で実装しておき、 上記の「大規模 Stmt モジュール」からはブラックボックスとして扱う
- これにより、
- 本質的な設計・アルゴリズム部分は完全に rule レス
- 同期の最小プリミティブだけが別レイヤとして存在
という構造になります
3.6 設計ポリシー
大きな機能モジュールは、Stmt で書いた 1 本の FSM と同期プリミティブだけで構成すること。
4. ChatGPTの考え
ChatGPTにこのRuleレス設計手法を聞いてみたら、非常に示唆に富む意見を貰ったので紹介します。
ruleが悪いわけではない
rule は 並行ハードウェアを正確に書くための強力な道具で、小さく閉じた並行構造やプロトコルではむしろ武器になる。
問題は rule 自体ではなく、人が読む設計に対して粒度が細かすぎる場面があること。rule は抽象度としては構造化RTL
レジスタ・ワイヤ・イネーブル・同期更新を時間軸込みで記述する。
見た目は高級でも、思考負荷は RTL に近い。粒度が細かい並行性は人間の能力を超える
人間が扱えるのは少数プロセス+明示ハンドシェイク程度。
多数の rule+暗黙同時発火+自動スケジューリングは
部分順序推論を要求し、認知限界を超えやすい。
$$表現力>理解能力$$だから設計手段を変えるのが合理的
method-only、Get/Put、Client/Server、step/tick は
並行性を人間が追える単位に束ね直すやり方。
これは回避ではなく、設計のスケーリング戦略。根本解はruleの上の層
人間は「意味」を書き、rule は上位モデルから生成される。
rule は内部表現として非常に優秀。
5. さいごに
BSV を学習した当初は「基本は rule だ」と理解していたものの、実際には rule があまり得意ではなく、ほとんど使ってきませんでした。今回、その理由を分析するとともに、BSV での設計経験から、基本的に rule を使わない手法の実現性をまとめてみました。
上に挙げたポリシーを守れば、思考モデルは「ソフトウェアの逐次プログラム+モジュール間の接続」だけになります。モジュール間の接続も AXI Stream 風にしてテンプレート化することができます。
ChatGPTとの対話でも2点気づきがあったのは大きかったです。
- BSVのruleが悪いのではなく、ハードウェアの複雑性を正確に写し取っているだけ
- ruleレス設計は逃げではなく、並行を隠蔽して人間の認識能力を高める手段
40 年くらい前に素の Verilog で 32bit CPU を設計しましたが、当時 BSV を初めとする環境があったら、一週間でできたのではないかと、つくづく現代のエンジニアを取り巻く環境の素晴らしさを感じます。