我們有一個夢想,讓每一名研發工程師擁有一臺“超級”計算機。
作者:字節跳動終端技術——孫雄
大型工程的效率瓶頸
近年來,基於Devops流水線的研發流程,逐漸成爲軟件研發的行業標準。流水線的運行效率,決定了團隊的研發效能。對大型項目來說,編譯構建往往是流水線中耗時佔比的大頭。有些工程的編譯時長超過30分鐘,甚至達到幾個小時。這樣的性能,是非常糟糕的。
字節iOS大型項目的構建時長,大多控制在5分鐘以內。這主要得益於內部的編譯加速解決方案,它集分佈式編譯和分佈式緩存爲一體,本文將詳細介紹它的工作原理。不過在這之前,我們先來分析一下大型項目的編譯瓶頸和解決思路。
先說結論,機器性能不足和重複作業,是影響工程編譯效率的兩個最大因素,對此,可以採取分佈式編譯+編譯緩存的方式,提升整體的性能。
分佈式編譯
工程的編譯,往往可以拆解爲可並行的編譯子任務。以C系列語言(C
, C++
, ObjC
)爲例,項目中往往存在上千甚至上萬的源代碼文件(以 .c
, .cc
或 .m
作爲擴展名的文件),每個編譯子任務將源代碼文件編譯爲目標文件(以 .o
作爲擴展名的文件),再整體鏈接成最終的可執行文件。
這些編譯子任務可以並行執行,如下圖所示:
CPU的數量,決定了編譯的並行度上限。個人電腦(PC)的CPU核心數通常在4~12之間,專用服務器可以達到24~96,但對於動輒上萬文件的大型工程,CPU的數量還是顯得不足。這時候,利用分佈式編譯的技術,可以得到一臺“超級計算機”。
編譯緩存
大型工程全量編譯,需要處理幾千甚至幾萬個編譯子任務。但大多數子任務,之前已經編譯過,如果我們能通過某種方式,直接獲取編譯產物,就可以大大節省時間。
建立一箇中央倉庫,存儲編譯子任務的產物,這些產物可以通過“任務摘要”來索引。這樣每次遇到一個新任務,我們首先向中央倉庫查詢摘要,如果查詢成功,直接下載編譯產物,就省去了重複編譯的動作。
上面提到的分佈式編譯和編譯緩存,是提升大型項目編譯效率的兩大法寶,本文主要介紹字節跳動的分佈式編譯解決方案。
“超級”計算機
藉助雲計算,我們可以以組裝的方式,得到一臺“超級”計算機,如下圖所示:
這臺“超級”計算機,由一臺中心節點和若干臺工作節點組成。中心節點負責生成和調度編譯子任務,依照它們的執行順序,將任務發送給空閒的工作節點來執行。這樣整個系統的並行處理能力,取決於所有工作節點的CPU之和,性能比單機高出數倍,甚至數十倍。
像這樣把任務分發給工作節點的方案,又稱爲分佈式編譯。分佈式編譯並不是新鮮的概念,2008年開源的distcc工具就提供了分佈式編譯的解決方案。Google在2017年提出的Remote Execution API,又從協議的角度規範了分佈式編譯和編譯緩存的實現方式。
我們先看一下分佈式編譯的核心思路。
核心思路
核心思路很簡單,本地計算出編譯命令需要讀的文件,把文件列表和編譯命令,發給遠端機器,執行編譯命令。編譯結束後,再請求拉取編譯產物。
其中,如何找到所需文件是關鍵。
背景知識——預處理
在介紹我們的做法之前,需要先補充一些編譯原理相關的背景知識。
待編譯的源文件,可以通過#include xx.h
和 #import xx.h
的方式,聲明對某頭文件的依賴。
編譯器處理編譯命令的第一階段叫做“預處理”,該階段的一個重要工作是頭文件展開。假設入口文件main.m
中有一行爲#import Car.h
,編譯器會遍歷所有搜索路徑,找到Car.h
文件,並讀取該文件內容,替換掉main.m
中的#import Car.h
行。其中搜索路徑由編譯命令中的 -I
, -isystem
等參數給出
接下來,如果 Car.h
文件中有 #import
語句,編譯器會重複上述動作,找到依賴的文件,讀取內容,進行替換,直到把所有的 #import
語句全部展開。
因此,假設我們模擬預處理的過程,找到所有依賴的頭文件,就可以將該任務發送到遠端執行。
重要引擎
由上述編譯原理可知,依賴分析是實現分佈式的前提。不僅如此,依賴分析也是性能的決定因素。
由於依賴分析只能在本地進行,計算資源是有限的。依賴分析的性能,決定了任務分發是否流暢,如果依賴分析過慢,會導致大量工作節點限制,任務分發出現瓶頸。
可以把依賴分析,理解爲分佈式編譯的重要引擎。
依賴分析的實現並不複雜,編譯器本身就提供了相關參數,以clang
爲例。-M
可以獲取完整的編譯依賴,而 -MM
則可以得到用戶定義的依賴,相關參數解析如下:
-M
,
--dependencies
Like -MD, but also implies -E and writes to stdout by default
-MD
,
--write-dependencies
Write a depfile containing user and system headers
-MM
,
--user-dependencies
Like -MMD, but also implies -E and writes to stdout by default
-MMD
,
--write-user-dependencies
Write a depfile containing user headers
開源框架recc
直接使用了編譯器能力。
這種方法的好處是開發簡單,並且足夠安全,但性能存在瓶頸。我們早期以頭條項目測試的時候,通過編譯器獲取依賴,平均耗時在200毫秒左右。而單個文件的編譯時長,大多在500毫秒~3000毫秒的區間內。依賴分析耗時佔比太高,導致任務分發效率不夠理想。
依賴分析時間過長,一方面由於編譯器命令由獨立進程執行,不同的編譯任務之間無法複用緩存。另一方面,編譯器的 -M
參數隱含了參數 -E
,後者代表“預處理”,預處理階段除了依賴分析,還做了不少其它工作,這部分工作我們可以優化掉。
Google的 goma
採用了自研的依賴分析模塊,並且在Chromium和Android這兩個大型項目上取得了非常好的結果。它在實現依賴分析的時候,藉助常駐進程的架構優勢,運用了大量緩存,索引等技巧,提高了中間數據的複用率。
在使用 goma
加速內部iOS的項目的過程中,我們發現當編譯任務依賴的Framework過多,或者依賴的hmap文件過大的情況下,性能會受到較大影響,於是,我們針對大型iOS項目的特點,在goma
基礎上進行了優化,最終可以以平均50ms的速度,完成編譯任務依賴解析。
接下來,讓我們一起看看goma
在設計時運用了哪些技巧,以及我們針對iOS項目做了哪些優化。由於篇幅有限,本文只介紹比較有代表性的部分。
快速依賴分析
goma採用了依賴緩存和依賴分析結合的方案,如果之前在工作目錄下進行過編譯,下次使用時,可以直接使用依賴緩存,只有在緩存不命中的情況下,才進行依賴分析。
依賴緩存
依賴緩存的核心原理是:檢查相同編譯參數對應的,上一次的依賴,如果依賴的文件都沒變,即複用依賴關係。
其流程如下圖所示:
有人可能會有疑問,爲什麼可以檢查上一次的依賴?如果這次引入了列表外的新文件,豈不是無法判斷文件是否改變嗎。
其實不然,引入新文件的前提是加入了新的#import
指令,它必然導致舊依賴列表中的某個文件發生改變,因此這種做法是相對安全的。
命中依賴緩存的話,可以在5毫秒以內得到編譯命令的依賴文件列表,這是一個很理想的性能。
不過在實踐中經常發現,即使文件修改了,依賴關係也大多是不變的,例如修改變量的值或增加一個類成員。如果我們能抓住這個特性,就可以大大增加緩存命中率。
忽略無關行
有些代碼修改影響依賴,有些則不會,如果我們只考慮影響依賴的改動,就可以排除掉大量干擾因素。下面是兩個例子,展示了有效改動和無效改動的區別。
- 有效改動(導致依賴分析緩存失效)
- #include <foo.h>
+ #include <bar.h>
- 無效改動 (不影響依賴分析緩存)
- int a = 2;
+ int a = 3;
除了前文提及的#include
和 #import
,還有如下語句可能造成緩存失效:#if
, #else
, #define
, #ifdef
, #ifndef
, #include_next
。
它們的共性是以#
開頭,在預處理階段會被編譯器解析。這些指令統稱爲Directive
,因此,我們只需緩存文件的Directive
列表,當文件內容發生改變時,重新獲取Direcitive
列表,並和之前緩存的內容對比,如果列表不變,就可以認爲該文件的改動不影響依賴關係。
依賴分析
深度優先分析
如果沒命中依賴緩存或者關閉了該功能,就會進入依賴分析的階段。
依賴分析採用深度優先搜索的算法,找到代碼中所有的 #include
和 #import
對應的文件。需要注意的是,#if
和#else
這樣的條件宏,也需要在預處理階段解析。
深度優先採用文件棧 + 行指針的方式實現,如圖所示:
圖中紫色部分是一個文件棧,棧中每一個元素都存放了文件相關的信息。每一個文件都對應一個Directive
(預處理指令)列表,並維護一個指針,指向當前的Directive
。
流程開始階段,入口文件進棧,隨後遍歷入口文件的所有Directive
,當讀到 #include
或 #import
相關的 Directive
時,搜索依賴文件,併入棧。
此時,雖然入口文件還沒有解析完,但按照規則應該優先解析新入棧的文件,所以需要通過指針維護入口文件當前讀到的行號,以保證下次回到入口文件時,可以繼續向下解析。
優化技巧
依賴分析的過程中,存在大量重複的操作,可以通過很多小技巧來優化這個過程。本文將介紹兩個比較典型的小技巧。
倒排索引
依賴分析中最常見的操作在一堆備選目錄中,找到對應名稱的文件。
假設我們需要找到#import <A/A.h>
語句中提到的A.h
文件。命令行中有10個-I
參數,分別指向10個不同的目錄-Ifoo, -Ibar, ...
,最樸素的方法是依次遍歷這10個目錄,拼接路徑,嘗試找到A.h
文件。
這種方法當然可行,但是效率較低。對於大型項目,僅一條編譯命令就可能涉及超過5000條#import
語句,和超過50個頭文件搜索路徑。這意味着至少5000*50=25萬次文件系統查找,時間開銷非常大。
建立倒排索引,可以大大加快這個過程。其思路是預先遍歷待搜索目錄(directory
),找到目錄下的文件和子目錄(統稱entry
),然後建立entry
指向directory
的倒排索引, 如下圖所示:
回到上面的問題,當我們搜索#import <A/A.h>
時,首先需要找到foo
, bar
, taz
三個目錄裏,哪個含有A
子目錄,根據倒排索引,可以快速定位到bar
目錄,而不需要從頭開始遍歷。
值得注意的是,objc工程普遍採用HeaderMap技術(即Xcode自動生成的.hmap
文件),提升編譯時查找頭文件的效率。HeaderMap本質上也是一種索引表,它建立了 Directive -> Path 的直接映射關係。我們在建倒排索引的時候,需要解析.hmap
中的內容,併合併到倒排索引中。
跨任務緩存(針對iOS項目的優化)
不同的編譯任務,可能存在相同的依賴文件。例如foo.m
和bar.m
可能都依賴了common.h
文件,編譯foo.m
的時候已經找到了common.h
, 編譯bar.m
的時候,是否不需要再找一次了呢?
很遺憾,大多數情況需要重新查找,因爲不同命令的查找條件往往不一樣。影響查找條件的參數有很多,例如-I
, -isystem
影響頭文件搜索路徑,-F
影響Framework搜索路徑。
不過,iOS項目往往可以複用之前的查找結果。
iOS項目通常採用Xcode + CocoaPods的研發模式,針對同一個Pod內源文件的編譯命令,頭文件搜索路徑基本是一致的。利用這個特性,我們提供了跨任務的緩存加速方案。
我們對搜索路徑列表整體做了一層hash,當兩個命令的搜索路徑相同時,對同名Directive的搜索結果一定相同。方案如下所示:
- 在對單條命令進行依賴解析之前,先提取搜索路徑的特徵值。
2. 尋找頭文件時,先查詢緩存,如果查不到,在找到頭文件後,將結果緩存。
舉一個具體的例子:
編譯任務1:
clang`` -c ``foo.m`` -IFoo -IBar -FCar
編譯任務2:
clang`` -c ``bar.m`` -IFoo -IBar -FCar
foo.m
和bar.m
均包含行:#import common.h
假設編譯任務1先執行,我們的做法應該是:
- 提取搜索目錄列表爲:
-IFoo -IBar -FCar
- 使用SHA-256算法計算摘要,對應的搜索摘要爲:598cf1e...(僅展示前8位)
- 進行依賴分析,讀到
foo.m
依賴common.h
的部分,遍歷搜索目錄,找到common.h
的位置,假設在目錄Bar
下面。 - 寫緩存,緩存用哈希表實現,key爲
<598cf1e..., common.h>
,value爲Bar
- 執行編譯任務2,再次遇到尋找
common.h
的請求。 - 直接從緩存中查到
common.h
在Bar
目錄下
索引緩存(針對iOS項目的優化)
建索引可以減少遍歷目錄尋找頭文件的次數,是非常有效的優化方案。但是當頭文件搜索目錄過多,或者hmap過大時,建索引本身也需要幾十毫秒的時間,對於性能要求十分嚴苛的依賴解析來說,這個時間還是略長。
所以我們想到,對索引本身是否可以做緩存呢?
按照跨任務緩存的思路,索引本身也是可以緩存的,只要兩個任務的頭文件搜索路徑,以及hmap中的索引內容都一直,它們就可以共用一套索引。
具體的方案和跨任務緩存類似,本文就不詳細展開了。通過對索引的緩存,我們將依賴分析的速度又提升了20毫秒左右。
總結
分佈式編譯和編譯緩存是提升大型項目編譯效率的兩大法寶。本文主要介紹了字節跳動的分佈式編譯解決方案。
該方案核心部分採用了開源框架goma的代碼,並在此基礎上,針對iOS項目的特性,做了一定的優化。
分佈式編譯的核心思想是空間換時間,引入額外的機器,提升單次編譯的CPU數量。分佈式編譯的效果,取決於中心節點分發任務的速度,任務的分發又取決於依賴的解析效率。
傳統方案利用編譯器的預處理來解析依賴,方法可行,但由於每次解析都要單獨fork進程,數據難以複用,存在性能瓶頸。我們採用了開源框架goma的代碼,並在此基礎上,針對iOS項目的特性,做了一定的優化。
本文介紹了依賴解析的四種技巧,分別從消除噪音,索引,緩存三個角度進行了優化。編譯優化的道路,任重而道遠。感謝goma團隊,提供了許多優秀的設計思路和技巧,我們也會在此方向持續研究,儘可能的把思路分享給大家。
# 關於字節終端技術團隊
字節跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個字節跳動的大前端基礎設施建設,提升公司全產品線的性能、穩定性和工程效率;支持的產品包括但不限於抖音、今日頭條、西瓜視頻、飛書、番茄小說等,在移動端、Web、Desktop等各終端都有深入研究。
就是現在!客戶端/前端/服務端/端智能算法/測試開發 面向全球範圍招聘!一起來用技術改變世界,感興趣請聯繫[email protected]。郵件主題:簡歷-姓名-求職意向-期望城市-電話。
MARS- TALK 04 期來啦!
2月24日晚 MARS TALK 直播間,我們邀請了火山引擎 APMPlus 和美篇的研發工程師,在線爲大家分享「APMPlus 基於 Hprof 文件的 Java OOM 歸因方案」及「美篇基於MARS-APMPlus 性能監控工具的優化實踐」等技術乾貨。現在報名加入活動羣 還有機會獲得最新版VR一體機——Pico Neo3哦!
⏰ 直播時間:2月24日(週四) 20:00-21:30
💡 活動形式:線上直播
🙋 報名方式:掃碼進羣報名
作爲開年首期MARS TALK,本次我們爲大家準備了豐厚的獎品。除了Pico Neo3之外,還有羅技M720藍牙鼠標、筋膜槍及字節周邊禮品等你來拿。千萬不要錯過喲!
👉 點擊這裏,瞭解APMPlus