Chiselを使ったRISC-Vの勉強(7.ハザードの解決)
MEMステージの実装中です。
制御ハザードはすでに分岐命令を実装した時点で実装しており、パイプライン化をしても特筆すべき点はありません。
ここではデータハザードを直していきます。
データハザードの解決が終わると、MEMステージをこのままにするか、もう1ステージ増やして6段パイプラインにするかが見積もりやすくなるはずです。
データハザードの解決
命令は上から下に実行されていきます。横軸は時間で、各命令のどのステージが進行中かがわかるようになっています。
例えばaddi命令。
- IF ステージ:命令フェッチ
- IDステージ:rs1,rs2決定
- EXステージ:ALUの入力と命令が確定
- MEMステージ:ALUからの演算結果(addiの場合は足し算の値)確定
- WBステージ:rdへALUからの演算結果代入
rd確定前に、次命令がrs1,rs2が確定しているべきステージ(IDステージ)を過ぎているからです。
例は少し変わりますが、以下のような命令が考えられます。
lui x1, 0x08 # x1 = 0x08 << 12 = 0x8000 addi x1, x1, 0x04 # x1 = x1 + 0x04 = 0x8004となるべきだが.....?
このままだと、最終的なx1の値は0x04となってしまいます。x1の値がまだWBステージまで到達する前に次の命令のEXステージで計算が開始されてしまうからです。
データフォワーディング
ここでハザードが起こることは分かりました。
解決していきます。この場合はデータフォワーディングすることによってこの問題に対処します。
rdに結果が代入されるのはWBステージですが、一つ前のMEMステージで値は確定しています。ですのでMEMステージで次命令のEXステージに結果をバイパスします。
他にデータフォワーディングが必要とされる状況が考えられるでしょうか。
データメモリにアクセスする命令でハザードが多発すると思われます
(例1)
addi x2 x0, 0xAA # x2 = 0xAA sw x2, 5(x31) # dmem[x31+5] = x2 lw x3, 5(x31) # x3 = dmem[x31+5]
ここでx3は0xAAとなるべきです。先ほど、データハザードの対策を適切にしたはずなのでこの場合でも正しい値が出るはずです。
でも以下の場合はどうでしょうか。
(例2)
addi x2 x0, 0xAA # x1 = 0xAA nop sw x2, 5(x31) # dmem[x31+5] = x2 lw x3, 5(x31) # x3 = dmem[x31+5]
「nopも入れてるんだし当然動くでしょ」と思ってたのですが、x3=0。しばらくデータメモリの実装を疑いましたが異常はありませんでした。
というか(例1)で動いているのでデータメモリの実装がおかしいはずはないのですが。。。
val ex_reg_rs1_bypass: UInt = MuxCase(ex_rs(0), Seq( (ex_reg_raddr(0) =/= 0.U && ex_reg_raddr(0) === mem_reg_waddr && mem_ctrl.rf_wen === REN_1) -> mem_alu_out, (ex_reg_raddr(0) =/= 0.U && ex_reg_raddr(0) === wb_reg_waddr && wb_ctrl.rf_wen === REN_1 /*&& wb_ctrl.mem_en === MEN_1*/) -> io.r_dmem_dat.data )) val ex_reg_rs2_bypass: UInt = MuxCase(ex_rs(1), Seq( (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === mem_reg_waddr && mem_ctrl.rf_wen === REN_1) -> mem_alu_out, (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === wb_reg_waddr && wb_ctrl.rf_wen === REN_1 && wb_ctrl.mem_en === MEN_1) -> io.r_dmem_dat.data, (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === wb_reg_waddr && ex_ctrl.rf_wen === REN_0 && ex_ctrl.mem_en === MEN_1) -> wb_alu_out ))
データストールによる解決
他にどんな場合があるでしょうか。
WBステージでないと値が確定できない命令があります。Load命令全般です。
この場合はMEMステージから値だけもらってフォワーディングする、といった技が使えません。ストールを行うことで対処します。
(Load命令 && 次命令がLoad命令が使用したアドレスをrs1,rs2に使う) → ストール信号生成
ストール信号を発行するとpcの更新がとまり、ストールしたいステージのレジスタの更新が停止することになります。今回、IDステージを停止させるので、以下の図のようにIDステージが2回実行され、その時のEXステージはNOPとなります。
密結合メモリをFPGAの内部に作り込む前提である場合はここは問題とはなりませんが、FPGA外部のSDRAMなどにデータメモリを持つ場合はこの実装では動かないでしょう。
今回の実装の場合、メモリ側からackが帰ってくるまで待つのが正しい実装と言えます。ackが帰ってくるまで永久にIDステージを繰り返します。*1
var mem_stall: Bool = RegInit(false.B) when (ex_ctrl.mem_wr === M_XRD) { mem_stall := true.B } .elsewhen(io.r_dmem_dat.ack === true.B) { mem_stall := false.B } // メモリリード信号が発行されてから、メモリからACK信号(データ送信済み)がくるまでmem_stall信号をtrueにする。 load_stall := ((ex_reg_waddr === id_raddr(0) || ex_reg_waddr === id_raddr(1)) && (ex_ctrl.mem_en === MEN_1) && (ex_ctrl.mem_wr === M_XRD)) || mem_stall
テストコード
今回の実装を包括的に網羅するアセンブラを記述します。
_label1: lui x1, 0x08 # x1 = 0x08 << 12 addi x1, x1, 0x04 # x1 = x1 + 0x04 = 0x8004 addi x2, x1, 0x00 # x2 = x1 sw x2, 12(x31) # dmem[x31 + 12] = x2 = 0x8004 lw x3, 12(x31) # x3 = dmem[x31 + 12] => 0x8004になっているべき addi x4, x0, 0xAA # x4 = 0xAA sw x4, 12(x31) # dmem[x31 + 12] = x4 = 0xAA lw x5, 12(x31) # x5 = dmem[x31 + 12] = 0xAA beq x4, x5, _label2 # x4 = x5 なら_label2へジャンプ addi x10, x0, 0x44 addi x11, x0, 0x55 _label2: jal x0, _label2 # 無限ループ
所感
ようやく5ステージパイプラインを持つCPUっぽくなってきました。さらにストールやフォワーディングが必要になった場合は今回のコードに追記する形でできると思いますので、そんなに辛くなさそう。
あと、ストールのところで「メモリリード信号が発行されてから、メモリからACK信号(データ送信済み)がくるまでmem_stall信号をtrueにする。」としていますが、ハザードは解決されたもののちょっとタイムロスが発生しているので、もう少し工夫してみようかなと思います。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.15 --depth 1 cd KyogenRV/
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
makefileでOSによってod/godをスイッチしています。
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
次回は?
この一ヶ月ほど、駆け足でやってきました。
コロナの影響で止まっていた製造関係も少しづつ再開が始まってきました。
通常作業がいつもの80%くらいまで復活してきています。*2
ですのでRISCV関係は少しペースは落としていかなければならないと思いますが、引き続き続けていきますのでよろしくお願いいたします。