03.2 五級流水線與資料冒險處理#
學習目標#
- 理解流水線如何將多週期的各級交疊執行,達成 CPI 趨近 1 的吞吐量提升。
- 能以 Verilog 實作完整五級流水線:IF/ID/EX/MEM/WB 與其間的 pipeline register。
- 分析資料冒險(data hazard)的成因,實作 forwarding 與 load-use stall。
- 能用波形追蹤表逐週期驗證 hazard 處理的正確性。
3.2.1 流水線的基本原理#
3.1 節的多週期 CPU 把指令切成五步,但任何時刻只有一道指令在執行。流水線的觀察是:當第一道指令進入 ID 級時,IF 級的硬體是閒置的——何不讓第二道指令進來用?於是五個級各自服務一道不同的指令,如同洗衣店裡洗衣機、烘乾機、摺衣台同時服務不同批衣服。
理想情況下,五級流水線的吞吐量提升 5 倍:
由於每級只需容納原關鍵路徑的約 1/5 加上暫存器開銷, \(T_{clk}\) 縮短;CPI 理想值為 1。以 3.1 節的例子:非流水線 6 ns × 1 CPI = 6 ns/指令;流水線每級約 1.4 ns(含 pipeline register 開銷)× 1 CPI = 1.4 ns/指令,加速比約 4.3 倍。
但流水線引入了新的問題——冒險(hazard),本章聚焦資料冒險,控制冒險見 3.3 節。
3.2.2 資料冒險#
考慮:
add x5, x1, x2
sub x6, x5, x3 ; 需要 add 的結果 x5
add 要到 WB 級(第 5 週期)才寫入暫存器堆,但 sub 在第 3 週期的 EX 級就需要 x5。若不做處理,sub 會讀到舊值。
關鍵洞察:add 其實在第 3 週期結束(EX 級輸出)就已經算出 x5 的值,只是還沒寫進暫存器堆。與其等它寫回,不如用一條旁路(bypass)直接把 ALU 輸出接到下一道指令的 ALU 輸入——這就是前遞(forwarding)。
唯一的例外是 load-use hazard:
lw x5, 0(x1)
add x6, x5, x3
lw 的資料要等到第 4 週期(MEM 級結束)才會出現,但 add 在第 3 週期就需要它。硬體無法前遞時間上還不存在的資料,唯一的解法是停頓(stall)一個週期,之後再前遞。
3.2.3 五級流水線 Verilog 實作#
Pipeline Register#
級與級之間的 pipeline register 是流水線的骨幹,每個時脈把上一級的結果推進下一級:
// IF/ID
typedef struct packed {
logic [31:0] pc; // 供分支計算目標位址
logic [31:0] instr;
logic valid; // flush 時清為 0
} ifid_t;
// ID/EX
typedef struct packed {
logic [31:0] pc, rs1_data, rs2_data, imm;
logic [4:0] rs1, rs2, rd;
logic rs1_en, rs2_en; // 該欄位是否真的來源暫存器
logic [2:0] funct3;
logic [6:0] funct7;
logic reg_write, mem_read, mem_write, alu_src, branch;
logic [2:0] alu_op;
} idex_t;
// EX/MEM、MEM/WB 依此類推
ifid_t ifid; idex_t idex; exmem_t exmem; memwb_t memwb;
每級的主體:
// IF
always_ff @(posedge clk or posedge rst) begin
if (rst) begin
ifid <= '0;
pc <= '0;
end else if (!stall_if) begin
ifid.pc <= pc;
ifid.instr <= imem[pc[11:2]];
ifid.valid <= flush_if ? 1'b0 : 1'b1;
pc <= flush_if ? branch_target : pc + 4;
end
end
// ID:解碼 + 讀暫存器堆
always_comb begin
idex_next.rs1_data = rf[ifid.instr[19:15]];
idex_next.rs2_data = rf[ifid.instr[24:20]];
idex_next.imm = imm_gen(ifid.instr);
// 控制訊號由 opcode 解碼...
end
// EX:ALU + forwarding mux
always_comb begin
op_a = (fwd_a == 2'b01) ? wb_result_w :
(fwd_a == 2'b10) ? exmem.alu_result : idex.rs1_data;
op_b = (fwd_b == 2'b01) ? wb_result_w :
(fwd_b == 2'b10) ? exmem.alu_result : idex.rs2_data;
alu_result = alu(idex.alu_op,
op_a,
idex.alu_src ? idex.imm : op_b);
end
// MEM / WB 略,同第 2 章單週期邏輯
注意 rs1_en/rs2_en:若指令的某欄位不是真正的來源(例如 addi 的 rs2 欄位其實是立即數的一部分),forwarding unit 必須忽略它,否則會前遞錯誤的資料。
Hazard Detection Unit#
// Load-use hazard: 前一道是 lw,且其目的暫存器正是本道來源
assign load_use = idex.mem_read &&
((idex.rd == ifid.rs1_field && idex.rd != '0) ||
(idex.rd == ifid.rs2_field && idex.rd != '0) ||
(idex.mem_read && idex.rd == '0 && 1'b0)); // x0 永遠不算
assign stall_if = load_use;
assign stall_id = load_use;
assign flush_ex = load_use; // ID/EX 暫存器清為 nop
// Forwarding A
always_comb begin
if (idex.rs1_en && exmem.reg_write && exmem.rd != '0 &&
exmem.rd == idex.rs1 && !exmem.mem_read)
fwd_a = 2'b10; // 從 EX/MEM 前遞
else if (idex.rs1_en && memwb.reg_write && memwb.rd != '0 &&
memwb.rd == idex.rs1)
fwd_a = 2'b01; // 從 MEM/WB 前遞
else
fwd_a = 2'b00;
end
// Forwarding B 同理
兩條規則的優先順序很重要:EX/MEM(較新)優先於 MEM/WB(較舊),因為較新的指令才是程式順序上最後寫入該暫存器的。
Flush 與 Stall 的實作慣例#
- Stall:凍結 PC 與 IF/ID 暫存器(資料不推進)。
- Flush:把某個 pipeline register 清為 nop(
valid=0,控制訊號全 0)。
stall 與 flush 常同時發生:load-use 時 stall 了 IF/ID,同時 flush 了 ID/EX,效果是在流水線中插入一個泡泡(bubble)。
3.2.4 Load-Use Hazard 停頓追蹤表#
lw x5, 0(x1)
add x6, x5, x3
sub x7, x6, x4
| 週期 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| lw | IF | ID | EX | MEM | WB | |||
| add | IF | ID | ~ | EX | MEM | WB | ||
| sub | IF | ID | ~ | EX | MEM | WB |
(~ 代表 stall 產生的泡泡)
逐週期解說:
- 週期 3:
add在 ID,hazard unit 發現idex(此時是 lw)的mem_read=1且rd=x5等於add的 rs1 → 觸發 stall。 - 週期 4:PC 與 IF/ID 凍結(
add的 IF/ID 內容不變),ID/EX 被 flush 成 nop。lw 在 MEM 級讀到資料。 - 週期 5:
add重做 ID(讀到的是舊值,沒關係),進 EX;此時 lw 已到 WB,MEM/WB 的寫回值可以前遞給add的 EX 級 →fwd_a = 2'b01。 - 週期 6:
sub因同樣原因 stall 一次,週期 7 從 EX/MEM 前遞add的結果。
注意:sub 依賴 add,而 add 本身被 stall 拖慢,所以冒險會沿著依賴鏈往下游傳播。這是「停頓的代價會累積」的具體呈現。
一般規則:相鄰的 ALU 指令之間零停頓(前遞解決);load 後緊接使用需停頓 1 週期;編譯器可以用指令排程(把無關指令填入 load 之後)消除許多 load-use 停頓。
3.2.5 正確性驗證要點#
撰寫 testbench 時建議:
- 參考模型:用 C 或 Python 寫一個循序(非流水線)模擬器,比對每道指令 commit 時的架構狀態(register file + memory + PC)。
- 危險模式:back-to-back 依賴(
add x5,...; sub x6,x5,...)、load-use、load 後隔一道再用(不該 stall)、寫 x0(不該前遞也不該 stall)、raw 讀寫同暫存器。 - 合併控制冒險:加入 beq 後配合 3.3 節的 flush,驗證分支後第一道指令被正確沖掉。
本章小結#
- 流水線把多週期的各級交給不同指令,理想加速比趨近級數,實際受冒險與 pipeline register 開銷限制。
- Pipeline register 是各級的介面,需攜帶資料、控制訊號與 valid 位元。
- Forwarding 把尚未寫回的結果直接送往下游 ALU 輸入,解決大多數 RAW 冒險;EX/MEM 優先於 MEM/WB。
- Load-use hazard 無法前遞,必須 stall 一個週期並 flush ID/EX 成泡泡。
- Stall 凍結上游,Flush 清除錯誤路徑,兩者是所有流水線處理異常的基本手段。
實作題#
- 完成 3.2.3 節中省略的 EX/MEM 與 MEM/WB pipeline register 與 MEM、WB 兩級的 Verilog,確保
lw/sw正確運作。 - 撰寫 testbench 執行
lw; add; sub; and; or五道鏈式依賴指令,繪製類似 3.2.4 節的週期追蹤表,標出所有前遞與停頓。 - 實作「load 後隔一道指令使用不會 stall」的測試:
lw x5; or x7,x3,x4; add x6,x5,x3,驗證只靠 MEM/WB 前遞即可,CPI 不受影響。 - 加入
sw x5, 0(x6)緊跟在lw x5之後的案例,說明 MEM 級內部的「前遞」(MEM 級直接取用 load 的資料路徑)如何處理,並修改程式碼。 - 統計一段 100 道指令的測試程式的實際 CPI(數波形中每 5 級都佔滿的週期比例),與理論值比較,解釋差異來源。