---
tags: Optimization, 編譯器
---
# Understanding Compiler Optimization
:::info
以下段落摘要自影片
* [Understanding Compiler Optimization - Chandler Carruth - Opening Keynote Meeting C++ 2015](https://www.youtube.com/watch?v=FnGCDLhaxKU&feature=youtu.be)
* [slide](https://meetingcpp.com/files/mcpp/2015/talks/meetingcxx_2015-understanding_compiler_optimization_themed_copy.pdf)
* [How to optimize C and C++ code in 2018](https://medium.com/@aka.rider/how-to-optimize-c-and-c-code-in-2018-bd4f90a72c2b)
加上我一些拙劣的理解與補充
:::
編譯器可以分成三個部分:
* frontend: 包含 [lexical analysis](https://en.wikipedia.org/wiki/Lexical_analysis)(拆解 token), [syntax analysis](https://en.wikipedia.org/wiki/Parsing)(檢查語法), 與 [semantic analysis](https://en.wikipedia.org/wiki/Semantic_analysis_(compilers))(解析語意、[Type checking](https://en.wikipedia.org/wiki/Type_system#Type_checking)、變數宣告的檢查等)。並將程式轉換成中間表示式([IR、Intermediate representation](https://en.wikipedia.org/wiki/Intermediate_representation))
* optimizer: IR 的存在使得最佳化獨立於 CPU 架構之外,optimizer 會利用 IR 來做最佳化
* code generator: 從最佳化過的 IR,進一步檢查、根據 CPU 的架構做額外的最佳化,產生真正 CPU 對應的 assembly code
![](https://i.imgur.com/dlAgKI9.png =200x)
本段落會聚焦在 optimizer,並以 LLVM IR 做為舉例。
## First of all, what 's IR ?
### Hello world
讓我們從一個簡單的 LLVM IR 開始
```c=
declare i32 @g(i32 %x)
define i32 @f(i32 %a, i32 %b) {
entry:
%c = add i32 %a, %b
%d = call i32 @g(i32 %c)
%e = add i32 %c, %d
ret i32 %e
}
```
很容易的可以理解其內容:
* `declare`、`define` function `g`、 `f`
* 型別如 `i32`
* 變數如 `a`、`b`
* 指令如 `add` `call` `ret`
### Control flow
```c=
declare i32 @g(i32 %x)
define i32 @f(i32 %a, i32 %b, i1 %flag) {
entry:
%c = add i32 %a, %b
br i1 %flag, label %then, label %else
then:
%d = call i32 @g(i32 %c) ret i32 %d
else:
ret i32 %c
}
```
IR 可以被視作許多的 basic block(`entry`、`then`...),IR 透過如 `br` 等指令,去操作程式運作的 control flow
### Data flow
首先要從 [SSA(Static Single Assignment)](https://gcc.gnu.org/onlinedocs/gccint/SSA.html)說起。
在 SSA representation 中,每個變數只會被 Assign 一次,並且在使用前會被定義。藉此,SSA 得以把原本複雜的條件和程式邏輯轉化為 Graph,將程式邏輯轉換成明確的路徑
```c=
declare i32 @g(i32 %x)
define i32 @f(i32 %a, i32 %b, i1 %flag) {
entry:
%c = add i32 %a, %b
br i1 %flag, label %then, label %end
then:
%d = call i32 @g(i32 %c) br label %end
end:
%result = phi i32 [ %entry, %c ], [ %then, %d ]
ret i32 %result
}
```
有個問題是,如果需要得到一個只在某條件分支下的值(舉例來說,上面 IR 中的 `%d`),在 SSA 中該如何使用它呢?
SSA representation 可以透過 `phi` (Φ),根據現在的 basic block 是從哪裡進入的來合併分支。以上面的 IR 來說,如果是從 entry 進入 end,則 assgin `%c`; 如果是從 then 進入,則 assign`%d`。
## What are optimizer doing?
### Step 1. Cleanup
以這個程式為例
```c=
int atoi(const char *);
int main(int argc, char **argv) {
if (argc != 3)
return -1;
return atoi(argv[1]) + atoi(argv[2]);
}
```
如果我們用
```
$ clang -cc1 -emit-llvm -O0 tmp.c -o tmp.ll
```
在沒有最佳化的狀況下去產生 IR,會得到
```c=
define i32 @main(i32 %argc, i8** %argv) #0 {
%retval = alloca i32, align 4
%argc.addr = alloca i32, align 4
%argv.addr = alloca i8**, align 8
store i32 0, i32* %retval, align 4
store i32 %argc, i32* %argc.addr, align 4
store i8** %argv, i8*** %argv.addr, align 8
%1 = load i32, i32* %argc.addr, align 4
%cmp = icmp ne i32 %1, 3
br i1 %cmp, label %2, label %3
; <label>:2: ; preds = %0
store i32 -1, i32* %retval, align 4
br label %8
; <label>:3: ; preds = %0
%4 = load i8**, i8*** %argv.addr, align 8
%arrayidx = getelementptr inbounds i8*, i8** %4, i64 1
%5 = load i8*, i8** %arrayidx, align 8
%call = call i32 @atoi(i8* %5)
%6 = load i8**, i8*** %argv.addr, align 8
%arrayidx1 = getelementptr inbounds i8*, i8** %6, i64 2
%7 = load i8*, i8** %arrayidx1, align 8
%call2 = call i32 @atoi(i8* %7)
%add = add nsw i32 %call, %call2
store i32 %add, i32* %retval, align 4
br label %8
; <label>:8: ; preds = %3, %2
%9 = load i32, i32* %retval, align 4
ret i32 %9
}
```
用 SSA 方式來 modeling dataflow 並不容易,至少這並不是 frontend 該負責的工作。因此,frontend 會假設程式擁有無限多的 memory 把所有東西都放在 memory,因此可以看到一堆的 alloc、 store、 load。
因此,在 cleanup 階段,optimizer 會嘗試用 SSA form 替換這些 memory-based 的指令、替換需要使用記憶體或者暫存器、並且去除某些多餘的變數。
當我們將參數替換成 `-O1` 來編譯,可以得到相對簡潔的 IR:
```c=
define i32 @main(i32 %argc, i8** nocapture readonly %argv) local_unnamed_addr #0 {
%cmp = icmp eq i32 %argc, 3
br i1 %cmp, label %1, label %4
; <label>:1: ; preds = %0
%arrayidx = getelementptr inbounds i8*, i8** %argv, i64 1
%2 = load i8*, i8** %arrayidx, align 8, !tbaa !2
%call = tail call i32 @atoi(i8* %2)
%arrayidx1 = getelementptr inbounds i8*, i8** %argv, i64 2
%3 = load i8*, i8** %arrayidx1, align 8, !tbaa !2
%call2 = tail call i32 @atoi(i8* %3)
%add = add nsw i32 %call2, %call
br label %4
; <label>:4: ; preds = %0, %1
%retval.0 = phi i32 [ %add, %1 ], [ -1, %0 ]
ret i32 %retval.0
}
```
### Step 2: Canonicalization(規範化)
一個相同行為的 C/C++ 程式,可以被表達成多種形式:
![](https://i.imgur.com/cEzouXu.png)
optimizer 會將原本的 control flow 重寫,例如: 把 not-equal 都用同等 equal 取代(即使 IR 中有 neq)、拿掉在 control flow 中的的 empty basic block 等,確保相同行為的 control flow 有一致的 pattern。因此,即使我們將上面的程式改寫為:
```c=
int main(int argc, char **argv) {
return argc == 3? atoi(argv[1]) + atoi(argv[2]) : -1;
}
```
使用 `-O1` 後得到的 IR 仍然是相同的形式!
透過 Canonicalization,相同邏輯的程式對於 optimizer 就可以維持一致的樣貌,更有利於 optimizer 設計最佳化的方法。
### Step 3: Collapse Abstractions
3 個主要的 abstraction
1. Functions, calls, and the call graph
2. Memory, loads, and stores
3. Loops
#### `Function call`
funtion call 是有巨大成本的(如 call stack),所以對於 optimizer 來說,具備判別 function 是否足夠 **"複雜"** 以至於需要對 function 做 [inline](https://en.wikipedia.org/wiki/Inline_function) 的能力就極其重要。
而 optimizer 對 function call 做最佳化的基礎就建立在 call-graph 上
![](https://i.imgur.com/0mqHfxA.png)
首先找出 call-graph 中的 [strongly connected components](https://en.wikipedia.org/wiki/Strongly_connected_component)
![](https://i.imgur.com/nFHboPf.png)
有了這些 strongly connected components 後,對於 optimizer 而言,圖中的 leaves components(G-H-I) 是做 **inline** 最佳化的第一候選,因為 leaves 不會再往下做 call function。
![](https://i.imgur.com/10XzL7j.png)
一旦對 (G-H-I) component 做了充分的最佳化後,接著 bottom up 的去對 (D-E-F)。這個步驟是很重要的,由於 E call 了 G,如果沒有事先對 (G-H-I) 進行最佳化,E 和 G 之間的關係資訊會產生錯誤(舉例來說,如果 G 是不重要的 function,E 大可跳過 G 直接 call H,但如果沒有事先對(G-H-I) 做最佳化則不會曉得)。
![](https://i.imgur.com/SwJ0U8L.png)
下一個問題是,optimizer 怎麼知道一個 function 是否足夠不 **"複雜"** 以至於可以被 inline?
```c=
int g(double x, double y, double z);
int f(struct S* s, double y, double x) {
return g(x, y, s->z);
}
```
許多的 function 可能都如上面的程式: 例如為了使一個 function 接受要求的 parameters 型別,而建立 g 的 wrapper f。以這個例子而言,g 是可以被 inline 在 f 中的。
但是 inline 與否的問題並不總是如此單純,argument 的差異、branch 狀況的差異對 function 執行行為的影響等,成為了 optimizer 決定是否要做 inline 的難點。
例如你想設計一個 function,依據 vector 大小做不同的排序演算法。optimizer 需 bottom up 搜尋 SSA tree,得到 vector 實際大小的資訊,才可以決定 inline 與否。也就是說,對於跨 function 的 optimization,需要大量資源才能正確達成,並且可能有無數的優化選項。
:::info
:triangular_flag_on_post: 你可能會想問: 既然 function call 會有成本,為甚麼不 inline 所有 function 呢?顯然這是不可行的,原因是:
* 並不是所有 function 都可以容易的、甚至根本不可能被 inline
* 動態連結的函式庫
* 非 tail recursion 的 recursive function
* inline 並不完美,它也有某些缺點
* inline 使得 executable 更大,代表需要更多的儲存空間、更多的 loading time
* 考慮到 cache,過度的 inline 可能反而導致 instruction cache 上的指令互相排擠,locality 不佳
:::
#### `Memory, loads, and stores`
如前所述,frontend 以 memory 是無限的前提來產生 IR,這使得它的工作變得容易。
而 optimizer 需透過 SSA form,觀察實際的值為何,並擅用 register 來安排這些變數。
![](https://i.imgur.com/LZteHbu.png)
以這個例子而言,左邊相當於 frontend 直接產出的結果,可以看到 IR 會把計算出的 return 值 store 到 `%mem` 中,再用 load 把 `%mem` 讀進 `%result` 作 return; 以右邊來說,optimizer 則透過 phi,迴避了對記憶體額外的使用。
#### `Loops`
大部份狀況下 loop 總是影響程式執行效率的地方,也因此是 optimizer 做最佳化的主要目標。
用以下程式為例:
```c=
#include <vector>
int sum(const std::vector<int> & array) {
int result = 0;
for (auto i: array) {
result += i;
}
return result;
}
```
可以透過下面的 cmd 得到未經 optimization 的 IR
```
$ clang++ -S -emit-llvm -O0 tmp.cpp -o tmp.ll
```
然後,你會看到小小的 loop 產生出數量龐大的 IR。
現在,我們假設 optimizer 已經對這一堆 IR 做了除了 loop 以外的,前面所說的最佳化(function/memory abstraction...),此時的 IR 大概可以概括為
```c=
define i32 @sum(%vector* nocapture readonly dereferenceable(24) %v) {
entry:
%begin_ptr = getelementptr inbounds %vector, %vector* %v, i64 0, i32 0, i32 0
%begin = load i32*, i32** %begin_ptr, align 8
%end_ptr = getelementptr inbounds %vector, %vector* %v, i64 0, i32 0, i32 1
%end = load i32*, i32** %end_ptr, align 8
br label %loop.head
loop.head:
%ptr = phi i32* [ %begin, %entry ], [ %ptr.next, %loop.latch ]
%x = phi i32 [ 0, %entry ], [ %x.next, %loop.latch ]
%cond = icmp eq i32* %ptr, %end
br i1 %cond, label %exit, label %loop.latch
loop.latch:
%i = load i32, i32* %ptr, align 4
%x.next = add nsw i32 %x, %i
%ptr.next = getelementptr inbounds i32, i32* %ptr, i64 1
br label %loop.head
exit:
ret i32 %x
}
```
如前所述,optimizer 要對 IR 有做 cleanup,並使之有統一的格式(Canonicalization),當然 loop 也不例外,因此會把 IR 調整成:
```c=
define i32 @sum(%vector* nocapture readonly dereferenceable(24)) #0 {
entry:
%begin_ptr = getelementptr inbounds %vector, %vector* %0, i64 0, i32 0, i32 0
%begin = load i32*, i32** %begin_ptr, align 8, !tbaa !2
%end_ptr = getelementptr inbounds %vector, %vector* %v, i64 , i32 0, i32 1
%end = load i32*, i32** %end_ptr, align 8
%empty = icmp eq i32* %begin, %end
br i1 %empty, label %exit, label %loop.ph
loop.ph: ; preds = %entry
br label %loop
loop: ; preds = %loop.ph, %loop
%x = phi i32 [ 0, %loop.ph ], [ %x.next, %loop ]
%ptr = phi i32* [ %begin, %loop.ph ], [ %ptr.next, %loop ]
%i = load i32, i32* %ptr, align 4
%x.next = add nsw i32 %x, %i
%ptr.next = getelementptr inbounds i32, i32* %ptr, i64 1
%cond = icmp eq i32* %ptr.next, %end
br i1 %cond, label %loop.exit, label %loop
loop.exit: ; preds = %loop
%x.lcssa = phi i32 [ %x.next, %loop ]
br label %exit
exit: ; preds = %loop.exit, %entry
%x.result = phi i32 [ %x.lcssa, %loop.exit ], [ 0, %entry ]
ret i32 %x.result
```
你可以注意到幾個重要的Canonicalization:
* loop perheder `loop.ph` 的唯一工作,就是擔任 `loop 的進入點
* `loop.exit` 則擔任 loop 的退出點,而沒有其他的 predecessor
* `loop.exit` 的 phi 有些特別,裡面只有單個 input。藉此,和前面的 phi 是用來合併(merge) branch 不同,SSA form 可以表達選擇 loop iteration 後的最終 `%x.next`。
抽離出的 `loop.exit` 唯一的 predecessor 是 `loop`,並且確切是 block 離開 `loop` 的點,這使得 loop 的表達更為單純,optimizer 可以藉此更好的做 optimization。
在此把 IR 調整成統一格式後,接著 optimizer 便可以開始嘗試對其做各種最佳化了:
* optimizer 嘗試判斷程式作為 loop 的必要性,嘗試將其展開([loop unrolling](https://en.wikipedia.org/wiki/Loop_unrolling))
* 類似以下的程式,假設 a 跟 b 都是固定的數值
```c=
// version 1
for (int i = 0; i < 10; i++) {
out += (a + b);
}
```
那麼寫成下面的形式,對於程式的執行似乎來比較有效率?(減少了 9 次的加法成本)
```c=
// version 2
int tmp = a + b;
for (int i = 0; i < 10; i++) {
out += tmp;
}
```
事實上,對於聰明的編譯器來說,optimizer 會追蹤每個 phi 節點,因此它知道在 loop 內外有什麼被更動了,而甚麼沒有。因此,不勞程式撰寫者費心,需特別宣告 variable 來 cache 某個 constant,如果寫成 version 1 的可讀性更好,你當然可以那樣寫!
* 對於 loop 內部的 branch condition,optimizer 除會嘗試把條件分支移動到 loop 之外,還會針對每種情況創建獨立的 loop block
* 現代處理器的 [SIMD](https://en.wikipedia.org/wiki/SIMD) 架構提供更佳的運算效率。因此 optimizer 可以擅用 [loop vectorization](https://en.wikipedia.org/wiki/Automatic_vectorization#Loop-level_automatic_vectorization) 使用 SIMD 指令提升程式運行的效率
## When abstraction combined
不難想像,當 optimizer 需要最佳化所有上述的 abstraction 時,optimization 面臨巨大的挑戰。以下面的程式來說:
```c=
int f(int a, int b) {
int c;
g(a, b, c);
return a + b + c;
}
void g(int a, int b, int &c) {
c = a * b;
}
```
在這個程式中,因為 g 接受的參數 `c` 是一個 output parameter,這意味著是否 inline `g()` ,除了考慮 function 本身,也需要結合 memory 的資訊。
```c=
struct S {
float x, y, z;
double delta;
double compute();
};
double f() {
S s;
s.x = /* expensive compute */;
s.y = /* expensive compute */;
s.z = /* expensive compute */;
s.delta = s.x - s.y - s.z;
return s.compute();
}
```
上述的程式中也存在相同的問題,function `f` 設置 `struct S` 的 members,然後再呼叫 s.compute() 利用這些數值進行計算。由於 `s.compute()` 相當於是一個 class method,因此呼叫 `s.compute()` 涉及了對 `'this'` pointer 的操作,因此,function 本身隱含了 memory 的 abstraction。或許你可以考慮改寫成下面的形式。
```c=
struct S {
float x, y, z;
double delta;
};
double compute(S s);
double f() {
S s;
s.x = /* expensive compute */;
s.y = /* expensive compute */;
s.z = /* expensive compute */;
s.delta = s.x - s.y - s.z;
return compute(s);
}
```
這使得 optimizer 可以比較容易的進行最佳化。
## Conclusion
如講者 Chandler Carruth 所說,演講的題目之所以是 Understanding Compiler Optimization 而不是 How to write your C++ faster,是因為並不存在一個黃金準則,告訴你怎麼撰寫程式可以得到最佳的 performance。
然而,藉由瞭解 optimizer 的運作,我們可以更深入的思考程式與 optimization 的關係,預期自己的程式將被優化而成的樣子。
:::danger
因為我的英文不好加上實力也不好:cry:,其實感覺自己有很多地方都理解的很含糊,建議大家要自己看過演講的影片,如果有發現我有誤解的地方務必要糾正我~
:::