👋

自作CPUのパイプライン処理を改良した

2024/03/10に公開

はじめに

現在、chiselを用いて仮想CPUを自作しています。
もともとは書籍「RISC-VとChiselで学ぶ はじめてのCPU自作」のコードを写経して作ったもので、今はそれを自分なりに改良している段階です。
今回はパイプライン処理について、来るスーパスカラ実行に向けて改良した点をまとめておきます。

1 ID、EXステージのユニット化

もともとは1つのクラスの中でパイプラインの処理を全て行っていました。これを、IDステージ、EXステージについてはそれぞれ専用のクラスを作成し、ユニット化しました。

2 適切な演算器に指令を振り分け

EXステージには複数の演算器(現状ALU、乗算、除算)があります。もともとは全ての指令を全ての演算器に接続し、その指令を受けて演算を実行するかどうかは演算器側で決めていました。
つまり、指令がMULの場合、ALUと除算器は無視し、乗算器が掛け算を実行する、といった具合です。

しかし、対応していない指令に対しても演算器を動かすのは無駄ですし、後々スーパスカラ実行をする際に演算器が空いていなくて並列化できない、ということにもなります。

そこで、指令の種類に応じて適切な演算器に指令を振り分けるように変更しました。

val multiplyer = Module(new Multiplyer())
multiplyer.io.exe_fun := BUBBLE
switch(exe_fun){
  is(ALU_MUL, ALU_MULH, ALU_MULHSU, ALU_MULHU){
   multiplyer.io.exe_fun := exe_fun   
  }
}
multiplyer.io.op1_data := op1_data
multiplyer.io.op2_data := op2_data
val mul_out = multiplyer.io.calced

exe_funが実行する関数種類、op1_data、op2_dataがオペランドに格納されたデータと思ってください。乗算器が対応している指令の場合のみ乗算器に指令を入れ、それ以外の場合はBUBBLEを入力します。

3 パフォーマンスカウンタの追加

CPUに機能を追加していく上で、パフォーマンスがどの程度改善するのかは把握したいですよね。
そこで、パフォーマンスを測定し、表示できるようにしました。

追加したのは以下の指標です。

  • 処理サイクル数: プログラムを実行し終わるまでに要したサイクル数
  • 処理指令数:処理した指令数
  • ストール回数:途中でストールした回数
  • CPI:処理サイクル数/処理指令数

それぞれ以下のように計算します。

処理サイクル数

これはレジスタを追加し、サイクルごとにインクリメントするだけです。

val cycleCount = RegInit(0.U(32.W))
cycleCount := cycleCount + 1.U

ストール回数

「ストールするかどうか」を判定するフラグ(stall_flag)は実装済みです。
これが立っていない場合のみインクリメントします。

val stallCount = RegInit(0.U(32.W))
when(stall_flag){
  stallCount := stallCount + 1.U
}

処理指令数

以下の式で求められます。
処理指令数 = 処理サイクル数 - ストール回数 - (パイプライン段数 - 1)
右辺最終項は、パイプライン化することで余計に生じるサイクル数です(最後のコマンドは並列化の恩恵を受けられず、パイプラインの段数分余計にかかります)

val instCount = cycleCount - stallCount - 4.U

CPI

ただ割り算をするだけです。
Chiselでは浮動小数点型というのは存在しないので、内部では10倍したものを保存しておき、表示するときに整数部と小数部を別々に表示するようにしました。

val cpi10 = Mux(instCount === 0.U, 0.U, cycleCount * 10.U / instCount)

最後にprintf関数でこれらの指標を表示します。

printf(p"cycle count: ${cycleCount}\n")
printf(p"stall count: ${stallCount}\n")
printf(p"instruction count: ${instCount}\n")
printf(p"CPI: ${cpi10/10.U}.${cpi10%10.U}\n"

実行結果

以下のプログラムを実行してみます。

int main(){

  asm volatile("li a0, 5");
  asm volatile("li a1, 9");
  asm volatile("divu a2, a1, a0");
  asm volatile("unimp");

  return 0;
}

無事各種指標が測定できました。

処理終了条件の変更

"unimp"指令を検知するとプログラムを終了するようにしています。
もともとはIDステージでデコードした指令の種類を判別し、unimpなら終了、というようにしていました。
しかし、IDステージがunimpを処理しているとき、例えばその1つ前の行はEXステージにあります。
ここで終わってしまうと、(unimpを除いた)最後の行はMEM、WBステージを通らなくなってしまいます。
これを防ぐため、unimpの前にnop指令を3つ余分に入れていましたが、これも無駄です。

//元々のプログラム
int main(){

  asm volatile("li a0, 5");
  asm volatile("li a1, 9");
  asm volatile("divu a2, a1, a0");
  //↓↓これらを無理やり入れていた
  asm volatile("nop");
  asm volatile("nop");
  asm volatile("nop");
  //↑↑
  asm volatile("unimp");

  return 0;
}

ということで、以下のように解決しました。

  • IDステージでunimpを検出したら、その時のpcの4つ前(pcは4ずつ増える)をpc_before_endとしてレジスタに記憶
  • WBステージで処理するpcがpc_before_endに到達したら終了
  • unimp以降の指令はBUBBLE(何もしない)として処理
    • end_flagを追加し、unimp検出で1にセット
    • end_flagが立っていたらEX以降にはBUBBLEを渡す

コードでは以下のようになります。

val end_flag = RegInit(false.B)
val pc_before_end = RegInit(0.U)

//id_reg_instがフェッチしてきた本来の指令
//exe_br_flgは分岐が起こったことを表すフラグ 他も指令をBUBBLE化する条件を表すフラグ
val id_inst = Mux((exe_br_flg || exe_jmp_flg || stall_flag || end_flag), BUBBLE, id_reg_inst)

//IDステージが処理するpc
id_reg_pc := Mux(stall_flag, id_reg_pc, if_reg_pc)
  
when((id_inst === UNIMP) && (end_flag === false.B)){
  end_flag := true.B
  pc_before_end := id_reg_pc - 4.U //UNIMPの1つ前が最後
}

・・・

io.exit := false.B  //これがtrueになると終了
//wb_reg_pcがWBステージが処理するpc
when((pc_before_end > 0.U) && (wb_reg_pc > pc_before_end)){
  io.exit := true.B
}

参考

細かい話は以下に書きました。
https://www.rm48.net/post/chisel-scala-小数点のデータを導入する

今後の展望

まず直近の大きな目標はスーパスカラ実行です。
ただしその前に以下の特徴を再現できる必要があるので、まずはここをやっていきたいです。

  • 1つの演算に複数サイクルかかるようにする

このためには以下のようなことが必要かなぁと考えています。

  • 演算の種類ごとに必要サイクル数を指定
  • 演算が完了したかを表すフラグの追加
  • 演算が終わるまでは次の処理を待たせる

Discussion