最佳化編譯器與其限制 (Optimizing Compilers & Blockers)

Overview Table

小節 主題 核心概念 書頁
5.1 最佳化編譯器的能力與限制 safe optimization、兩大 optimization blockers:memory aliasing 與函式 side effect p.534-538
5.2 表達程式效能 CPE (cycles per element):以時脈週期為單位量測迴圈效能 p.538-540
5.3 程式範例 vector 抽象資料型別 + combine1 作為一路最佳化的基準 p.540-544
5.4 消除迴圈低效率 code motion:把 vec_length/strlen 移出迴圈;隱藏的漸近低效率 p.544-548
5.5 減少程序呼叫 combine3 直接存取陣列,去除迴圈內函式呼叫(意外地沒有立即加速) p.548-549
5.6 消除不必要的記憶體參考 combine4 用區域變數 acc 累積,記憶體操作從 2 讀 1 寫降為 1 讀 p.550-553
版本 方法 int + int * FP + FP *
combine1 抽象、未最佳化 22.68 20.02 19.98 20.18
combine1 -O1 10.12 10.12 10.17 11.14
combine2 移出 vec_length 7.02 9.03 9.02 11.03
combine3 直接存取資料 7.17 9.02 9.02 11.03
combine4 累積於暫存變數 1.27 3.01 3.01 5.01
全章策略 (p.534)

只做「剛好足夠」的原始碼改寫,讓編譯器接手產生高效率程式碼——比直接寫組合語言保留了可讀性、模組性、可攜性。反覆「修改 → 量測 → 讀組譯輸出」是高效能程式的實際寫法;辨識 critical path(迴圈重複執行時形成的資料相依鏈)可推得執行時間下界。

5.1 最佳化編譯器的能力與限制 (p.534-538)

現代編譯器能簡化運算式、共用重複計算、減少計算次數。gcc 以 -Og 做基本最佳化,-O1/-O2/-O3 逐級加強;層級越高效能越好,但程式體積膨脹更難用除錯工具除錯。書中刻意以 -O1 為主(業界慣例為 -O2),以突顯 C 寫法本身對產出碼效率的影響——寫得好的程式配 -O1 可大勝 naive 版本配最高最佳化等級。

Blocker 1:Memory Aliasing(記憶體別名)

void twiddle1(long *xp, long *yp) { *xp += *yp; *xp += *yp; }  /* 6 次記憶體參考 */
void twiddle2(long *xp, long *yp) { *xp += 2* *yp; }           /* 3 次記憶體參考 */

xp == yp(aliasing)時兩者行為不同:twiddle1 使 *xp4 倍(連續兩次翻倍),twiddle2 只變 3 倍(*xp += 2*(*xp))。編譯器不知道呼叫端會怎麼傳參數,必須假設兩指標可能相等,故不能把 twiddle1 最佳化成 twiddle2 的形式。

xp ──┐                xp ──┬──> [ 同一位址 ]
     ├─> [不同位址]        │
yp ──┘   (無 alias)   yp ──┘   (aliased:最佳化不安全)

另一例:*q = y; *p = x; t1 = *q;t1 的值取決於 pq 是否 alias(無 alias 得 3000,有 alias 得 1000)——編譯器無法判定時,兩種情況都得假設可能,可用的最佳化因而受限 (p.535-536)。

Blocker 2:函式呼叫的 Side Effects

long f();
long func1() { return f() + f() + f() + f(); }  /* 呼叫 4 次 */
long func2() { return 4*f(); }                   /* 呼叫 1 次 */

f()side effect(如 return counter++; 修改全域狀態),func1 回傳 0+1+2+3 = 6func2 回傳 4*0 = 0。多數編譯器不去分析函式是否無 side effect,一律假設最壞情況、保留所有函式呼叫。

Inline substitution(行內展開,p.537 Aside)

把函式呼叫替換成函式本體(gcc 於 -O1 以上或 -finline 嘗試),既省呼叫開銷又開啟後續最佳化空間,例如展開後的 func1 可整併成 t = 4*counter + 6; counter += 4;。限制與代價:

  • gcc 只對同一個檔案內定義的函式做 inlining(跨檔的函式庫呼叫不適用)。
  • 被 inline 掉的呼叫無法用 gdb 設中斷點追蹤,也無法被 profiler 正確計入(§3.10.2 的除錯工具、§5.14.1 的 profiling)。

5.2 表達程式效能:CPE (p.538-540)

