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

LLVM IR 與最佳化 pass 管線

上一篇導引給了你抽象的 SSA。這一篇要讓它成真:LLVM IR 是一種寫得出來、讀得懂的語言,你可以把它印出來、用手編輯它、並一次一個 pass 地對它跑最佳化——而看著那些 pass 在一個五行的函式上開火,就是窺見每一個現代編譯器究竟如何思考的最清晰的一扇窗。

從一個概念,到一種你讀得懂的語言

在上一篇導引裡,SSA 形式phi 節點是住在中端裡的概念——是編譯器拿來自言自語的一種乾淨方式。LLVM IR 則是當你把那個概念拿來、賦予它一套真正寫得出來的文法時所發生的事:一個真實的、帶型別的、以 SSA 為基礎的中介表示法,你可以把它印成檔案、用眼睛讀、甚至用手寫出來。正是這一點,讓 LLVM 如此值得拿來學。大多數編譯器把它們的 IR 鎖在記憶體裡,當作你只能透過除錯器窺視的資料結構;LLVM 則讓你把它傾印成文字,其中每一行都是一個小而規則的操作,正是先前那種三位址的形狀——但如今具體到足以用手指指著它。

關於這種語言的幾個事實,是其餘一切的錨。LLVM IR 裡每個值都把它的型別寫在身上——i32 是 32 位元整數、i8 是一個位元組、i8* 是指向一個位元組的指標,依此類推——所以 IR 清楚它碰到的每樣東西的寬度與形狀,這是後端稍後會需要的。值的名字有兩種風味:百分號開頭的虛擬暫存器,例如 %1 或 %sum,供應量是無限的(你永遠用不完,因為把它們對映到真正的暫存器,是後端另一件分開的工作),以及 at 符號開頭、給全域變數與函式用的,例如 @main 或 @counter。而 IR 遵守 SSA:每個 %name 都是恰好一條指令的結果,定義一次,此後自由使用。

讀一個 IR 裡的小函式

學 IR 最快的方法就是讀一些。拿一個把兩個整數相加並回傳總和的函式,在不做最佳化的情況下編譯,好讓 IR 貼近原始碼。函式本體被劃分成基本區塊(basic block)——一段直線排列的指令,頂端只有一個入口、底端只有一個出口,標上像 entry: 這樣的標籤——而區塊之間的控制流程,只透過區塊結尾明確的分支指令(br)來進行。在 -O0,編譯器刻意照字面行事:它用 alloca 配置堆疊上的格子、用 store 把引數存進去、再用 load 把它們讀回來,於是 IR 幾乎逐行地映照那段 C。它很囉嗦,但每一行都誠實地反映了那個未最佳化的程式做了什麼。

C source:
    int add(int a, int b) { return a + b; }

LLVM IR at -O0 (verbose: everything goes through the stack):
    define i32 @add(i32 %a, i32 %b) {
    entry:
      %a.addr = alloca i32          ; reserve a stack slot for a
      %b.addr = alloca i32          ; reserve a stack slot for b
      store i32 %a, i32* %a.addr    ; spill argument a to memory
      store i32 %b, i32* %b.addr
      %1 = load i32, i32* %a.addr   ; read a back
      %2 = load i32, i32* %b.addr   ; read b back
      %3 = add i32 %1, %2           ; the actual addition
      ret i32 %3                    ; return the sum
    }

LLVM IR at -O2 (the stack traffic is gone):
    define i32 @add(i32 %a, i32 %b) {
      %1 = add i32 %a, %b
      ret i32 %1
    }
同一個一行函式的 IR,在 -O0 與 -O2 兩種情況下。在 -O0,引數在堆疊格子間彈來彈去(alloca / store / load);在 -O2,一個叫做 mem2reg 的 pass 把那些格子提升成了 SSA 虛擬暫存器,整個函式塌縮成一個 add 加一個 return。

仔細看這兩個版本之間改變了什麼,因為那正是最佳化如何開始的核心。-O0 的 IR 把 a 與 b 放進記憶體、透過 load 與 store 來回搬運;-O2 的 IR 則完全沒有記憶體往來。一個叫做 mem2reg(memory-to-register,記憶體轉暫存器)的早期 pass 是幕後功臣:它注意到那些 alloca 格子只在區域範圍內被存入與讀出,便把它們提升成純粹的 SSA 值,並在分支原本會需要的地方插入 phi 節點。正是這一個 pass,讓最佳化器其餘的部分得以如此乾淨——幾乎每一個後續的最佳化都假設它的值住在 SSA 虛擬暫存器裡、而非記憶體裡,而 mem2reg 正是把它們弄到那裡去的那一個。

一個 pass 究竟是什麼

