編譯是靜態語言不可避免的步驟。對於開發者而言,編譯是個又愛又恨的東西,好處是他可以幫助在編譯時期找出部分的錯誤又可以幫忙最佳化,但是壞處則是編譯要時間,當專案越來越大時,小小改個檔案可能就要花數分鐘去等編譯。
隨著 C++ 的發展,現在 modern C++ 如 C++14, 17 等等,新增了更多方式讓開發者在編譯時期完成更多事情,比如說更方便的if contexpr
等等功能。而這其實也是被鼓勵的,因為能在編譯時期就處理完的話就可以讓 runtime 執行得更快!
但當大量使用 template 或引用更多的 library 也讓 compiler 的工作越來越多,而如果每改幾行就要等待編譯幾分鐘才能知道執行結果的話,對於一天要編譯數百次的開發者而言實在是太浪費生命了。
本篇文章就要來探討各種加速 C++ Compile Time 的方式,大部分的方法都是 Stack Overflow 搜刮來,然後由我自行實測。測試環境如下:
- Ubuntu 18.04 LTS
- GCC 9
- CMake 3.23
- Ninja 1.8
- Project LOC ~20k
Use ccache
引入 ccache 絕對是效益最高的加速方式,完全不用改程式就可以減少大量的編譯時間。ccache 是一個全域的 compiler cache,藉由快取編譯的中繼檔來節省重新編譯的時間。安裝好以後只要在 CMakeLists.txt
中加入:
1 | # CMakeLists.txt |
即可使用ccache
,如果專案沒使用 build tools 的話,則是直接在gcc
指令前加上ccache
1 | # before |
使用 ccache 之後整體編譯速度大約可以提升兩倍以上,十分讚!
Use forward declaration as more as possible
C++ 的 #include
關鍵字其實就是複製貼上,所以當你在 A.h include 了 B.h,在預處理階段編譯器會把 B.h 內容複製到 A.h,而如果不幸的 B.h 又 include 一堆檔案,那也會通通展開。所以如果引用太多檔案,除了會造成預處理之後檔案肥大以外,也會造成檔案之間相依性混亂,間接導致每次編譯要重新編譯不必要的檔案。
除了將沒用的 include 清乾淨以外,還可以更激進的避免在 header include 東西,那就是利用 forward declaration。
試想以上情境,當你變更 A.h 時,A B C 都必須重新編譯,因為內容改變了,但實際上 C 並未使用到 A,其實應該可以避免重新編譯 C。
由於 C 會重新編譯是因為 B.h 內容改變了,而 B.h 內容改變的原因則是因為 A.h 更新了。這時候可以檢視為甚麼 B.h 需要引用 A.h,看看是否可以避免引用。
1 | /// B.h |
以上是常見的使用情境,B 存了一個 class A 的參考A& a
。
我們可以改寫成這樣,將 include 移至 B.cpp 實作檔中。這是因為A&
, A*
等這類東西的大小是固定的,所以在定義時不需要知道實際 class A 的大小,只需先告知 compiler 有這個 class 即可。
1 | /// B.h |
如此一來,當你變更 A.h 時,B.h 內容並不會改變,也就不會觸發 C 需要重新編譯拉,可喜可賀~
在大量使用這個技巧以後,我所測試的專案進步幅度也是非常明顯,更動 A.h 原本會牽動 54 個檔案需要重編譯,改完以後則只會牽動 29 個檔案,自然編譯速度也就變快了。
1 | # before use fwd v.s. after use fwd |
Unity Build
Unity build 又稱 Jumbo build, Mega build,其原理是透過將*.cpp
彙整成一個all.cpp
再一起執行編譯,這樣就是省下 N 個檔案的編譯時間 (具體而言是省下如 template 展開等原本每個 Translate Unit 都要做的事情)。
CMake v3.16 開始就支援 Unity Build 的設定,他支援將 batch size 個檔案先匯總成all_x.cpp
之後再進行編譯。
不過這方法會遇到一些問題,由於這方法之原理說白了就是cat *.cpp > all.cpp
如此暴力,如果專案本身常常使用全域變數的話,這會很容易導致 ODR (One definition rule) 錯誤。所以也有可能不容易引入 Unity Build。
這個技巧我認為也是 CP 值十分之高的方法,幾乎不用改程式 (如果專案用太多全域變數就要改很多😅) 卻可以獲得大幅的進步。我測試的結果如下,可以看到無論是 incremental build 還是 clean build 都取得 50% 以上的進步。
1 | # w/o unity build v.s. with unity build (batch_size=8) |
Better linker
編譯的最後階段是 linking,這部分可以替換成比較厲害的 linker,市面上目前有三種較有名的 linker
- ld (gcc default)
- gold
- lld
要替換使用 linker 只需要在 compile flag 加上fuse-ld=<linker_name>
即可。詳細可參考 gcc document。而我實測不同 linker 表現如下,
1 | # rebuild using single thread, unit in second |
使用更強的 linker 雖然使 linking time 進步許多,但對整個專案的 compile time 而言其實佔比不是很大,相較於前面幾個章節算是進步較小的技巧。(但 CP 值也是很高,只要改一個 compile flag)
Disable var-tracking for huge variable object
我們可以透過 gcc flag -ftime-report
來剖析編譯各個階段的耗時,然後針對各個耗時大的改善。
我測試的專案中,有一個 auto-generate 的unordered_map
,該檔案動輒數萬行,每次編譯該檔案都會成為瓶頸。從-ftime-report
得知編譯該檔案耗時最大的部分是 var-tracking,var-tracking 是讓 debug info 有更多資訊的功能,但當專案中有巨大的變數時,這會讓 compiling 速度大幅變慢。
在對我那個數萬行的unordered_map
檔案拿掉 var-tracking 之後 (針對該檔案加上一個-fno-var-tracking
flag) 結果如下,
1 | # gcc -ftime-report auto_gen.cpp |
結果是從原本耗時 134 秒降低至耗時 55 秒,減少超過 50% 的時間。這也使得該檔案不會再是整個專案的瓶頸。
Summary
本文嘗試了許多技巧來加速編譯所需的時間,總結各點如下列:
- Use
ccache
[big improvement] - Use forward declaration as more as possible [big improvement]
- Unity Build [big improvement]
- Use LLVM linker [good improvement]
- Disable var-tracking for huge variable object [good improvement]
- Pre-compiled headers [no improvement]
- Explicit template instantiation [no improvement]
在爬文時網友提及 pre-compiled headers 以及 explicit (extern) template 也對減少編譯時間有幫助,但實測並未有顯著差異,故本文未提及,也許實際上是有用只是剛好不適用於我的環境之類的。