經典優化:死碼消除、常數折疊與循環展開#
如果說 CFG 與 SSA 是優化的骨架,那麼一套流傳數十年的經典優化演算法就是血肉。它們大多誕生於 1960–70 年代 IBM 與大學實驗室的 FORTRAN 編譯器專案,因為當時 CPU 時間比程式設計師時間昂貴得多,編譯器多花一小時,程式就能跑得快一分。今天它們仍是每一個優化編譯器的核心。
常數折疊與常數傳播#
常數折疊(constant folding)是最古老的優化:在編譯期直接算出常數表達式。
// 優化前 // 優化後
int x = 2 + 3 * 4; int x = 14;
早在 1950 年代,FORTRAN 團隊(包括 John Backus)就把它寫進編譯器。常數傳播(constant propagation)則更進一步:把賦了常數值的變數一路傳遞下去,若變數到使用處仍是常數,就替換並折疊。在 SSA 形式上,這兩件事幾乎是順手完成——沿 def-use 鏈走一圈即可。
int a = 10; int a = 10;
int b = a * a; // b 直接變成 100
死碼消除#
死碼消除(DCE, Dead Code Elimination)刪除兩種代碼:永遠不會執行的代碼,以及計算了卻沒人使用的代碼。
int f(int x) { int f(int x) {
int y = x * 2; return x;
int z = y + 100; }
return x;
}
y、z 算了卻沒人用,就是死碼。工程師改了程式之後常常留下這類殘骸;死碼消除讓「先寫出來再清掉」成為廉價的操作。配合 SSA,「變數是否被使用」就是 def-use 鏈是否為空,一次反向掃描即可完成,這是 SSA 帶來的紅利。
公共子表達式消除與強度削弱#
公共子表達式消除(CSE)發現重複的計算,只算一次:
y = a * b + c; t = a * b;
z = a * b + d; y = t + c;
z = t + d;
強度削弱(strength reduction)把昂貴的運算換成便宜的等價運算。在早期沒有浮點器、乘法要花數十個週期的機器上,這是救命稻草:
y = x * 8; y = x << 3;
y = x * 3; y = (x << 1) + x;
它最著名的應用是在循環中:把循環內的乘法 i * 4 改成每圈加 4 的歸納變數,這正是 1970 年代 Allen 與 Cocke 資料流分析的招牌成果。
循環優化家族#
程式的執行時間八成花在少數循環裡,所以循環優化值得特殊對待。
循環不變碼外提(LICM):把循環內不隨迭代改變的計算搬到循環外。
for (i = 0; i < n; i++) t = a * b;
s[i] = a * b + i; for (i = 0; i < n; i++)
s[i] = t + i;
循環展開(loop unrolling):把循環本體複製多份,減少分支與循環維護的開銷,同時讓指令調度有更大空間:
for (i = 0; i < 4; i++) s[0] += a[0];
s[i] += a[i]; s[1] += a[1];
s[2] += a[2];
s[3] += a[3];
展開的深度是一門學問:展太多,指令碼膨脹,反而把指令快取與資料快取擠爆——展開後連續存取的資料超過一條快取行(64 bytes),硬體預取器的收益就會被代碼膨脹吃掉。優化從此不只是演算法問題,而是與硬體快取階層的合縱連橫。
Inline 的取捨#
函式內聯(inline expansion)把函式呼叫直接替換成函式本體,消除了呼叫開銷,並讓上述所有優化能跨越函式邊界施展——沒有內聯,編譯器看到 f(x) 就必須假裝什麼都不知道。但內聯是所有優化中取捨最微妙的一個:過度內聯造成代碼膨脹,擠壓指令快取,效能反而不升反降。現代編譯器用複雜的成本模型(呼叫次數、函式大小、是否在熱點路徑上)決定內聯誰,這幾乎是半門藝術。
優化的現代處境#
這些演算法寫於 1960–70 年代,卻在 2020 年代更加重要——因為硬體已複雜到人類無法手動看穿。亂序執行讓指令的實際執行順序與書寫順序脫鉤,多層快取讓「第幾次碰這個陣列」決定效能,SIMD 向量指令要求編譯器把純量循環自動改寫成向量循環(auto-vectorization)。程式設計師寫的是意圖,編譯器必須替人算出「在這顆晶片上怎麼跑最快」。
但另一條戰線上,經典優化遭遇了新的敵人:Python 與 JavaScript 這類動態語言,變數型別要到執行期才知道,a + b 可能是整數加法、浮點加法或字串串接;物件的屬性查找要穿過一層層字典。傳統的靜態編譯器在此束手無策——它需要一個能在執行期邊跑邊編譯、邊觀察邊優化的新角色。這正是 JIT 編譯器登場的伏筆。