Clang 的編譯——面向 Clang Static Analyzer 開發的編譯配置指北

隨着時代的發展,LLVM 項目已經變得越來越大了。代碼越來越多,越來越複雜,於是也變得越來越不好編譯了。從最開始做作業時第一次嘗試編譯 Clang 3.7 開始,到現在已在 LLVM 項目中有過幾個 commit,最直觀的感受就是編譯的時間以及消耗的內存越來越多。以至於在編譯時直接大手一揮地 make all && make check-all 已經成爲了一種奢侈,也就只在實驗室的 256 核 2TB 內存的服務器上纔敢這麼做,而日常開發用的服務器也已經經過過若干次添加內存而達到了 256GB 之巨的容量。於是,便想到要去折騰一下配置和編譯的流程,以便能讓自己將更多的精力投入到開發中,而不是一邊等編譯一邊刷手機。

正巧,最近在 LLVM 的項目週報中翻到了一本新書以及兩篇新文章都有涉及相關內容,於是便去研讀了一下。在借鑑了這些文字中提及的內容之後,找到了一個更適合自己現在開發工作的配置。先放出我 CMake 時的參數配置,後面再細細說。

cmake ../llvm \
    -G Ninja \
    -DLLVM_ENABLE_PROJECTS=clang \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
    -DLLVM_TARGETS_TO_BUILD=X86 \
    -DLLVM_USE_SPLIT_DWARF=ON \
    -DBUILD_SHARED_LIBS=ON \
    -DLLVM_OPTIMIZED_TABLEGEN=ON \
    -DLLVM_ENABLE_Z3_SOLVER=ON \
    -DCLANG_ENABLE_ARCMT=OFF \
    -DCMAKE_C_COMPILER=clang \
    -DCMAKE_C_FLAGS=-fno-limit-debug-info \
    -DCMAKE_CXX_COMPILER=clang++ \
    -DCMAKE_CXX_FLAGS=-fno-limit-debug-info \
    -DLLVM_USE_LINKER=lld

作爲一個 Clang Static Analyzer (CSA) 相關的開發者,日常會使用到的 LLVM 組件也僅有三個:clangclang-check 以及 clang-extdef-mapping。而且,clang-extdef-mapping 更多的是作爲工具使用而非去修改它的代碼。因此,對於提升編譯效率,我首先想到的便是只編譯這三個組件。然而,自己 naive 的想法并沒有得到多大的優化。作爲開發者,編譯操作往往不是針對所有代碼的,往往只會有數個文件需要編譯。此時,鏈接 clang 便成了整個編譯過程中最耗時的步驟。此外,在 GNU Make 在編譯的過程中每次都會觸發 TableGen 的重新生成。而這一部分的耗時相比於每個需要被重新編譯的源代碼來説,也是不可小覷的。因此,本次介紹的編譯配置主要針對這些問題。

首先,在開發過程中所必須的功能參數如下。這些參數是根據我這邊開發的需求來配置的,因此並不一定適用於所有人。這裏也不做過多介紹,只是簡單列舉一下。

    • -DLLVM_ENABLE_PROJECTS=clang:啓用 clang 子項目。這個是編譯 CSA 所必須的。
    • -DCMAKE_EXPORT_COMPILE_COMMANDS=ON:導出 compilation database 來給 clangd 使用。主要用於代碼補全和定義引用查找。
    • -DENABLE_z3_SOLVER=ON:啓用 Z3 求解器。主要用於 CSA 中的路徑約束求解。

剩下的參數,便是在此之上對於編譯效率優化的參數了。介紹的順序是依照我添加到配置中的時間先後來排序的。

-DLLVM_TARGETS_TO_BUILD=X86,僅編譯 X86 後端的部分。LLVM 支持交叉編譯,但這一特性對於 CSA 開發來説并不是必須的。因此,這裏僅僅啓用了 X86 這一種 target 來避免編譯過多不需要的其他代碼。這個 target 會默認同時啓用 32 位和 64 位的後端代碼。這裏需要注意一下,因爲我這邊開發用的機器是 x86_64 架構的,因此是這樣配置的。若開發平臺為其他架構,則需要調整這裏的參數配置。

-DLLVM_USE_SPLIT_DWARF=ON,拆分 debug 信息與可執行程序。這個配置會在編譯時添加 -gsplit-dwarf 參數,將 debug 信息生成到額外的 dwo 文件中。由於鏈接時不再需要將 debug 信息拷貝到生成的二進制程序中,因而可以減小鏈接時的内存開銷並同時減小編譯完成之後的磁盤空間占用。

-DLLVM_OPTIMIZED_TABLEGEN=ON,對 llvm-tblgenclang-tblgen 程序使用編譯優化。由於 TableGen 後端并不是開發 CSA 時所需要 debug 的部分,通過這個參數可以在 build 目錄下的 NATIVE 目錄中配置一個 release 編譯的環境,並在此環境中編譯這兩個程序。這樣,可以更迅速地完成 TableGen 的部分。但缺點是會在編譯過程中再一次出現 CMake 配置過程,并且編譯這兩個程序的進度條是另算的,因此不建議有强迫症的同學使用。

-DCLANG_ENABLE_ARCMT=OFF,禁用 ARCMT。這一部分主要與 Objective-C 相關,且 CSA 中并不涉及相關内容,因此將其禁用。這部分我的瞭解並不多,但禁用之後沒有發現過多問題,因而這個選項就保留了下來。

