讓工程師擁有一臺“超級”計算機——字節跳動客戶端編譯加速方案

我們有一個夢想,讓每一名研發工程師擁有一臺“超級”計算機。 

作者:字節跳動終端技術——孫雄

大型工程的效率瓶頸

近年來,基於Devops流水線的研發流程,逐漸成爲軟件研發的行業標準。流水線的運行效率,決定了團隊的研發效能。對大型項目來說,編譯構建往往是流水線中耗時佔比的大頭。有些工程的編譯時長超過30分鐘,甚至達到幾個小時。這樣的性能,是非常糟糕的。

字節iOS大型項目的構建時長,大多控制在5分鐘以內。這主要得益於內部的編譯加速解決方案,它集分佈式編譯和分佈式緩存爲一體,本文將詳細介紹它的工作原理。不過在這之前,我們先來分析一下大型項目的編譯瓶頸和解決思路。

先說結論,機器性能不足和重複作業,是影響工程編譯效率的兩個最大因素,對此,可以採取分佈式編譯+編譯緩存的方式,提升整體的性能。

分佈式編譯

工程的編譯,往往可以拆解爲可並行的編譯子任務。以C系列語言(C, C++, ObjC)爲例,項目中往往存在上千甚至上萬的源代碼文件(以 .c , .cc.m 作爲擴展名的文件),每個編譯子任務將源代碼文件編譯爲目標文件(以 .o 作爲擴展名的文件),再整體鏈接成最終的可執行文件。

這些編譯子任務可以並行執行,如下圖所示:

image.png

CPU的數量,決定了編譯的並行度上限。個人電腦(PC)的CPU核心數通常在4~12之間,專用服務器可以達到24~96,但對於動輒上萬文件的大型工程,CPU的數量還是顯得不足。這時候,利用分佈式編譯的技術,可以得到一臺“超級計算機”。

編譯緩存

大型工程全量編譯,需要處理幾千甚至幾萬個編譯子任務。但大多數子任務,之前已經編譯過,如果我們能通過某種方式,直接獲取編譯產物,就可以大大節省時間。

建立一箇中央倉庫,存儲編譯子任務的產物,這些產物可以通過“任務摘要”來索引。這樣每次遇到一個新任務,我們首先向中央倉庫查詢摘要,如果查詢成功,直接下載編譯產物,就省去了重複編譯的動作。

上面提到的分佈式編譯和編譯緩存,是提升大型項目編譯效率的兩大法寶,本文主要介紹字節跳動的分佈式編譯解決方案。

“超級”計算機

藉助雲計算,我們可以以組裝的方式,得到一臺“超級”計算機,如下圖所示:

image.png

這臺“超級”計算機,由一臺中心節點和若干臺工作節點組成。中心節點負責生成和調度編譯子任務,依照它們的執行順序,將任務發送給空閒的工作節點來執行。這樣整個系統的並行處理能力,取決於所有工作節點的CPU之和,性能比單機高出數倍,甚至數十倍。

像這樣把任務分發給工作節點的方案,又稱爲分佈式編譯。分佈式編譯並不是新鮮的概念,2008年開源的distcc工具就提供了分佈式編譯的解決方案。Google在2017年提出的Remote Execution API,又從協議的角度規範了分佈式編譯和編譯緩存的實現方式。

我們先看一下分佈式編譯的核心思路。

核心思路

核心思路很簡單,本地計算出編譯命令需要讀的文件,把文件列表和編譯命令,發給遠端機器,執行編譯命令。編譯結束後,再請求拉取編譯產物。

image.png

其中,如何找到所需文件是關鍵。

背景知識——預處理

在介紹我們的做法之前,需要先補充一些編譯原理相關的背景知識。

待編譯的源文件,可以通過#include xx.h#import xx.h的方式,聲明對某頭文件的依賴。

編譯器處理編譯命令的第一階段叫做“預處理”,該階段的一個重要工作是頭文件展開。假設入口文件main.m 中有一行爲#import Car.h,編譯器會遍歷所有搜索路徑,找到Car.h文件,並讀取該文件內容,替換掉main.m中的#import Car.h行。其中搜索路徑由編譯命令中的 -I, -isystem 等參數給出

接下來,如果 Car.h 文件中有 #import 語句,編譯器會重複上述動作,找到依賴的文件,讀取內容,進行替換,直到把所有的 #import 語句全部展開。

