)
剛接手這個C項目的時候最讓我頭疼的倒不是代碼歷史包袱而是全量編譯一次要等足足四十分鐘。項目體量不算夸張真實業(yè)務(wù)代碼也就幾百個編譯單元但牽扯到Protobuf、Boost這類重型依賴每次clean build都像一次長途旅行。后來我把構(gòu)建緩存加速策略完整落地——本地用ccache兜底、跨機用sccache共享——全量編譯直接從40分鐘壓到5分鐘左右CI單次構(gòu)建的耗時也降到了原來的三分之一。這篇文章就把整個落地過程、背后的緩存原理、以及我在命中率排查中踩過的幾個典型坑完整記錄下來給同樣被C編譯速度折磨的讀者一份可以直接抄作業(yè)的參考。1. 四十分鐘的全量編譯這個項目到底慢在哪1.1 項目構(gòu)成與耗時現(xiàn)狀這個項目的依賴大致分三層。第一層是Protobuf、Boost這類第三方庫Boost頭文件模板展開的CPU開銷極大Protobuf生成的代碼文件動輒上千行單個編譯單元就非常重第二層是項目內(nèi)部的公共基礎(chǔ)模塊封裝了網(wǎng)絡(luò)、日志、線程池這些通用能力幾乎所有業(yè)務(wù)模塊都會include它們的頭文件第三層才是業(yè)務(wù)代碼。問題恰恰出在第三層和第二層的咬合關(guān)系上。業(yè)務(wù)代碼文件本身不大編譯不慢但只要有人動了某個公共頭文件哪怕只是加了一個空行、改了一處注釋下游幾百個文件全部要跟著重編。最初的現(xiàn)狀就是只改一個.cpp文件增量編譯幾秒完成改了一個公共頭文件構(gòu)建時間立刻被拉到十幾分鐘甚至更久。1.2 手動拆解編譯耗時的定位思路當(dāng)時我做了三件事來確認瓶頸到底在哪兒。第一觀察增量編譯行為不改任何代碼直接重新執(zhí)行編譯命令看構(gòu)建系統(tǒng)是否會老老實實得出無操作的結(jié)論如果構(gòu)建系統(tǒng)自己就傻傻地全量重編那問題出在構(gòu)建腳本的依賴關(guān)系上而不是編譯器本身。第二挑幾個耗時最長的編譯單元打開編譯器自帶的耗時報告從日志里找出真正吃CPU的翻譯單元。第三做一次對比實驗只改一個普通源文件記錄耗時再改一個被廣泛包含的頭文件記錄耗時。實測結(jié)果非常能說明問題單文件改動增量編譯在5秒內(nèi)完成公共頭文件改動則把增量編譯拉長到十幾分鐘。依賴關(guān)系和編譯器本身沒有問題真正的問題是被動重編太多。頭文件驅(qū)動是C的古老傳統(tǒng)當(dāng)項目規(guī)模上來之后這幾乎是每個團隊都會撞上的墻不是換個編譯器就能繞開的。1.3 為什么緩存能解決而不是靠改代碼既然成本集中在同樣的代碼被反復(fù)編譯最直接的思路就是讓編譯器少干重復(fù)勞動。構(gòu)建緩存加速正是沿著這個思路落地的——它把目標(biāo)文件.o和對應(yīng)的哈希鍵存下來下次遇到相同輸入時直接復(fù)用不再重新走一遍編譯全過程。這里必須澄清一個常見誤解緩存不是修改編譯行為而是在編譯過程中多了一道查表。它不改變編譯器版本、不改變優(yōu)化級別、不改變頭文件解析結(jié)果只是跳過已經(jīng)見過并且沒變過的編譯事務(wù)。所以它帶回來的收益是純粹的時間收益不引入任何代碼層面的風(fēng)險。理解了這一點后面遇到各種命中率問題時你才知道排查方向在哪里。2. ccache的核心機制它到底緩存了什么、又為什么失效2.1 和構(gòu)建系統(tǒng)增量編譯的本質(zhì)區(qū)別ccache的核心是一個鍵值緩存。它會對一個編譯單元做預(yù)處理隨后基于一系列輸入計算哈希鍵主要包括預(yù)處理后的源代碼內(nèi)容、編譯器版本號、編譯參數(shù)宏定義、include路徑、優(yōu)化級別等、相關(guān)環(huán)境變量以及被包含文件的內(nèi)容特征。它和構(gòu)建系統(tǒng)自帶增量編譯的最大區(qū)別在生命周期。增量編譯依賴文件時間戳和依賴關(guān)系一旦clean build或者CI機器上重新拉取代碼過去的依賴記錄全部歸零ccache則把結(jié)果持久化到文件系統(tǒng)緩存目錄里和源文件、構(gòu)建目錄的生命周期完全分離。哪怕把整個build目錄刪掉重新構(gòu)建只要源文件和編譯參數(shù)沒變ccache依然能命中。這一點在CI場景里價值巨大后面單獨展開說。還有一個容易被忽略的細節(jié)ccache不只緩存編譯好的目標(biāo)文件在direct模式下它還會緩存預(yù)處理結(jié)果。命中時連預(yù)處理的開銷都省了。對于Boost這類頭文件地獄預(yù)處理階段本身就占了不少時間這個節(jié)省相當(dāng)可觀。2.2 實戰(zhàn)中必須關(guān)注的核心參數(shù)ccache的默認參數(shù)有幾個在真實項目里需要立刻調(diào)整。第一個是CCACHE_DIR默認放在當(dāng)前用戶主目錄下的緩存路徑里。如果多個項目共用一臺開發(fā)機建議給不同項目指定獨立目錄避免緩存互相污染。第二個是CCACHE_MAXSIZE默認5GB對大型C項目經(jīng)常不夠我習(xí)慣調(diào)到20GB以上設(shè)太小會導(dǎo)致緩存頻繁被清理命中率肉眼可見地往下掉。第三個是CCACHE_BASEDIR這個參數(shù)極其關(guān)鍵。它做的是路徑歸一化把編譯時遇到的絕對路徑替換成相對路徑。不加它時同一份工程被clone到不同目錄哈希立刻不同緩存全部失效。加入之后路徑變化不影響命中。很多教程一筆帶過但真實項目里同事在不同路徑下開發(fā)、CI路徑和本地路徑不一致這些都是常態(tài)沒有這個參數(shù)命中率會非常慘。第四個是CCACHE_SLOPPINESS用于放寬某些場景下過嚴(yán)的一致性檢查。比如代碼里用了__TIME__、__DATE__這類時間宏不加放寬策略的話每次預(yù)處理結(jié)果都不同緩存永遠不會命中。這個細節(jié)我放在第四章的踩坑場景里細說。2.3 為什么先上ccache而不是直接上分布式編譯搜索編譯加速時經(jīng)常能看到ccache和distcc同時出現(xiàn)但兩者思路完全不同。distcc是把編譯任務(wù)分發(fā)到多臺機器并行執(zhí)行解決的是單機CPU不夠的問題ccache解決的是相同代碼被重復(fù)編譯的問題。一個花錢買CPU一個省掉無謂CPU兩者可以共存但不互斥。實際落地時我強烈建議先從ccache入手。它零硬編碼、風(fēng)險極低不依賴網(wǎng)絡(luò)和額外機器單臺機器立刻見效。distcc的收益高度依賴網(wǎng)絡(luò)延遲和機器數(shù)量配置復(fù)雜度大得多。如果單機命中率都不理想直接上distcc只會把問題放大到整個集群。3. 把ccache接入現(xiàn)有構(gòu)建流程CMake與Makefile兩條路3.1 CMake接入方式編譯器launcher現(xiàn)在多數(shù)新項目都在使用CMake接入ccache非常順手標(biāo)準(zhǔn)做法是把ccache作為編譯器的launcherfind_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM}) set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_PROGRAM}) endif()這段邏輯要放在project()聲明之后、各種add_library和add_executable目標(biāo)定義之前。也可以在運行cmake時直接用參數(shù)指定-DCMAKE_CXX_COMPILER_LAUNCHERccache。兩種方式效果相同區(qū)別在于前者寫入CMakeLists后對團隊所有人隱式生效后者只對當(dāng)前配置生效。接入之后不需要清理構(gòu)建目錄重新cmake配置一次再重新編譯即可。有一點需要留意如果構(gòu)建目錄是從舊配置復(fù)制過來的建議檢查一下CMakeCache.txt里的編譯器路徑確認launcher確實被寫進去了避免出現(xiàn)以為自己裝了ccache其實編譯命令完全沒經(jīng)過它的尷尬。3.2 傳統(tǒng)Makefile接入方式還在用純Makefile的老項目接入方式更直白在Makefile里把編譯器命令前面加上ccacheCXX ? ccache g CC ? ccache gcc這里有一個隱藏的坑如果項目里某些規(guī)則是直接調(diào)用$(CXX)編譯但有人在命令行末尾追加CXXg這種覆蓋就會繞過ccache緩存不生效還不容易察覺。排查的時候如果發(fā)現(xiàn)某臺機器命中率一直為零先看有沒有人對CXX變量做過手腳。Makefile場景還有一個比緩存更底層的問題依賴關(guān)系缺失。ccache判斷的是這次要編譯的結(jié)果和上次是否相同但如果Makefile沒有把頭文件寫進依賴那么頭文件改了也會被make誤判為無變化而跳過編譯。這不是緩存的問題是構(gòu)建系統(tǒng)依賴不完整的問題。遇到改了頭文件卻不重編先查依賴聲明別急著懷疑緩存工具。3.3 如何驗證緩存真的在工作接入后不要急著看總耗時先做冷靜的驗證。最簡單的做法是連續(xù)編譯兩次第二次接近秒級完成說明命中生效。更嚴(yán)謹(jǐn)?shù)姆绞绞怯胏cache自帶的狀態(tài)命令ccache -s輸出里有hits、misses、uncacheable等指標(biāo)。第一次全量編譯后misses會增加第二次再編譯同一個目標(biāo)hits應(yīng)該明顯增長。想進一步確認每個編譯單元走了哪條路徑可以設(shè)置CCACHE_LOGFILE打開具體命中日志。我個人的習(xí)慣是接入后第一周每天看一眼ccache -s關(guān)注cache size和hits的比值。如果連續(xù)幾天命中率穩(wěn)定低于50%基本可以判斷存在系統(tǒng)性因素在搗亂而不是偶然的代碼改動導(dǎo)致需要按第四章的思路做一次系統(tǒng)排查了。4. 命中率被砍半的排查實錄五個典型失效場景4.1 調(diào)試符號攜帶的絕對路徑接入ccache之后本機第一次全量編譯非常順利但第二天CI報告命中率只有三成。我第一反應(yīng)是CI和本地編譯器版本不一致逐一核對后發(fā)現(xiàn)版本相同、構(gòu)建參數(shù)也相同。繼續(xù)深挖問題出在調(diào)試符號上。當(dāng)編譯參數(shù)里帶-g或-ggdb時目標(biāo)文件里會記錄源文件的絕對路徑。默認情況下ccache認為路徑變化會導(dǎo)致產(chǎn)物變化于是鍵也跟著變。解決辦法就是設(shè)置CCACHE_BASEDIR指向工程根目錄同時盡量讓include參數(shù)使用相對路徑如果CMake里配置了絕對根目錄最好配合-fdebug-prefix-map之類的選項把源碼路徑統(tǒng)一成固定前綴。這個坑非常隱蔽因為沒接緩存時根本不會有人關(guān)心目標(biāo)文件里記錄的路徑。4.2 時間宏和文件宏導(dǎo)致的白白失效另一個讓我查了很久的問題是某個模塊怎么編譯都是miss其他模塊卻一切正常。最后發(fā)現(xiàn)該模塊有一段日志代碼用了__TIME__宏記錄構(gòu)建時刻另一處又用__FILE__保存源文件名。這些內(nèi)置宏的值會隨著當(dāng)前時間和編譯單元所在路徑變化每次預(yù)處理結(jié)果都不同ccache自然認為輸入變了。常規(guī)解法是設(shè)置CCACHE_SLOPPINESStime_macros,file_macro告訴ccache忽略這兩類宏對鍵的影響。注意這個放寬只有在確認代碼沒有依賴這些宏實現(xiàn)強制重建時才建議打開。如果代碼確實把構(gòu)建時間當(dāng)版本號用那應(yīng)該改成顯式傳入編譯期版本號而不是依賴__TIME__。4.3 公共頭文件引發(fā)的大面積連鎖失效還有一種場景更讓人無奈命中率報告一切正常但那段時間每天都是清一色的miss。排查下來不是緩存配置有問題而是團隊幾乎每天都會改動一個被全項目包含的公共頭文件。這個頭文件一變下游幾百個編譯單元的哈希全會變哪怕只改了一行注釋。這種情況緩存本身無解它只是暴露了頭文件依賴結(jié)構(gòu)不健康。我的處理辦法是把公共頭文件里頻繁變化的結(jié)構(gòu)定義拆出去靜態(tài)穩(wěn)定的接口單獨放一個頭文件再用前向聲明減少include傳遞。結(jié)構(gòu)優(yōu)化之后公共頭文件的變更頻率下降cache命中率自然回升。這個經(jīng)歷讓我意識到緩存不是銀彈它甚至可以說是工程健康度的試金石。4.4 編譯器升級與構(gòu)建參數(shù)不一致編譯器升級對緩存的影響最好理解。GCC從舊版本升到新版本后即使源碼相同生成的機器碼也可能不同ccache的鍵里帶著編譯器身份信息所以升級后歷史緩存全部作廢。這是正確行為不是bug。真正的坑在于團隊開發(fā)環(huán)境不統(tǒng)一。有人本地是某個版本編譯器CI上跑另一個版本大家共享一個緩存目錄時彼此的命中率都會被拉低。我的建議是團隊固定編譯器主版本號至少讓本機和CI保持一致確實沒法統(tǒng)一的就在緩存目錄設(shè)計上按編譯器版本分目錄管理。CI流水線的緩存key也必須把編譯器版本放進去否則會出現(xiàn)不可控的緩存污染。4.5 用ccache狀態(tài)數(shù)據(jù)做定位排查這些坑的過程我總結(jié)了一套實用路徑。先跑ccache -s看整體比例如果miss偏高再開CCACHE_LOGFILE把日志拉出來日志里會寫明每個編譯單元的miss原因比如某個需要的文件缺失、檢測到不同編譯器、編譯選項發(fā)生變化等等。這套方法很像做性能分析時的熱點定位先看全局指標(biāo)再逐層下鉆到具體編譯單元不要靠猜。有一次我懷疑是編譯參數(shù)寫錯了盯著CMake排查了半天結(jié)果看了日志才發(fā)現(xiàn)是共享機器上的環(huán)境變量被改動過CCACHE_DIR被指到了別處。日志能直接告訴你答案比你反復(fù)驗證配置快得多。5. 讓緩存跨越機器邊界sccache與分布式緩存5.1 單機緩存在團隊場景下的天花板ccache很好用但它有一個天然的邊界緩存只存在于本機。個人開發(fā)環(huán)境下完全夠用到了團隊協(xié)作尤其是CI場景每一臺負責(zé)構(gòu)建的機器環(huán)境都是新的緩存冷得一塌糊涂。要么每次CI都從頭編譯要么額外寫腳本把緩存目錄打包上傳下載。打包方案能解燃眉之急但緩存大到幾十GB時光壓縮和傳輸?shù)臅r間就夠重新編譯一輪了。這正是我引入分布式緩存的原因。分布式緩存把編譯產(chǎn)物放到遠端存儲所有機器共享同一份鍵值狀態(tài)。任何一臺機器編譯過某個編譯單元其他機器都能直接命中不再各自為戰(zhàn)。5.2 sccache接入與存儲后端配置我選擇sccache是因為它和ccache的接入方式幾乎一致遷移成本很低。在CMake里同樣用CMAKE_CXX_COMPILER_LAUNCHERsccache即可。它默認也能用本地目錄做存儲但如果只是本地存儲和ccache的差異不大真正拉開差距的是它支持把緩存放到遠端對象存儲里讓所有機器共享。以對象存儲為例核心配置是三個環(huán)境變量export SCCACHE_BUCKET你的桶名 export SCCACHE_REGION你的地域 export SCCACHE_ENDPOINT你的服務(wù)地址sccache處理流程是編譯命令先查遠端緩存miss了就在本地編譯再把結(jié)果推上去。對CI流水線來說這臺機器編譯過的內(nèi)容下一臺機器直接拿到結(jié)果整體構(gòu)建時間從每次冷啟動變成只有新增改動才需要編譯。5.3 分布式緩存的一致性、延遲與體積問題上分布式緩存之后實際遇到的問題有三類。第一類是延遲遠端存儲每次查詢都有網(wǎng)絡(luò)往返如果大量文件首次進入緩存首次構(gòu)建反而比純本地編譯更慢。解決思路是本地緩存和遠端緩存串聯(lián)使用本地命中優(yōu)先miss時才訪問遠端。sccache實際就維護了這兩個層次。第二類是一致性多臺機器同時編譯同一個模塊并寫入同一個緩存鍵原則上要求遠端存儲支持原子寫入。我在常見對象存儲上沒遇到過問題但如果用自建存儲且沒有鎖機制偶爾會讀到文件寫到一半的異常。穩(wěn)妥的做法是選帶校驗?zāi)芰Φ拇鎯蠖嘶蛘呓o緩存對象加一層完整性校驗。第三類是體積分布式緩存的膨脹速度比本地更快因為所有分支、所有歷史版本的產(chǎn)物都堆積在遠端。必須設(shè)置生命周期策略定期清理或者按編譯選項維度分層存放不能放任不管。6. 緩存之外的組合拳預(yù)編譯頭文件、unity build與增量編譯6.1 預(yù)編譯頭文件與緩存的疊加效果ccache命中之后編譯時間已經(jīng)縮短大半但它仍然要走一遍預(yù)處理和詞法解析。如果項目的公共頭文件極重可以疊加預(yù)編譯頭文件PCH。PCH把穩(wěn)定的公共頭文件一次解析并序列化之后每個編譯單元復(fù)用解析結(jié)果不再重復(fù)解析。不過PCH和緩存結(jié)合的時候要注意PCH本身也是一個文件它的生成過程可以被ccache緩存但不同編譯單元之間復(fù)用的PCH路徑必須一致。如果每個編譯單元都生成前綴不同的PCH反而會把緩存鍵搞得一團亂。我在這上面踩過一次最后是把PCH作為獨立的構(gòu)建目標(biāo)統(tǒng)一路徑再讓ccache接管PCH生成收益才真正顯現(xiàn)出來。6.2 unity build的加速邏輯與取舍unity build的做法是把多個.cpp合并成一個大的編譯單元來編譯減少編譯器進程啟動次數(shù)和公共頭文件的重復(fù)解析。在純本地構(gòu)建場景里效果立竿見影單次全量編譯時間能再壓一截。但它和緩存的配合要仔細權(quán)衡。unity build會讓單個編譯單元變得很大只要其中一個源文件有改動整個合并單元都要重編緩存的命中粒度也會變粗。我建議在追求快速全量出包的場景嘗試unity build但在以增量小改動為主的日常開發(fā)循環(huán)里保持常規(guī)編譯單元粒度反而更有利于緩存命中。兩者間的平衡點取決于團隊是偶爾全量重編還是長期反復(fù)小改。6.3 在CI流水線里設(shè)計緩存生命周期分布式緩存引入之后CI流水線還有最后一件事要處理緩存的生命周期。我給緩存key設(shè)定的維度包括分支名、編譯器版本、構(gòu)建參數(shù)集合的哈希、第三方依賴鎖定文件的哈希。這樣既能保證不同編譯環(huán)境不互相污染又能讓同一分支的穩(wěn)定構(gòu)建共享緩存。清理策略上我每兩周對遠端存儲做一次增量清理保留近期內(nèi)有命中的鍵把其余舊鍵刪除。不要設(shè)全部分支永久保留大型C項目的緩存是按GB級別上漲的不加策略很快會變成新的運維負擔(dān)。緩存的價值在于命中率而不在于囤積量。最后說一點個人體會。構(gòu)建緩存加速不是銀彈它在C項目里的意義是把沒必要的重復(fù)編譯時間從交付路徑里抽走。真正決定構(gòu)建上限的仍然是依賴結(jié)構(gòu)、編譯單元粒度和工程整體的健康度。我在這個項目上經(jīng)歷從40分鐘到5分鐘的轉(zhuǎn)變之后最大的感觸是緩存工具帶來的數(shù)字變化很誘人但更值得珍惜的是它逼著我把項目的依賴邊界重新梳理了一遍。如果你正在被全量編譯折磨建議按這個順序動手先接ccache把單機命中率調(diào)到八成以上再用分布式緩存打通團隊共享和CI最后才考慮PCH和unity build這類結(jié)構(gòu)性改動。不要一上來就貪多每個環(huán)節(jié)確認命中率真的上來之后再往前走不然最后連問題出在哪都分不清。