本頁面概略說明撰寫高效率 Bazel 規則的具體問題和挑戰。
摘要規定
- 假設:以正確性、處理量、易用性和延遲為目標
- 假設:大規模的儲存庫
- 假設:類似 BUILD 的說明語言
- 歷史:載入、分析和執行作業之間的硬式分隔已過時,但仍會影響 API
- 內在:遠端執行和快取功能難以實作
- 內建:使用變更資訊來建立正確且快速的增量版本,需要使用不尋常的程式碼模式
- 內在:避免平方時間和記憶體消耗
假設
以下是對建構系統的幾項假設,例如需要正確性、易用性、吞吐量和大型存放區。以下各節將說明這些假設,並提供指南,確保規則以有效的方式編寫。
以正確性、處理量、易用性和延遲為目標
我們假設建構系統必須首先針對增量建構作業正確運作。對於特定來源樹狀結構,無論輸出樹狀結構長相為何,同一個版本的輸出內容應一律相同。這表示 Bazel 需要知道進入特定建構步驟的每個輸入內容,以便在任何輸入內容變更時重新執行該步驟。Bazel 的正確性有其限制,因為它會洩漏一些資訊 (例如建構日期 / 時間),並忽略某些類型的變更 (例如檔案屬性的變更)。沙箱可防止讀取未宣告的輸入檔案,有助於確保正確性。除了系統的內在限制之外,還有幾個已知的正確性問題,其中大多與 Fileset 或 C++ 規則相關,而這兩者都是難題。我們長期致力於解決這些問題。
建構系統的第二個目標是提高總處理量;我們會持續突破目前機器分配的遠端執行服務,以便執行更多作業。如果遠端執行服務超載,就無法完成工作。
其次是易用性。在多種正確方法中,如果有相同 (或類似) 的遠端執行服務足跡,我們會選擇較容易使用的做法。
延遲時間是指從開始建構到取得預期結果所需的時間,無論是通過或失敗測試的測試記錄,或是 BUILD
檔案有錯字的錯誤訊息。
請注意,這些目標經常重疊;延遲時間是遠端執行服務的吞吐量函式,而正確性則與易用性相關。
大規模存放區
建構系統需要以大型存放區的規模運作,而大型規模是指無法在單一硬碟上執行,因此幾乎無法在所有開發人員的電腦上執行完整的檢出作業。中型版本需要讀取及剖析數萬個 BUILD
檔案,並評估數十萬個 glob。雖然理論上可以在單一機器上讀取所有 BUILD
檔案,但我們尚未能在合理的時間和記憶體內完成這項操作。因此,BUILD
檔案必須能夠獨立載入及剖析。
類似 BUILD 的說明語言
在這個情況下,我們假設設定語言大致類似於宣告程式庫和二進位檔規則及其相互依賴關係的 BUILD
檔案。BUILD
檔案可獨立讀取及剖析,我們盡可能避免查看來源檔案 (除了確認檔案是否存在)。
歷史古蹟
Bazel 版本之間的差異會造成挑戰,我們會在下文中說明其中一些差異。
載入、分析和執行作業之間的硬式分隔已過時,但仍會影響 API
從技術層面來說,只要在將動作傳送至遠端執行作業之前,規則就知道動作的輸入和輸出檔案即可。不過,原始 Bazel 程式碼集會嚴格區分載入套件的作業,然後使用設定 (基本上是指令列標記) 分析規則,然後才執行任何動作。雖然 Bazel 核心不再需要這項區別,但這項區別目前仍是規則 API 的一部分 (詳情請見下文)。
也就是說,規則 API 需要規則介面的宣告式說明 (包含的屬性、屬性類型)。在某些例外狀況中,API 會允許自訂程式碼在載入階段執行,以便計算輸出檔案的隱含名稱和屬性的隱含值。舉例來說,名為「foo」的 java_library 規則會隱含產生名為「libfoo.jar」的輸出內容,可從建構圖中的其他規則參照。
此外,規則的分析無法讀取任何來源檔案或檢查動作的輸出內容;相反地,它需要產生部分導向的二元圖表,其中的建構步驟和輸出檔案名稱,僅由規則本身及其依附元件決定。
本質上可解釋
有些內在屬性會讓您難以編寫規則,以下幾節將說明其中最常見的屬性。
遠端執行和快取作業相當困難
相較於在單一電腦上執行建構作業,遠端執行和快取功能可將大型存放區的建構時間縮短約兩個數量級。不過,這項服務需要處理的規模相當龐大:Google 的遠端執行服務可在每秒處理大量要求,而且通訊協定會小心避免不必要的來回傳輸,以及服務端不必要的工作。
目前,此協定規定建構系統必須事先知道特定動作的所有輸入內容;然後,建構系統會計算不重複的動作指紋,並向排程器要求快取命中。如果找到快取命中,排程器會回覆輸出檔案的摘要;檔案本身會在稍後透過摘要處理。不過,這會對 Bazel 規則施加限制,因為 Bazel 規則需要預先宣告所有輸入檔案。
如要使用變更資訊來進行正確且快速的漸進式建構作業,就必須採用不尋常的程式碼模式
如上所述,為了確保正確性,Bazel 需要知道進入建構步驟的所有輸入檔案,以便偵測該建構步驟是否仍為最新版本。套件載入和規則分析也是如此,我們設計的 Skyframe 可處理一般情況。Skyframe 是圖表程式庫和評估架構,可取得目標節點 (例如「build //foo with these options」),並將其分解為構成部分,然後評估並組合這些部分,產生這項結果。在這個程序中,Skyframe 會讀取套件、分析規則,並執行動作。
在每個節點中,Skyframe 會追蹤特定節點用於計算自身輸出的確切節點,從目標節點一路到輸入檔案 (也是 Skyframe 節點)。在記憶體中明確呈現這類圖表,可讓建構系統準確找出哪些節點受到輸入檔案的特定變更 (包括建立或刪除輸入檔案) 影響,並以最少的工作量,將輸出樹狀圖還原至預期狀態。
在這項作業中,每個節點都會執行依附元件探索程序。每個節點都可以宣告依附元件,然後使用這些依附元件的內容宣告更多依附元件。原則上,這會對應到每個節點的執行緒模型。不過,中型版本包含數十萬個 Skyframe 節點,這在目前的 Java 技術中並不容易實現 (而且基於歷史原因,我們目前仍使用 Java,因此沒有輕量級執行緒和繼續執行)。
相反地,Bazel 會使用固定大小的執行緒集區。不過,這也代表如果節點宣告的依附元件尚未可用,我們可能必須在依附元件可用時終止該評估,並重新啟動 (可能在另一個執行緒中)。這反過來說,節點不應過度執行這項操作;以序宣告 N 個依附元件的節點可能會重新啟動 N 次,耗費 O(N^2) 的時間。相反地,我們會預先大量宣告依附元件,這可能需要重新整理程式碼,甚至將節點分割成多個節點,以限制重新啟動的次數。
請注意,這項技術目前無法在規則 API 中使用;相反地,規則 API 仍是使用載入、分析和執行階段的舊版概念定義。不過,基本限制是,所有對其他節點的存取作業都必須經過架構,才能追蹤對應的依附元件。無論建構系統實作或編寫規則所使用的語言為何 (兩者不一定相同),規則作者都不得使用會略過 Skyframe 的標準程式庫或模式。就 Java 而言,這表示您應避免使用 java.io.File 和任何形式的反射,以及任何執行這兩項操作的程式庫。支援這些低階介面依附元件注入功能的程式庫,仍需要正確設定 Skyframe。
因此,強烈建議您一開始就避免讓規則作者接觸完整語言的執行階段。不小心使用這類 API 的風險太大。過去幾個 Bazel 錯誤都是因為使用不安全的 API 而導致,即使這些規則是由 Bazel 團隊或其他領域專家編寫,也無法避免。
避免平方時間和記憶體用量很困難
更糟的是,除了 Skyframe 強制規定的要求、使用 Java 的歷史限制,以及規則 API 過時的問題外,在任何以程式庫和二進位規則為基礎的建構系統中,不小心引入平方時間或記憶體消耗量,都是基本問題。有兩種非常常見的模式會導致二次方記憶體用量 (因此二次方時間用量)。
程式庫規則鏈結 - 請考慮以下情況:程式庫規則鏈結 A 依附於 B,依附於 C,依此類推。接著,我們想針對這些規則的傳遞閉包計算某些屬性,例如 Java 執行階段的 classpath,或是每個程式庫的 C++ 連結器指令。我們可能會採用標準清單實作方式,但這會導致二次方記憶體用量:第一個程式庫在 classpath 中包含一個項目,第二個程式庫包含兩個項目,第三個程式庫包含三個項目,以此類推,總共為 1+2+3+...+N = O(N^2) 個項目。
依據相同程式庫規則的二進位規則 - 請考慮一組二進位檔依據相同的程式庫規則的情況,例如如果您有許多測試規則來測試相同的程式庫程式碼。假設在 N 個規則中,一半是二元規則,另一半是程式庫規則。請考慮以下情況:每個二進位檔都會複製某些屬性,這些屬性是根據程式庫規則的傳遞閉包計算而得,例如 Java 執行階段的 classpath 或 C++ 連結器指令列。舉例來說,它可以展開 C++ 連結動作的指令列字串表示法。N/2 個元素的 N/2 個副本為 O(N^2) 記憶體。
自訂集合類別,避免二次方複雜度
Bazel 會受到這兩種情況的嚴重影響,因此我們引入了一組自訂集合類別,可避免在每個步驟中複製資料,有效壓縮記憶體中的資訊。幾乎所有這些資料結構都已設定語意,因此我們稱之為 depset (在內部實作中也稱為 NestedSet
)。過去幾年,我們為了減少 Bazel 的記憶體用量,做出了許多變更,其中大部分是改用 depset,而非先前使用的任何內容。
很遺憾,使用 depset 無法自動解決所有問題;特別是,即使只是在每個規則中重複執行 depset,也會導致二次平方的時間耗用。在內部,NestedSets 也提供一些輔助方法,方便與一般集合類別進行互通作業;不幸的是,不小心將 NestedSet 傳遞至其中一個方法,會導致複製行為,並重新引入二次方記憶體用量。