閒置的機器
在資料路徑那一級,你打造了一顆處理器,它把一整條指令從頭跑到尾,才去碰下一條。我們看著它分成五個邏輯階段運作:取指令、解碼並讀暫存器、做算術、搆記憶體、把結果寫回去。問題——也就是我們結束那一級時談到的單週期限制——在於任何一瞬間都只有一個階段在忙。當算術單元在計算時,指令記憶體、暫存器讀取埠、資料記憶體全都閒著沒事,無聊地敲手指。你付錢買了五塊硬體,卻只用了其中的五分之一。
管線化就是解法,而你腦中要握住的畫面是一間自助洗衣店。你有四批衣物要洗,每一批都得先洗、再烘、再折。笨方法是把一批從頭做到尾——洗、烘、折——才開始下一批。但就在你第一批衣物離開洗衣機、移到烘乾機的那一刻,洗衣機就空出來了。於是你立刻開始洗第二批,同時第一批在烘乾。很快三台機器就同時運轉,各自處理三批不同的衣物,而四批衣物完成所需的時間,大約只是笨方法處理兩批的時間。這就是整個點子:別讓任何一批變快;只要別讓機器閒坐著就好。
吞吐量,不是延遲
仔細看那堆洗衣,你會看見那個關鍵、卻很容易被忽略的真相。任何單一批衣物仍然要花「洗加烘加折」的時間;管線化一點也沒縮短這個。改變的是完成的衣物多久從末端掉出來一批。一旦流水線填滿,每一個「洗的時間」就有一批完成,而不是每一個「洗加烘加折的時間」才完成一批。這個完成的速率,就是吞吐量,而管線化倍增的正是它。一件物品走完整條線所需的時間——它的延遲——則原封不動,甚至可能還悄悄上升一點點。
洗衣的比喻還悄悄假設掉了第二個隱藏成本:三台機器必須運轉一樣長的時間,否則最慢的那台就決定了步調。如果烘乾要花洗衣兩倍的時間,洗衣機早早做完也只能乾等。在處理器裡,各階段很少能完美平衡,所以時脈週期由最慢的階段決定,而不是平均值。當你能把工作切成長度大致相等的階段時,管線化的收穫最大——而追求這份平衡,正是結構設計師為什麼會選五個階段、或十四個、或三十個的一大原因。
五個階段,以及它們之間的閂鎖
教科書上那條經典管線,把一條指令的一生切成你的單週期資料路徑本來就有的同樣五個階段——這就是五階段管線,下一篇導覽會把它細細搭起來。這幾個階段有縮寫:IF(取指令)、ID(解碼並讀暫存器)、EX(在 ALU 中執行)、MEM(存取資料記憶體)、WB(寫回暫存器檔)。在一條填滿的管線裡,五條不同的指令同時佔據這五個階段——一條正被取進來,同時另一條在執行,第三條在寫回。
cycle: 1 2 3 4 5 6 7 8
add -> IF ID EX MEM WB
sub -> IF ID EX MEM WB
and -> IF ID EX MEM WB
or -> IF ID EX MEM WB
^
pipeline full here: 5 instructions in flight,
one finishes (WB) every cycle from now on可是機器要怎麼避免五條指令踩到彼此的訊號?每一對階段之間,都坐著一個管線暫存器——一道由正反器構成的牆,在每一次時脈跳動時,把下一個階段會用到的一切拍成快照,並把這條指令的私有資料往前帶。每一批衣物都坐在自己的籃子裡,在機器之間移動;那些籃子就是管線暫存器。它們純粹是額外開銷——什麼都不計算——但少了它們,各階段就會糊成一團;而邏輯那一級講關鍵路徑的章節會告訴你,為什麼每一次跳動都必須長到足夠讓最慢的階段加上它的暫存器都安定下來。
當流水線卡住:三種危障
真實的指令並不像各做各事的洗衣批次——它們互相依賴,而那會打破乾淨的重疊。任何阻止下一條指令在它應有的週期啟動的東西,都是一個管線危障,而它們剛好分成三種口味。這一級接下來的內容,本質上就是這三種危障、以及工程師如何各個馴服它們的故事。
- 結構危障——兩條指令在同一個週期想用同一塊硬體。就像一批衣物要用洗衣機,另一批也要用。經典的解法是不共用:給管線分開的指令記憶體與資料記憶體,這樣取指令與存取記憶體就永遠不會相撞(一種悄悄帶有哈佛風味的分隔)。
- 資料危障——一條指令需要某個值,而較早、仍在管線裡飛的那條指令還沒把它寫回去。這就是「寫後讀」相依,也是真實程式碼裡最常見的卡點。
- 控制危障——一條分支決定要繼續直走還是跳去別處,但這個決定要等分支走到管線中段才知道,而機器此時早已把後面的指令取進來了。賭錯了,這些指令就得整批丟掉。
把資料危障講得具體些。假設一條加法把結果寫進暫存器 x1,而緊接著的下一條指令用 x1 做減法。在我們的圖裡,加法要到第 5 週期才走到 WB,但減法卻想在它的 ID 階段、也就是第 3 週期就讀 x1——早了整整兩個週期。這就是寫後讀相依:讀的人緊緊貼著寫的人的後頸。放著不管,減法就會抓到一個過時的舊值,算出一堆垃圾。
解法,以及付出的週期代價
對付危障有兩種工具,而你會先伸手去拿比較便宜的那個。聰明的那個是前饋(forwarding,又叫旁路 bypassing):注意到加法其實早在它的 EX 階段就算出了 x1 的值,遠在它費事把值寫進暫存器檔之前。於是加一條捷徑線,把剛算好的結果直接從某個階段抓出來,餵進那條等待中的指令的 ALU 輸入端——不必苦等那趟緩慢的寫回旅程。大多數的寫後讀危障,就這樣免費消失了;第 4 篇導覽會把它接起來。
但前饋變不出還不存在的值。一條載入指令是在 MEM 讀記憶體,比 EX 還晚一個階段,所以一條要用剛載入之值的指令,真的來不及從任何地方抓到它。這就是載入-使用危障(load-use hazard),而這裡你只能退回去用那個笨工具:暫停(stall)。硬體把那條相依指令凍結一個週期,並在它後面塞進一個什麼都不做的氣泡(bubble)——輸送帶上一個空籃子——剛好給載入足夠的時間。這個氣泡讓你正好浪費一個週期。對於分支,對應的解法是在發現預測錯誤時,把錯誤取進來的指令沖掉(flush),它們也同樣變成氣泡。
每一個氣泡都會出現在一個數字裡:有效 CPI,也就是機器實際達到的平均每指令週期數。一條完美、沒有危障的五階段管線,會逼近「每週期 1 條指令」的理想。每一次暫停、每一次沖刷,都把這個平均值往 1 以上推一點——而基礎那一級的鐵律提醒你,執行時間等於指令數乘以 CPI 乘以週期時間,所以更糟的 CPI 會直接拖慢整個程式。這一級接下來的功夫,就是把有效 CPI 維持在危障所允許的、盡可能貼近 1 的地方。
這條流水線該挖多深?
如果五個階段是好的,那為什麼不挖五十個?把工作切成更多、更短的階段,會讓每個階段的活兒更小,於是你能縮短時脈週期、拉高時脈頻率——也就是行銷最愛拿出來說的那個招牌數字。2000 年代初的一些設計,為了追逐 GHz,把階段數推過二十、甚至三十。這就是管線深度的取捨,而陷阱很殘酷:管線越深,出事時飛在裡面的指令就越多,所以每一次預測錯誤、每一次暫停,都會丟掉更多指令。一台 30 階段的機器若分支預測錯了,可能要沖掉數十條指令;一台 5 階段的機器只損失寥寥幾條。