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

推測執行、SIMD 與預取

第 4 篇讓管線停在「猜分支往哪走」那一刻。這最後一篇跟著那個猜測一路往下——進到「在還沒確定該不該跑之前就先跑指令」、進到「一條指令做十六件事」、進到「在程式開口要之前就把資料搬進快取」。這是現代核心每個時脈做更多事的三條路,以及每一條誠實的代價。

從一個猜測到一場賭注:推測執行

第 4 篇結束在分支預測器對一個 if 會往哪走做出有根據的猜測,好讓管線不必在條件還在計算時就停擺。推測執行(speculative execution)就是核心那個猜測去做的事:它不只是去取預測路徑上的指令,它真的去它們——做那些它根本還不確定該不該執行的加法、載入、比較。結果堆在重排序緩衝區裡,處於一種懸而未決、尚未成真的狀態。只有當分支終於解析出來,核心才決定要提交(commit)那些工作(讓它們永久地在暫存器與記憶體中可見),還是抹除(squash)它們、當作從沒發生過。

想像一個小迴圈,走過一個陣列、只把正的元素加總。`if (a[i] > 0)` 上的分支是「跳」還是「不跳」,取決於核心無法事先看見的資料。有了預測加上推測,核心不等:它預測「大概是正的」,推測性地執行那個加法,把管線維持滿載。如果預測對了,加法早就做完了——純賺。如果錯了,亂序執行的機制就把架構狀態回捲到分支那裡,再重新導向正確的路徑。這就是為什麼一個預測準的分支幾乎免費、而一個預測爛的分支要花十幾個以上的時脈:誤判不只是讓管線停擺,它還丟掉了核心已經做好的真實工作。

SIMD:一條指令,多條車道

到目前為止的一切,都在追求更多每時脈的指令數SIMD 追求的是更多每指令的資料量。這名字代表「單指令、多資料(Single Instruction, Multiple Data)」:核心不是用一個只裝一個 32 位元整數的加法暫存器,而是有寬的向量暫存器——128、256、甚至 512 位元——以及一條一次對所有車道(lane)動手的加法指令。一個 256 位元的暫存器裝得下八個 32 位元浮點數;一條向量加法在純量加法處理一個的時間裡,把全部八對都加起來。各車道彼此獨立:第 3 道從不跟第 4 道講話。這是把裝配線當真——八個一模一樣的工人,齊步對八個項目做同一個步驟。

Scalar: one 32-bit float per instruction
  add  s0, s1, s2          ; s0 = s1 + s2   (1 result)

SIMD: eight 32-bit floats in a 256-bit register, one instruction
  [ a0 a1 a2 a3 a4 a5 a6 a7 ]   <- vector register v1
+ [ b0 b1 b2 b3 b4 b5 b6 b7 ]   <- vector register v2
  ---------------------------
  [ c0 c1 c2 c3 c4 c5 c6 c7 ]   <- one vector add, 8 results

Lanes are independent: c3 = a3 + b3, and never touches lane 4.
一條純量加法產出一個結果;一條 SIMD 加法產出八個,因為那個寬暫存器被切成獨立的車道。這是資料層級的平行,跟管線那種指令層級的平行不同。

你很少親手寫這些指令。編譯器的工作是自動向量化(auto-vectorization):在較高的最佳化等級(想想 gcc -O2 或 -O3),它試著認出一個迭代彼此獨立的迴圈,把它改寫成每一趟用向量指令處理好幾個元素。那些誠實的但書很要緊。自動向量化是脆弱的:一個迴圈攜帶相依性(第 i 趟需要第 i-1 趟的結果)、迴圈內的一個函式呼叫、無法預測的分支、或編譯器無法證明彼此不重疊的指標,任何一個都會悄悄地讓它失效。沒有錯誤,預設也沒有警告——你的迴圈就只是維持純量而緩慢。這正是為什麼向量化會回報這一級正在教的機械同理心(mechanical sympathy):順著硬體紋理寫的程式碼,比逆著它寫的快上好幾倍,即使兩者都「正確」。

預取:在你開口之前先把記憶體取進來

