---
# System prepended metadata

title: Understanding Compiler Optimization
tags: [Optimization, ' 編譯器']

---

---
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:，其實感覺自己有很多地方都理解的很含糊，建議大家要自己看過演講的影片，如果有發現我有誤解的地方務必要糾正我~
:::