處理器活動由時脈 (clock) 控制,頻率以 GHz(每秒 109 週期)表示;週期時間 = 時脈頻率的倒數,如 4 GHz 時脈的週期為 1/(4×109) 秒 = 0.25 ns = 250 ps。以「時脈週期數」而非 ns/ps 表達量測,才反映執行了多少指令而非時脈多快。

對迭代處理 n 個元素的程序,執行時間可近似為:

T=Overhead+CPE×n
交叉點計算模式(Practice 5.2 型)

給多個 Ti=ci+CPEin,問哪個 n 區間誰最快 → 解不等式找整數交叉點。例:60+35n136+4n157+1.25n 三版本,小 n 時常數項低者勝,大 n 時 CPE 低者勝(記得 n 是整數)。

5.3 程式範例:vector ADT 與 combine1 (p.540-544)

貫穿全章的例子:vector 抽象資料型別由 header + data array 兩塊記憶體構成:

vec_ptr ──> ┌─────────┐
            │ len     │             data array
            │ data ───┼──> ┌────┬────┬────┬─ ─ ─┬───────┐
            └─────────┘    │ 0  │ 1  │ 2  │ ... │ len-1 │
              header       └────┴────┴────┴─ ─ ─┴───────┘
                             (len 個 data_t 元素)
typedef struct { long len; data_t *data; } vec_rec, *vec_ptr;

5.4 消除迴圈低效率:Code Motion (p.544-548)

combine1vec_length(v) 放在 for 迴圈的測試條件中,而測試條件每次迭代都要重新求值(03-Machine-Level-Programs/03-Control-Flow §3.6.7 的迴圈翻譯),但向量長度在迴圈中不變。combine2 在迴圈前先算 long length = vec_length(v); → int + 的 CPE 從 10.12 降到 7.02。

極端案例:lower1 的隱藏平方複雜度

/* 慢:strlen 在迴圈測試中,每次迭代重算 */
for (i = 0; i < strlen(s); i++)             /* lower1: O(n²) */
    if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a');

/* 快:先存長度 */
long len = strlen(s);
for (i = 0; i < len; i++) ...               /* lower2: O(n) */
隱藏的漸近低效率 (asymptotic inefficiency)

看似無害的小程式(大小寫轉換)在小測資下沒問題,部署後遇上百萬字元輸入就成了主要瓶頸——程式通常只用小資料集測試,平方複雜度不會現形。避免寫出這種程式碼是合格程式設計者的職責 (p.547-548)。

呼叫次數分析模式(Practice 5.3 型)

for 迴圈中:init 執行 1 次;test 每次迭代前都執行(含最後失敗那次);update 與 body 每迭代 1 次。如 for (i = min(x,y); i < max(x,y); incr(&i,1))max 在測試裡會被重複呼叫;先把 min/max 存入區域變數(片段 C)即是手動 code motion。

5.5 減少程序呼叫 (p.548-549)

combine2 每迭代仍呼叫 get_vec_element,其中的 bounds checking 每次都比對索引 i 與邊界——但簡單分析即知此迴圈所有存取必然合法,檢查是明顯的低效率來源。combine3 新增 get_vec_start(v) 回傳 data array 起始位址,迴圈內改為直接 data[i] 存取,內層迴圈零函式呼叫

5.6 消除不必要的記憶體參考 (p.550-553)

檢視 combine3 內層迴圈的 x86-64 組譯碼(data_t = doubleOP = *):

combine3 內層迴圈 (-O1):每迭代 2 讀 1 寫
.L17:
  vmovsd (%rbx), %xmm0        ← 從 dest 讀回累積值(浪費!)
  vmulsd (%rdx), %xmm0, %xmm0 ← 乘上 data[i](1 次讀)
  vmovsd %xmm0, (%rbx)        ← 寫回 dest
  addq   $8, %rdx             ← 指標 data+i 前進 8 bytes
  cmpq   %rax, %rdx           ← 與 data+length 比較
  jne    .L17

combine4 內層迴圈:累積值留在 %xmm0,每迭代僅 1 讀
.L25:
  vmulsd (%rdx), %xmm0, %xmm0
  addq   $8, %rdx
  cmpq   %rax, %rdx
  jne    .L25

