JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

管線何時卡住:危障、前遞與分支預測

紙上談兵時,五級管線每個時脈就吐出一條指令——一條完美的生產線。但真實矽晶片永遠到不了那裡,因為指令會為了資料、也為了程式下一步往哪走而互相爭執。本篇拆解三大類管線危障,再帶你看現代 CPU 反擊的兩招:在答案還沒寫進暫存器前就「前遞」過去,以及在分支結果揭曉前就先賭一把。

會稍微說謊的生產線

在第 1 階我們把指令執行切成五級——擷取、解碼、執行、記憶體、寫回——讓五條指令同時在輸送帶上跑。那個標題數字很迷人:深度為五的指令管線承諾每個時脈完成一條指令(1 IPC),即使單一條指令還是得爬過五個週期。這就像汽車工廠的魔法:每台車要花一整天組裝,但每隔幾分鐘就有一台成品開下產線。

但工廠類比藏了一個謊。一扇車門不需要知道*下一台*車的引擎是什麼顏色,但指令沒這麼有禮貌。指令 B 常常需要指令 A 還在算的數字;一條分支指令甚至決定了 C、D、E 該不該存在。當產線被迫等待,我們把這個事件稱為管線危障,而每一次危障都把真實 IPC 從那個漂亮的 1.0 往下拖。

管線卡住的三種方式

架構師把危障分成三大家族,而這些名字正好告訴你大家在搶什麼。

  1. 結構危障——兩條指令在同一週期想用*同一塊硬體*。想像擷取與記憶體存取同時去搶唯一的記憶體埠。乾淨的解法就是別共用:把指令與資料記憶體分開(經典的哈佛式 L1 快取),或替暫存器檔加第二個讀埠。這多半是架構師的問題,設計期一次解決。
  2. 資料危障——某條指令需要一個*數值*,而這個值正由前一條還沒做完的指令產生。這是最家常的危障,本篇接下來主要就在獵捕它。
  3. 控制危障——管線還不知道*下一條指令是哪一條*,因為分支尚未解算。擷取很貪心、從不想停,所以它必須猜。猜錯了,就得把做過的工作丟掉。

逐週期追蹤一個資料危障

我們把它講到痛徹具體。看兩條 RISC 風格指令,第二條讀的正是第一條剛寫的東西:

  ADD  x3, x1, x2     # x3 = x1 + x2
  SUB  x5, x3, x4     # x5 = x3 - x4   <-- needs the new x3
SUB 要讀 x3,但 ADD 還沒算完它。

把它們攤在管線格子上。每條指令隨時脈一級一級走 IF → ID → EX → MEM → WB。ADD 要到第 5 週期的 WB 級才真正*寫入*新的 x3,但 SUB 卻想在第 3 週期的 ID 級就*讀*x3——早了整整兩個週期。

cycle:     1    2    3    4    5    6
ADD x3:   IF   ID   EX   MEM  WB
SUB x5:        IF   ID   EX   MEM  WB
                    ^         ^
                    |         x3 finally written here (WB)
                    SUB reads x3 here (ID) -- STALE!
經典的「先寫後讀(RAW)」危障:讀的人追上了寫的人。

最樸素的解法就是直接暫停(stall):把 SUB(以及它後面的一切)凍住,等到 ADD 的值安穩寫進去,再放它走。暫停以氣泡(bubble)形式插入——什麼都不做的空級。這樣是對的,但每個氣泡都是浪費的週期,這裡就得浪費兩個。每對相依指令都這樣搞,你的 1.0 IPC 就會塌向 0.5 甚至更糟。

前遞:提早把答案送出去

聰明的觀察在這裡。ADD 其實在第 3 週期 EX 級結束時*就已經算出* x3——那個數字就躺在 ALU 輸出栓鎖裡。SUB 之所以非等不可,只是因為「數值必須住在暫存器檔」這條記帳規則。那就打破規則。拉一條私線——旁路(bypass)——直接從 ALU 輸出接回 ALU 輸入,把新鮮的 x3 直接餵給 SUB,完全跳過暫存器檔。這招就叫前遞(forwarding)

