RISC-V のベクトル命令の仕様一覧のページ
サイクル精度シミュレータ Sniperの勉強
- AMBA CHIについての勉強
- オープンソース形式検証ツールSymbiYosysを用いて形式検証に入門する
- Vivado Simulatorを使ってUVMに入門する
- RISC-V IOMMU の構成についてマニュアルを読んでまとめる
RISC-V OoO CPUを実装する (15. Commit slot expose: 実機コスト0のDIFFTEST hook)
前回でIQ age matrix入れて4命令が完走できる状態になった。
次のステップは「実行traceを外に出す」機構。retire (commit) 時の (pc, rd_addr, rd_data) をtop moduleのportとして拾えるようにして、reference ISS (spike / whisper) とco-simulationで状態一致を検証する下ごしらえ。
同時に「rd_dataは実機で不要なのにROBに持たせる」ジレンマを\ifdef DIFFTEST_ENABLE`のguardで解決した話。
DIFFTESTとは
XiangShanなどが呼んでいる名前で、要はcycle-by-cycleのtrace比較。retire時のarchitectural state (pc / rd write) をDUTとreference ISSで並行に流して、1 stepでも差分が出たら止める、という仕組み。in-house RTLでは極めて有効なbug早期発見手段。
必要なcommit trace情報:
| 情報 | source |
|---|---|
pc |
実行した命令のPC (ROB entry) |
rd_addr |
アーキreg番号 (ROB entry / arch RAT commit) |
rd_data |
rdに書いた値 (wb bus / PRF) |
valid |
このcycleにretireしたか |
追加でstoreの (addr, data)、CSR writeの (addr, data)、trapの (cause, epc) も欲しくなるが、まずはGPR writeだけexposeして基礎を作る。
rd_dataをROBに持たせるか、PRFから読むか
Retire時にrd_dataを取得する方法は2択:
- (a) PRFにretire時read portを追加、
PRF[cmt_preg]を読む - (b) wb時にROB entryにrd_dataをlatchしておき、retireで読み出す
(a) は追加read port分のPRF cost (per-lane 1 port × DISP_SIZE + arbitration) が痛い。
(b) はROB entryにlogic [XLEN_W-1: 0] rd_data;を足すだけ。今回は (b) を採用。
ただしROB entryへのadditional fieldは面積コストがある: XLEN × ROB_ENTRIES × 1 (per entry) = 64 × 32 = 2048 FF。無視できない。
実機ではDIFFTEST不要なのでrd_dataは要らない。この不要なコストを避けるため、\ifdef DIFFTEST_ENABLE`でguard:
typedef struct packed { logic done; logic [VADDR_W-1: 0] pc; // trapのmepcに使うのでunconditional reg_type_t rd_type; logic [GPR_W-1: 0] rd_addr; logic [XPR_PRF_W-1: 0] prd; `ifdef DIFFTEST_ENABLE logic [XLEN_W-1: 0] rd_data; // trace verification only `endif logic excpt_valid; logic [ 3: 0] excpt_cause; } rob_entry_t;
これで実機合成時 (DIFFTEST_ENABLEをdefineしない) はrd_data fieldが消え、面積コストゼロ。sim時はMakefileのVFLAGS_BUILDに+define+DIFFTEST_ENABLEを足して自動enable。
wb latchとretire outputの追加
wb側:
`ifdef DIFFTEST_ENABLE logic [XLEN_W-1: 0] w_pr_ports_rd_data; `endif always_comb begin ... `ifdef DIFFTEST_ENABLE w_pr_ports_rd_data = '0; `endif for (int w = 0; w < PRF_WRITE_PORTS; w++) begin if (w_rob_wr_valid[w] & (w_rob_wr_bank_idx[w] == b_idx[BW-1:0])) begin ... `ifdef DIFFTEST_ENABLE w_pr_ports_rd_data = i_wb[w].result; `endif end end end always_ff @(posedge i_clk) begin ... if (w_wr_ports_valid) begin r_rob[b_idx][w_wr_ports_entry_idx].done <= 1'b1; `ifdef DIFFTEST_ENABLE r_rob[b_idx][w_wr_ports_entry_idx].rd_data <= w_pr_ports_rd_data; `endif ... end end
Retire output:
output logic [VADDR_W-1: 0] o_cmt_pc [DISP_SIZE], `ifdef DIFFTEST_ENABLE output logic [XLEN_W-1: 0] o_cmt_rd_data [DISP_SIZE], `endif
o_cmt_pcはpc fieldがtrap用に既にunconditionalで保持されているので、exposeもunconditional (追加コスト = wireだけ)。o_cmt_rd_dataだけがifdef guard。
Top module配線
同じifdef patternでtopも:
scariv_rob u_rob ( ... .o_cmt_pc ( w_cmt_pc ), `ifdef DIFFTEST_ENABLE .o_cmt_rd_data ( w_cmt_rd_data ), `endif ... ); assign o_cmt_pc = w_cmt_pc; `ifdef DIFFTEST_ENABLE assign o_cmt_rd_data = w_cmt_rd_data; `endif
Top moduleのo_cmt_pcはunconditionalで、o_cmt_rd_dataはifdef guard。 内部wire宣言も同様。
LATCH warning: default init忘れ
Verilogのalways_combで 分岐の一部でしかassignしないと、latch推論されてlint warningが出る。今回:
`ifdef DIFFTEST_ENABLE logic [XLEN_W-1: 0] w_pr_ports_rd_data; `endif always_comb begin w_wr_ports_valid = 'h0; ... // w_pr_ports_rd_dataのdefault init忘れ for (int w = 0; ...) begin if (...) begin ... `ifdef DIFFTEST_ENABLE w_pr_ports_rd_data = i_wb[w].result; `endif end end end
for loopのif分岐でmatchしなかったcycleはw_pr_ports_rd_dataに何もassignされず、latch化される。修正はalways_comb冒頭でdefault 0:
always_comb begin ... `ifdef DIFFTEST_ENABLE w_pr_ports_rd_data = '0; `endif for (int w = 0; ...) ... end
VerilatorがLATCH warningでcatchしてくれる。このpatternはexcpt_valid / excpt_causeでも同じ。
imm muxの追加 (実測で発覚)
configurationが済んでsmoke testをrunしたらpcは正しいのにrd_dataが全部0。ADDI x1..x4のはずがx1=0, x2=0, ...。
原因: top moduleのPRF-read glueが常にop2 = PRF[prs2]で構築していた。ADDIの場合rs2は使わずimmをop2に渡すべきなのに、PRF[0] (=0) が入って結果が0 + 0 = 0に。
修正: imm_validを見てmux:
always_comb begin w_issued[p] = '0; w_issued[p].rn = w_iss_uop[p]; w_issued[p].op1 = w_prf_rd_data[2*p+0]; w_issued[p].op2 = w_iss_uop[p].uop.imm_valid ? w_iss_uop[p].uop.imm : w_prf_rd_data[2*p+1]; end
これでADDI x1..x4が想定通りrd_data = 1..4になった。
なおAUIPCはrd = pc + immでop1も特別 (PCを入れる必要)。現状は未対応 (rs1_type=REG_NONEでprs1=0 → op1=0になり、AUIPC実行時rd = 0 + imm、pc加算が抜ける)。backlogに追加。 現段階のsmoke test (ADDI × 4) では影響なし。
検証結果
Top smoke testのtrace validationを追加:
localparam int EXPECTED_TRACE_LEN = 4; trace_t expected_trace [EXPECTED_TRACE_LEN]; initial begin expected_trace[0] = '{pc: 64'h8000_0000, rd_addr: 5'd1, rd_data: 64'h1}; expected_trace[1] = '{pc: 64'h8000_0004, rd_addr: 5'd2, rd_data: 64'h2}; expected_trace[2] = '{pc: 64'h8000_0008, rd_addr: 5'd3, rd_data: 64'h3}; expected_trace[3] = '{pc: 64'h8000_000C, rd_addr: 5'd4, rd_data: 64'h4}; end
Trace出力と1対1比較 (retiredの順序 × pc / rd_addr / rd_dataの3 field):
[75000] commit lane[0]: pc=0x0000000080000000 rd=x1 data=0x0000000000000001 [85000] commit lane[0]: pc=0x0000000080000004 rd=x2 data=0x0000000000000002 [95000] commit lane[0]: pc=0x0000000080000008 rd=x3 data=0x0000000000000003 [105000] commit lane[0]: pc=0x000000008000000c rd=x4 data=0x0000000000000004 === tb_scariv_top : 13 tests, 0 fail, 4 retired === PASS
13 tests = 4 retire count検証 + 4命令 × 3 field検証 = 12 + 1。
Regression全16 TB pass、累計196k+ tests / 0 fail。
RISC-V OoO CPUを実装する (14. IQ age matrix: 古い命令を絶対に追い越させない)
前回のtop moduleでpipelineが一通り繋がったが、smoke testの結果2命令retireで止まって「deadlock」していた。 原因はIQのpriorityが「physical entry indexのLSB」で決まっていて、free slotに埋まった若い命令が古い命令を追い越して発行され、in-order retireのROBで古い命令の完了待ちのまま止まる、というOoOの初歩的な設計不備。 今回はage matrixを入れてこれを解消し、ついでに絡み合った別のbugも一つ潰した話。
Age matrixの実装
古典的なOoO schedulerの1テクニックで、BOOMやXiangShanが使っているのと同じ形。
logic [IQ_ENTRIES-1: 0][IQ_ENTRIES-1: 0] r_age_matrix;age[i][j] = 1= entry iはjより古い、というbit matrix- Dispatchで新規に埋まるentryのrowをall-clear、逆に既存valid entryのcolumn (新規entryを表す) は1
- Issueは「row iがall (ready & cat-matching) entryに対して1」ならoldest → 1個だけ1になるone-hot
update logicの構造:
generate for (genvar i_idx = 0; i_idx < IQ_ENTRY_NUM; i_idx++) begin for (genvar j_idx = 0; j_idx < IQ_ENTRY_NUM; j_idx++) begin always_ff @(posedge clk, negedge rst_n) begin if (!rst_n | flush_all) begin r_age_matrix[i_idx][j_idx] <= 1'b0; end else if (w_being_dispatched[i_idx] & w_being_dispatched[j_idx]) begin // 両方同時dispatch: 若いlane indexが古い命令 r_age_matrix[i_idx][j_idx] <= (w_dispatch_lane[i_idx] < w_dispatch_lane[j_idx]); end else if (w_being_dispatched[i_idx]) begin r_age_matrix[i_idx][j_idx] <= 1'b0; // i is new; not older than anyone end else if (w_being_dispatched[j_idx]) begin r_age_matrix[i_idx][j_idx] <= r_iq[i_idx].valid; // existing i older than new j end end end end endgenerate
Multi-lane dispatchでlane順序を「若い方が古い」に決める
最初lane 0 vs lane 1の比較をhardcodeで書いていたが、これだとDISP_SIZE=2前提で6-way構成 (最終形態) にscaleしない。 「entryごとにどのlaneがdispatchしたか」をsignal化して、そのlane indexの順序で古さを決める形にした:
logic [$clog2(DISP_SIZE)-1: 0] w_dispatch_lane [IQ_ENTRY_NUM]; always_comb begin for (int e = 0; e < IQ_ENTRY_NUM; e++) begin w_dispatch_lane[e] = '0; for (int d = 0; d < DISP_SIZE; d++) begin if (i_dis_valid[d] & w_iq_free_slot_oh[d][e]) begin w_dispatch_lane[e] = d[$clog2(DISP_SIZE)-1: 0]; end end end end
これでage matrix更新はlane index比較1本にまとまり、DISP_SIZEを大きくしても同じcodeが使える。
「lane 0がlane 1より古い」のinvariantはfront-endの構造から自動的に保たれる:
- Fetch F3 output: lane 0のPCがlane 1のPCより4小さい (program order)
- Rename: all-or-nothing dispatchなのでpartial dispatchが発生しない
- ROB: 若いlane 0のrob_idがlane 1より1小さい
なのでdispatch時点で「lane indexが若い = 古い命令」が常に成立。
Issue selectorの書き換え
bit_extract_lsbのLSB priorityは削除して、age matrixでoldest-among-readyを選ぶ形に:
generate for (genvar p = 0; p < PRF_WRITE_PORTS; p++) begin logic [IQ_ENTRY_NUM-1: 0] w_oldest_ready; for (genvar i = 0; i < IQ_ENTRY_NUM; i++) begin logic w_older_than_all; always_comb begin w_older_than_all = 1'b1; for (int j = 0; j < IQ_ENTRY_NUM; j++) begin if (i != j) begin if (w_issue_ready[p][j] & ~r_age_matrix[i][j]) begin w_older_than_all = 1'b0; end end end end assign w_oldest_ready[i] = w_issue_ready[p][i] & w_older_than_all; end // w_oldest_ready is one-hot (unique oldest) assign w_iss_slot_oh[p] = w_oldest_ready; ... end endgenerate
「entry iのage matrix rowが『同portの候補でreadyなentry全部』をdominateする」ならiは該当portのoldest ready。1個だけ1が立つone-hotになるので、そのままselectorとして使える。
埋め込まれていたBRU wb typo
age matrixを入れてrunしたら... 2命令retireから増えない。deadlock状況は同じ。
debug printsを追加して、issueはrob 2 (ADDI x3) がoldestとして選ばれて発火しているのに、ROBのr_rob[0][1].doneが1に立たない、という現象を突き止めた。
原因はage matrixとは無関係で、top moduleのBRU writeback構築のtypo:
always_comb begin w_wb_result[1] = 'h0; w_wb_result[1].valid = w_iss_valid[0]; // ← [0] はtypo、[1] が正 w_wb_result[1].rob_id = w_iss_uop[1].rob_id; ... end
BRUのwb portはindex 1なのに、validだけport 0の値を追随していた。結果、BRUが全くissueしていないcycleでもwb.valid[1]=1が立ち、rob_idはw_iss_uop[1] (IQ port 1の出力 = stale値) から取られて、bank 0 / row 2のような「たまたま」ヒットするROB entryをwb扱いする形になっていた。
ROBはper-bankのwb collectorで「該当bankにwbしているport」の情報を集めるが、複数portが同じbankを触った場合は最後にヒットしたportがw_wr_ports_entry_idxを上書きする:
for (int w = 0; w < PRF_WRITE_PORTS; w++) begin if (w_rob_wr_valid[w] & (w_rob_wr_bank_idx[w] == b_idx[BW-1:0])) begin w_wr_ports_valid = 1'b1; w_wr_ports_entry_idx = w_rob_wr_entry_idx[w]; // 上書き end end
port 0 (ALU) がrob 2 (bank 0 row 1) をwbしていても、port 1 (BRU) がspuriousにwb.valid=1かつbank_idx=0 (別のrow) を報告すると、w_wr_ports_entry_idxがその別rowで上書きされて、rob 2のdoneがsetされない。
Typo 1文字 ([0] → [1]) 直しただけで、pipelineが4命令完走した。
Age matrixのdebugに時間を取られてBRU wb typoに気付くのに時間がかかったが、教訓としては:
- 統合debugでは「一番怪しい所」以外にも複数bugが同居している可能性を常に念頭に
- wb busのように複数producerで共有するpathは、片方のspurious出力が他方のkeyに影響を及ぼす形になりがち。valid gatingを厳密にする
検証結果
Top module smoke testでADDI × 4が全部retire:
[75000] commit lane[0]: rd=x1 ... [85000] commit lane[0]: rd=x2 ... [95000] commit lane[0]: rd=x3 ... [105000] commit lane[0]: rd=x4 ... === tb_scariv_top : 1 tests, 0 fail, 4 retired === PASS
Regression全16 TB pass、累計196k+ tests / 0 fail。
RISC-V OoO CPUを実装する (13. Top module: 配線とIQ deadlockの発見)
CSRまで単体moduleが揃ったので、全部を1本のtop moduleに繋いで実命令を流すフェーズに入った。 Fetch → Decode → Rename → Dispatch → ROB → IQ → EX (ALU/BRU/LSU/CSR) → WB → Retireのloopを閉じるのに、interfaceの型合わせ・PRF read glue・redirect fan-in・wb bus構築などの細かい配線を一通り書いた話。 そして統合してsmoke testを回したところ、IQのdispatchとissueのpriorityがROBのin-order retireと組み合わさってdeadlockする、という設計問題を発見した。
統合前の下準備
Top moduleの実装に入る前に、以下の3点を先に片付けた。
(a) CSR opをuop typeに追加
Decoderのoutput uop_tには既にis_load / is_store / is_branch / is_jumpがあるが、CSR ops / ECALL / EBREAK / MRETのためのflagが無かった。以下追加:
// scariv_pkg.sv logic is_csr; logic is_ecall; logic is_ebreak; logic is_mret;
さらにop unionにcsr_op_tを追加してCSR addressとRW/RS/RC kindを載せる:
typedef union packed { logic [14: 0] raw; alu_op_t alu_op; // 5 bit br_op_t br_op; // 5 bit csr_op_t csr_op; // 15 bit (12b addr + 2b kind + 1b use_imm) } op_u_t;
Packed unionは最大幅で確保されるので、alu_op / br_opは上位10bitがdon't care扱いになる。
(b) CSRのcat routing
DecoderのOP_SYSTEM branchを追加、catを分けてIQで振り分けられるように:
OP_SYSTEM: begin case (funct3) 3'b000: cat = INST_CAT_SYSTEM; // ECALL/EBREAK/MRET default: cat = INST_CAT_CSR; // CSRRW/RS/RC(I) endcase end
(c) IQ 4 port化
IQのissue portを3 (ALU/BRU/LSU) から4 (+CSR) に増やす。conf_pkgにCSR_INST_NUM=1を追加、PRF_WRITE_PORTS = ALU + BRU + LD + CSR = 4と連動。IQ側で4番目portのcat filterを追加:
w_issue_ready[3][i] = base & (cat == INST_CAT_CSR | cat == INST_CAT_SYSTEM);
Top moduleの構造
Sub-moduleが12個 (fetch / decoder / rat / free_list / rename / rob / iq / prf / alu / bru / lsu / csr) + memory 2個 (itcm / dtcm)。Memoryはplan docに従ってtop portとして外に出し、TB側でinstantiate。
主要signal chain (簡略):
Fetch → Decoder → Rename ↔ (RAT + FL) → ROB → IQ → PRF read → EX 4 unit → wb bus
↓
↓ ROB done/retire
(rn_readyでfetch backpressure)
抜けているglue: PRF read + issued_t構築
一つ「無かった」のが、IQ output → EX inputの間の中間stage。IQが出すrenamed_uop_tはprs1 / prs2 / prdの「番号」だけ持っていて、実際のoperand値は持たない。ALU/BRU/LSU/CSRはoperand値を要求する。
Issueがcycle Tなら、cycle Tの中でPRF readを発火してT+1でEXに流す形が典型だが、現段階ではPRF read自体がcombinational (register fileの1-cycle latencyは無視) なので、issueと同cycleで:
generate for (genvar p = 0; p < PRF_WRITE_PORTS; p++) begin // PRF read address: 各issue portが2 operand要求 assign w_prf_rd_addr[2*p+0] = w_iss_uop[p].prs1; assign w_prf_rd_addr[2*p+1] = w_iss_uop[p].prs2; // issued_t構築 (renamed_uop_t + operand値) always_comb begin w_issued[p].rn = w_iss_uop[p]; w_issued[p].op1 = w_prf_rd_data[2*p+0]; w_issued[p].op2 = w_prf_rd_data[2*p+1]; end end endgenerate
こうして構築したw_issued[N]をLSU/CSRのi_iss_dataに、またALU/BRUのi_op1/i_op2のsourceとして使う。
PRF read port数はPRF_READ_PORTS = 2 * PRF_WRITE_PORTS (= 8) に定義変更した (以前は2*DISP_SIZE = 4だった)。
Redirect fan-in
Fetchのi_redirect_valid / i_redirect_pcに流れるsourceが複数ある:
- CSR: trap起動 / MRET
- BRU: branch mispredict
BRUはtaken / targetを出すだけなのでtopでredirectを組み立てる:
// 現段階はbranch prediction無し = sequential fetchなのでtakenなら常にmispredict assign w_bru_redirect_valid = w_iss_valid[1] & w_bru_branch_taken; assign w_bru_redirect_pc = w_bru_branch_target;
CSRのredirectとORで束ねる (trap優先):
assign w_redirect_valid = w_csr_redirect_valid | w_bru_redirect_valid; assign w_redirect_pc = w_csr_redirect_valid ? w_csr_redirect_pc : w_bru_redirect_pc;
Flush出力も同じくfan-in:
assign w_flush_all = w_rob_flush_all | w_csr_flush_all;
実装で踏み抜いたbugの連鎖
Top moduleは「portを埋めるだけ」に見えて、実際は15回以上のreview往復になった。主要どころ:
- FetchのITCM port 3本 (o_itcm_ren/addr, i_itcm_rdata) 接続漏れ
- Fetchの
i_dec_ready(array) をscalarw_rn_readyに繋いでいた → fan-out - RATのwrite signal名
w_rat_wr_*とrename側w_rn_wr_*のnaming zure - renamed_uop_tのmemberをissued_tのようにaccess (
.rn.rob_idは存在しない →.rob_id) - ALU / BRU / LSU / CSRのoperand sourceをrenamed_uop_t直参照 → PRF-readで構築した
w_issued[]に修正 w_wb_valid[N].wb_en(scalarにfield access) →w_wb_result[N].wb_enのtypo- Multi-driver: ROB
o_flush_allをw_flush_allに直繋ぎしつつ、別assignでw_rob_flush_all | w_csr_flush_allとしていた w_rn_dis_readyの未駆動 (= 0でpipelineが起動しない)- Renameの
o_rn_valid / o_rn_uopoutputが空port scariv_decoderwrapperのo_dec_validassign抜け (decoderがvalidを出さない)- Decoderが
i_rn_readyをAND gateに使っていて、renameのo_rn_readyと閉じたcombinational loopになりVerilatorが "Active region did not converge" でabort - FLの全port (7本) とrenameのFL側3本が空
- RATの
o_cmt_old_prd未接続 (FLのpushに流すsourceが消えていた) - IQの
i_wbempty (wb bus受け取り忘れ) - Top port
o_cmt_valid / o_cmt_pc / o_cmt_rd_*がROB出力に配線されていない (TBから見えない)
LintとVerilator elaborationが段階的にerrorを出してくれるので、順に潰していけば必ず動くところまで持って行けるが、量が量なので「配線量が支配的」というのがtop moduleのリアリティ。
Bug 11 (combinational loop) は特に厄介で、解消するために「decoderのvalid gatingを外してpass-throughにする」という設計変更を入れた。Backpressureはfetch → rename → 上流fetchの順で流れれば十分で、decoder自身はcombinational unitとしてgate不要という判断。
Smoke testの結果と発見したdeadlock
TBはITCMに4命令 (ADDI x1 / x2 / x3 / x4) + NOP paddingを読み込ませて、pipelineがretireに届くかを見た。
結果:
[75000] commit lane[0]: pc=... rd=x1 data=... [85000] commit lane[0]: pc=... rd=x2 data=... === tb_scariv_top : 1 tests, 0 fail, 2 retired === PASS
2命令はcommitまで到達。ADDI x3 / x4は... 何百cycle待ってもretireしない。
Debug printで追ったところ、IQのdispatchとissueのpriorityに問題があった。
症状
- IQ dispatch:
bit_extract_lsbで「空いているentryのLSB」を選ぶ - IQ issue: 各issue portも
bit_extract_lsbで「readyなentryのLSB」を選ぶ - ROB retire: in-order (rob_id順)
Rob 0 / 1 (ADDI x1, x2) がIQ entry 0 / 1にdispatch、issue、wb、retire、entry 0 / 1がfreeになる。
次cycle、fetchが既にadvancedした先の命令 (rob 4, 5 = NOP padding) がdispatchされると、空いたentry 0 / 1が最下位なのでそこに入る。このentry 0のrob 4がentry 2のrob 2 (ADDI x3) よりlower priorityでissueされてしまう。
Rob 4はwbするがROB.head = 2なのでretire待ち。Rob 2はIQでreadyな状態で待ち続ける。次cycleまたentry 0が新dispatchで埋まってissue... の繰り返しで、entry 2 (rob 2) が永遠に選ばれない。
500 cycle回しても2 commitのまま。ROBが満杯 (32 entry) になった時点でdispatch stallが入り、そこから漸くentry 2がissueされるが、その頃にはROB entryのrob_id encodingがwrap済みで、wbとretireのrob_id matchが壊れて (と思われる) retireできない。
根本原因
「IQがentry indexのLSBでpriorityを決める」と「ROBがin-order retire」の組み合わせが本質的に壊れている。若いuopがfreed slot経由で古いuopを追い越してissueされ、ROB側で「古いuopが終わるのを永遠に待つ」状態になる。
対策 (backlog)
- Age-based priority: IQ entryにdispatch order tagを持たせて、tagの若い順にissue
- またはmatrix scheduler (最終形態の本命): dispatch / wakeup / issueがentry indexに依存しない構造
現段階のsmoke testでは「2命令retire = pipelineが閉じている証拠」までを合格判定にして、上記deadlockはproject_backlogに追記して先送りとした。
RISC-V OoO CPUを実装する (12. CSR + Trap: mstatus struct、WARL、trap fsm)
LSUまで一通りEXユニットが揃ったので、次はtrap pathを閉じるためのCSR unitを組んだ。 現段階のスコープは「M-modeのみ、8 CSR、CSR read/write 6命令、ECALL/EBREAK/MRETによるtrap起動」の最小構成。 interrupt (mie/mip/mideleg) やU/S mode、CSR fieldの高度なsemanticsは全部後回し。
対象CSRの絞り込み
Fullなspec上CSRは4096個あるが、trap pathを回すのに必要なのは以下8個だけ:
| CSR | addr | 役割 |
|---|---|---|
| mstatus | 0x300 | MIE/MPIE/MPP (trap状態) |
| misa | 0x301 | ISA report (read-only) |
| mtvec | 0x305 | Trap vector base |
| mscratch | 0x340 | Handler用スクラッチ |
| mepc | 0x341 | Exception PC |
| mcause | 0x342 | Trap cause code |
| mtval | 0x343 | Trap value |
| mhartid | 0xF14 | Hart ID (read-only、0固定) |
これでECALL/EBREAK/illegal instructionのtrap全部回せる。 Interruptを入れるとmtimer / MTIP / MEIPといった信号線が生えて実装量が跳ねるので、まずtrap pathを閉じることに絞った。
uop_tへのcsr_op追加
op unionにcsr_op_tを追加する形にした。既存のalu_op_t / br_op_t (5bit) より広い15bitだが、命令タイプごとに参照方法が排他 (ALU / BR / CSR opは同時に立たない) なのでunionで共存できる:
// scariv_pkg.sv typedef enum logic [1: 0] { CSR_OP_NOP = 2'b00, // ECALL/EBREAK/MRETなど、CSR read/writeなし CSR_OP_RW = 2'b01, // CSRRW / CSRRWI CSR_OP_RS = 2'b10, // CSRRS / CSRRSI CSR_OP_RC = 2'b11 // CSRRC / CSRRCI } csr_op_kind_t; typedef struct packed { logic [11: 0] addr; csr_op_kind_t kind; logic use_imm; } csr_op_t; // 15 bit typedef union packed { logic [14: 0] raw; alu_op_t alu_op; br_op_t br_op; csr_op_t csr_op; } op_u_t;
Packed unionはwidth統一を要求されるので、最大幅を明示するlogic [14:0] rawを最初に入れておく。5bitのalu_op / br_opは上位10bitがdon't care扱いになる。
is_csr / is_ecall / is_ebreak / is_mretの4 flagもuop_tに追加。decoder側でOP_SYSTEMのfunct3 / funct12を見て分岐する:
OP_SYSTEM: begin case (w_funct3) 3'b000: begin case (i_inst[31:20]) // funct12 12'h000: is_ecall = 1'b1; 12'h001: is_ebreak = 1'b1; 12'h302: is_mret = 1'b1; default: illegal = 1'b1; endcase end 3'b001, 3'b010, 3'b011, // CSRRW/RS/RC (rs1) 3'b101, 3'b110, 3'b111: begin // CSRRWI/RSI/RCI (imm) is_csr = 1'b1; op.csr_op.addr = i_inst[31:20]; op.csr_op.use_imm = w_funct3[2]; op.csr_op.kind = ...w_funct3[1:0]で分岐...; end endcase end
mstatusのstruct化
mstatusは複数のbit fieldが混在するCSRで、事象ごとに触るbitが違う:
| 事象 | 更新 |
|---|---|
| Trap entry | MPIE ← MIE; MIE ← 0; MPP ← 2'b11 |
| MRET | MIE ← MPIE; MPIE ← 1'b1 |
| CSRRW mstatus | writableなbitだけ更新 (WARL) |
これを64bitのbit vectorで扱うとbit位置に依存したmagic numberが散らばるので、riscv_pkgにstruct定義を置いた:
typedef struct packed { logic [50: 0] pad_hi; // [63:13] logic [ 1: 0] mpp; // [12:11] logic [ 2: 0] pad_10_8; logic mpie; // [7] logic [ 2: 0] pad_6_4; logic mie; // [3] logic [ 2: 0] pad_2_0; } mstatus_t;
Trap entry / MRETの更新はfield直参照で読める:
if (i_trap_valid) begin r_mstatus.mpie <= r_mstatus.mie; r_mstatus.mie <= 1'b0; r_mstatus.mpp <= 2'b11; end else if (i_iss_data.rn.uop.is_mret) begin r_mstatus.mie <= r_mstatus.mpie; r_mstatus.mpie <= 1'b1; end
CSR read側はstructを64bit vectorに自動flattenして返す (pad bitはreset値の0のまま):
12'h300: w_csr_rdata = r_mstatus; // 自動flatten
将来SPP / SUM / MPRV / FS / VSなどのfieldを追加する時も、structに枝を足すだけで済む。
WARL: writable bitのみ反映
CSRRW mstatusで全bit書き込もうとしても、実装がsupportしないbitは無視するのがWARL (Write Any, Read Legal) semantics。 現段階のmstatusはMIE/MPIE/MPPしかsupportしないので、書き込み側で該当bitだけ拾って残りは触らない:
12'h300: begin r_mstatus.mie <= w_csr_wnext[ 3]; r_mstatus.mpie <= w_csr_wnext[ 7]; r_mstatus.mpp <= w_csr_wnext[12:11]; end
これでTBでCSRRW mstatus, 0xFFFFFFFF_FFFFFFFFを試すとreadbackで0x1888 (MIE=1, MPIE=1, MPP=11) だけ立って他bitは0のまま、というWARL挙動が取れる。
Trap起動とfetch redirect
Trapが起きた時、fetchはmtvec.baseに、MRETの時はmepcに飛ばす必要がある。fetch側は既にi_redirect_valid / i_redirect_pcを受けるinterfaceがあるので、CSR unitからそこに出力するだけ:
assign o_redirect_valid = i_trap_valid | (i_iss_valid & i_iss_data.rn.uop.is_mret); assign o_redirect_pc = i_trap_valid ? {r_mtvec[VADDR_W-1:2], 2'b00} : r_mepc; assign o_flush_all = o_redirect_valid;
mtvecの下位2bitはmode field (Direct=00 / Vectored=01)。M1はDirectのみsupport前提なのでbase部分を{[63:2], 2'b00}で切り出す。
o_flush_allはpipeline全squashトリガで、fetch / decode / rename / IQ / ROBの各段が受け取って自身のstateをclearする (実装は各moduleのi_flush_all port経由、既に他モジュールで支度は済んでいる)。
CSRRWの返り値: 旧CSR値をwriteback
CSR命令のsemanticsは「rdに旧CSR値を書く、CSRに新値を書く」。writeback pathで旧CSR値を返す:
if (i_iss_valid & i_iss_data.rn.uop.is_csr) begin o_wb_valid = 1'b1; o_wb_data.rob_id = i_iss_data.rn.rob_id; o_wb_data.prd = i_iss_data.rn.prd; o_wb_data.reg_type = REG_XPR; o_wb_data.result = w_csr_rdata; // 旧CSR値 o_wb_data.wb_en = 1'b1; end
ECALL/EBREAK/MRETは書き込み先レジスタが無いのでwb_en=0でROBのmark doneだけ発火:
end else if (i_iss_valid & (is_ecall | is_ebreak | is_mret)) begin o_wb_valid = 1'b1; o_wb_data.rob_id = i_iss_data.rn.rob_id; o_wb_data.reg_type = REG_NONE; o_wb_data.wb_en = 1'b0; end
RISC-V OoO CPUを実装する (11. LSU: STQ + blocking Load、LDQを持たない理由)
Fetchまで一通り繋がったので、EX sideに残っていた最後のunit、LSUを組んだ。 今回はminimal構成 (cache無し、DTCM直付け、in-order commit、STQ 8-entry、blocking load、Store-to-Load forwarding無し) で、それでも「なぜLDQが要らないか」「STQのcommit orderingをどう扱うか」など、書いてみないと見えなかった判断がいくつかあった。
サブモジュール分割
3 module構成:
scariv_dtcm.sv: 1-cycle registered read/write SRAM stub (ITCMにwrite portを追加した形)scariv_stq.sv: 8-entry FIFO Store Queue、commitまでstoreをholdscariv_lsu.sv: AGU + alignment + DTCM/STQ接続wrapper
ALU/BRUがbare primitiveだった路線と同じで、DTCM (memory model) とSTQ (状態を持つFIFO) を切り出し、LSU本体は「addr計算 + align + STQ enq + load pending register」だけに絞った。
なぜLDQを持たないか
Plan docではLDQ / STQ各8-entryと書いていたのだが、実装に入って「M1 minimalではLDQ要らない」と気付いた。
理由:
- DTCMは1-cycle latencyのSRAM直付け ==> loadは必ず「発行後1 cycleで読み終わる」
- Blocking (次loadは前loadがwbするまで発行しない) ==> in-flightは常に1個
- Cache miss / non-blockingのケースが無い ==> queueで「複数のin-flight load」を管理する必要が無い
つまりLDQの実体はregister 1個 (r_ld_pending + rob_id + prd + addr_lo + funct3) で済む。 これは実質「1-entryのLDQ」。
将来必要になるのは:
- D$ / L1D$ 導入時: cache missでloadがmulti-cycle停滞
- Non-blocking load: miss中に次loadを発行してMSHR queueで管理
- Store-to-load forwarding: STQ lookupとloadを並列に
これらが入る段階でLDQ moduleを切り出す。今はskip。
STQの構造
FIFOベースで、head / tailのsemanticsは慣例とswapして:
r_head: 次にenqueueする空きslot (write pointer)r_tail: 次にdequeueする古いentry (read pointer)
top bit (STQ_W:0の最上位) でfull / emptyを判別する古典的なcircular buffer:
// full: top bits differ, bottom bits equal (wrap 1周した) // empty: head == tail assign o_enq_ready = ~((r_head[STQ_W] != r_tail[STQ_W]) & (r_head[STQ_W-1: 0] == r_tail[STQ_W-1: 0]));
Enqueue on dispatch、dequeue on commit。
Load側の「same-line conflict」判定は8-byte line単位 (addr[VADDR_W-1:3]の完全一致 + STQ entry valid):
generate for (genvar e_idx = 0; e_idx < STQ_ENTRIES; e_idx++) begin assign w_ld_stq_hit[e_idx] = r_stq[e_idx].valid & (r_stq[e_idx].addr[VADDR_W-1: 3] == i_ld_addr[VADDR_W-1: 3]); end endgenerate assign o_ld_conflict = |w_ld_stq_hit;
8-byte line粒度は「安全側に振った」判定で、sub-word overlapを見逃す (例: STQにSB @0x100、load LB @0x107は同じlineでconflictになるが、byte単位では別領域)。 本来forwardingを入れる段階ではaddress + sizeでrange overlapを取るが、M1では「保守的にstall」で正しく動く。
LSU本体のpipeline
Load (2 cycle):
- Cycle T: issueのaddr = op1 + immを計算、DTCM read発火 (
o_dtcm_ren=1)、r_ld_pending <= 1とrob_id / prd / addr_lo / funct3をregister - Cycle T+1:
i_dtcm_rdataが返ってきて、r_ld_addr_loでbyte offset shift +r_ld_funct3でsign/zero extend、o_wb_valid=1
Store (1 cycle):
- Cycle T: issueのaddr / dataを作ってSTQ enqueue、同cycleで
o_wb_valid=1(side-effect無しでROBをmark done)
Commit (別always_ff、ROBから通知):
i_cmt_store=1のcycleにSTQ headをdequeue、DTCMにwrite発火 (o_dtcm_wen=1)
o_iss_readyは3要素AND:
assign o_iss_ready = !r_ld_pending & (is_store ? w_stq_ready : // STQ空きがあればOK is_load ? !w_ld_conflict : // same-line conflictなし 1'b1);
w_iss_is_load / w_iss_is_storeは自分で& o_iss_readyを含むので、この式の右辺で使うとcombinational loopになる。
loop回避のため右辺はrawな.is_load / .is_storeを参照 (DTCM read発火の判定側ではw_iss_is_loadを使う、といった具合に用途で使い分けた)。
Load align / Store wmask生成
Load側はfunct3でsign/zero extendの7通り:
logic [63: 0] w_shifted; assign w_shifted = i_dtcm_rdata >> (r_ld_addr_lo * 8); always_comb begin case (r_ld_funct3) 3'b000: w_ld_align = {{56{w_shifted[ 7]}}, w_shifted[ 7: 0]}; // LB 3'b001: w_ld_align = {{48{w_shifted[15]}}, w_shifted[15: 0]}; // LH 3'b010: w_ld_align = {{32{w_shifted[31]}}, w_shifted[31: 0]}; // LW 3'b011: w_ld_align = w_shifted; // LD 3'b100: w_ld_align = {56'h0, w_shifted[ 7: 0]}; // LBU 3'b101: w_ld_align = {48'h0, w_shifted[15: 0]}; // LHU 3'b110: w_ld_align = {32'h0, w_shifted[31: 0]}; // LWU default:w_ld_align = 'h0; endcase end
Store側はwmask生成 + data shiftの2段:
case (w_iss_funct3) 3'b000: w_iss_st_wmask = 8'b0000_0001 << w_iss_addr_lo; // SB 3'b001: w_iss_st_wmask = 8'b0000_0011 << w_iss_addr_lo; // SH 3'b010: w_iss_st_wmask = 8'b0000_1111 << w_iss_addr_lo; // SW 3'b011: w_iss_st_wmask = 8'b1111_1111; // SD endcase assign w_stq_enq_wdata = i_iss_data.op2 << (w_iss_addr_lo * 8);
op2 << (addr_lo * 8)でstore dataをline内の適切なbyte位置に配置してからSTQに入れる。DTCM側はwmaskで該当byte laneだけ更新するので、その他のbyteは温存される。
踏み抜いたbug: shift amountのwidth bug
最初、shift式を以下のように書いていた:
assign w_shifted = i_dtcm_rdata >> ({3'b000, r_ld_addr_lo} * 3'd8); // buggy
SystemVerilogの*演算結果widthはoperandのmaxだが、{3'b000, r_ld_addr_lo} = 6-bit × 3'd8 = 3-bitの場合、contextによっては3-bit truncationが起きて実質& 0x07になる。
addr_lo = 1の時、1*8=8が3-bitで8'b000 (=0) にラウンドされ、shift量が0になっていた。
結果、byte 1をloadするとbyte 0の内容が返ってきて全laneがpayload[0] を返すという症状で、TB debugで判明。修正は超シンプル:
assign w_shifted = i_dtcm_rdata >> (r_ld_addr_lo * 8);
同じパターンがstore側のw_stq_enq_wdataにもあって、両方直して初めて全lane passした。
「Verilogの演算結果widthはoperand幅のmax」というルールにint literal (3'd8) を混ぜると罠になる、というのが教訓。単に* 8と書けばSystemVerilogは32-bit intとして扱うのでtruncationは起きない。
RISC-V OoO CPUを実装する (10. Fetch: 3-stage F1-F2-F3 + ITCM + 4B alignment)
Decoderが出来上がったので、実命令を供給する側のFetchを組んだ。
BTB / TAGEなどのbranch predictionは後段の拡張に回し、現段階ではsequential fetch + redirectだけに絞る。一方で4-byte alignment、特にmid-line redirectの扱いは最初から真面目に実装した。
これでFetch → Decode → Rename → Dispatch → ROB → IQ → EXの流れが片方向で繋がる状態になった。
Fetch stageの割り付け
将来的なXiangShan Kunminghu相当の6-way構成を目指す前提で、fetch stageは最終的にF1-F2-F3の3-stage構成にする。
- F1: PC gen + BTB lookup
- F2: L1 I$ read (1-cycle SRAM)
- F3: Predecode + alignment + BTB update
現段階ではL1 I$ / BTBを持たないので、以下の縮約版で組んだ。
- F1: PC gen。BTBは無し、redirectだけ受ける
- F2: ITCM read (1-cycle registered SRAM)
- F3: 64-bit read dataを2命令 (32-bit × 2) に分解してDecoderへ渡す
段数を最終形と揃えたのは、後でBTB / I$を挿入しても前後のvalid chainを大きく書き換えずに済むため。
最初は2-stageで書くつもりだったが、F2を明示的なregisterとして持った方が命名もtimingも素直なので、そのまま3-stageにした。
ITCMは1-cycle registered SRAM
まずFetchの前提となるITCMを書いた。
module scariv_itcm #( parameter int ITCM_ENTRIES = 256, parameter string INIT_FILE = "" )( input logic i_clk, input logic i_rst_n, input logic i_ren, input logic [VADDR_W-1:0] i_addr, output logic [63:0] o_rdata ); localparam int IDX_W = $clog2(ITCM_ENTRIES); logic [IDX_W-1:0] r_idx; logic [63:0] mem [ITCM_ENTRIES]; always_ff @(posedge i_clk, negedge i_rst_n) begin if (!i_rst_n) r_idx <= '0; else if (i_ren) r_idx <= i_addr[IDX_W+2:3]; end assign o_rdata = mem[r_idx]; initial if (INIT_FILE != "") $readmemh(INIT_FILE, mem); endmodule
64-bit幅、つまり1 entry = 2命令。入力はbyte addressなので下位3-bitを捨ててindexにする。
Write portは現段階では不要なので持たせていない。初期化は$readmemhでhex fileを指定でき、TBでは空文字列を渡してmemをhierarchical writeで埋めた。
Fetch pipeline
scariv_fetch.svのportはITCM、Decoder、Redirectの3 groupに分けた。
module scariv_fetch ( input logic i_clk, input logic i_rst_n, input logic [VADDR_W-1:0] i_init_pc, // ITCM output logic o_itcm_ren, output logic [VADDR_W-1:0] o_itcm_addr, input logic [63:0] i_itcm_rdata, // To decoder output logic o_f3_valid [DISP_SIZE], output logic [INST_W-1:0] o_f3_inst [DISP_SIZE], output logic [VADDR_W-1:0] o_f3_pc [DISP_SIZE], input logic i_dec_ready[DISP_SIZE], // Redirect input logic i_redirect_valid, input logic [VADDR_W-1:0] i_redirect_pc );
i_init_pcはportで受ける。将来的にはreset vectorやCSR側の設定値から配線する想定で、現段階ではFetch自身に固定値を持たせない。
i_dec_readyはper-lane array。ただし現段階のRenameは全laneまとめてstallする粒度で組む予定なので、実質的には両laneに同じ値が来る。
F3のadvance条件は、現在保持している命令をDecoderがすべてacceptした時だけ進める方式にした。
assign w_f3_accept = !r_f3_valid | (i_dec_ready[0] & (r_f3_pc[2] | i_dec_ready[1]));
Mid-line fetch (pc[2] = 1) ではlane 1自体が無効なので、i_dec_ready[1]はdon't careになる。
4-byte alignmentを真面目にやる
ITCMは8B/entryなので、Fetchから見るとpc[2]が「lineの前半から始めるか、後半から始めるか」を決める。
JAL / JALR / BRANCHのtargetは4B境界なので、redirect先がpc[2] = 1になることは普通にある。
pc[2] |
inst[0]のsource | inst[1]のsource | 有効lane数 |
|---|---|---|---|
| 0 | rdata[31:0] |
rdata[63:32] |
2 |
| 1 | rdata[63:32] |
無効 | 1 |
次のPCは両ケース共通で、
assign w_next_pc = {r_f1_pc[VADDR_W-1:3] + 1'b1, 3'b000};
とした。
pc[2] = 0なら現在lineの先頭から次の8B lineへ進むので+8、pc[2] = 1なら現在lineの後半1命令だけ消費して次のlineへ進むので実質+4になる。
最初は単純にr_f1_pc + 8としていたが、mid-line redirectで例えば0x2004 → 0x200Cとなり、次の8B aligned lineを指さないbugになった。
F3ではPCに応じて64-bit dataを分解する。
always_comb begin o_f3_valid[0] = r_f3_valid; o_f3_valid[1] = r_f3_valid & !r_f3_pc[2]; o_f3_inst[0] = r_f3_pc[2] ? r_f3_rdata[63:32] : r_f3_rdata[31:0]; o_f3_inst[1] = r_f3_rdata[63:32]; o_f3_pc[0] = r_f3_pc; o_f3_pc[1] = {r_f3_pc[VADDR_W-1:3], 3'b100}; end
Backpressureとredirectを共存させる
今回一番書き直したのは、3-stage pipelineでbackpressureとredirectをどう共存させるか。
最初は各registerのRHSにw_f3_acceptを掛けていた。
// buggy r_f2_valid <= !i_redirect_valid & r_f1_valid & w_f3_accept; r_f3_valid <= !i_redirect_valid & r_f2_valid & w_f3_accept;
これだとstallした瞬間にvalidが0へ落ち、F3で保持していた命令まで消える。
正しくは「stallならregisterを更新しない」。そこでupdate条件自体を分岐した。
end else if (i_redirect_valid) begin // Redirect: squash F2/F3, restart at redirect_pc r_f1_pc <= i_redirect_pc; r_f1_valid <= 1'b1; r_f2_valid <= 1'b0; r_f3_valid <= 1'b0; end else if (w_f3_accept) begin // Normal advance r_f1_valid <= 1'b1; if (r_f1_valid) r_f1_pc <= w_next_pc; r_f2_valid <= r_f1_valid; r_f2_pc <= r_f1_pc; r_f3_valid <= r_f2_valid; r_f3_pc <= r_f2_pc; r_f3_rdata <= i_itcm_rdata; end // else: stall, all registers hold
Redirectはstallより優先し、in-flightのF2 / F3を捨ててredirect先から再開する。stallは単純に全register hold。
またreset直後はr_f1_valid = 0なので、r_f1_pcのadvanceにもr_f1_validのguardが必要。これがないとi_init_pcを読み出す前に次lineへ進んでしまう。
3-stage + stallのcorner case
まだ1個、簡略化したままのcorner caseがある。
F3がfullでstallしているcycleにF2のITCM read responseが返ってくると、そのdataを退避する場所がない。現在のF2はPC / validだけを持ち、read dataはF3へ直接latchする構成だから。
真面目に対応するなら、
- F2に
r_f2_rdataを持たせてskid buffer化する - F2を潰して2-stage構成に折り畳む
あたりになる。
現段階ではDecoder / Renameが基本always readyで、backpressure testもF3 holdの確認を目的としているのでbacklog送りにした。
検証
TBは2本。
tb_scariv_itcm.sv: 270 tests- sequential read
- byte-address alias
renhold- random 200 reads
tb_scariv_fetch.sv: 75 tests- sequential fetch
- mid-line redirect (
pc[2] = 1) - aligned redirect
- 5-cycle backpressure hold
ITCMの初期化は$readmemhではなく、TBからu_dut.mem[i] = ...でhierarchical writeした。
ここでもVerilatorで1個踏んだ。assign rdata = mem[r_idx]のようなcombinational readでは、TBからmemをhierarchical writeした直後にrdataが再評価されず、古い値が残る場合があった。r_idxが動けば再評価されるので、TB側ではその前提でsequenceを組んだ。
Stimulusは@(negedge clk)で入力を設定し、@(posedge clk)でDUTにsampleさせる形にするとscheduling raceが起きにくい。
Fetch側のTBではITCMに入れる命令を、
{16'hC0DE, pc[15:0]}
というPC依存のpatternにした。F3へ出てきたinstructionを見るだけで、どのPCからfetchされたものか分かる。
計ITCM 270 tests、Fetch 75 tests、全通過。
現時点の全モジュール状況
| Module | TB tests | Status |
|---|---|---|
| PRF | 40062 | PASS |
| RAT | 49896 | PASS |
| Free list | 43193 | PASS |
| Rename | 60 | PASS |
| ROB | 51 | PASS |
| IQ | 29 | PASS |
| ALU | 11500 | PASS |
| BRU | 50736 | PASS |
| Decoder | 73 | PASS |
| ITCM | 270 | PASS |
| Fetch | 75 | PASS |
これでbackendの主要moduleに加えて、frontendもFetch → Decodeまで揃った。