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

管線、亂序執行,與分支預測

從組合語言那一級你知道 CPU 提取一道指令、執行它、然後前進到下一道。那幅圖是個禮貌的謊。這篇導引打開那台機器——它其實正同時暗中拋接著好幾十道指令、把它們亂序完成,並對每個分支會往哪走下注賭一把。

「一次一道」這個禮貌的謊

從組合語言那一級,你帶著一套乾淨的指令週期心智模型:CPU 讀取程式計數器(program counter)裡那個位址處的指令、解碼它、做它所說的任何事、更新暫存器與旗標、推進程式計數器,然後重複。下一道指令開始前,前一道已完整結束。這是個漂亮的模型,而你該留著它——用來推理一支程式的意義時,它一字不差地正確。機器的行為彷彿就是這麼回事。但這並不是真實 CPU 花費時間的方式,而模型與矽晶之間的這道縫隙,正是這整個級數安身立命之處。

這裡是壓垮那個樸素模型的壓力。一顆現代核心跑在,比方說,3 GHz——一個週期大約是三分之一奈秒。從前幾篇導引你已經知道,一次 L1 快取命中要花屈指可數的幾個週期,一次 L3 命中幾十個,而跑一趟 DRAM 遠超過一百個。若 CPU 真的在碰下一道之前把一道指令從頭到尾做完,那麼每一道指令就只能跑得跟它最慢的那一階段一樣快,而快的階段只能呆坐著乾等。那是矽晶的災難性浪費。解法跟廚房在晚餐尖峰時段用的把戲一樣:你不會把一份餐點完全做好才開始下一份——你把它們交疊起來。

管線:給指令用的生產線

把一道指令的工作拆成幾個階段——經典上分五階:提取(fetch)指令位元組、解碼(decode)它們的意思、執行(execute)算術、為任何載入或儲存而存取記憶體(memory),以及把結果寫回(write back)暫存器。現在為每一階段打造一塊獨立的硬體,排成一列,讓一道指令每個週期前進一階地穿過它們。訣竅在於:當第 1 道指令在執行時,第 2 道可以在解碼,第 3 道在提取——同時有三道指令在飛,各自在不同的階段裡。這就是 指令管線,也是 CPU 如何跑得快這件事裡,單一最重要的觀念。

cycle:     1    2    3    4    5    6    7
          ---  ---  ---  ---  ---  ---  ---
  instr A   F    D    E    M    W
  instr B        F    D    E    M    W
  instr C             F    D    E    M    W
  instr D                  F    D    E    M    W

  F=fetch  D=decode  E=execute  M=memory  W=writeback

  one instruction still takes 5 cycles end to end,
  but a *new* one completes every cycle once full
一條五階管線。每道指令從 F 到 W 仍要花 5 個週期(它的延遲),但管線一旦填滿,每個週期就完成一道(它的吞吐量)。你感受到的是吞吐量,不是延遲。

仔細看那張表,一個微妙而關鍵的區別便跳了出來。每一道個別指令仍要花五個週期,從提取走到寫回——這是它的延遲(latency),而管線並不會把它縮短。管線買給你的是吞吐量(throughput):管線一旦填滿,每一個週期就完成一道新指令。一條工廠生產線不會讓某一輛車造得更快,但它讓一輛完工的車從末端滾下來的頻率高得多。真實的核心把這件事推得遠多了——現代 x86-64 晶片的管線深達十五到二十階——但原理就恰恰是這幅五階草圖,只是更長而已。

管線卡住的地方:危障

那幅漂亮的圖假設了每道指令都和它的鄰居互不相干。真實的程式碼並非如此。假設指令 B 需要指令 A 正在算的那個值——`a = x + y;` 然後 `b = a * 2;`。B 的執行階段想要 `a`,但在那張表裡 A 直到第 5 週期才寫回 `a`,而 B 在第 4 週期就走到執行。B 會讀到一個陳舊的暫存器。這是一個資料危障(data hazard):一個會被交疊違反的相依關係。最直接的修法是讓 B 等——插入停滯(stall)週期(叫做氣泡 bubble),直到 A 的結果備妥。停滯是純粹的浪費:管線一部分什麼都不做的那些週期。硬體會反擊,用轉送(forwarding)把 A 剛算好的結果直接接到 B 的輸入,不等寫回;但那些你繞不過去、無法轉送的相依關係,仍要付出真實的週期。

更討厭的危障是控制危障(control hazard),而它是這整篇導引的反派。當 CPU 提取一道條件分支——一個 `if` 或一個迴圈測試的機器碼——它還不知道這個分支會不會被採取。分支的條件常相依於一個仍在管線深處被計算的值。但提取階段不能等:它此刻就需要知道下一道該提取哪一道指令,是分支之後那一道、還是分支目標處那一道。在一條 20 階的管線裡,這個答案也許要十幾個週期後才知道。在每一個分支上把整個前端停滯十幾個週期——而分支大約每五道指令就有一道——會把管線當初為了賺取而打造的吞吐量丟掉一大半。

分支預測:對未來下注