cycle:     1    2    3    4    5
ADD x3:   IF   ID   EX---MEM  WB
SUB x5:        IF   ID   EX   MEM  WB
                        ^
                        x3 forwarded EX->EX, no stall!
有了旁路路徑,SUB 在第 4 週期取得 x3,零氣泡。

一個小小的前遞單元盯著管線:「在 EX 的指令需要的來源暫存器,是不是某條在 MEM 或 WB 的指令正要產生?如果是,就用多工器把那個新鮮值切進來,取代過時的暫存器讀取。」它就是個由比較器與多工器組成的小控制器,免費救回絕大多數的資料危障。

分支:對未來下注

資料危障是局部的小爭執,控制危障卻威脅到整條管線的存在權。當機器撞上一條條件分支——`if (x > 0) goto L`——它要到比較在 EX 深處解算後,才知道該往哪走。但擷取不肯閒著,下一個週期就得把*某個東西*載入 IF。哪個位址?分支目標,還是順流而下?這真的還沒定。

等分支解算再走雖然正確,卻很殘忍:在五級管線裡,*每一次*分支大約浪費 2 到 3 個死週期,而真實程式碼大約每五、六條指令就分支一次。於是 CPU 改用預測結果,並沿著猜測的路徑投機式擷取。這就是分支預測,在現代核心上它好得驚人。

  1. 靜態預測——燒進去的固定規則,例如「往後跳的分支視為會跳」(迴圈會往回跳,所以多半猜對)。便宜,不學習。
  2. 動態預測——一張以分支位址索引的飽和計數器小表。每個條目通常是 2 位元的狀態機(強/弱、跳/不跳),記住該分支上次怎麼走,並在真實結果出來後更新。
  3. 現代預測器——TAGE、感知器式與類神經式設計會關聯近期分支的長歷史,準確率動輒超過 95% 到 99%,正是這個讓深管線得以存活。

猜錯的代價

預測是賭博,賭就有壞處。當分支終於在 EX 解算、而當初猜錯了,分支之後擷取進來的每一條指令都是垃圾——做在一條不存在的路上的工。管線會執行清空(flush):把那些投機指令壓成氣泡,把擷取重導向正確位址,從頭來過。浪費掉的週期就是誤測懲罰(misprediction penalty)

Branch resolves in EX (stage 3). On a miss, IF and ID work is trashed:

BR:    IF   ID   EX*  ...        (* = outcome known, was MISpredicted)
wrong: --   IF   ID  <-- FLUSHED (squashed to bubbles)
wrong: --   --   IF  <-- FLUSHED
correct target:       IF   ID   EX ...   (refetch starts here)

  Penalty ~= pipeline stages before resolve  (here ~2 cycles)
  Deep CPU (15-20 stages): 15-20 wasted cycles per miss!
清空把誤測的工作變成氣泡;越深的管線付得越多。

這就是為什麼管線深度是把雙面刃,也釘死了一個真實的設計張力。簡單的 5 級核心每次誤測只損失幾個週期;高頻、15 到 20 級的核心可能每次損失 15 到 20 個週期,所以即使 2% 的誤測率也會悄悄放血。我們可以把傷害寫成指令串流上的有效成本:

Average CPI  =  1.0 (ideal)
             +  branch_frequency x mispredict_rate x penalty

Example (deep core):
   branch_frequency = 0.20   (1 in 5 instructions)
   mispredict_rate  = 0.05   (95% accurate predictor)
   penalty          = 18 cycles

   added CPI = 0.20 x 0.05 x 18 = 0.18
   -> ~18% slower than the 1.0-CPI dream, from branches alone.
即使是「不錯」的 95% 預測器,在深管線裡也漏掉了實實在在的效能。