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

危障:當生產線卡住的時候

管線唯有在每個週期都能讓一條新指令進來時才划算——但有時下一條偏偏不准前進。這些就是危障:結構性、資料性、與控制性的。來認識讓這條生產線卡住的三件事,以及它們為什麼會吃掉貨真價實的週期。

美夢,與其中的玄機

在前兩篇導覽裡我們把畫面建好了:管線化就像在跑一條洗衣生產線,所以一批衣服在烘、另一批在洗、第三批在摺。我們的五級管線同時讓五條指令在路上——擷取、解碼、執行、記憶體、寫回——而級與級之間的管線暫存器,把每條指令做了一半的工,一拍一拍地往前帶。在完美情況下每個時脈週期都進來一條新指令,於是即使每條指令首尾仍要五個週期,機器卻是每個週期完成一條。

但洗衣這個比喻藏了一個假設:那些衣物彼此獨立。真實的指令並非如此。一條指令常常需要剛剛跑在它前面那條指令還沒算出來的結果;一個分支可能改變接下來究竟輪到哪條指令;而兩條指令也可能在同一個週期都想用同一塊硬體。當下一條指令無法安全地照表前進時,我們就稱之為危障。一個管線危障,正正就是「若放任管線全速繼續前進就會算出錯誤答案」的那種處境。

結構性危障:兩隻手伸向同一件工具

最單純的危障純粹是管線「水路」的問題。當處在不同級的兩條指令,在同一個週期需要同一塊硬體資源,而那塊資源只有一份時,就發生結構性危障。想像一間洗衣店只有一台「洗烘合一」機:當一批衣服想洗、另一批想烘的那一刻,就得有人等。這個衝突不是資料算錯——而是金屬不夠分。

經典例子是記憶體。在某一個週期裡,一條指令可能正在擷取級伸進指令記憶體去抓下一個運算碼,而領先它四級的另一條指令正在記憶體級伸進資料記憶體去做載入或儲存。若這兩者是同一個單一記憶體埠,每當管線填滿時它們就會相撞。這正是為什麼管線化的機器偏向採取哈佛式的分離——指令快取與資料快取各一份——好讓擷取與記憶體存取同時發生。給每個競爭者各自一份資源的副本,結構性危障便乾脆消失了。

資料性危障:結果還沒準備好

更深層的麻煩是值與值之間的相依。當一條指令需要讀一個暫存器,而較早、仍在路上的某條指令即將寫入那個暫存器時,就出現資料性危障。教科書上的典型情形是寫後讀相依:程式的意思是「先算出 x,再用 x」,但在管線裡,第二條指令走到讀取 x 的那一級時,第一條指令還在前往寫入 x 的路上。紙面上的順序沒問題;管線中時間上的順序才是問題。

用兩條 RISC-V 指令把它變具體,第二條用 x1 來做加法——正是第一條剛剛產生的那個暫存器。把它們追過五個級,看看危險落在哪裡。

  add  x1, x2, x3   # writes x1
  sub  x5, x1, x4   # reads  x1

cycle:        1    2    3    4    5    6
  add        IF   ID   EX  MEM   WB
  sub             IF   ID   EX  MEM   WB
                       ^
          sub reads x1 in ID (cycle 3),
          but add writes x1 in WB (cycle 5)
          --> sub would read a STALE x1
sub 在 add 完成寫入 x1 之前兩個週期就讀了 x1——這是一個寫後讀資料性危障。

誠實的出路有兩條,下一篇導覽會專門講它們,所以這裡只先點出它們的樣貌。便宜的解法是前遞(又叫旁路):x1 的值其實在第 3 週期就已存在於 ALU 的輸出端——只是還沒寫進暫存器檔——所以我們加上電線,把它直接從算出來的地方繞到需要的地方,不必等。退而求其次的是停頓:當連前遞都來不及把值送到時,管線會把那條相依指令凍住一兩個週期,讓生產者趕上來。

載入使用危障:連前遞也贏不了物理

有一種資料性危障拒絕被完全前遞,值得單獨拎出來講。當生產者是一條載入——一條從記憶體取值的指令——那個值要到記憶體級的最末端才存在。如果下一條指令在它的執行級就需要那個值,根本沒有更早的時刻可以從那裡前遞:資料晚生了整整一個週期。這就是載入使用危障,與「加法到加法」的相依不同,即使前遞線全鋪好了,它仍逼出至少一個週期的氣泡。

氣泡不過是塞進管線的一個空操作——一個週期裡那條相依指令原地踏步、沒有任何有用的指令退場。硬體可以偵測載入使用危障並自動插入氣泡,或者聰明的編譯器可以排一條不相干、彼此獨立的指令進那個空檔,把它藏起來,讓那個週期不至於白費。無論哪種,代價都是真的,這也是為什麼這個小情形在效能調校裡份量很重:滿是「追指標」載入的緊湊迴圈,正是這些氣泡堆積之處。

控制性危障:路要往哪邊岔?

第三個家族關乎「接下來究竟輪到什麼」。控制性危障(或稱分支危障)出現在條件分支處:機器每個週期都沿直線擷取指令,但分支可能改向——而管線要走進好幾級之後,才知道分支到底有沒有跳、又跳去哪。在這同時,它早已把實體上緊跟在分支後面的那些指令擷取進來了。如果分支結果是跳去別處,那些已被擷取的指令就是錯的,必須丟掉。

把它們扔掉就是管線清空:那些被錯誤擷取的指令在還沒能改動任何暫存器或記憶體之前,就被變成氣泡,於是程式可見的結果依然正確——你只在浪費的週期上付費,絕不在錯誤的答案上付費。每猜錯一次損失幾個週期,取決於分支的結果在管線多深處才被解出。在第 3 級解出就清掉兩條指令;更晚解出就清掉更多。

與其每次都停下來等,真實的機器選擇下注。一個分支預測器正是如此——一個關於程式會走哪條岔路的猜測——管線於是沿著預測的路徑投機地擷取。猜對了你什麼都不必付;猜錯了就清空重來。這是下一篇導覽的種子,也是個安靜的警訊:這種「憑猜測搶先跑」的習慣,正是日後 Spectre 與 Meltdown 漏洞學會利用的東西。眼下,先把樣貌記住:預測、搶先跑、猜錯就清空。

把帳算清:危障對 CPI 做了什麼

把它接回鐵律:執行時間 = 指令數 x CPI x 週期時間。管線化整個承諾,就是把每指令週期數往理想的 1 壓。每一次停頓、每一次清空,都加進不讓任何指令退場的閒置週期,於是它們把有效 CPI 推到 1 以上。比方說,若有兩成指令是載入、其中五分之一觸發一個週期的載入使用氣泡,又有一成五是分支、其中十分之一猜錯、每次罰兩個週期,你大可把這些罰則直接加總:有效 CPI 約莫就是 1 加上每條指令攤到的全部氣泡週期。

這正好框出收束本級的那個誠實取捨。一條更深的管線把工作切成更多、更小的級,於是每一級更短、機器能打更快的時脈——每個週期少幾奈秒。但級數更多意味著分支結果更晚才解出,於是每次猜錯都清掉更多指令,每次停頓也相對更貴。更深的管線時脈更快,卻為每次停頓、每次猜錯付出更多;甜蜜點是個平衡,而非極大值,而單追時脈不過是又一次「兆赫迷思」。