編譯優化
OI 界的常用編程語言是 C++。既然使用了這門語言,就註定要和編譯器、語言標準打交道了。眾所周知,C++ 非常混亂邪惡,本文旨在給出實用的編譯器相關知識,足夠競賽使用。
編譯器優化簡介
什麼是優化 (Optimization)
根據 如同規則(The as-if Rule),在保持語義不變的情況下,對程序運行速度、程序可執行文件大小作出改進。
常見的編譯器優化
常量摺疊 (Constant Folding)
常量摺疊,又稱常量傳播 (Constant Propagation),如果一個表達式可以確定為常量,在他的下一個定義 (Definition) 前,可以進行常量傳播。
1 2 3 4 5 | |
這段代碼在編譯期間即可被轉換為:
1 2 3 4 5 | |
實例:https://godbolt.org/z/oEfY35TTd
死代碼消除 (Deadcode Elimination)
故名思義,就是一段代碼沒用上就會被刪去。
1 2 3 4 5 6 | |
將被轉換為
1 | |
注意,這個代碼首先進行了常量摺疊,使得返回值可以確定為 234,a, b 為不活躍變量,因此刪除。
循環旋轉 (Loop Rotate)
將循環從 "for" 形式,轉換為 "do-while" 形式,前面再多加一個條件判斷。這個變換主要為其他變換做準備。
1 2 3 4 | |
變換為
1 2 3 4 5 6 7 | |
循環不變量外提 (Loop Invariant Code Motion)
基於別名分析 (Alias Analysis),將循環中被證明是不變量(可能包含內存訪問,load/store,因此依賴別名分析)的代碼外提出循環體,這樣可以讓循環體內部少一些代碼。
1 2 3 4 | |
這個代碼直觀來看可以外提為:
1 2 3 4 | |
但實際上,如果 n <= 0,這個循環永遠不會被進入,但我們又執行了一條多的指令(可能有副作用!)。因此,循環通常被 Rotate 為 do-while 形式,這樣可以方便插入一個 "loop guard"。之後再進行循環不變量外提。
1 2 3 4 5 6 7 | |
循環展開 (Loop Unroll)
循環包含循環體和各類分支語句,需要現代 CPU 進行一定的分支預測。直接把循環展開,用一定的代碼大小來換取運行時間。
1 2 3 | |
變換為:
1 2 3 | |
循環判斷外提 (Loop Unswitching)
循環判斷外提將循環中的條件式移到循環之外,然後在外部的兩個條件各放置兩個循環,這樣可以增加循環向量化、並行化的可能性(通常簡單循環更容易被向量化)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | |
代碼佈局優化 (Code Layout Optimizations)
程序在執行時,可以將執行的路徑分為冷熱路徑 (cold/hot path)。CPU 跳轉執行,絕大多數情況下沒有直接順序執行快,後者通常被編譯器作者稱為 "fallthrough"。與之對應的,經常被執行到的代碼成為熱代碼,與之相對的成為冷代碼。OI 代碼中,如果有一段是循環中的特判邊界條件,或者異常處理,類似的邏輯,則此部分代碼為冷代碼。
基本塊 (Basic Block),是控制流的基本結構,一個過程 (Procedure) 由若干個基本塊組成,形成一個有向圖。生成可執行文件的過程中,編譯器需要安排一個放置基本塊的佈局 (Layout),而如何編排佈局,是此優化的重點。
原則上,應該更偏好與將熱代碼放在一起,而將冷代碼隔開。原因是這樣能夠更好地利用指令緩存,熱代碼的局部性會更好。
1 2 3 4 5 6 | |
基本塊放置 (Basic Block Placement)
我們用 label 來表達一種「偽機器碼」,這個 C++ 程序有兩種翻譯方法:
佈局 1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
另一種佈局為:
佈局 2
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
我們看到後一種佈局中,兩個熱代碼塊被放到了一起,執行效率更優秀。
為了告訴編譯器分支是否容易被執行,可以使用 C++20 [[likely]] 和 [[unlikely]]:https://en.cppreference.com/w/cpp/language/attributes/likely
如果比賽沒有采用 C++20 以上標準,則可以利用 __builtin_expect(GNU Extension)。
1 2 3 4 5 6 | |
冷熱代碼分離 (Hot Cold Splitting)
一個過程 (Procedure) 包含同時包含冷熱路徑,而冷代碼較長,更好的做法是讓冷代碼作為函數調用,而不是阻斷熱路徑。這同時也提示我們不要自作聰明的讓所有函數 inline。冷代碼對執行速度的阻礙比函數調用要多得多。
不好的代碼佈局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
好的代碼佈局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
冷熱代碼分離,其實就是函數內聯 (Function Inlining) 的反向操作,這一優化的存在啓示我們,函數內聯不一定會讓程序跑的更快。甚至如果內聯代碼是冷代碼,反而會讓程序跑的更慢!一些編譯器存在強制內聯的編譯選項,但不推薦使用。編譯器內部有一個靜態分析過程,計算每個基本塊、分支的概率,以及一個函數調用相關的代價模型,以此決定是否內聯,自己決定是否內聯不一定比編譯器的決策好。
事實上,在沒有額外信息的情況下,編譯器通常會假設分支跳轉與不跳轉的概率一致,以此為依據傳播各個控制流路徑的冷熱程度。PGO (Profile Guided Optimization) 的一部分便是通過若干次性能測試與實驗得出真正環境下的程序分支概率,這些信息可以讓代碼佈局更加優秀。
函數內聯 (Function Inlining)
函數調用通常需要寄存器和棧傳遞參數,調用者 (caller) 和被調用者 (callee) 都需要保存一定的寄存器狀態,這個過程通常被叫做調用約定 (calling convention)。一個函數調用因此會引起一些時間損耗,而內聯函數就是指將函數直接寫在調用方過程中,不進行真正的函數調用。
1 2 3 4 5 6 | |
add() 可以被內聯到 foo() 當中:
1 2 3 4 | |
always_inline,__force_inline
https://clang.llvm.org/docs/AttributeReference.html#always-inline-force-inline
一些編譯器提供了手動內聯函數調用的方法,在函數前加 __attribute__((always_inline))。這樣使用不一定會比函數調用快,編譯器在這個時候相信程序員有足夠好的判斷能力。
尾調用優化 (Tail Call Optimization)
當一個函數調用位於函數體尾部的位置時,這種函數調用被成為尾調用 (Tail Call)。對於這種特殊形式的調用,可以進行一些特別的優化。絕大多數體系結構擁有 Frame Pointer (a.k.a FP) 和 Stack Pointer (a.k.a SP),維護者函數的調用幀 (Frame),而如果調用位於函數尾部,則我們可以不保留外層函數的調用記錄,直接用內層函數取代。
用跳轉指令代替函數調用
函數調用在絕大多數體系結構下,需要保存當前程序計數器 $pc 的位置,保存若干 caller saved register,以便回到現場。而尾調用不需要此過程,將被直接翻譯為跳轉指令,因為尾遞歸永遠不會返回到函數運行的位置。
一個簡單的例子:https://godbolt.org/z/e7b1safaW
1 2 3 | |
1 2 | |
自動尾遞歸改寫
如果一個函數的尾調用是自身,則此函數是尾遞歸的。廣義來講,間接遞歸(由兩個函數 以上共同形成遞歸)形成遞歸,且都是尾調用的,也屬於尾遞歸的範疇。尾遞歸可以被編譯器優化為非遞歸的形式,減小額外的棧開銷和函數調用代價。許多算法競賽選手熱衷於寫非遞歸的代碼,在不開優化下這樣可以極大優化代碼的常數,然而如果開優化,遞歸代碼生成的二進制質量和手寫的代碼沒有什麼區別。
1 2 3 4 | |
注意到這個函數並不是尾遞歸的,但可以改寫為:
1 2 3 4 | |
新的代碼即是尾遞歸的。
現代編譯器可以自動幫你完成這個過程,如果你的代碼有機會被改寫為尾遞歸,則編譯器可以識別出這種形式,然後完成改寫。
尾遞歸消除 -Rpass=tailcallelim
既然函數已經尾遞歸,那就可以直接刪除遞歸語句,通過一定的靜態分析,將函數直接轉換為非遞歸的形式。我們此處並不去深究編譯器作者如何做到這一點,從實際體驗來看,絕大多數 OI 代碼,如果存在遞歸版本和非遞歸版本,則此代碼一般可自動優化為非遞歸版本。這裏給讀者一些具體的例子:
GCD
1 | |
斐波那契數列
1 2 3 4 5 6 | |
階乘
1 2 3 4 5 | |
這些函數被優化後的彙編和非遞歸版完全相同,遞歸將被直接消除。對於 OI 選手而言,可以在開 O2 的情況下放心寫遞歸版本的各種算法,和非遞歸版不會有什麼區別。如果你寫的函數本身無法被改寫成非遞歸的形式,那麼編譯器也無能為力。
強度削減 (Strength Reduction)
常見的編譯優化。最簡單的例子是 x * 2 變為 x << 1,第二種寫法在 OI 中相當常見。編譯器會自動做類似的優化,在打開優化開關的情況下,x * 2 和 x << 1 是完全等價的。強度削減 (Strength Reduction) 將高開銷的指令轉換為低開銷的指令。
標量運算符變換
位運算代替乘法
1 2 3 | |
需要注意的是有符號數和無符號數在移位 (shifting) 和類型提升 (promotion) 層面有明顯的差異。符號位在移位時有着特別的處理,包括算術移位和邏輯移位兩種類型。這在編寫二分查找/線段樹等含有大量除二操作的時候表現突出,有符號整數除法不能直接優化為一步右移位運算。
1 2 3 4 5 6 7 8 9 | |
1 2 3 4 | |
可行的解決方案:
- 用
unsigned l, r;,下標本來就應該是無符號的 - 在源代碼中使用移位
乘法代替除法
1 | |
此過程可以被變換為 x = a * 0x55555556 >> 32,具體可以看 這篇知乎回答 或者 原始論文。
索引變量強度削減 (IndVars)
編譯器自動識別出循環中的索引變量,並將相關的高開銷過程轉換為低開銷
1 2 3 4 5 | |
此處如果直接使用 a = 3 * i 在 OI 中很常見,而編譯器可以自動分析出,等價的變換為 a = a + 3,用代價更低的加法代替乘法。分析循環變量的迭代過程,被稱為 SCEV (Scalar Evolution)。
SCEV 還可以做到優化一些循環:
1 2 3 4 5 6 7 | |
此函數會被優化為 \(O(1)\) 公式求和,參考 https://godbolt.org/z/ET8d89vvK。這個行為目前僅有基於 LLVM 的編譯器會出現,GCC 編譯器更加保守。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
自動向量化 (Auto-Vectorization)
單指令流多數據流是很好的提供單核並行的方法。使用這種指令,可以利用 CPU 的 SIMD 寄存器,比通用寄存器更寬,例如一次放 4 個整數然後計算。OI 選手不需要了解自動向量化的細節,通常而言,Clang 編譯器會做比 GCC 更激進的自動向量化:
1 2 3 4 5 6 | |
__restrict type specifier (GNU, MSVC)
兩個任意指針對應的區域可能出現重疊 (overlap),此時需要特判是否可以使用向量代碼。下圖展示了一個指針重疊的例子:

