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

分支冒險與分支預測

前遞治好了大部分資料冒險,但管線還剩一個頑固的麻煩:分支要走哪條路,得等它深入機器後才知道,可是取指階段每個週期還是不停地抓下一條指令。這篇導覽會說明分支為什麼會卡住管線、用「猜」(預測)與沖刷如何讓水流繼續往前,以及更深的管線每猜錯一次要付出的誠實代價。

前遞修不好的那個冒險

在上一篇裡,前遞把剛算好的結果直接送給需要它的指令,救回了大部分資料冒險,而前遞唯一處理不了的載入-使用情形,也只用一個氣泡就解決了。分支則是另一種東西。像 beq x5, x0, target 這樣的分支,並不是產生某個後面指令在等的值——它決定的是接下來究竟該取哪一條指令。這就是控制冒險,再多的「把結果繞回管線」的接線都修不了它,因為問題不在缺一個值,而在缺一個決定

尷尬就尷尬在時間點上。在經典的五階段管線——取指、解碼、執行、記憶體、寫回——裡,程式計數器每一個週期都抓一條新指令。但 beq 要到執行階段才比較兩個暫存器,也要到那時才算出分支目標。等機器搞清楚到底要不要跳的時候,取指早就已經沿著「順著往下走」的路徑,多抓進了一兩條指令——而萬一分支真的跳了,這些指令可能完全是錯的。取指階段沒辦法客氣地等;管線全部的速度,正來自「絕不讓取指閒下來」。

停頓、沖刷,還是用猜的

最簡單、最誠實的修法是停頓:當取指看到一條分支,就凍結取指,直到分支拍板,再從正確的位址重新開始。它永遠正確,但會在每一條分支上——不管跳不跳——塞進一到三個週期的停頓。由於真實程式裡,大約每五、六條指令就有一條是分支,每次都白白浪費幾個週期,會把吞吐量毀掉。管線化的整個重點就是讓水流保持滿載;每條分支都停頓,等於一次又一次把它放空。

於是真正的機器會做更大膽的事:它用猜的,然後繼續取指。一個分支預測器,正是那個比喻裡「賭你會走岔路口的哪一邊」。管線挑一個方向——比方說,假設不跳,就一路往前直直取指——讓這些猜出來的指令流進來。這是一小段推測執行:機器憑直覺往前跑,但把提交壓著不放,直到確認猜對為止。猜中了,這條分支就一毛錢都不花——取指從未停過。猜錯了,機器就得把闖出的禍收拾乾淨。

把猜錯的撤銷,就是一次沖刷。當分支終於在執行階段拍板、而結果顯示猜錯了,機器就把那些「錯誤取進、還停在管線前段」的指令丟掉——在它們能寫進任何暫存器或記憶體之前,把它們變成氣泡——再從正確的目標重新開始取指。這就是管線沖刷。關鍵在於:那些被沖掉的指令從未提交,所以程式看得見的結果,和它們「從未跑過」一模一樣;損失的只有時間。一次預測失誤的代價,就是被丟掉的有用週期數,常稱作分支(或預測失誤)懲罰。

預測器到底怎麼猜得準

一個擲硬幣的預測器有一半時間會猜對——不夠好。訣竅在於,分支極度重複:一個迴圈的回邊分支,除了最後一次之外,每一輪都會跳,所以「這條分支上次怎麼做,這次大概還會那樣做」是一條好得驚人的規則。最便宜的動態預測器,為每條分支留一個位元——記住上次跳或不跳——下次就照同樣方式賭。它在幾乎每一輪迴圈裡都猜對,只在迴圈離開時失手。

但一位元方案很脆弱:一個跑 1000 次的迴圈,每完整執行一次就會失誤兩次——一次在最後一輪(它猜跳,但迴圈離開了),又一次在下回迴圈再進來時(它還卡在「不跳」上)。經典的修法是二位元預測器:一個小小的飽和計數器,要連續兩次猜錯才會改變心意。一輪不尋常的迭代,不再翻動預測,所以一個長迴圈只在離開時失誤一次。這多出來的一位元遲滯,是整個微結構裡性價比最好的交易之一。

  two-bit saturating counter (one per branch)

    11  strongly taken   --(not taken)-->  10
    10  weakly   taken   --(not taken)-->  00
    01  weakly   not-tkn --(taken)----->  11
    00  strongly not-tkn --(taken)----->  01

  predict TAKEN if the top bit is 1; one stray
  outcome only nudges the counter, it does not flip
  the prediction. Two misses in a row are needed
  to cross from 'taken' to 'not-taken'.
