タイトルの通りなのだが、Chiselというハードウェア記述言語を用いて、5ステージのRISC-Vパイプラインプロセッサの設計に挑戦していた。
プロセッサをはじめとするハードウェア設計は、大体の場合はVerilog-HDLやVHDLなどのハードウェア記述言語を使用するか、最近だと高位合成言語を使うケースがある。 ハードウェア記述言語を使う場合、主にデバッグをする際には波形をダンプして、波形を確認しながらデバッグを行うことになる。
ただし、Verilog-HDLやVHDLをはじめとするRTLシミュレータは、ソフトウェアシミュレーションに比べて動作が重いし、市販のもの(といってもSynopsys, Cadence, Mentorくらいしか無い)のものはライセンス料が非常に高い。個人でRTL開発するのに、RTLシミュレータにお金を払うことは不可能だ。
個人で使うならばMentorのQuestaSimの無料版を使うか、Verilatorを使うということになる。 (後述するがChiselで設計したハードウェアは、そのままのフローでVerilatorでシミュレーションすることができる)
そんな中でChiselだ。ChiselはScalaというソフトウェア言語を用いてハードウェアを設計する。 結論から言うとScalaというソフトウェア言語を用いているにもかかわらず、コーディング時はハードウェア脳で考えないと全くうまく書けない。 だからソフトウェア言語と言いつつハードウェアの気分でコーディングしなければならないのだが、利点としてはVerilogではない(最終的にVerilogに変換するが)ので、RTLシミュレータを使わなくてもシミュレーションができるという点だ。
このため、RTLシミュレータの、
- ライセンスに数の限りがあるためリグレッションテスト時の並列性に制限がある
- RTLシミュレーションはそもそも遅い
という問題をChiselが解決できるのではないか、と考えて、「Verilogを使わず、全てChiselを使ってパイプラインプロセッサの開発を行う」というものに挑戦し、どのような問題があるのかについて調査することにした。
実装したのは、RISC-Vプロセッサで非常に単純なパイプラインを持っている。5ステージでサポートしているISAはRV32I(非常に基本的な整数命令のみ)で、たぶんこれだったらVerilog-HDLで書くと目を閉じていても設計できるレベルのCPUだ。 GitHub上に公開しているので、興味があったら使ってみてほしい。
ただし、今回はChiselというハードウェア記述言語を使う。ChiselはScalaをベースにしてシミュレーションまでできるので、せっかくだから波形を全くダンプせずに、ScalaだけでどこまでCPUを作ることができるのか、挑戦してみることにした。
といっても、すぐに5ステージのパイプラインを作るのは、Chiselの知識も乏しいため自信がない。 そこで、以下のフローを踏んでCPUを設計することにした。
- ステートの存在しない、パイプラインの存在しないCPUを設計し、機能的に正しいことを検証する(ここでリグレッションテスト環境を立ち上げる)。
- 機能的に正しいことを確認しつつ(リグレッションを通しながら)、ステージを切っていって5ステージパイプラインプロセッサに拡張する。
まず、RISC-Vのテストパタンriscv-testsはセルフチェックのテストなので、全てのテストが成功すると、特定のメモリに特定の値を書いて終了するので、これを検知すればよい。 そうでなければ無限ループに入るか、特定のメモリに違う値を書いて終了するので、それを見ればよい。
で、波形を見ずにどのようにデバッグするかだが、毎サイクル実行した命令と、レジスタへの書き込み情報、メモリアクセスをした情報を1行にしてダンプし、ファイルに書き出す。 これをISSの実行結果と突き合わせて、どこが間違えたかを確認するのが基本となる。
以下のようなトレースファイルをChiselで生成し、これをISSと突き合わせた。
実際にこのようなトレースファイルを出力するためにいろいろと苦労したのだが、まずChisel上ではまともなprintf()が使えない。使えても"%08x"のような高度な書き方ができなかったり、stdoutにしか出力されないのでデバッグが死ぬほどやりにくい。 そこで、DebugPortを宣言してCPUの入出力ポートと別に、シミュレーション時にだけ登場するデバッグポートを生成し、CPU本体ではなく、テストベンチにCPUの情報を取り込んで出力する機構を用意する。一度Chiselから、外のScalaのテストベンチに出てしまえば、あとはJavaの機能などをふんだんに使用できるので、ファイルの書き出しなども思いのままに可能となる (その環境を作るのも結構大変だったが)。
デバッグポートは以下のようにして記述した。config.debug
が有効ならば、デバッグポートが有効になり、そうでないときは全てビット幅が0に設定される。
Chiselの仕様は良く分からないが、どうもビット幅を0にするとそのポートは宣言されないらしい。
class CpuDebugMonitor [Conf <: RVConfig](conf: Conf) extends Bundle { val inst_fetch_req = if (conf.debug == true) Output(Bool()) else Output(UInt(0.W)) val inst_fetch_addr = if (conf.debug == true) Output(UInt(conf.bus_width.W)) else Output(UInt(0.W)) val inst_fetch_ack = if (conf.debug == true) Output(Bool()) else Output(UInt(0.W)) val inst_fetch_rddata = if (conf.debug == true) Output(SInt(32.W)) else Output(SInt(0.W)) val pc_update_cause = if(conf.debug == true) Output(UInt(3.W)) else Output(UInt(0.W)) val inst_valid = if (conf.debug == true) Output(Bool()) else Output(UInt(0.W)) val inst_addr = if (conf.debug == true) Output(UInt(conf.bus_width.W)) else Output(UInt(0.W)) ...
以下のようにしてテストベンチ上でトレースポートを引き出し、ファイルに書き出す。
val writer = new PrintWriter(new File(pipename)) ... val inst_fetch_req = peek(cpu_tb.io.dbg_monitor.inst_fetch_req) val inst_fetch_addr : Long = peek(cpu_tb.io.dbg_monitor.inst_fetch_addr).toLong ... if (inst_fetch_req == 1) { writer.printf("[%08x]".format(inst_fetch_addr)) } else { writer.printf(" ") }
これにより、以下のようなトレースファイルを生成した。これでCPUの内部動作を監視できるようになる。
2 : x00<=0x0000000000000004 ( 0, 0000000000000000, 0000000000000000) : 0x00000000 : INST(0x04c0006f) : j pc + 0x4c 4 : x10<=0x0000000000000000 (21, 0000000000000000, 0000000000000000) : 0x0000004c : INST(0xf1402573) : csrr a0, mhartid 5 : : 0x00000050 : INST(0x00051063) : bnez a0, pc + 0 6 : x05<=0x0000000000000054 ( 1, 0000000000000000, 0000000000000054) : 0x00000054 : INST(0x00000297) : auipc t0, 0x0 7 : x05<=0x0000000000000064 ( 1, 0000000000000054, 0000000000000010) : 0x00000058 : INST(0x01028293) : addi t0, t0, 16 8 : x00<=0x0000000000000000 (21, 0000000000000064, 0000000000000000) : 0x0000005c : INST(0x30529073) : csrw mtvec, t0 9 : x00<=0x0000000000000000 (21, 0000000000000000, 0000000000000000) : 0x00000060 : INST(0x18005073) : csrwi satp, 0 10 : x05<=0x0000000000000064 ( 1, 0000000000000000, 0000000000000064) : 0x00000064 : INST(0x00000297) : auipc t0, 0x0 11 : x05<=0x0000000000000080 ( 1, 0000000000000064, 000000000000001c) : 0x00000068 : INST(0x01c28293) : addi t0, t0, 28 12 : x00<=0x0000000000000064 (21, 0000000000000080, 0000000000000064) : 0x0000006c : INST(0x30529073) : csrw mtvec, t0 13 : x05<=0xffffffffffffffff ( 1, 0000000000000000, ffffffffffffffff) : 0x00000070 : INST(0xfff00293) : li t0, -1 14 : x00<=0x0000000000000000 (21, ffffffffffffffff, 0000000000000000) : 0x00000074 : INST(0x3b029073) : csrw pmpaddr0, t0 15 : x05<=0x000000000000001f ( 1, 0000000000000000, 000000000000001f) : 0x00000078 : INST(0x01f00293) : li t0, 31 16 : x00<=0x0000000000000000 (21, 000000000000001f, 0000000000000000) : 0x0000007c : INST(0x3a029073) : csrw pmpcfg0, t0 17 : x05<=0x0000000000000080 ( 1, 0000000000000000, 0000000000000080) : 0x00000080 : INST(0x00000297) : auipc t0, 0x0
5ステージパイプラインへの拡張
ここから、5ステージのパイプラインに拡張していくのだが、これも同様に波形をダンプしない。 波形をダンプしない代わりに、ファイルにパイプラインをどんどん書き出していき、各ステージの状況がどのようになっているのかテキストファイルを見ればわかるようにする。
これで、ALUの演算の種類、ALUに入力されたオペランド、レジスタリードでの読み込みデータ、レジスタの書き込み情報、ストールの発生状況などを確認する。
パイプラインのフォワーディングミスとか、レジスタ書き込みのミスとか確認するのはちょっとつらかったが、Chiselのコードを冷静に見直しながらシミュレーションすることで、無事にすべてのリグレッションテストを通すことができた。
デバッグに波形を使わないことで、「とりあえず実行してみる」ということがなくなる
「とりあえず波形を出してからデバッグしよう」ということができなくなり、冷静に自分の作ったデザインを見直して、どういう状況が発生しうるかを見なければならない。これはある意味時代錯誤だ。
ここから分かることは、便利な(波形という)ツールがあるのならばそれは使った方がよい。ただし、シミュレータのライセンス数や、RTLシミュレーションに時間がかかるのであれば、RTLを使わないChiselをうまく活用すべきだ。
Scalaを使った場合のシミュレーション速度はどうなのか?
Chiselを使った場合の利点として、Scalaのコードのままシミュレーションを実施できるということだ。 今回の目標は、RTLシミュレータを一切使わずにRISC-Vプロセッサを設計することだ。したがって、リグレッション環境もScalaを使って構築することになる。
流さなければならないのは、rv64ui系のテストパタン(60本くらい)だ。これを実行するためには、60種類のhexファイルをRTLにロードしてはシミュレーションし、結果を回収することになる。
1本のテストパタンを流すためには、以下のようなクラスを作成してsbtで実行する。
class Tester_rv64ui_p_add extends ChiselFlatSpec { "Basic test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() iotesters.Driver.execute(Array(), () => new CpuTop()) { c => new CpuTopTests(c, "tests/riscv-tests/isa/rv64ui-p-add.hex", "pipetrace.rv64ui_p_add.log") } should be (true) } }
いろいろ試行したのだが、コマンドラインから引数を渡して、hexファイルなどを指定できるように頑張ったのだが、どうもやり方が良く分からなかった。 そこで、スクリプトを使って60本分のテストパタン分のクラスを自動生成するようにした。
以下のようなファイルを60個、スクリプトで自動生成する。
Test_rv64ui-p-add.scala
class Tester_rv64ui_p_add extends ChiselFlatSpec { "Basic test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() iotesters.Driver.execute(Array(), () => new CpuTop()) { c => new CpuTopTests(c, "tests/riscv-tests/isa/rv64ui-p-add.hex", "pipetrace.rv64ui_p_add.log") } should be (true) } }
Test_rv64ui-p-sub.scala
class Tester_rv64ui_p_sub extends ChiselFlatSpec { "Basic test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() iotesters.Driver.execute(Array(), () => new CpuTop()) { c => new CpuTopTests(c, "tests/riscv-tests/isa/rv64ui-p-sub.hex", "pipetrace.rv64ui_p_sub.log") } should be (true) } }
そして、リグレッションテストを実行するためのクラスを作成し、これを実行した。
Test_AllPatterns.scala
class Tester_AllPattern extends ChiselFlatSpec { "rv64ui_p_add test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() iotesters.Driver.execute(Array(), () => new CpuTop()) { c => new CpuTopTests(c, "tests/riscv-tests/isa/rv64ui-p-add.hex", "pipetrace.rv64ui_p_add.log") } should be (true) } "rv64ui_p_addi test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() iotesters.Driver.execute(Array(), () => new CpuTop()) { c => new CpuTopTests(c, "tests/riscv-tests/isa/rv64ui-p-addi.hex", "pipetrace.rv64ui_p_addi.log") } should be (true) } "rv64ui_p_addiw test using Driver.execute" should "be used as an alternative way to run specification" in { implicit val conf = RV64IConf() ...
現時点ではすべてのテストはシーケンシャルに流れる。並列に実行したいが、これはまた後日。
同じような構成で、RTLを生成してVerilatorを実行し、VCDを取得することもできる。 こちらと処理速度を比較した。
- Verilator実行 : 225秒
- Chisel実行 : 257秒
あれ、Verilatorの方が早いじゃないか?まあ、確かにデザインが小さいし、リグレッションテスト自体は短いからかもしれない。小さなデザインで短いテストを流すならば、Verilatorで十分かもしれないということが分かった。
Chisel実行を並列化した
なんかおかしいので、Chiselのテストパタン実行を並列化してみた。以下のようにしてループを並列化して、並列に実行できるようにしてみた。
(0 to 50).par foreach { idx => iotesters.Driver.execute(Array(), () => new CpuTop(new RV64IConfig)) { c => new CpuTopTests(c, pattern_path(idx), log_path(idx)) } should be (true) }
4コアCPU、8スレッドで実行して結果は以下となった。
- Verilator実行 : 225秒
- Chisel実行 : 257秒
- 4コア Chisel実行 : 202秒
予想外に伸びない。Scalaの環境は、sbtの依存関係の関係上makeの並列実行をすることができない。 Chiselの実行環境については、もう少し改善の余地はありそうな気がする。 ちなみに、同じ方法でVerilatorでVCDを取ろうとしたが、途中でハングしてしまい実行できなかった。