這一級的第 1 篇,立起了藏在其餘一切背後的那個殘酷事實:一個一路打到主記憶體的快取未命中,要花上百個以上的時脈,在那期間連一顆亂序核心最終也會用光獨立的工作而停擺。預取(prefetching)是硬體的防禦。硬體預取器是一個小單元,它盯著你那串記憶體存取、偵測出一個模式——最常見的是固定的步幅(stride),像是「每次存取都比上一次多 64 位元組」——然後對它預測你接下來會碰的快取線推測性地發出載入,好讓資料在需要它的那條指令抵達時,早就坐在快取裡了。做得好,那個上百時脈的未命中就乾脆從不發生。

模式的依賴,就是整個故事,也是把這一篇接回這一級開頭的那一環。直直地走過一個陣列(步幅是一個元素)是預取器的美夢——它瞬間就鎖定。追一個鏈結串列,每個節點的位址都是上一個節點碰巧存下來的東西,則是它的惡夢:下一個位址在目前這個載入完成之前無從得知,所以沒有步幅可偵測,每個節點都是一次全新的快取未命中。這就是為什麼一個扁平的數值陣列,常常輾壓一個裝著相同資料、「更優雅」的指標鏈結結構——具體、用時脈數來算的理由。演算法的大 O 可以一模一樣,其中一個卻跑快五倍,純粹因為一個餵飽了預取器、另一個把它餓著。

三具引擎,一份預算

退一步,注意這三個花招攻擊的是三個不同的瓶頸。推測與管線對抗控制冒險——不知道下一條指令是哪條的代價。SIMD 對抗算術吞吐量——當工作又寬又一致時,卻一次只做一個運算的代價。預取對抗記憶體延遲——等待遠方資料的代價。一顆真實的核心同時跑這三者,層疊著:一個被推測執行、預測為「跳」的迴圈本體,裝滿向量指令,行軍走過一個由預取器從前方幾條快取線餵料的陣列。那幅疊起來的圖,才是「現代 CPU 很快」真正的意思。

而它們每一個都是推測性的、或受模式束縛的,這正是整個這一級誠實的主題。分支預測器在猜;推測在跑可能被丟掉的工作;預取器在賭一個步幅;自動向量化假設你的迭代彼此獨立。當你的程式碼吻合硬體所期待的——可預測的分支、彼此獨立的迴圈本體、循序的記憶體——這些引擎就全力運轉,機器感覺起來像有魔法。當它不吻合時——一個預測器學不會的、與資料相關的分支,一個迴圈攜帶相依性,一場指標追逐——引擎就停擺、誤判、挨餓,而同一個演算法就跑慢好幾倍。你不直接控制這些單元;你控制的是「你的程式碼有沒有給它們一個能贏的東西」。

  1. 讓分支可預測,或乾脆移除它。一個幾乎每次都往同一邊走的分支,基本上免費;一個五五波、與資料相關的分支則是一台誤判機器。當你無法讓它變得可預測時,看看無分支寫法(一個條件搬移、一個遮罩、一個算術技巧)是不是更快——但要量測,因為分支預測器往往比你以為的更聰明。
  2. 寫編譯器能向量化的迴圈:彼此獨立的迭代、迴圈內沒有意外的函式呼叫、沒有迴圈攜帶相依性,並用 restrict(或區域副本),好讓編譯器能證明指標不重疊。然後檢查最佳化器的報告(gcc -fopt-info-vec),確認它真的有向量化——別假設。
  3. 把資料佈局成循序存取,好讓硬體預取器鎖定:對熱資料偏好扁平陣列勝過指標鏈結結構、把你會碰的欄位相鄰地擺在一起、用一條直直的步幅走過記憶體。光這一個習慣,往往就勝過每一項微最佳化,因為它把上百時脈的未命中變成了命中。
  4. 量測,絕不猜測。這三具引擎在原始碼層面全是隱形的,所以要知道它們到底有沒有在運作,唯一誠實的方法是一個帶硬體效能計數器(誤判的分支、快取未命中、每時脈指令數)的剖析器。整個這一級的口號是機械同理心:把機器理解到足以寫出它能跑得快的程式碼——然後用數字證明它。