因此,假設我們模擬預處理的過程,找到所有依賴的頭文件,就可以將該任務發送到遠端執行。

image.png

重要引擎

由上述編譯原理可知,依賴分析是實現分佈式的前提。不僅如此,依賴分析也是性能的決定因素。

由於依賴分析只能在本地進行,計算資源是有限的。依賴分析的性能,決定了任務分發是否流暢,如果依賴分析過慢,會導致大量工作節點限制,任務分發出現瓶頸。

可以把依賴分析,理解爲分佈式編譯的重要引擎

依賴分析的實現並不複雜,編譯器本身就提供了相關參數,以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採用了依賴緩存和依賴分析結合的方案,如果之前在工作目錄下進行過編譯,下次使用時,可以直接使用依賴緩存,只有在緩存不命中的情況下,才進行依賴分析。

image.png

依賴緩存

依賴緩存的核心原理是:檢查相同編譯參數對應的,上一次的依賴,如果依賴的文件都沒變,即複用依賴關係

其流程如下圖所示:

image.png

有人可能會有疑問,爲什麼可以檢查上一次的依賴?如果這次引入了列表外的新文件,豈不是無法判斷文件是否改變嗎。

其實不然,引入新文件的前提是加入了新的#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這樣的條件宏,也需要在預處理階段解析。

深度優先採用文件棧 + 行指針的方式實現,如圖所示:

image.png

圖中紫色部分是一個文件棧,棧中每一個元素都存放了文件相關的信息。每一個文件都對應一個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的倒排索引, 如下圖所示:

image.png

回到上面的問題,當我們搜索#import <A/A.h>時,首先需要找到foo, bar, taz三個目錄裏,哪個含有A子目錄,根據倒排索引,可以快速定位到bar目錄,而不需要從頭開始遍歷。

值得注意的是,objc工程普遍採用HeaderMap技術(即Xcode自動生成的.hmap文件),提升編譯時查找頭文件的效率。HeaderMap本質上也是一種索引表,它建立了 Directive -> Path 的直接映射關係。我們在建倒排索引的時候,需要解析.hmap中的內容,併合併到倒排索引中。

跨任務緩存(針對iOS項目的優化)

不同的編譯任務,可能存在相同的依賴文件。例如foo.mbar.m可能都依賴了common.h文件,編譯foo.m的時候已經找到了common.h, 編譯bar.m的時候,是否不需要再找一次了呢?

很遺憾,大多數情況需要重新查找,因爲不同命令的查找條件往往不一樣。影響查找條件的參數有很多,例如-I, -isystem 影響頭文件搜索路徑,-F 影響Framework搜索路徑。

不過,iOS項目往往可以複用之前的查找結果。

iOS項目通常採用Xcode + CocoaPods的研發模式,針對同一個Pod內源文件的編譯命令,頭文件搜索路徑基本是一致的。利用這個特性,我們提供了跨任務的緩存加速方案。

我們對搜索路徑列表整體做了一層hash,當兩個命令的搜索路徑相同時,對同名Directive的搜索結果一定相同。方案如下所示:

  1. 在對單條命令進行依賴解析之前,先提取搜索路徑的特徵值。

image.png


2. 尋找頭文件時,先查詢緩存,如果查不到,在找到頭文件後,將結果緩存。

image.png

舉一個具體的例子:

編譯任務1:clang`` -c ``foo.m`` -IFoo -IBar -FCar

編譯任務2:clang`` -c ``bar.m`` -IFoo -IBar -FCar

foo.mbar.m 均包含行:#import common.h

假設編譯任務1先執行,我們的做法應該是:

  1. 提取搜索目錄列表爲:-IFoo -IBar -FCar
  2. 使用SHA-256算法計算摘要,對應的搜索摘要爲:598cf1e...(僅展示前8位)
  3. 進行依賴分析,讀到foo.m依賴common.h的部分,遍歷搜索目錄,找到common.h的位置,假設在目錄Bar下面。
  4. 寫緩存,緩存用哈希表實現,key爲<598cf1e..., common.h>,value爲Bar
  5. 執行編譯任務2,再次遇到尋找common.h的請求。
  6. 直接從緩存中查到common.hBar目錄下

索引緩存(針對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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章