如何挖掘 Bazel 的極致性能

2023-07-25     InfoQ

原標題:如何挖掘 Bazel 的極致性能

作者 | 孫雄

策劃 | Tina

Bazel 是 Google 公司於 2015 年開源的一款構建框架,至今收穫了 21k 的 star 數,遠超 gradle、maven、cmake 等同類產品。近幾年來,位元組阿里騰訊等網際網路大廠也逐步擁抱 Bazel,搭建自己的構建體系。

Bazel 為什麼如此受歡迎,原因正如它的宣傳: "Correct & Fast, Choose Two",這並不是一句口號,從實際的用戶體驗也能看出。

(1) 得益於強大的增量構建機制,幾萬個文件的大型項目,可以做到秒級構建。

(2) Bazel 的封閉性設計,使得增量構建和緩存可信賴,用戶不需要通過 clean 操作在構建前清理環境。

本文將分兩部分闡述文章的主題。第一部分將分析 Bazel 高性能,高可靠的原理;第二部分則結合實際場景,聊一聊如何挖掘 Bazel 的極致性能。

Bazel 的優勢

基於製品 (Artifact) 的構建系統

傳統構建系統有很多是基於任務的,例如 Ant,Maven,Gradle。用戶可以自定義"任務"(Task),例如執行一段 shell 腳本。用戶配置它們的依賴關係,構建系統則按照順序調度。

圖 1 基於 Task 的調度模型

這種模式對使用者很友好,他可以專注任務的定義,而不用關心複雜的調度邏輯。構建系統通常給予任務制定者極大的"權利",比如 Gradle 允許用戶用 Java 代碼編寫任務,原則上可以做任何事。

如果一個任務,在輸入條件不變的情況下,永遠輸出相同的結果,我們就認為這個任務是"封閉"(Hermeticity) 的。構建系統可以利用封閉性提升構建效率,例如第二次構建時,跳過某些輸入沒變的 Task,這種方式也稱為 增量構建

不滿足封閉性的任務,則會導致增量構建失效,例如 Task 訪問某個網際網路資源,或者 Task 在執行時依賴隨機數或時間戳這樣的動態特徵,這些都會導致多次執行 Task 得到不同的結果。

Bazel 採用了不同的調度模型,它是基於製品 (Artifact) 的。Bazel 官方定義了一些規則 (rule),用於構建某些特定產物,例如 c++ 的 library 或者 go 語言的 package,用戶配置和調用這些規則。他僅僅需要告訴 Bazel 要構建什麼 Artifact,而由 Bazel 來決定如何構建它。

規則由官方和可信賴第三方維護,規則產生的任務,滿足封閉性需求,這使得用戶可以信賴系統的增量構建能力。

用戶需要構建的 Artifact,在 Bazel 概念里被稱為 Target,基於 Target 的調度模型如下圖所示:

圖 2 基於 Target 的調度模型

圖 2 中,File 表示原始文件,Target 表示構建時生成的文件。當用戶告訴 Bazel 要構建某個 Target 的時候,Bazel 會分析這個文件如何構建(構建動作定義為 Action,和其他構建系統的 Task 大同小異),如果 Target 依賴了其他 Target,Bazel 會進一步分析依賴的 Target 又是如何構建生成的,這樣一層層分析下去,最終繪製出完整的執行計劃。

並行編譯

Bazel 精準的知道每個 Action 依賴哪些文件,這使得沒有相互依賴關係的 Action 可以並行執行,而不用擔心競爭問題。基於任務的構建系統則存在這樣的問題:

圖 3 基於任務的構建系統存在競爭問題

如圖 3 所示,兩個 Task 都會向同一個文件寫一行字符串,這就造成兩個 Task 的執行順序會影響最終的結果。要想得到穩定的結果,就需要定義這兩個 Task 之間的依賴關係。

Bazel 的 Action 由構建系統本身設計,更加安全,也不會出現類似的競爭問題。因此我們可以充分利用多核 CPU 的特性,讓 Action 並行執行。

通常我們採用 CPU 邏輯核心數作為 Action 執行的並發度,如果開啟了遠端執行 (後面會提到),則可以開啟更高的並發度。

增量編譯

對 Bazel 來說,每個 Target 的構建過程,都對應若干 Action 的執行。Action 的執行本質上就是"輸入文件 + 編譯命令 + 環境信息 = 輸出文件"的過程。

圖 4 Action 的描述

如果本地文件系統保留著上一次構建的 outputs,此時 Bazel 只需要分析 inputs, commands 和 envs 和上次相比有沒有改變,沒有改變就直接跳過該 Action 的執行。

這對於本地開發非常有用,如果你只修改了少量代碼,Bazel 會自動分析哪些 Action 的 inputs 發生了變化,並只構建這些 Action,整體的構建時間會非常快。

