0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自作CPUをRocket chipと比較してBranch Predictor周りを改善してみた

0
Posted at

はじめに

CPUを1から作成しています。
https://github.com/tomamints/mints-riscv-cpu

SystemVerilogで自作しているRV64IMAC CPU「MiNTs-CPU」を、Rocket Chipと比較しながら改善しました。

この記事では、その中でも branch predictor / frontend に絞って書きます。

MiNTs-CPUはすでにOpenSBI、Linux 6.12、BusyBoxが動くところまで来ています。ただ、Linuxが動くようになると次に気になるのは、

世の中の公開されているモデルとどこまで性能に差があるのか
というところです。

そこでRocket Chipを参照CPUとして使い、自作CPUのどこに余分なサイクルがあるのかを測りました。

結論から言うと、今回のbranch-heavyな共通assembly microbenchmarkでは、

MiNTs-CPU:
  Rocketとほぼ同等のcyclesまで改善

Linux boot workload:
  CPI 2.529 -> 2.393
  IPC 0.395 -> 0.417

まで改善できました。(約5.4%ほど改善)

ただし、これは「CPU全体でRocket」という意味ではありません。今回言えるのは、

共通RV64 integer assemblyのbranch-heavy loopにおいて、MiNTs-CPUのfrontend bubbleをRocketとほぼ同等まで削れた

ということです。

比較で最初に気をつけたこと

最初はCで小さなbenchmarkを書いて比較していました。

しかしC benchmarkだと、CPUそのもの以外の差が混ざります。

- compiler version
- optimization level
- ABI
- startup/runtime
- linker layout
- libc / host interface

実際、-O0 と -O2 だけでもMiNTs側のCPIが大きく変わりました。

なので、branch predictorのようなmicroarchitectureを比較するには、C benchmarkのままだと少し曖昧です。

そこで、比較対象を 共通のhand-written assembly kernel に切り替えました。

構造はこうです。

platform-specific wrapper
  ↓
rdcycle / rdinstret
  ↓
common assembly benchmark
  ↓
rdcycle / rdinstret
  ↓
platform-specific exit / print

RocketとMiNTsでwrapperは違いますが、測定区間のassembly kernelは共通です。

これにより、比較したい部分をかなり絞れます。

same measured instruction stream
same logical workload
same iteration count
same final result
same rdcycle / rdinstret measurement

Benchmark

今回見た基本ループは、整数ALU + loop branchだけの小さいものです。

1 iterationあたりの命令はこうです。

xor     t0, t0, t1
slli    t4, t0, 7
srli    t0, t0, 57
or      t0, t0, t4
addi    t1, t1, 1
add     t0, t0, t2
bne     t1, t3, 1b

つまり、ほぼ、

6 ALU instructions + 1 taken branch

です。

このbenchmarkでは、D-cache、store buffer、PTW、MMIO、Linuxなどはほぼ関係ありません。

なので差が出るなら、主に、

- basic pipeline
- forwarding
- instruction fetch
- branch prediction / redirect

あたりです。

さらに、branchの影響を見るために、同じ仕事を2x、4xにunrollしたvariantも用意しました。

1x:
  6 ALU + branch

2x:
  12 ALU + branch

4x:
  24 ALU + branch

もし差がbranch回数に比例するなら、unrollすると差も比例して減るはずです。

最初の結果

最初の結果はこうでした。

variant Rocket cycles MiNTs cycles MiNTs extra
1x 70,045 90,013 +19,968
2x 65,042 75,025 +9,983
4x 62,545 67,537 +4,992

これはかなり分かりやすい結果でした。

MiNTsの余分なサイクルが、unrollするたびにほぼ半分になっています。

1x extra:  約20,000 cycles
2x extra:  約10,000 cycles
4x extra:  約 5,000 cycles

つまり、MiNTs側の余分なコストは、

branch回数にほぼ比例している

ということです。

この時点で、主犯はmemoryではなく、taken branchを処理するfrontend側だと見えてきました。

既にbranch predictorはあった

ここで少しややこしいのは、MiNTs-CPUにはすでにbranch predictorがありました。

大まかには、

- 2-bit PHT
- BTB
- RAS

を持っています。

なので最初は、

branch predictorが外れているのか?

と思いました。

しかし実際には、問題は精度そのものではありませんでした。

問題は、

予測が当たっていても、その予測を使うのが遅い

ことでした。

当時の流れは、概念的にはこうです。

fetch instruction
  ↓
instructionを切り出す
  ↓
