程式效能最佳化練習題 (Practice - Program Optimization)
Related Concepts
- 05-Program-Optimization/01-Optimizing-Compilers-and-Blockers
- 05-Program-Optimization/02-Modern-Processor-Operation
- 05-Program-Optimization/03-Loop-Unrolling-and-Parallelism
- 05-Program-Optimization/04-Limiting-Factors-and-Memory-Performance
| 關鍵字 / 情境 | 答案模式 |
|---|---|
| 兩指標可能相等、編譯器不敢合併 | memory aliasing(twiddle1 ×4 vs twiddle2 ×3) |
f()+f()+f()+f() ≠ 4*f() |
函式 side effect(counter++:6 vs 0) |
| 執行時間 |
k = CPE;週期時間 = 1/時脈頻率 |
| 嚴格循序相依鏈 | latency bound:CPE ≥ L |
| 功能單元產能極限 | throughput bound:CPE ≥ I/C(還要看 2 個 load units) |
| 含記憶體參照的指令 | 解碼成多個 micro-ops(load/op/store 分離) |
| k × 1 展開 | 只省 loop overhead,critical path 仍 n 個運算 |
| k × k 多重累加器 | k 條獨立鏈 → CPE ≈ L/k;填滿 pipeline 需 k ≥ C·L |
| k × 1a reassociation | 只改括號,元素互乘脫離依賴鏈 → CPE ≈ L/2 |
| 整數重排安全、浮點不安全 | 二補數滿足結合/交換律;浮點不具結合律 |
| 展開過度反而變慢 | register spilling(超過 16 整數 / 16 YMM 暫存器) |
| 隨機資料慢、規律資料快 | 不可預測分支;改 functional style 促成 cmov |
| linked list 走訪 CPE = 4 | load 位址相依 → CPE = load latency(L1 = 4 cycles) |
| 同址 store 後緊接 load | write/read dependency:load 須等 s_data |
Question 1 - Memory Aliasing [recall]
考慮
twiddle1(long *xp, long *yp) { *xp += *yp; *xp += *yp; }與twiddle2(long *xp, long *yp) { *xp += 2* *yp; }。
為什麼編譯器不能把twiddle1最佳化成twiddle2的形式?當xp == yp時兩者結果各是原值的幾倍?
因為 memory aliasing:編譯器無法排除 xp 與 yp 指向同一位置的可能,必須假設最壞情況。
當 xp == yp 時:twiddle1 連續兩次翻倍,結果為原值的 4 倍;twiddle2 執行 *xp += 2*(*xp),結果為 3 倍。行為不同,故此轉換不是 safe optimization。
Question 2 - Function Side Effects [recall]
設全域變數
long counter = 0;且long f() { return counter++; }。
func1()回傳f()+f()+f()+f(),func2()回傳4*f()。兩者回傳值各為何?這說明編譯器的什麼限制?
func1 回傳 0+1+2+3 = 6;func2 回傳 4×0 = 0。
f 具有 side effect(修改全域狀態),故兩寫法不等價。多數編譯器不分析函式是否無 side effect,一律假設最壞情況、保留所有函式呼叫,不會把 4 次呼叫合併成 1 次。
Question 3 - CPE 與週期時間 [recall]
某程式處理 n 個元素的執行時間量測為
個時脈週期,執行於 4 GHz 處理器。
(a) 此程式的 CPE 為何?(b) 該處理器的週期時間為多少?
(a) CPE = 9.0:CPE (cycles per element) 是執行時間線性項的斜率,常數 368 是 overhead(n 夠大時可忽略)。
(b) 週期時間 = 1/頻率 =
Question 4 - 版本交叉比較 [application]
兩個版本的執行時間分別為
與 (單位:cycles,n 為整數元素數)。
求 n 在哪個範圍時版本 1 較快?
解
模式:小 n 時常數項(overhead)低者勝,大 n 時 CPE 低者勝;記得 n 是整數,交叉點要取整。
Question 5 - 兩大效能下界 [recall]
何謂 latency bound 與 throughput bound?參考機器整數加法有 4 個功能單元、issue time = 1,為何 combining 運算的 throughput bound 是 0.50 而非 0.25?
Latency bound:運算間嚴格循序相依(下一個需要上一個的結果)時,CPE 下限 = 運算 latency L。
Throughput bound:功能單元原始算力極限,吞吐量 = C/I ops/cycle,CPE 下限 = I/C。
整數加法 4 單元理論上 0.25,但參考機器只有 2 個 load units,每 cycle 最多讀 2 個元素,故逐元素讀取的 combining 運算下限為 0.50。
Question 6 - 指令解碼為 Micro-operations [recall]
addq %rax,%rdx與addq %rax,8(%rdx)各被解碼成幾個 primitive operations?一個 store 需要幾個功能單元?
addq %rax,%rdx(純暫存器)= 1 個 operation;addq %rax,8(%rdx) = 3 個:load(讀記憶體)、add(相加)、store(寫回)——記憶體參照與算術分離,讓專用單元平行分工。
一個 store 需要 2 個功能單元:一個計算 store 位址、一個實際存資料。
Question 7 - Speculative Execution 與程式狀態 [recall]
處理器對分支做 speculative execution,若之後發現預測錯誤,為什麼程式狀態(暫存器、記憶體)不會被破壞?
推測執行期間結果不寫入 program registers 或 data memory。指令解碼後進入 retirement unit 的 FIFO queue,只有當其 operations 全部完成、且通往它的所有分支都確認預測正確時才 retire(此時才更新暫存器);預測錯誤則整批 flush,丟棄推測結果並從正確目標重新 fetch。register renaming(tag 轉發)讓整串運算可以完全 speculative 地執行。
Question 8 - k × 1 展開的邊界 [recall]
以展開因子 k 做 k × 1 loop unrolling(單一累加器)處理長度 n 的向量:主迴圈的上限條件怎麼寫?收尾迴圈會執行幾次?此轉換能突破 latency bound 嗎?
主迴圈條件為 i < n − k + 1(即 limit = n − k + 1),保證最大索引 i + k − 1 < n;收尾迴圈執行 0 到 k − 1 次。
不能突破 latency bound:單一 acc 使所有運算仍在同一條相依鏈上,critical path 仍有 n 個運算。實測只有整數加法(原本受 loop overhead 拖累)從 1.27 → 1.01,其餘完全不變。
Question 9 - 多重累加器 CPE 預測 [application]
在參考機器上(double 乘法 latency = 5、2 個 FP 乘法單元、issue time = 1),對 double * 的 combining 迴圈做 5 × 5 展開(5 個平行累加器)。
(a) 預測 CPE 約為多少?(b) 要達到 throughput bound 0.50,展開因子至少要多大?
(a) k 條獨立鏈 → CPE ≈ L/k = 5/5 = 1.00(仍高於 throughput bound 0.50,故不會被下界截斷)。
(b) 填滿所有 pipeline 需 $k \ge C \cdot L = 2 \times 5 = $ 10。書中實測 double * 在 k ≥ 10 時 CPE 達 0.51。
Question 10 - Reassociation 轉換 [application]
2 × 1 展開的迴圈本體為
acc = (acc OP data[i]) OP data[i+1];。只移動一對括號改成:acc = acc OP (data[i] OP data[i+1]);對 double *(L = 5)而言,CPE 大約從多少變成多少?為什麼?
從 5.01 降到約 2.51(CPE ≈ L/2)。
改括號後,data[i] OP data[i+1] 的元素互乘不依賴 acc,可脫離相依鏈提前平行執行;只有第二個運算(乘入 acc)留在鏈上 → critical path 從 n 個運算縮為 n/2 個。這就是 2 × 1a reassociation(combine7),效果與 2 × 2 多重累加器相當。
Question 11 - 整數 vs 浮點重排的正確性 [recall]
多重累加器與 reassociation 都改變了運算順序。為什麼整數版轉換在所有情況下結果都與原版相同,而浮點版可能不同?
整數:二補數加法與乘法即使溢位仍滿足結合律與交換律,任意重排結果不變,故部分編譯器會自動做此轉換。
浮點:加法與乘法不具結合律——重排可能因 rounding 或 overflow/underflow 改變結果(例如偶數索引全是極大值、奇數索引接近 0 時,分組乘積可能 overflow 而依序計算正常)。因此多數編譯器不對浮點碼做此轉換,開發者需自行確認可接受。
Question 12 - Horner 法為何更慢 [analysis]
多項式求值兩版本:
poly每迭代執行result += a[i]*xpwr; xpwr = x*xpwr;(每迭代 2 乘 1 加);polyh(Horner 法)執行result = a[i] + x*result;(每迭代 1 乘 1 加)。
參考機器 FP 乘法 L=5、FP 加法 L=3。Horner 法運算次數較少,為何實測 CPE(8.00)反而比 poly(5.00)差?
效能由 critical path 上的 latency 總和決定,而非運算總數。
polyh 的跨迭代相依鏈是 result 的更新:mul (5) 與 add (3) 串聯在同一鏈上 → 每迭代 8 cycles → CPE 8.00。
poly 的跨迭代鏈只有 xpwr = x*xpwr 的單一 mul(5 cycles);a[i]*xpwr 與 result += 不在跨迭代鏈上,可與之平行執行 → CPE 5.00。運算總數少 ≠ 快。
Question 13 - 不可預測分支與 cmov [recall]
某函式對隨機資料 CPE ≈ 13.5、對排序過(規律)的資料 CPE ≈ 3.5。診斷原因,並說明如何改寫程式碼消除此差距。
原因:不可預測分支 + branch misprediction penalty(參考機器約 19 cycles,與 13.5 − 3.5 ≈ 20 的差距吻合)。隨機資料使依資料決定方向的分支猜錯率約一半。
解法:改寫成 functional style——先用 ?: 條件運算算出兩個結果值、再無條件賦值,促使編譯器產生 conditional move (cmov):無需預測、無罰款,CPE 與資料模式無關(minmax 例:13.5 → 4.0)。注意可預測分支(loop-closing、恆成立的 bounds check)幾乎零成本,不必刻意消除。
Question 14 - Register Spilling [recall]
把 combine 系列從 10 × 10 展開加大到 20 × 20 展開,整數加法 CPE 反而從 0.55 升到 0.83。解釋原因。
Register spilling:x86-64 只有 16 個整數暫存器與 16 個 YMM 暫存器,平行度(累加器數)超過可用暫存器時,編譯器把部分 accumulator 溢出到 run-time stack。每次更新 accumulator 從 1 條指令變成 load + 運算 + store 3 條,多重累加器的優勢消失。辨識特徵:組譯碼中 accumulator 以 %rsp 位移存取(如 vmovsd 40(%rsp),%xmm0 ... vmovsd %xmm0,40(%rsp))。
Question 15 - Load Latency 與 Linked List [recall]
計算 linked list 長度的迴圈核心為
ls = ls->next;,實測 CPE = 4.00。這個 4 從哪裡來?為什麼一般陣列走訪的 load 不受此限制?
每次 load 的位址依賴前一次 load 的結果,load 進入 critical path、無法重疊 → CPE = load latency = L1 cache 存取時間 4 cycles。
陣列走訪的 load 位址只依賴迴圈索引 i,各 load 彼此獨立、可完全 pipeline;此時限制是 throughput(2 個 load units → 每元素 k 次 load 時 CPE ≥ k/2,combining 系列故不低於 0.50)。
Question 16 - Write/Read Dependency [analysis]
函式
write_read(long *src, long *dst, long n)內迴圈為*dst = val; val = (*src) + 1; cnt--;。
Example A 以src != dst呼叫,CPE = 1.3;Example B 以src == dst呼叫,CPE = 7.3。
分析兩者 critical path 的差異,並說明 store buffer 在其中的角色。
Store 指令被拆成 s_addr(算位址、在 store buffer 建 entry)與 s_data(填入資料)兩個操作;每個 load 都要比對 store buffer 中的位址,相符時直接轉發 buffer 資料(store-to-load forwarding)。
Example A(異址):位址不符,load 與 store 獨立進行,critical path 只剩 cnt-- 的 sub 鏈 → CPE 1.3(接近下限 1.0)。
Example B(同址):load 必須等 s_data 完成才能取值,critical path 變成 s_data → load → add 的循環,每輪約 7 cycles → CPE 7.3。這就是 write/read dependency;記憶體運算的相依要等位址算出來才知道,是記憶體效能難以預測的根源。
Question 17 - Dest Aliasing 與 combine4 [application]
combine3 每迭代把累積值寫回
*dest;combine4 改用區域變數acc累積、迴圈結束才寫一次*dest。
設整數乘法、IDENT = 1、v = [2, 3, 5],以dest = get_vec_start(v) + 2(alias 到最後一個元素)呼叫,兩版本的最終向量各為何?這說明什麼?
combine3:每迭代寫回污染 v[2],計算過程 1 → 2·1=2 → 3·2=6 → 6·6=36,最終 [2, 3, 36]。
combine4:acc 在暫存器累積 1·2·3·5 = 30,結束才寫入,最終 [2, 3, 30]。
兩者行為在 aliasing 下不同,故編譯器不能自動把 combine3 轉成 combine4——即使後者更貼近程式意圖,編譯器只能保守假設 dest 可能指向向量內部。
| 答題模式 | 要點 |
|---|---|
| 編譯器為何不最佳化 X | 兩大 blockers:memory aliasing(舉 xp==yp 反例)、函式 side effect(舉 counter++ 反例);safe optimization 要求所有情況行為相同 |
| CPE 計算 | |
| CPE 下界判斷 | 先看相依鏈:嚴格循序 → latency bound L;打散成 k 條 → L/k;最終不低於 throughput bound I/C(留意 2 個 load units → 0.50) |
| 展開轉換效果 | k×1 只省 overhead(僅 int + 受益);k×k → CPE ≈ L/k,需 k ≥ C·L 達 throughput bound;k×1a 改括號 → 鏈上剩 n/2 個運算 |
| 重排正確性 | 整數(二補數)溢位仍結合/交換 → 恆安全;浮點不結合 → rounding/overflow 可能改變結果 |
| 過度展開 | register spilling:超過 16 整數 / 16 YMM 暫存器,accumulator 溢出到 stack |
| 分支效能 | 可預測(loop-closing、恆真檢查)幾乎零成本;不可預測罰約 19 cycles → functional style 促成 cmov |
| 記憶體效能 | load 位址相依 → CPE = load latency 4;同址 store→load → write/read dependency(s_data→load→add 鏈) |
| Critical path 分析 | 只看 loop registers 的跨迭代相依鏈;運算總數少 ≠ 快(Horner 8.00 vs poly 5.00) |