最佳化器不是一個又大又聰明的程序。它是一長排小而專一的變換,叫做 pass,而把 LLVM 凝聚在一起的紀律是:每個 pass 吃進 IR、產出等價的 IR。因為輸入與輸出是同一種語言,pass 就像串在線上的珠子一樣可以組合:你幾乎能用任何順序跑它們、跑某一個兩次、丟掉某一個、或插入一個新的,而 IR 在每一步都仍然是合法的 IR。每個 pass 也都有一個整齊的作用範圍——函式 pass 一次看一個函式,模組 pass 看整個編譯單元(當一個函式需要被內聯進另一個時很有用)——這讓每個變換都小到足以各自正確、且能各自被測試。

排序與 pass 本身一樣重要,因為每一個都為下一個擺好了桌子。跑常數傳播,一個被摺疊的常數就把一個條件分支變成永遠被走的;跑一個化簡 pass,那個如今不可達的區塊就被刪掉;跑死碼消除,那些只餵養該區塊的指令也跟著消失。這些 pass 沒有一個是單獨看來戲劇化的,但把它們串成管線、依刻意的順序施加、並常常重複,正是把一份照字面的 -O0 翻譯複利成緊湊 -O2 程式碼的東西。確切是哪些 pass、確切的順序,是第 4 與第 5 篇導引的事;這裡的重點是那個機制——一條保持等價的改寫所組成的管線。

管線作為一個整體,以及主宰它的東西

從個別的 pass 退後一步,pass 管線就是一份具名、排好序的配方,規定哪些 pass 要跑、各跑幾次。LLVM 內建了好幾條標準管線,依你要求的最佳化等級而定,而它們之間差異巨大。在 -O0,管線幾乎是空的——比 mem2reg 多不了多少——正是為了讓 IR 貼近你的原始碼、好讓除錯器能把機器碼對應回你的程式行;這就是除錯建置之所以用 -O0 建出來的全部理由。在 -O2,管線是一條長而精心調校、歷經數十年累積出來的序列,整組整組的 pass 會跑不只一次,因為一輪會為另一輪暴露出機會。-O3 把幾個更激進(也會把程式碼變大)的 pass 放開;-Os 與 -Oz 則把同一套機械裝置偏向更小的程式碼、而非更快的。

值得誠實地談談這條管線實際上是怎麼建起來的,因為它沒有聽起來那麼神奇。pass 的順序大致是一份由人工程化、靠經驗法則調校出來的排程——編譯器工程師量測了在真實程式上什麼有幫助,然後把一個好的順序定下來——而不是一份可被證明為最佳的計畫,你甚至能構造出一些程式,讓 pass 換個順序跑就能產出更好的程式碼。最佳化器也不保證找到可能的最佳機器碼;它是一組務實的改寫,通常能讓事情變好,並在一個時間預算內跑完。知道這件事能讓你的期待校準:-O2 很出色、值得信任,但它是工程,不是神諭。

為什麼這一切仍然正確:彷彿規則

一個合理的擔憂籠罩著整條管線:如果一個又一個 pass 不停改寫你的程式碼,我們怎麼知道程式仍然做著你寫的事?答案就是你在第 1 篇導引裡遇過的同一份授權——彷彿規則(as-if rule)。每個 pass 都被要求保住程式的可觀察行為——它的 I/O、它對 volatile 物件的寫入、它最終的輸出——同時可以自由更動任何不可觀察的東西:內部算術的順序、某個值是否曾經碰到記憶體、某個暫時值是否根本存在。mem2reg 刪掉你的堆疊格子、死碼消除移除一項計算、先前那整個堆疊往來憑空消失的把戲——這一切之所以合法,恰恰是因為其中沒有一樣是可觀察的。IR 帶型別且處於 SSA,正是讓每個 pass 能檢查它的改寫保住了原意的關鍵。

但彷彿規則有著先前警告過你的那個黑暗孿生兄弟,而它正是在這條 pass 管線裡最為鋒利。一個 pass 是對著抽象機器推理的,而一旦你的程式觸發未定義行為,那台機器就沒有已定義的行為——於是 pass 有權直接假設 UB 從不發生、並在那個假設上改寫。這正是「在 -O0 好好的、到 -O2 就壞了」背後真實的機制:-O2 管線裡多出來的那些 pass,倚靠著一個你那有缺陷的程式碼所違反的假設(一個不可能發生的有號溢位、一個從不為空的空指標),而它們做的改寫,對每一個沒有 UB 的程式都有效、對你的卻是錯的。第 5 篇導引會把這件事完整拆解,談由 UB 驅動的最佳化;眼下,你只要記住:正是那條讓管線對正確程式安全的規則,讓它對不正確的程式毫不留情。