為什麼一顆快核心等不起路口
到這裡,你手上已經有一具強大的引擎了。本階前幾篇給了你一顆每週期能發射好幾條指令的超純量核心、一套能化解暫存器名稱間假相依的暫存器更名、能在每條指令真正的運算元一就緒就立刻發動它的保留站與 Tomasulo 演算法,以及一個讓結果亂序完成卻仍能循序提交的重排序緩衝區。這一整套機械的存在,是為了找出數十條彼此獨立的指令並一次跑掉它們。但有一道牆它老是撞上:分支。
回想一下管線那一階的控制冒險。像「若 x > 0 就跳到 L」這樣的條件分支,在它的條件被算出來之前,並不知道自己會往哪走——而那可能還在好幾個週期之外。真實程式大約每五到七條指令就分支一次。如果核心在每個分支處都停下來、苦等結果揭曉,那整台亂序機器枯坐閒置的時間,會遠多於它在跑的時間。一具每六條指令就暫停一次的寬引擎,幾乎不比窄引擎寬。為了保持忙碌,核心根本不能等到「知道」分支往哪走才動。
於是它做了唯一能讓管線保持滿載的事:它猜。這就是分支預測(branch prediction)——對程式會走上哪條岔路所下的一注——而隨之而來、憑著一個猜測往前狂奔,就是推測執行(speculative execution)。本篇剩下的全部,講的就是這一注如何下得漂亮,以及它輸掉時會發生什麼事。
預測方向:從一個位元到兩個
最便宜的猜法,是假設一個分支這次的行為跟上次一樣。為每個分支保留一個位元:「上次跳了」或「上次沒跳」,然後預測這次也一樣。這招效果好得驚人,因為多數分支都是迴圈,而迴圈的回邊會接連跳上好幾百次。但單一個位元正好在接縫處最脆弱。一個跑 100 次的迴圈會錯兩次:一次在最後一輪(它預測「跳」,迴圈這次卻要離開了),接著在下一次進入迴圈時又錯一次,因為那個位元剛剛已被離開的那一輪翻成了「不跳」。
解法就是經典的雙位元預測器(two-bit predictor)。不再用一個位元,而是保留一個有四個狀態的小型飽和計數器——強跳、弱跳、弱不跳、強不跳——並在前兩個「跳」狀態裡預測「跳」。一次出乎意料的結果只把計數器推動一格;要連續錯兩次才會真正翻轉預測。如今一個只離開一次的迴圈不會把它的信念整個丟掉:那一次離開只把它從「強跳」移到「弱跳」,預測仍是「跳」,於是下一次進入迴圈就對了。多加這一位元的遲滯,大約把迴圈的誤判砍掉了一半。
Two-bit saturating counter (one per branch slot)
11 strongly taken -- predict TAKEN
10 weakly taken -- predict TAKEN
01 weakly not-taken -- predict NOT taken
00 strongly not-taken -- predict NOT taken
branch resolves TAKEN -> move one step up (toward 11)
branch resolves NOT-TAKEN -> move one step down (toward 00)
loop run 100x with one exit:
counter sits at 11 the whole time,
the single exit nudges 11 -> 10 (still predicts TAKEN),
so re-entering the loop is predicted correctly.當歷史押韻:關聯式與競賽式預測器
雙位元計數器孤立地看單一個分支,但分支彼此之間往往是有關聯的。想像「if (a == 0) ...」之後緊接著「if (a == 2) ...」。每當第一個跳了(a 是零),第二個就保證不跳(a 不可能同時又是二)。一個逐分支的計數器看不到這層關聯。關聯式預測器(correlating predictor)卻看得到:它保留一個短短的全域歷史暫存器——記著所有分支最近幾次跳/不跳的結果——並把這個近期樣式,連同分支自己的位址,一起拿來索引一張雙位元計數器的表。實際上它學會的是這樣的規則:「在最近這串分支恰好是這個序列之後,這個分支通常會往這邊走。」許多真實分支只有在脈絡裡才可預測,而這正是硬體捕捉脈絡的方式。
但沒有任何單一方案能處處取勝。有些分支最好用它自己的區域歷史來預測(一個迴圈每次都用同樣的方式計數);有些則只有對照全域樣式才講得通。於是現代核心用一個競賽式預測器(tournament predictor)兩面下注:讓兩個不同的預測器並肩同時跑——比方說一個區域的、一個全域/關聯式的——再加上第三張小表,一個選擇器(chooser),它逐分支地學習這兩者近來哪一個比較準。每做一次預測,選擇器就挑出當下較強的那位專家。這是一注疊在另一注之上,而它在尋常程式碼上,往往把準確率推過 95%——有時遠超過 98%。
也要預測目標:分支目標緩衝區
猜對方向(跳或不跳)只是工作的一半。如果分支會跳,核心還必須知道接下來要從哪裡取指——也就是目標位址——而且它得在取出這個分支的同一個週期裡就拿到,那時指令甚至還沒被解碼。它沒餘裕去解碼分支、讀它的指令格式、再慢吞吞地把目標算出來;那樣早就把預測本該省下的週期花掉了。於是分支目標緩衝區(branch target buffer,BTB)登場:一個以分支位址為索引的小型快取,它記得「上次我們在這個位址取到一個分支時,它跳到了這裡。」
於是取指每個週期是這樣運作的:拿當前位址,在讀取指令的同時、平行地去 BTB 查它。一次 BTB 命中,再加上方向預測器給出的「跳」判決,就能讓下一次取指直接跳到預測的目標,毫無氣泡。方向預測器回答「要不要」跳;BTB 回答「跳去哪」。間接跳躍——一個函式指標、一次虛擬方法呼叫、一個 switch——比較棘手,因為同一條指令在不同次執行時可能跳到不同目標,所以核心會加上專門的預測器(例如給函式返回用的返回位址堆疊)來好好處理這些情況。
憑著一個猜測往前跑,再乾淨地撤銷它
現在兩半合流了。有了預測的方向與預測的目標,核心就沿著猜測的路徑持續取指、更名、並執行指令——這就是推測執行——遠在分支真正的條件揭曉之前。這些指令把真實的結果算進已更名的實體暫存器裡、待在重排序緩衝區中,但它們被標記為「推測的」。讓這一切安全無虞的那唯一一條規則,正是第二篇講過的:任何東西在不再是推測的之前,都不准提交。一條指令可以提早跑;它不能提早讓自己的效果變成永久。
- 預測:取指讀到分支;方向預測器說出跳/不跳,BTB 提供目標。取指不暫停,繼續沿著預測的路徑往下走。
- 推測式執行:猜測路徑上的指令從保留站發射、算進已更名的暫存器、落入重排序緩衝區,每一條都被標上「推測」。
- 解決:分支真正的條件終於算出來了,預測就拿來跟它對照查驗。
- 若猜對:清掉那些推測旗標。那些指令現在就是普通指令了,它們會循著程式順序、透過重排序緩衝區照常提交,彷彿什麼特別的事都沒發生過。
- 若猜錯:把比這個分支還新的每一條推測指令都清掉、丟掉它們已更名的暫存器、把預測器與目標硬體復原,並從正確的位址重新開始取指。
最後一步的這個復原,就是你先前見過的管線清空,如今以工業規模揮舞:一條走錯路的指令也許一路算到了結果,但因為它從未提交,那結果就從沒抵達過架構暫存器或記憶體。把它撕掉,對你的程式是隱形的。這正是這台機器之所以始終值得信賴的深層原因:它放手推測,但拜循序提交之賜,那些可被觀察到的結果,永遠恰恰是一顆簡單循序中央處理器會產生的那些。
當一條指令流見底:同時多執行緒
就算預測得再好,單一條指令流有時也餵不飽一顆寬核心——它撞上一次長延遲的快取未命中,或一串彼此相依的指令,於是執行單元只能枯等。同時多執行緒(simultaneous multithreading,SMT,以 Intel 的超執行緒之名行銷)靠著在同一顆核心裡同時讓兩條或更多硬體執行緒存活,來填補那些空檔。每條執行緒有自己的架構狀態——自己的程式計數器與架構暫存器——但它們共用執行單元、保留站與重排序緩衝區的槽位。當一條執行緒卡住時,另一條的指令正待發射,於是那些昂貴的硬體繼續運轉。
對 SMT 買到什麼要誠實:它是靠收割閒置槽位來提升吞吐量,而不是提升任何單一執行緒的速度——共用一顆核心的兩條執行緒,各自都比獨佔時跑得稍慢一些。它是一種從你早已付過錢的電晶體裡榨出更多總工作量的辦法,尤其在單流平行性枯竭時。而那種枯竭,正是最後一篇要面對的懸崖:一條指令流裡能找到的獨立工作就那麼多,而把眼睛瞪得越來越大去找的功率代價成長得兇殘,那道牆,正是把整個產業從越來越花俏的單核心推開、轉而把許多核心擺到同一塊晶片上的原因。