-G Ninja,默認使用 Ninja 替代 GNU Make 來控制構建過程。Ninja 可以預先規劃所有的構建指令,並僅執行編譯過程中需要被執行的步驟,從而可以避免在使用 GNU Make 時遇到的即使該步驟無事可做卻亦會被檢視一遍而造成效率低下的問題。這一點的優化主要體現在增量編譯的過程中。當修改了一個源代碼文件之後,僅有相關的編譯和鏈接任務會被 Ninja 激活並重新被執行。但若是使用 GNU Make 的話,則依舊會重新遍歷所有子文件夾中的全部任務,也因此造成了效率的低下。這裏還要說一下這兩個工具最大的不同:控制臺輸出的不同。Ninja 的編譯進度為任務個數,新的一條 log 會覆蓋掉之前一條,不會造成刷屏。而 GNU Make 的進度為百分比,新的一條 log 會換行輸出,因此一次增量編譯時可能會無法找到被執行了的編譯指令,需要向上滾屏纔能找到。

-DLLVM_USE_LINKER=lld,使用 lld 或其他非 BFD 鏈接器來鏈接二進制程序。這裏所説的 BFD 鏈接器我并沒有具體去查它的概念,因此也無法做過多原理方面的解釋。但這一條是若干 blog 以及書上都予以推薦的,而個人測試效果確實也可以實現加速,因此就採用了這一配置。書上提到的鏈接器包括 GNU 的 Gold 以及 LLVM 的 LLD,個人使用體驗感覺 LLD 會更快一些,因此也就採用了這個配置。

-DCMAKE_C_COMPILER=clang-DCMAKE_CXX_COMPILER=clang++,使用 Clang 來替代 GCC 編譯代碼。LLVM 的 buildbot 是使用 CCCXX 兩個環境變量來設置編譯器的,但我這裏給出的配置是使用 CMake 的編譯器配置變量來做這件事。個人認爲,採用這種方式為 CMake 設置編譯器導致出問題的概率會更低。此外,在使用 Clang 編譯代碼時,會導致 libstdc++ 的 pretty print 信息無法被 GDB 和 LLDB 正確加載,從而導致無法打印 STL 容器的問題。添加 -fno-limit-debug-info 參數可以有效解決這一問題。若還不能解決,則可以使用 STL 的 Debug Mode 來實現打印 STL 容器的功能(編譯時定義 _GLIBCXX_DEBUG 宏即可啓用),但這也會導致一定程度上的效率低下的問題。若介意相關問題,則可以依舊採用 GCC 來編譯代碼。

最後,-DBUILD_SHARED_LIBS=ON,將每個組件的鏈接庫生成為動態鏈接庫。這個參數也是我猶豫再三之後最後啓用的一個選項。請注意這個參數與 a 參數的不同:我這裏啓用的這個參數是每一個組件生成一個獨立的動態鏈接庫,而 s 選項則會為 LLVM 和 Clang 各生成一個動態鏈接庫(共計兩個文件)。啓用這個選項會帶來一利一弊。這個選項的優勢在於可以讓 clang 以及 clang-check 這些程序在連接時不用像靜態鏈接那樣寫入大量的磁盤文件,從而使得編譯過程變得輕鬆迅速。但劣勢在於 debug 時,每次重新運行程序都會需要大量的時間來加載各個動態鏈接庫以及其中的 debug 信息。因此,更適宜採用日志 debug 的方法,以及選擇性地在 debug 時手動加載所需的動態鏈接庫的 debug 信息。後者需要對於每一個函數所在的庫有比較深刻的瞭解,同時在啓動 gdb 時也需要一些代碼來手動處理相關事項。關於這一點,我會在後續的其他文章中詳細介紹,更新之後我會附在本文的最後。

實驗我就不放了,其他人的 blog 中已經有很多了。我這裏推薦的這份配置也只是其他人的推薦的總和之中進行的篩選。還是那句話,it works for me。最近大家好像都很熱衷於編譯 LLVM 的競速比賽,有條件的同學也可以自己試試。

這些就是我在編譯 Clang 時所採用的全部配置項,其主要目的就是提高編譯速度。之所以會這麽着急,主要的原因是日常會擔心自己畢業不能。於是也就只能節約時間來做更多的事情。組裏的工具要維護、自己的研究也還要做、LLVM 的 revision 還要繼續提……我也日常在思考,爲什麽別人讀書都這麽輕鬆,而自己從小一直都是媽咪同事的孩子之中讀書最累的那一個。大概,是因爲我是所有人之中最笨的那一個吧……

以上


參考文獻:

    1. LLVM Techniques, Tips, and Best Practices Clang and Middle-End Libraries(https://www.oreilly.com/library/view/llvm-techniques-tips/9781838824952/
    2. Speedbuilding LLVM/Clang in 5 minutes(https://www.cambus.net/speedbuilding-llvm-clang-in-5-minutes/
    3. Speedbuilding LLVM/Clang in 2 minutes on ARM(https://www.cambus.net/speedbuilding-llvm-clang-in-2-minutes-on-arm/

更新:210620

最近發現選項 BUILD_SHARED_LIBS 能帶來的最大收益是鏈接十分迅速。尤其是在 commit 了之後,需要重新鏈接所有庫的版本信息時。幾十個鏈接操作可以在幾秒鐘之内完成,就很贊。但是需要注意的是,由於鏈接迅速,但是 debug 時加載緩慢,因此 debug 操作到底是打印 log 來一點點看還是用 GDB 來調試就成了一個很重要的選擇。因此需要好好權衡,該調試的時候還是最好去調試,要不然反而會白白誤了時間。