我們把流水線卡在哪裡
上一篇你認識了資料危障:當一條指令需要某個值,而較早、仍在流水線上飛行的指令還沒把它寫回時。最乾淨的例子是一對寫後讀——一條加法產生某個暫存器的值,緊接著一條減法讀它。在五階段管線中,加法在 EX 階段算出答案,卻要等三個週期後的 WB 才把它存進暫存器檔。落後一個階段的減法,在它自己的 ID 讀暫存器時,那次寫入「還沒發生」,於是會讀到陳舊的舊值。樸素的修法是把流水線凍結到寫入落地——正確,卻很慢。
但仔細一看,總有個地方讓人不安。加法在 EX 結束時其實「早就知道」答案了,比它費事寫回早了整整兩個週期。而減法要到「它自己的」EX 才需要那個答案,也就是下一個週期。值是存在的;它只是正坐在一個管線暫存器裡,而不在減法正在張望的暫存器檔裡。整個問題是一個「遞送」問題,不是一個「計算」問題——而這個重新框定,正是開啟便宜解藥的鑰匙。
前遞:把結果直接遞過去
前遞(forwarding)——也叫旁路(bypassing)——加上幾條短線,把剛算好的結果從較晚的階段「往回」直接送進較早階段的輸入,繞過經由暫存器檔那趟漫長的來回。再想想那條洗衣流水線:折衣的人需要一件乾淨襯衫,與其等它被一路送進衣櫃再被取回來,烘乾機乾脆把那件溫熱的襯衫直接從工作檯遞過去。襯衫早就好了;我們只是讓它走了近路。
具體來說,在 EX 階段算出的 ALU 結果會在時脈滴答時被鎖進 EX/MEM 管線暫存器。一條前遞路徑從那個暫存器接回 ALU 的輸入多工器。於是當減法在一個週期後進入 EX,一個小小的前遞單元(forwarding unit)注意到它某個來源暫存器,正好等於此刻位於 MEM 那條指令的目的暫存器,便把多工器導去抓旁路線上的值,而不是那次陳舊的暫存器檔讀取。甚至還有第二條來自 MEM/WB 暫存器的路徑,用來涵蓋領先兩條指令的結果。兩條路徑都到位後,一對普通的、背靠背的算術相依只多花「零」個週期——流水線根本不必停頓。
Without forwarding (stall 2 cycles so WB precedes ID):
add x1,x2,x3 IF ID EX ME WB
sub x4,x1,x5 IF ID -- -- EX ME WB <- two bubbles waiting
With forwarding (EX result bypassed into next EX):
add x1,x2,x3 IF ID EX ME WB
sub x4,x1,x5 IF ID EX ME WB <- 0 bubbles
^ x1 forwarded from EX/MEM into the ALU input載入-使用危障:連前遞都遲了一個週期
前遞感覺幾乎像魔法,於是很容易相信它能抹去每一個資料危障。它不能,而這個誠實的例外值得弄懂。一條載入並不在 EX 產生它的值——在 EX 它只算出記憶體位址。被載入的資料要到 MEM 階段結束才真正抵達,比 ALU 結果晚了一個階段。如果緊接的下一條指令想要那個被載入的值,它在「自己的」EX 一開始就需要它——而那正是載入還在 MEM 裡忙的「同一個」週期。值根本還沒誕生。再短的線,也無法把一個結果往時間的回頭方向前遞。
這就是著名的載入-使用危障(load-use hazard),它的代價恰好是一個無可避免的氣泡。硬體插入單一個停頓:一個危障偵測單元(hazard detection unit)察覺到某條載入的目的暫存器,與下一條指令的來源相符,便把 IF 與 ID 階段凍結一個週期,同時往 EX 注入一個氣泡——一條什麼都不做、所有控制訊號都關掉的指令。那一個浪費掉的週期讓載入跑完 MEM;現在它的資料坐進 MEM/WB 暫存器,「就能」被前遞進那條相依指令的 EX。所以一對載入-使用即使有完整前遞也要付一個氣泡,而一對普通算術則一個都不付。
用 CPI 誠實算出代價
每個氣泡都是一個浪費掉的時脈週期,所以危障會直接顯現在有效 CPI 上——也就是把停頓算進去之後,真正量到的每指令週期數。一條理想的流水線每個週期退役一條指令,有效 CPI 為 1.0。每個停頓都加上一個零頭。假設 20% 的指令是載入,而其中 40% 緊接著一條會用到結果的指令。那麼載入-使用懲罰替 CPI 加上 0.20 x 0.40 x 1 = 0.08,把它從 1.0 抬到 1.08。在這裡很小——但它會隨著你的程式有多相依、多分支而放大。
把這個對照效能鐵律:執行時間 = 指令數 x CPI x 週期時間。前遞之所以美妙,正因為它讓 CPI 保持在接近 1,「卻不」拉長週期時間——它不過是幾條短線加一個多工器的選擇,舒舒服服地待在一個階段之內。停頓則是我們只在物理禁止前遞時(如載入-使用的情形)才付的誠實稅。一條好流水線的藝術,就是把每一個「能」前遞的都前遞掉,好讓剩下的只有那罕見、真正無可避免的氣泡。
把它兜起來:追蹤、偵測、決定
把前遞與停頓看成硬體每個週期為「正要進入 EX 的那條指令」所做的同一個決定,會很有幫助。它問:我的兩個來源暫存器,有沒有哪一個與某個正在我前頭飛行的結果相符?如果那個結果已經算好了(一條位於 MEM 或 WB 的 ALU 運算),就前遞它——不費代價。如果它是一條「恰好領先一步」、尚未離開 MEM 的載入,那就還沒有值可前遞,於是停頓一個週期,「然後」再前遞。其餘一切原封不動地流過。
- 一條指令抵達 ID;危障單元讀出它的兩個來源暫存器編號。
- 把每個來源與此刻位於 EX/MEM 和 MEM/WB 的那些指令的目的暫存器比對。
- 若相符的是一個已算好的 ALU 結果,就把前遞多工器設成從旁路線上抓它——零停頓。
- 若相符的是一條仍在 MEM 的載入(即載入-使用的情形),凍結 IF 與 ID,往 EX 注入一個氣泡,然後在下一個週期前遞。
- 否則讓指令照常前進,從暫存器檔讀取它的運算元。
兩種解藥,一套哲學:偏好便宜的修法,只在被迫時才付昂貴的那個。前遞處理常見、免費的情形;停頓處理物理強求的罕見情形。第三種危障——分支的控制危障,在那裡我們甚至不知道「哪一條」指令接在後面——需要一種不同而更有意思的解藥,而那正是本階最後一篇要接續的地方。