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

亂序執行卻不打亂順序

繁忙的廚房會先做食材已備齊的那張單,卻仍按點餐的先後出菜。這就是亂序執行:輸入一就緒就讓指令開跑,但結果按原本的程式順序定案,於是外界看到的一切從不會亂掉。

為什麼超純量核心仍會停頓

上一篇我們給核心加了更多車道:超純量機器能在同一個時脈週期裡取指、解碼並啟動好幾條指令,從單一指令流中榨取更多指令級平行。但若車子仍得嚴格按程式順序出發,把路加寬也是徒勞。一旦排在最前面的下一條指令在等一個還沒備好的結果——比方一次慢吞吞的快取未命中,或一個還在磨的乘法——它後面每一條指令也都卡住了,連那些原本立刻就能跑的也不例外。順序執行的機器就是一條單行排隊:最前頭一位慢客人,就把所有人凍結。

想像一間廚房按順序接單。一號單需要一樣還在送貨車上的食材;二號、三號單的東西早就擺在檯面上了。一位順序作業的廚師就僵在一號單前、雙手空閒,眼看兩道簡單的菜乾等著。任何真正的廚師都看得出解法:先做已備齊的那張單,把做好的盤子先擱一旁,等輪到它出菜時再端出去。這個單一念頭——先開跑、按序出菜——就是亂序執行,也是自 1990 年代起每一顆高效能 CPU 所做的事。

必須遵守的真相依,與可閃避的假相依

你不能隨心所欲地任意排序執行——有些指令確實相依於另一些。誠實的那種是寫後讀相依(RAW),也就是你在管線那一階遇過的同一種資料危障:若指令 A 把一個值算進某暫存器,而指令 B 要讀那個暫存器,那 B 就真的得等 A。在 A 把值生出來之前,那個值並不存在。這是資料流上一支真實的箭頭,再聰明的硬體也不可打破它——打破它就會改變程式的答案。

但還有第二種更狡猾、只是「看起來」像相依的——假相依。假設兩段毫不相干的計算碰巧都拿暫存器 x5 當暫存空間。它們不共享任何資料;只是編譯器把暫存器名字用完了,便重複使用了一個。然而天真的機器看到兩者都碰 x5,就拒絕重排它們,彷彿真有一支箭頭。這其實是「寫後寫」或「讀後寫」衝突:一場命名撞名,而非資料流。這就像廚師硬說兩道不同的菜不能同時出盤,只因為它們寫在同一張紙片上。

  true (RAW) dependence -- you MUST wait:
      i1:  add  x1, x2, x3     ; x1 <- x2 + x3
      i2:  sub  x4, x1, x5     ; reads x1 -> needs i1's result

  false dependence (WAW / name reuse) -- only an illusion:
      i3:  mul  x6, x7, x8     ; uses x6 as scratch
      i4:  add  x6, x9, x10    ; reuses the NAME x6, no shared data

  rename i4's x6 to a fresh slot -> the false arrow vanishes,
  i3 and i4 may now run in parallel.
真正的寫後讀相依必須遵守;假相依只是重複使用的暫存器名字,可以靠改名消去。

美妙的把戲在此:假相依是可以化解的。既然它們源自暫存器名字的短缺而非值的短缺,硬體就能悄悄給第二位使用者另一個實體儲存槽。這就是暫存器更名,正是下一篇的主題——它是把亂序機器從程式裡那些偶然的撞名中解放出來的單一機制。眼下你只要記住這個區別:遵守真相依、抹去假相依。

指令窗口、保留站與喚醒

核心究竟怎麼找出那些已就緒的指令?它把一批解碼後、正在飛行的指令存成一個池子——稱作指令窗口——並讓其中任何一條一旦運算元到齊就立刻發射。容納等待中指令的經典結構是保留站:一個小小的待命停泊位,記住要做的運算、把已經備好的運算元先栓住,然後盯著、等其餘的到來。當一個結果終於在廣播線上產生時,每個在等那個值的保留站都會被喚醒、抓住它,於是準備好執行。

這整支舞——更名、坐進保留站、被廣播喚醒、然後執行——正是Tomasulo 演算法的核心,1967 年由 IBM 設計,至今仍是每一顆亂序核心的骨架。我們會在第 3 篇拆解它完整的機制;此處你要帶走的畫面,就只是一群指令各自坐在自己的停泊位裡,每一條都在它最後一個運算元落地的那一瞬獨立發射,順序如何便如何。廚房裡有許多廚師,每一位都在自己的食材一上檯面就開工。

指令窗口的大小,決定了核心能往前看多遠去找已就緒的工作。較大的窗口能跨過一次漫長的快取未命中,在指令流深處找到彼此獨立的指令,好讓執行單元維持忙碌。但窗口很昂貴:每個週期它都得拿每個等待中的保留站去比對每個結果。那種近乎平方的成本,正是我們在第 5 篇撞上的牆之一——那時我們會問,為何 ILP 不再增長、而多核心接手登場。

按序出菜:重排序緩衝區與順序提交

現在來到關鍵的承諾。如果指令是亂序完成的,為什麼程式的行為仍與寫出來的一模一樣?因為「完成」不等於「成為永久」。每條指令在執行時,把結果寫進一個私有的待命區,而尚未寫入官方、程式可見的狀態。一個叫做重排序緩衝區的結構,按原始程式順序保存這些「已完成但還不算數」的結果——一排托盤,每條飛行中的指令一個,依點餐的先後排好隊。

結果離開重排序緩衝區、成為真實,嚴格地從最前端、最舊的開始——這就是順序提交(也叫退役)。一條指令也許早在許多週期前就算完了,但它只在抵達緩衝區隊首、且它前面每一條都已提交時,才提交——才更新世界其他部分看得到的架構暫存器與記憶體。廚房把每道做好的菜擺上出菜檯,但服務生嚴格按單號把它們端出去。客人永遠看不到後場的那番手忙腳亂。

  1. 發放(按序):解碼每條指令,更名其暫存器以抹去假相依,並把它塞進一個保留站與一個重排序緩衝區的位置——全都嚴格按程式順序。
  2. 執行(亂序):一條指令在它的運算元就緒的那一刻發射,不管它在程式裡的位置;它結果的廣播會喚醒所有等著它的人。
  3. 寫入結果:完成的值停進重排序緩衝區的位置、並轉送給等待中的保留站,但它對架構狀態仍是隱形的。
  4. 提交(按序):當一條指令抵達重排序緩衝區的隊首,它的結果便寫進真正的暫存器/記憶體並退役;外界看到的結果純粹是按原始順序的。

為何順序提交值得這番麻煩

按序提交不只是為了整潔——它換來兩項無價的保證。第一是精確例外。若指令 5 除以零,機器必須回報這個錯誤,彷彿執行乾淨地停在 5:指令 1 到 4 已完成、6 以後分毫未動。因為重排序緩衝區隊首之後的東西都還沒提交,硬體只要把出錯指令後面每個未提交的位置全丟掉,架構狀態看起來就與「一次跑一條」一模一樣。亂序執行對程式設計師完全隱形。

第二項,正是讓接下來兩篇成為可能的東西:推測。因為結果在提交前都停在重排序緩衝區裡,核心可以一路衝過一個它只是「猜」了結果的分支——推測執行——而若猜錯了,就只要把那些未提交的結果丟棄、假裝它們從沒跑過。順序提交就是讓「猜」變安全的那塊橡皮擦。分支預測的深層機制——二位元、關聯式與競賽式預測器,以及分支目標緩衝區——則是第 4 篇完整的故事。