combine3 每迭代把累積值寫入 *dest、下一迭代又讀回——讀到的本來就是剛寫的值,純屬浪費。combine4 引入區域變數 data_t acc = IDENT;(accumulator)在迴圈中累積,迴圈結束後才寫一次 *dest;編譯器便能把 acc 放進暫存器 %xmm0。效能提升 2.2×–5.7×,int + 降到 1.27 CPE。

為何編譯器不能自動做這件事?又是 aliasing

dest 指向向量內部,兩版本行為不同。例:int 乘法、IDENT = 1v = [2, 3, 5],呼叫 combineX(v, get_vec_start(v) + 2)(dest alias 到最後一個元素):

函式 初始 迴圈前 i=0 i=1 i=2 最終
combine3 [2,3,5] [2,3,1] [2,3,2] [2,3,6] [2,3,36] [2,3,36]
combine4 [2,3,5] [2,3,5] [2,3,5] [2,3,5] [2,3,5] [2,3,30]
combine3: 每迭代寫回 *dest ──> 改到 v[2] ──> 之後的迭代讀到被污染的值
          1 → 2·1=2 → 3·2=6 → 6·6=36
combine4: acc 在暫存器累積 1·2·3·5 = 30 ──> 迴圈結束才寫入 v[2]

combine4 的行為或許更貼近函式的「意圖」,但編譯器不能猜測程式設計者的意圖與使用情境;給它 combine3,保守做法就是保留每次讀寫記憶體,即使較慢。

-O2 的例外 (Practice 5.4, p.552)

gcc -O2 編譯 combine3 時 CPE 大幅改善至接近 combine4(int + 為 1.60,其餘相同):它把累積值留在 %xmm0,但每迭代仍執行 vmovsd %xmm0, (%rbx) 寫回 dest、只是消除了讀回。此轉換仍是 safe 的——暫存器中的值恆等於剛寫入 *dest 的值(迭代間沒有其他寫入者),故即使 aliasing 存在,行為與 -O1 版完全一致。可見「消除讀取」可被安全自動化,「消除寫入」則會在 aliasing 情況下改變行為,編譯器不能做。

通往 5.7 的橋樑

至此的最佳化(降低程序呼叫開銷、排除 optimization blockers)不依賴目標機器的任何特性,CPE 已從剛開最佳化時的 9–11 降到 1.25–5。要再往下壓,必須理解處理器微架構 (microarchitecture) 與指令級平行 → 05-Program-Optimization/02-Modern-Processor-Operation

Exam/Test Patterns

情境 / 關鍵字 答案
twiddle1 vs twiddle2,xp == yp memory aliasing:twiddle1 使值 ×4,twiddle2 使值 ×3 → 編譯器不能互轉
三步加減 swap 且 xp == yp(Practice 5.1 型) 第一步後 *xp = 2x;第二步 *yp = *xp - *yp = 0;第三步仍為 0(原值被清成 0)
為何編譯器不把 f()+f()+f()+f() 改成 4*f() f 可能有 side effect(如 counter++):前者得 6、後者得 0
「4 GHz 處理器的週期時間?」 週期 = 1/頻率 = 0.25 ns = 250 ps
執行時間 c+kn,問 k 的意義 k 即 CPE;比較多版本快慢 → 解不等式找 n 的整數交叉區間
迴圈測試條件中呼叫 strlen/vec_length 手動 code motion 移到迴圈前;strlen 版總時間 O(n2)
「長度加倍、時間變 4 倍」 平方時間特徵;加倍→時間 ×2 才是線性
for (i = min(..); i < max(..); incr(..)) 各函式呼叫次數 init 1 次;test 條件每迭代重算;body/update 每迭代 1 次(Practice 5.3)
combine3 → combine4 沒被編譯器自動化的原因 dest 可能 alias 向量元素;[2,3,5] 乘積例:36 vs 30
combine2 → combine3(去除迴圈內呼叫/bounds check)為何沒加速 瓶頸在每迭代對 *dest 的記憶體讀寫,不在函式呼叫
-O2 下 combine3 內層迴圈只剩 store、無 load 消除讀取是 safe(暫存器值恆等於記憶體值);消除寫入才會在 aliasing 下變行為
inlining 的兩個副作用 gdb 無法對被展開的呼叫設中斷點;profiler 無法正確歸因(§5.14.1)
高最佳化等級 (-O2/-O3) 的代價 程式體積膨脹、標準除錯工具難以除錯;好的 C 寫法 + -O1 可勝 naive + 最高等級
bounds checking 每次存取都做 降低程式錯誤機會、但可能拖慢執行(類似 Java 陣列)