不過增量構建並不是 Bazel 獨有的能力,大部分的構建系統都具備。但對於幾萬個文件的大型工程,如果不修改一行代碼,只有 Bazel 能在一秒以內構建完畢,其他系統都至少需要幾十秒的時間,這簡直就是 降維打擊了。

Bazel 是如何做到的呢?

首先,Bazel 採用了 Client/Server 架構,當用戶鍵入 bazel build 命令時,調用的是 bazel 的 client 工具,而 client 會拉起 server,並通過 grpc 協議將請求 (buildRequest) 發送給它。由 server 負責配置的加載,ActionGraph 的生成和執行。

圖 5 Bazel 的 C/S 架構

構建結束後,Server 並不會立即銷毀,而 ActionGraph 也會一直保存在內存中。當用戶第二次發起構建時,Bazel 會檢測工作空間的哪些文件發生了改變,並更新 ActionGraph。如果沒有文件改變,就會直接復用上一次的 ActionGraph 進行分析。

這個分析過程完全在內存中完成,所以如果整個工程無需重新構建,即便是幾萬個 Action,也能在一秒以內分析完畢。而其他系統,至少需要花費幾十秒的時間來重新構建 ActionGraph。

遠程緩存與遠程執行

遠程緩存

增量構建極大的提升了本地研發的構建效率,但有些場合它的效果不是很好,例如 CI 環境通常採用「乾淨」的容器,此時沒有上一次的構建數據,只能全量構建。

即使是本地研發,如果從遠端同步代碼時修改了全局參數,也會導致增量構建失效。

緩存 (Remote Cache) 與遠程執行 (Remote Execution) 可以很好的解決這個問題。

前面聊到,Action 滿足封閉性,即相同的 Action 信息一定產生相同的結果。因此可以建立 Action 到 ActionResult 的映射。為了便於索引,Bazel 把 Action 信息通過 sha256 哈希算法壓縮成摘要 (Digest),把 Digest 到 ActionResult 的映射存儲在雲端,就可以實現 Action 的跨構建共享。

圖 6 Action 共享示意圖

這裡的 Storage 是完全基於內容尋址的,即「一個 Digest 唯一對應一個 ActionResult」,內容尋址的好處是不容易污染存儲空間,因為修改任何一行代碼會計算出不同的 Digest,不用擔心污染別人的 ActionResult。內容尋址的存儲引擎,被稱為 Content Addressable Storage(CAS),如果沒有特彆強調,本文後續使用簡稱 CAS 來表述。

CAS 里存放的任何文件,無論是 Action 的 Meta 信息還是編譯產物二進位,都被稱為 Blob。

為保證 CAS 的存儲空間被有效利用,通常會使用 LRU 算法管理 CAS 里存儲的 Blob,當存儲空間寫滿時,最久沒被訪問的 Blob 就會被自動淘汰,這樣就保證了空間裡的 Blob 是最活躍的。

遠程執行

既然 ActionResult 可以被不同的 Bazel 任務共享,說明 ActionResult 和 Action 在哪裡執行並沒有關係。因此,Bazel 在構建時,可以把 Action 發送給另一台伺服器執行,對方執行完,向 CAS 上傳 ActionResult,然後本地再下載。

這種做法減少了本地執行 Action 的開銷,使得我們設置更高的構建並發度。

Bazel 為 Remote Cache 和 Remote Execution 設計了專門的協議 Remote Execution API,用於規範協議的客戶端和服務端的行為。

完整的流程如下圖所示:

圖 7 遠程執行流程

可以看到,Client 和 Server 的直接交互是很少的,大部分情況還是和 CAS 交互,這部分採用了增量的設計,Client 先調用 findMissingBlobs 接口,該接口的請求參數是一堆 Blob Digest 列表,返回值是 CAS 缺失的 Digest 列表。這樣 Client 只上傳這些 Blob,可以減少網絡傳輸的浪費。

Remote Execution API 是一套通用的遠程執行協議,客戶端部分由 Bazel 實現,服務端部分可自行定製。Bazel 團隊開發兩款開源實現,分別是 Bazel Remote(CAS) 和 Buildfarm (Remote Executoin & CAS),除此之外也有 Buildbarn,Buildgrid 等開源實現以及 Engflow,Buildbuddy 這樣的企業版。

企業版除了提供更穩定,彈性的遠程執行服務外,通常還提供數據分析能力,用戶可以根據自己的條件選擇合適的開源軟體或企業版服務。

外部依賴緩存 (repository_cache)

前面我們主要分析了基於 Action 的增量構建,緩存和遠程執行機制。現在讓我們看看 Bazel 是如何管理外部依賴的。

