跳轉到

03.2 五級流水線與資料冒險處理#

學習目標#

  1. 理解流水線如何將多週期的各級交疊執行,達成 CPI 趨近 1 的吞吐量提升。
  2. 能以 Verilog 實作完整五級流水線:IF/ID/EX/MEM/WB 與其間的 pipeline register。
  3. 分析資料冒險(data hazard)的成因,實作 forwarding 與 load-use stall。
  4. 能用波形追蹤表逐週期驗證 hazard 處理的正確性。

3.2.1 流水線的基本原理#

3.1 節的多週期 CPU 把指令切成五步,但任何時刻只有一道指令在執行。流水線的觀察是:當第一道指令進入 ID 級時,IF 級的硬體是閒置的——何不讓第二道指令進來用?於是五個級各自服務一道不同的指令,如同洗衣店裡洗衣機、烘乾機、摺衣台同時服務不同批衣服。

理想情況下,五級流水線的吞吐量提升 5 倍:

\[ \text{加速比} = \frac{\text{非流水線每指令時間}}{\text{流水線每指令時間}} = \frac{T_{clk,nopipe} \times CPI_{nopipe}}{T_{clk,pipe} \times CPI_{pipe}} \]

由於每級只需容納原關鍵路徑的約 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 時建議:

  1. 參考模型:用 C 或 Python 寫一個循序(非流水線)模擬器,比對每道指令 commit 時的架構狀態(register file + memory + PC)。
  2. 危險模式:back-to-back 依賴(add x5,...; sub x6,x5,...)、load-use、load 後隔一道再用(不該 stall)、寫 x0(不該前遞也不該 stall)、raw 讀寫同暫存器。
  3. 合併控制冒險:加入 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 清除錯誤路徑,兩者是所有流水線處理異常的基本手段。

實作題#

  1. 完成 3.2.3 節中省略的 EX/MEM 與 MEM/WB pipeline register 與 MEM、WB 兩級的 Verilog,確保 lw/sw 正確運作。
  2. 撰寫 testbench 執行 lw; add; sub; and; or 五道鏈式依賴指令,繪製類似 3.2.4 節的週期追蹤表,標出所有前遞與停頓。
  3. 實作「load 後隔一道指令使用不會 stall」的測試:lw x5; or x7,x3,x4; add x6,x5,x3,驗證只靠 MEM/WB 前遞即可,CPI 不受影響。
  4. 加入 sw x5, 0(x6) 緊跟在 lw x5 之後的案例,說明 MEM 級內部的「前遞」(MEM 級直接取用 load 的資料路徑)如何處理,並修改程式碼。
  5. 統計一段 100 道指令的測試程式的實際 CPI(數波形中每 5 級都佔滿的週期比例),與理論值比較,解釋差異來源。