CPU 對控制危障的答案大膽得很:它用猜的分支預測器不停滯,而是賭這個分支會往哪走,然後前端沿著預測的路徑一頭衝去提取並執行指令——推測地,在任何人知道這個猜測對不對之前。若猜對了,那些指令正是該做的工作,沒有損失任何一個週期:這個分支變成免費的。預測器不是隨機擲一枚硬幣;它是一台小而快的學習機器,盯著每個分支的歷史。讓它管用的關鍵洞見是:分支出奇地可預測——一個迴圈的回邊會被採取上百次、然後一次不採取;一個錯誤檢查的 `if` 幾乎從不被採取。現代預測器在典型程式碼上的準確率超過 95%,常常達到 99%。

但猜測可能猜錯,而那正是帳單到期的地方。在一次錯誤預測(misprediction)上,CPU 已經花了十幾個週期,從錯誤的路徑提取並部分執行指令。它們的結果一個都不能被允許留下——它們本來就不該跑。硬體必須把它們清空(flush):把那一整批推測的工作通通丟掉、把前端倒帶,再從正確的位址重新開始提取。那次清空再重填就是分支錯誤預測懲罰(branch misprediction penalty),在一條深管線上它可是真金白銀——通常十五到二十個週期,一個跟跑一趟 L3 快取一樣昂貴的洞。所以一個分支並非一律便宜或一律昂貴:預測對時它幾乎免費,預測錯時卻意外地痛,而這個差別是由一個預測器要嘛學會了、要嘛沒學會的模式決定的。

超純量與亂序:同時許多道,按序完成

一條管線每週期最多完成一道指令。下一步飛躍是把它加寬:並排打造好幾個執行單元、一次提取並解碼一小撮指令,並把彼此獨立的派去在同一週期內平行地跑。這就是 超純量執行(superscalar),也是為什麼一顆晶片能宣稱不只一個 指令層級平行性——真真切切地在單一週期內讓兩道、三道、四道指令退役。但麻煩立刻回來了:指令唯有在彼此不相依時才能一起跑。一條長鏈,每道指令都需要前一道的結果——像一個指標在一張鏈結串列上一路追下去——幾乎不暴露任何平行性,於是不管晶片多寬,那一整批額外的執行硬體都只能呆坐著。

現在來看最大膽的點子。如果指令 B 因為等一個從 DRAM 來的慢載入而停滯,但緊跟在它後面的指令 C 是獨立且就緒的,C 憑什麼要客客氣氣地在隊伍裡排著?它不該。一顆亂序執行的核心讓 C 趁 B 還卡著時就跑,按照指令的輸入何時備妥的順序來執行它們,而不是按程式寫下它們的順序。核心保有一個視窗,裝著幾十道——有時上百道——在飛的指令,一旦某道的運算元抵達,就立刻把就緒的那道抽出來執行。CPU 就是這樣藏起一次快取未命中的:它不為等 DRAM 而停滯 200 個週期,而是在等待期間找其他獨立的工作來做,讓執行單元保持忙碌。

但這引出一個駭人的問題:如果指令以打亂的順序完成,程式怎麼維持正確?如果 C 在 B 之前完成,而 B 接著出錯(fault)——比方說它除以零、或碰到一個壞指標——架構就必須表現得彷彿 C 從來沒跑過。答案是重排序緩衝區(reorder buffer)。指令可以亂序執行,但它們退役(retire)——把結果提交到可見的架構狀態——嚴格地按程式順序進行。重排序緩衝區是一個佇列,把每道指令「已完成但尚未提交」的結果握著,直到排在它前面的每一道更舊的指令都已安全退役。這就是這整篇導引最深的調和:執行時一片混亂,退役時完美有序,於是你一開始那個樸素的「一次一道」模型,從外面看仍一字不差地為真。

為什麼這在你的鍵盤前要緊

這三台引擎——管線、分支預測器、亂序視窗——共享一個目的:讓執行單元每個週期都忙著,絕不停滯。而它們也共享一個阿基里斯腱:唯有當有獨立、可預測的工作可找時它們才幫得上忙。一個在陣列上跑的緊湊迴圈,有可預測的退出測試、沒有長相依鏈,就讓核心得以伸展手腳、跑到接近它的尖峰指令層級平行性(ILP)。一次穿過鏈結串列的指標追逐,或一個翻來覆去無法預測的分支,會同時餓死這三台引擎——於是剛剛還以每週期四道指令跑陣列迴圈的同一顆晶片,現在以不到一道的零頭爬行。這就是兩支做了同樣多算術的程式,速度卻能差到十倍的誠實原因。

還有一條線頭可以拉,而它直接通向下一篇導引。注意:分支預測器一猜,亂序核心並不只是沿著預測路徑提取——它執行那些指令、算出真實的結果,全都在任何人確認猜測之前。那就是推測執行(speculative execution),而在錯誤預測時,重排序緩衝區會盡責地把那些結果丟掉,好讓程式維持正確。幾十年來這看起來像一個完全安全的最佳化。它其實不太是:那些被推測執行的工作,在你先前研讀過的快取裡留下了淡淡的腳印,而那些腳印就算在結果被丟棄之後,仍能被讀取出來。那道裂縫正是 Spectre 與 Meltdown 棲身之處——而把它拆開來看,恰恰是第 5 篇導引的起點。