branch predictorが命令を見る
  ↓
takenと分かる
  ↓
fetch redirect
  ↓
frontend flush / restart

つまり、正しくtakenと予測できても、その時点でfrontend redirectとして扱っていました。

これはbranch predictorが「当たるかどうか」とは別の問題です。

prediction accuracy
  = 予測が正しいか

prediction timing
  = 予測をいつ使えるか

今回は後者が問題でした。

改善1: fetch-side BTB

最初の改善は、conditional branchのtargetをfetch側で早く使うことです。

改善前は、branch命令をfetchしてから予測していました。

before:
  fetch instruction
  -> branchだと分かる
  -> predictor says taken
  -> redirect

改善後は、fetch PCの時点でBTB/PHTを見るようにしました。

after:
  fetch PC
  -> BTB/PHT lookup
  -> predicted target PC
  -> target blockを早くfetch

ここで大事なのは、architecturalな正解はあくまでEX stageに残していることです。

predictor = hint
EX branch resolution = truth

なので、fetch側のBTBは間違っても構いません。EXで実際のbranch結果と比較し、外れていたらflush/recoveryします。

逆に、当たっているときはfrontendを無駄にflushしないようにします。

fetch-BTB後の結果

fetch-side BTBを入れた後のMiNTs結果です。

variant before cycles after fetch-BTB saved
1x 90,013 80,015 9,998
2x 75,025 70,027 4,998
4x 67,537 65,039 2,498

saved cyclesがbranch回数に比例しています。

つまり、fetch-side BTBで、

約1 cycle / taken branch

を削れました。

ただし、まだRocketとの差は残っていました。

variant Rocket cycles MiNTs after fetch-BTB remaining gap
1x 70,045 80,015 +9,970
2x 65,042 70,027 +4,985
4x 62,545 65,039 +2,494

これもまたbranch回数に比例しています。

つまり、もう1 cycle / branchくらいの固定costが残っていました。

改善2: fetch FIFO / issue FIFO bypass

次にtraceを見ました。

ここで分かったのは、branch targetへのfetch自体はかなり早くなったものの、その後にまだfetch block handoffのbubbleが残っていたことです。

MiNTsのfetch単位は8 byteです。

今回のloopでは、branch targetが8-byte blockの後半に来るケースがありました。

8-byte fetch block
  lower 4B: previous instruction
  upper 4B: branch target instruction

この場合、target instructionを1個出したあと、次の命令は次の8-byte blockにあります。

ところが以前は、

I-cache response
  ↓
target instructionを出す
  ↓
次cycle以降に次blockをrequest

のようになっていて、ここに固定の1 cycle bubbleがありました。

そこで、

- fetch FIFO response bypass
- issue FIFO fall-through / bypass
- I-cache response cycleで次requestを受けられるようにする

を入れました。

イメージとしては、FIFOを常に通過するpipeline stageとして使うのではなく、

downstreamが受け取れるなら直接流す
詰まった時だけFIFOに退避する

という形です。

new fetched instruction
  ↓
issue FIFO empty?
  ↓ yes
core ready?
  ↓ yes
bypass to core

もちろん、FIFOに既存命令がある場合は必ずFIFOを優先します。

FIFOに既存命令あり
  -> FIFO headを先に出す
  -> 新しい命令はFIFOへ

そうしないと命令順序が壊れます。

最終結果

fetch-side BTBに加えて、fetch/issue bypassとI-cache follow-on requestを入れた後の結果です。

variant Rocket cycles MiNTs cycles delta
1x 70,045 70,014 -31
2x 65,042 65,026 -16
4x 62,545 62,538 -7

このbranch-heavyな共通assembly benchmarkでは、Rocketとほぼ同等まで来ました。

この結果が示しているのは、

少なくともこのbranch-heavy integer loopでは、
以前MiNTsにあったbranch-frequency-proportional frontend bubbleはほぼ消えた

ということです。

Linuxでも効いたか

この状態で、同じfrontend改善後にOpenSBI + Linux + BusyBoxの100M cycle checkpointを測りました。

構成は以下です。

OpenSBI + Linux 6.12 + BusyBox
cycles:       100,000,000
D-cache:      512 lines x 32B
Store buffer: 8 entries
D-cache:      write-through

改善前後はこうです。

metric before after delta
retired 39,534,138 41,780,831 +2,246,693
CPI x1000 2,529 2,393 -136
IPC x1000 395 417 +22
primary ifetch 19,749,634 16,160,494 -3,589,140
primary mem 13,068,681 13,905,496 +836,815