一個二位元飽和計數器。高位元就是預測;要連續兩次結果相反才會改變方向,所以單獨一輪反常的迭代,不會讓一個穩定的迴圈出軌。

還有兩塊拼圖才算完整。光知道一條分支要跳,卻不知道要跳去哪,是沒用的——而目標要到很晚才算出來。一個分支目標緩衝區以分支的指令位址為鍵,快取每條分支最近的目標位址,於是取指可以在抓到分支的同一個週期就躍向預測目標,完全不停頓。而要再榨出最後幾個百分點,競賽式預測器會同時跑好幾個預測器——一個專攻各分支的區域歷史,一個專攻最近諸分支的全域樣式——再加一個元預測器,學習對每條分支該信哪一個。現代核心能正確預測絕大多數分支,這正是深管線值得打造的原因。

這對 CPI 做了什麼——一次實算

一條完全滿載的管線每週期退役一條指令,所以它的理想 CPI 是 1。每一次停頓、每一次沖刷都加上浪費的週期,把實際數字往上推;務實的那個數,就是有效 CPI。讓我們替分支算一算。假設 20% 的指令是分支、一次預測失誤要付 3 個週期的懲罰(沖掉三條指令)、而預測器有 90% 的時間是對的。分支帶來的額外 CPI 是:分支比例 x 失誤率 x 懲罰 = 0.20 x 0.10 x 3 = 0.06。所以有效 CPI 約為 1.06——分支只比理想多花了 6%。

現在看看一個差勁的預測器會多殘忍。其他都不變,只把準確率降到 50%(擲硬幣):0.20 x 0.50 x 3 = 0.30,有效 CPI 成了 1.30——光分支就慢了 30%。這就是「有好預測器」與「沒有」的機器之間的落差,也說明了為什麼要花這麼多矽片在預測上。效能鐵律(執行時間 = 指令數 x CPI x 週期時間)毫不留情:CPI 一高,就直接乘進執行時間,所以從 CPI 砍掉 0.24,對同一支程式而言,就是貨真價實、量得出來的加速。

管線挖更深的誠實取捨

人很容易以為更深的管線就是純粹比較好。把工作切成更多、更短的階段,每個階段的關鍵路徑就更短,時脈便能跳得更快——階段越多,時脈頻率越高。這是真的,也是為什麼在激進的設計裡,管線從五階段長到十幾、二十階段。但有個深度取捨就明擺在眼前:預測失誤的懲罰,正好就是「取指」到「分支拍板處」之間的階段數。把管線挖深,你不只是時脈更快——每次猜錯,你還丟掉更多指令。

替它配上數字。我們的 5 階段例子每次失誤沖掉 3 條指令。一條 20 階段的管線可能沖掉 15 條。在 90% 準確率下重算那筆倒楣帳:0.20 x 0.10 x 15 = 0.30 的額外 CPI,同樣的失誤率,懲罰是淺機器的五倍。所以深管線只有在它的預測器非常準時才划算——這正是預測研究與深管線一起長大的原因。深度推過頭,飆升的失誤與停頓成本,會把時脈速度的好處整個吃光;這也是為什麼 2000 年代初那場時脈競賽裡的極端管線深度,後來被往回收了。

退一步,看清整個這一級的形狀。管線化抬高了吞吐量,卻沒有降低任何單一指令的延遲——洗衣店每小時完成更多批,儘管一批衣物洗-烘-摺所花的時間還是一樣。代價是三種冒險:結構冒險(兩個階段搶同一個資源)、資料冒險(值還沒好,大多由前遞治好),以及控制冒險(方向還不知道,靠預測與沖刷來管)。最深的一課是:這些都不是該被消滅的臭蟲——它們是「一次做許多事」這件事與生俱來的張力,而好的架構,就是「按程式行為所允許的最便宜方式,去支付這份張力」的藝術。