__restrict 作為一種約定使編譯器假定兩個指針所指向的內存區域永遠不會重疊。
1 2 3 4 5 | |
__restrict 並非 C++ 標準的一部分,但各大編譯器都可以使用。此關鍵字影響自動向量化的代碼生成質量,極端卡常的情況下可以使用。
和編譯優化相關的常見語言誤用
inline - 內聯
函數內聯在開 O2 的情況下通常由編譯器自動完成。結構體定義中的 inline 完全是多餘的,如果準備的比賽開 O2 優化,則完全不必聲明為內聯。如果不開 O2 則使用 inline 也不會讓編譯器真正內聯。
inline 關鍵字在現代 C++ 被當作是一種鏈接、與導出符號的語義行為,而不是做函數內聯。
register - 虛假的寄存器建議
現代編譯器會直接忽略你的 register 關鍵字,你自己認為的寄存器分配一般沒有編譯器直接跑寄存器分配算法來的聰明。此關鍵字於 C++11 被棄用,於 C++17 被刪除1。
https://en.cppreference.com/w/cpp/keyword/register
未定義行為(Undefined Behavior)與編譯優化
編譯器可以認為 C++ 程序不存在 未定義行為(undefined behavior,UB),因此在編譯存在 UB 的程序時,編譯器可能會產生意想不到的結果。同時,編譯器也可以在假定不存在 UB 的情況下進行更加激進而自由的優化。
常見的 UB 有:
- 有符號溢出;
- 使用未初始化的變量;
- 訪問越界;
- 空指針解引用;
- 無副作用的無限循環。
其他 UB 和示例等可通過擴展閲讀詳細瞭解。
有符號溢出
1 | |
編譯器可以假定程序不存在有符號溢出的行為,進而此函數可能被優化為
1 | |
示例:https://godbolt.org/z/WKv3W5hvM、https://godbolt.org/z/qqE9nxP1j。
可通過 -fwrapv 選項禁用該假設。示例:https://godbolt.org/z/5x3K5KGnr、https://godbolt.org/z/4r4a4EzMW。
使用未初始化的變量
1 2 3 4 5 6 | |
編譯器可以假定程序不存在使用未初始化變量的行為,所以 a 一定會被初始化,進而此函數可能被優化為
1 | |
示例:https://godbolt.org/z/8WYMYYjdG、https://godbolt.org/z/qvGd1nvv9。
訪問越界
1 2 3 4 5 6 7 8 9 | |
編譯器可以假定程序不存在訪問越界的行為,所以該函數一定會在發生訪問越界之前返回,進而此函數可能被優化為
1 | |
示例:https://godbolt.org/z/xfePeYsE3。
空指針解引用
1 2 3 4 5 6 7 | |
編譯器可以假定程序不存在空指針解引用的行為,從而 !p 恆為 false,進而此函數可能被優化為
1 | |
示例:https://godbolt.org/z/GY1jvsrb5、https://godbolt.org/z/4ronPsnxf。
無副作用的無限循環
驗證 Fermat 大定理
由 Fermat 大定理 可知,不定方程 \(a^3=b^3+c^3\) 沒有正整數解。下面的程序試圖枚舉 \([1,1000]\) 內的整數驗證該方程是否成立,若返回 true 則説明在 \([1,1000]\) 範圍內找到了一組整數解,從而 Fermat 大定理不成立。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | |
編譯器可以假定程序不存在無副作用的無限循環,從而認為 fermat() 函數中的 for 循環一定會在某一時刻終止並返回 true,最終程序可能輸出:
1 | |
示例:https://godbolt.org/z/d834MK7bz、https://godbolt.org/z/Eov9nsKqf。
Sanitizer
理智保證器。在運行時檢查你的程序是否有未定義行為、數組越界、空指針,等等功能。 在本地調試模式下,建議開啓一些 sanitizer,可以極大縮短你的 Debug 時間。這些 sanitizer 由 Google 開發,絕大多數可以在 GCC 和 Clang 中使用。sanitizer 在 LLVM 中更加成熟,因此推薦選手本地使用 Clang 編譯器進行相關除錯。
Address Sanitizer -fsanitize=address
https://clang.llvm.org/docs/AddressSanitizer.html
GCC 和 Clang 都支持這個 Sanitizer。包括如下檢查項:
- 越界
- 釋放後使用 (use-after-free)
- 返回後使用 (use-after-return)
- 重複釋放 (double-free)
- 內存泄漏 (memory-leaks)
- 離開作用域後使用 (use-after-scope)
應用這項檢查會讓你的程序慢 2x 左右。
Undefined Behavior Sanitizer -fsanitize=undefined
https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
Undefined Behavior Sanitizer (a.k.a UBSan) 用於檢查代碼中的未定義行為。GCC 和 Clang 都支持這個 Sanitizer。自動檢查你的程序有無未定義行為。UBSan 的檢查項目包括:
- 位運算溢出,例如 32 位整數左移 72 位
- 有符號整數溢出
- 浮點數轉換到整數數據溢出
UBSan 的檢查項可選,對程序的影響參考提供的網頁地址。
雜項
Compiler Explorer
在這裏觀察各個編譯器的行為和彙編代碼:https://godbolt.org
擴展閲讀
- The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #⅓
- The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #⅔
- The LLVM Project Blog: What Every C Programmer Should Know About Undefined Behavior #3/3
參考資料與註釋
本页面最近更新:,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:inclyc
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用