會稍微說謊的生產線
在第 1 階我們把指令執行切成五級——擷取、解碼、執行、記憶體、寫回——讓五條指令同時在輸送帶上跑。那個標題數字很迷人:深度為五的指令管線承諾每個時脈完成一條指令(1 IPC),即使單一條指令還是得爬過五個週期。這就像汽車工廠的魔法:每台車要花一整天組裝,但每隔幾分鐘就有一台成品開下產線。
但工廠類比藏了一個謊。一扇車門不需要知道*下一台*車的引擎是什麼顏色,但指令沒這麼有禮貌。指令 B 常常需要指令 A 還在算的數字;一條分支指令甚至決定了 C、D、E 該不該存在。當產線被迫等待,我們把這個事件稱為管線危障,而每一次危障都把真實 IPC 從那個漂亮的 1.0 往下拖。
管線卡住的三種方式
架構師把危障分成三大家族,而這些名字正好告訴你大家在搶什麼。
- 結構危障——兩條指令在同一週期想用*同一塊硬體*。想像擷取與記憶體存取同時去搶唯一的記憶體埠。乾淨的解法就是別共用:把指令與資料記憶體分開(經典的哈佛式 L1 快取),或替暫存器檔加第二個讀埠。這多半是架構師的問題,設計期一次解決。
- 資料危障——某條指令需要一個*數值*,而這個值正由前一條還沒做完的指令產生。這是最家常的危障,本篇接下來主要就在獵捕它。
- 控制危障——管線還不知道*下一條指令是哪一條*,因為分支尚未解算。擷取很貪心、從不想停,所以它必須猜。猜錯了,就得把做過的工作丟掉。
逐週期追蹤一個資料危障
我們把它講到痛徹具體。看兩條 RISC 風格指令,第二條讀的正是第一條剛寫的東西:
ADD x3, x1, x2 # x3 = x1 + x2 SUB x5, x3, x4 # x5 = x3 - x4 <-- needs the new x3
把它們攤在管線格子上。每條指令隨時脈一級一級走 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!最樸素的解法就是直接暫停(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!一個小小的前遞單元盯著管線:「在 EX 的指令需要的來源暫存器,是不是某條在 MEM 或 WB 的指令正要產生?如果是,就用多工器把那個新鮮值切進來,取代過時的暫存器讀取。」它就是個由比較器與多工器組成的小控制器,免費救回絕大多數的資料危障。
分支:對未來下注
資料危障是局部的小爭執,控制危障卻威脅到整條管線的存在權。當機器撞上一條條件分支——`if (x > 0) goto L`——它要到比較在 EX 深處解算後,才知道該往哪走。但擷取不肯閒著,下一個週期就得把*某個東西*載入 IF。哪個位址?分支目標,還是順流而下?這真的還沒定。
等分支解算再走雖然正確,卻很殘忍:在五級管線裡,*每一次*分支大約浪費 2 到 3 個死週期,而真實程式碼大約每五、六條指令就分支一次。於是 CPU 改用預測結果,並沿著猜測的路徑投機式擷取。這就是分支預測,在現代核心上它好得驚人。
- 靜態預測——燒進去的固定規則,例如「往後跳的分支視為會跳」(迴圈會往回跳,所以多半猜對)。便宜,不學習。
- 動態預測——一張以分支位址索引的飽和計數器小表。每個條目通常是 2 位元的狀態機(強/弱、跳/不跳),記住該分支上次怎麼走,並在真實結果出來後更新。
- 現代預測器——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.