CPIで見ると、

2.529 -> 2.393

なので、約5.4%改善です。

固定100M cycleでretired instructionも約225万増えています。

また、primary ifetchが大きく減っています。

primary ifetch:
  19.75M -> 16.16M

これは、今回のfrontend改善がLinux workloadにも効いていることを示しています。

一方で、primary memは少し増えました。

これは悪いというより、frontendのbubbleが減ったことで、次のボトルネックとしてmemory側が見えやすくなった、と考えています。

frontend bubbleを削る
  ↓
より多くの命令が進む
  ↓
memory systemへの圧力が増える

CPUの性能改善ではよくあるボトルネック移動です。

correctnessでハマったところ

fetch-side predictionを入れると、fence.i が重要になります。

fence.i は、instruction memoryを変更した後に、その変更をinstruction fetchへ反映させるために使われます。

例えば、testでは命令列をmemoryに書き換えてから fence.i し、その後に書き換えた命令へ飛ぶケースがあります。

このときbranch predictorやBTBが古い情報を持ったままだと、fetch-side predictionが stale になります。

そこでMiNTs-CPUでは、fence.i をMEM stageでpreciseなfrontend invalidationとして扱うようにしました。

fence.i reaches MEM
  -> redirect fetch to fence.i + 4
  -> invalidate I-cache
  -> invalidate branch predictor state

ここで重要なのは、fence.i と sfence.vma / satp を分けて扱うことです。

fence.i:
  instruction fetch / predictor stateの同期

sfence.vma / satp:
  address translation / TLBの同期

ここを混ぜると、後でMMUやcacheまわりが読みづらくなります。

修正後は以下を確認しました。

make build        PASS
rv64ui-p-fence_i PASS
make test-rv64ui 54 / 54 PASS
Whisper lockstep  PASS

今回学んだこと

1. branch predictorは「当たる」だけでは足りない

今回一番大きかったのはこれです。

branch predictorが正しい方向を出していても、それを使うのが遅かったり、正解時にもflushしていたりすると、性能は出ません。

accuracy is not enough
timing matters

という感じです。

2. C benchmarkだけで原因を見るのは危ない

C benchmarkではcompilerやABIの差が混ざります。

今回も最初はCで見ていましたが、途中からcommon assemblyに切り替えたことで、

これはbranch回数に比例している

とかなり明確に言えるようになりました。

CPU同士を比べるときは、

same source

よりも、

same measured instruction stream

の方が強いです。

3. microbenchmarkとLinuxの両方を見るのが大事

microbenchmarkは原因を切るのに向いています。

一方でLinux workloadは、実際のシステムで効くかを見るのに向いています。

今回も、

common assembly:
  branch frontend bubbleをほぼ消せた

Linux 100M:
  CPI 2.529 -> 2.393

という形で、両方を見たことで納得感が出ました。

4. predictorはhint、EX/MEMがtruth

fetch側のpredictionはどれだけ賢くしても、architecturalな正解ではありません。

今回も設計思想として、

predictor = hint
EX/MEM/CSR = truth

を守るようにしました。

これを守っておくと、BTB、PHT、RASを後で差し替えてもpipeline本体を壊しにくいです。

次に見るところ

branch-heavy loopではかなり改善できたので、次はmemory側をRocketと比較しています。

候補はこのあたりです。

- L1 hit load
- dependent load
- store hit
- pointer chase
- cache miss
- TLB / PTW

実際、次の比較ではstoreやload pathにまだ差が見えています。

なので今後は、

frontend
  -> かなり改善

next:
  LSU / D-cache / store buffer / memory hierarchy

という流れになりそうです。

まとめ

今回やったことを短くまとめると、

1. Rocketと共通assembly benchmarkで比較した
2. MiNTsの余分なcycleがbranch回数に比例していると分かった
3. fetch-side BTBで約1 cycle / branchを削った
4. fetch/issue bypassとI-cache follow-on requestで残りのbubbleを削った
5. branch-heavy loopではRocketとほぼ同等になった
6. Linux 100MでもCPI 2.529 -> 2.393まで改善した

自作CPUを作っていると、最初は「命令が正しく動くか」が中心になります。

でもLinuxが動き始めると、次は、

なぜ遅いのか
どこで詰まっているのか
どう直すと効くのか

を見る段階になります。

今回のbranch predictor/frontend改善は、その入口としてかなり面白い題材でした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?