大部分項目都沒法避免引入第三方的依賴項。構建系統通常提供了下載第三方依賴的能力。為了避免重複下載,Bazel 要求在聲明外部依賴的時候,需要記錄外部依賴的 hash,例如下面的這種形式:

圖 8 外部依賴描述

Bazel 會將下載的依賴,以 CAS 的方式存儲在內置的 repository_cache 目錄下。你可以通過

bazel info repository_cache 命令查看目錄的位置。

Bazel 認為通過 checksum 機制,外部依賴應該是全局共享的,因此無論你的本地有多少個工程,哪怕使用的是不同的 Bazel 版本,都可以共享一分外部依賴。

除此之外,Bazel 也支持通過 1.0.0 這樣的 SerVer 版本號來聲明依賴,這是 Bazel6.0 版本加入的功能,也是官方推薦使用的,具體做法可以查看官網 相關部分。

如何高效使用 Bazel

Bazel 為了正確性和高性能,做了很多優秀的設計,那麼我們如何正確的使用這些能力,讓我們的構建性能「起飛」呢, 我們將從本地研發和 CI pipeline 兩種場景進行分析。

本地研發

本地研發通常採用默認的 Bazel 配置即可,無需為增量構建和 repository_cache 做額外配置,Bazel 默認就處理的很好。

使用時應該信任 bazel 的增量構建機制,即便是從遠端倉庫同步了代碼,也可以直接 build,無須先通過 bazel build 清理環境。

至於 Remote Cache 和 Remote Execution,則需要結合網絡狀況和 Action 的執行開銷,決定是否開啟,參數是 --remote_cache 和 --remote_execution。

正確開啟 bazel 的 remote 能力

正確開啟 remote_cache 和 remote_execution 對構建效率有顯著作用,但網絡或 Action 特性,也可能導致收益不明顯甚至劣化。

舉個例子說明使用 remote_cache 的利弊:

我們假設 Action 的執行時間是 a,上傳緩存和下載緩存的時間分別是 b 和 c, 緩存命中率是μ

如果不使用 remote cache,耗時恆定為 a,如果使用 remote cache, 命中緩存耗時是 c,不命中則是 a + b, 結合命中率,可以求出耗時的數學期望是 μc + (1 - μ)(a + b)

也就是說,只有 μc + (1 - μ)(a + b) < a 時,使用緩存才是有效的, 對該表達式進行化簡,可以得到:b < μ(a + b - c)

例如 Action 執行時間是 500ms,上傳產物時間是 200ms,下載產物時間是 100ms,緩存命中率是 30%, 代入到式子中:0.3 * (500 + 200 - 100)ms = 180ms < 200ms,在這種情況使用緩存反而會劣化。

舉個例子說明使用 remote_cache 的利弊:

我們假設 Action 的執行時間是 a,上傳緩存和下載緩存的時間分別是 b 和 c, 緩存命中率是μ

如果不使用 remote cache,耗時恆定為 a,如果使用 remote cache, 命中緩存耗時是 c,不命中則是 a + b, 結合命中率,可以求出耗時的數學期望是 μc + (1 - μ)(a + b)

也就是說,只有 μc + (1 - μ)(a + b) < a 時,使用緩存才是有效的, 對該表達式進行化簡,可以得到:b < μ(a + b - c)

例如 Action 執行時間是 500ms,上傳產物時間是 200ms,下載產物時間是 100ms,緩存命中率是 30%, 代入到式子中:0.3 * (500 + 200 - 100)ms = 180ms < 200ms,在這種情況使用緩存反而會劣化。

實踐中,我們不一定能對 Action 做如此精細的數據分析,但可以根據網絡狀況大致估算。Bazel 提供了精細化的控制方式,可以控制某一種類型的 Action 是否啟用 remote_cache,例如:

圖 9 針對 CppLink 禁用 remote_cache

圖 9 針對 CppLink 類型的 Action 禁用了 remote_cache 能力,其他類型則可以正常使用。甚至還可以通過 no-remote-cache-upload,設置為只禁止上傳緩存,不禁止下載緩存。

對於緩存的精細化設置屬於比較高級的功能,Bazel 暫時沒有過多開放相關能力,相關的文檔也不全。或許我們可以期待一下,未來能使用更方便的配置來管理。

緩存命中率調優

上面的例子可以看出,Action 的緩存命中率直接決定了 remote cache 的收益,如何優化緩存命中率呢?

前文介紹原理時,我們知道 Action 由 inputs 和 commands 組成,inputs 指執行 Action 所需的目錄結構和文件內容。而 commands 包括了參數 (args), 執行路徑 (workdir) 和環境變量 (envs)。

當緩存命中率不符合預期時,我們需要對 Action 的詳情進行調試。

bazel 的 --execution_log_binary_file 參數可以把 Action 的詳細信息列印到文件里。

對比兩次構建的 Action 詳情,就可以知道是什麼參數發生了變化。

該參數導出的原始信息是二進位格式,有一些特殊字符,如下圖所示:

圖 10 execution_log_binary_file 文本

可以藉助 bazel 的 execution_log_parser 工具,把它變成更可讀的形式:

該工具需要源碼編譯 bazel:

圖 11 使用 parser 工具把 log 變成可讀形式

轉換後的文件如下圖所示:

圖 12 轉換後的 execution_log

之後就可以用文本對比工具,對兩次構建生成的 execution_log 進行對比。

CI pipeline

再來看到 CI 場景,如果你在公司里搭建了持續集成流水線,則需要考慮更多的東西。在公司內網的模式下,CI 的網絡往往不再是瓶頸,我們應該完整的使用 Remote Cache 和 Remote Execution 的能力。

搭建 Remote Execution 服務

使用 Remote 能力的前提是部署支持 Remote Execution 協議的服務,一般來說,開源產品 buildfarm 或 buildbarn 就足夠使用了,如果對性能和數據分析有更加極致的要求,可以考慮企業版產品或者基於 Remote Execution API 協議自研。

Remote Execution 服務的架構設計是一個很大,也很有趣的話題。篇幅關係,本文不過多深入細節,但提供幾點設計要求可以參考:

客戶端調度增強

除了 Remote Execution 服務,另一塊需要注意的地方是客戶端調度。不同於本地構建,CI 場景為了追求強隔離性,往往以實時運行 Docker Container 的方式提供構建環境。也就是說,構建環境不包含上一次構建的數據。

這種模式對於 Bazel 構建很不友好,不僅外部依賴要重新下載,而且增量編譯功能也無法使用。但我們也有辦法儘可能的加快構建速度。

圖 13 CI 環可復用的要素

首先是使用 Remote Cache 和 Remote Execution 服務,在沒有增量構建的場景下,Remote Cache 和 Remote Execution 提供的優化效果是非常誇張的,根據我的觀察,提速普遍在 70%以上,甚至能達到 90%

其次是緩存本地數據,例如 trivas CI 這樣的流水線編排系統,就支持對特定目錄進行緩存。它的原理是把目錄打包上傳到對象存儲,下次構建時再下載下來。我們可以將 Bazel 的 repository_cache 和 action_local_cache 相關的目錄進行緩存,下次構建就可以直接復用。

如果條件允許的話,甚至可以要求流水線提供常駐容器,這樣 Bazel 的進程都可以長期保留著,下次構建時,直接 Attach 到已有的容器上執行命令即可。這種方式有望在 CI pipeline 場景實現秒級構建,這是多麼酷的一件事情啊!

不過,常駐容器對安全性也帶來了一定的挑戰,企業具體採用那種方案,也應該因實際情況而異。

總 結

本文從原理方面介紹了 Bazel 高性能的原因,從實踐方面針對本地研發和 CI pipeline 兩種場景給出了建議。

Bazel 在設計時非常注重「增量」,「緩存」和「並行」,這是高性能的 基礎。而 Bazel 官方推出並維護了不同語言的構建規則,也保證了構建過程時封閉,可靠的,這是高性能的 前提。除此之外,針對工作空間的完整 ActionGraph 的內存緩存機制 (skyframe),使得 Bazel 對大型項目擁有秒級的構建速度,這也是其他主流構建系統遠遠達不到的。

在實際使用中,我們不僅需要深度了解 Bazel 的緩存和遠程執行機制,也需要根據不同的場景配置不同的參數。本地場景需要關注網絡和緩存命中率,以決定是否開啟遠端緩存和遠端執行能力。CI 場景則需要關心流水線的調度能力,儘可能的提升數據的復用。

作者簡介 :

孫雄,曾就職於多家頭部網際網路企業,2015 年開始從事 devops 領域的相關工作,在構建領域擁有豐富的經驗,對多款構建系統(例如 Bazel,Gradle)有源碼級的理解。

點擊底部閱讀原文訪問 InfoQ 官網,獲取更多精彩內容!

今日好文推薦

C# 和 Type 之父親自帶隊開源 TypeChat,又一 AI 技術瓶頸被攻破?

終於找到 ChatGPT 「智商」下降的原因了!OpenAI 側面回應,GPT 可能真被你們玩壞了?

微信取消秋招;谷歌軟體工程師基本年薪超 500 萬;通報批評員工到點下班?比亞迪回應 | Q資訊

十年磨礪,持續闖入「無人區」,這家公司如何做好金融科技?

文章來源: https://twgreatdaily.com/zh-cn/0fb3e981e92b4b1869856886cfa27fa7.html