編寫規則的挑戰

回報問題 查看來源 Nightly · 8.4 · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

本頁面提供高階總覽,說明編寫有效率的 Bazel 規則時會遇到的特定問題和挑戰。

摘要規定

  • 假設:目標是正確性、處理量、易用性和延遲
  • 假設:大規模存放區
  • 假設:類似 BUILD 的說明語言
  • 歷史記錄:載入、分析和執行之間的硬性分隔已過時,但仍會影響 API
  • 內在:遠端執行和快取很困難
  • 內建:使用變更資訊進行正確且快速的增量建構需要不尋常的編碼模式
  • 內在:避免二次時間和記憶體消耗量很困難

假設

以下是針對建構系統所做的幾項假設,例如需要正確性、易用性、輸送量和大型存放區。以下各節將說明這些假設,並提供指南,確保規則能有效撰寫。

目標是正確性、處理量、易用性和延遲時間

我們假設建構系統首先必須正確處理增量建構。對於特定來源樹狀結構,無論輸出樹狀結構為何,相同建構作業的輸出內容一律相同。初步來說,這表示 Bazel 必須知道特定建構步驟的每個輸入內容,這樣一來,如果任何輸入內容有變更,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 必須知道建構步驟中的所有輸入檔案,才能偵測該建構步驟是否仍為最新狀態。套件載入和規則分析也是如此,我們設計了 Skyframe,一般來說就是為了處理這類情況。Skyframe 是圖形程式庫和評估架構,可接收目標節點 (例如「使用這些選項建構 //foo」),並將其分解為組成部分,然後評估及合併這些部分,產生結果。在此過程中,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 過時之外,在任何以程式庫和二進位規則為基礎的建構系統中,意外引入二次時間或記憶體耗用量都是基本問題。有兩種非常常見的模式會導致二次記憶體用量 (因此也會導致二次時間用量)。

  1. 程式庫規則鏈結 - 假設程式庫規則鏈結 A 依附於 B,B 依附於 C,依此類推。然後,我們想計算這些規則遞移封閉的某些屬性,例如 Java 執行階段類別路徑,或是每個程式庫的 C++ 連接器指令。天真地說,我們可能會採用標準清單實作,但這已經會導致二次記憶體耗用:第一個程式庫包含類路徑上的一個項目,第二個包含兩個,第三個包含三個,依此類推,總共包含 1+2+3+...+N = O(N^2) 個項目。

  2. 取決於相同程式庫規則的二進位規則 - 假設有一組二進位檔取決於相同程式庫規則,例如您有多個測試規則,用來測試相同程式庫程式碼。假設有 N 條規則,其中一半是二進位規則,另一半是程式庫規則。現在請考慮,每個二進位檔都會複製透過程式庫規則的遞移閉包計算出的某些屬性,例如 Java 執行階段類別路徑或 C++ 連結器指令列。舉例來說,它可以展開 C++ 連結動作的指令列字串表示法。N/2 個 N/2 元素副本的記憶體是 O(N^2)。

自訂集合類別,避免二次方複雜度

這兩種情況都會對 Bazel 造成嚴重影響,因此我們導入了一組自訂集合類別,可避免在每個步驟複製資料,有效壓縮記憶體中的資訊。幾乎所有這些資料結構都已設定語意,因此我們稱之為「depset」(在內部實作中也稱為 NestedSet)。過去幾年,為減少 Bazel 記憶體耗用量而進行的大部分變更,都是改用 depsets,而非先前使用的任何項目。

很抱歉,使用 depsets 並不會自動解決所有問題;特別是,即使只是在每個規則中疊代 depset,也會重新引入二次方時間消耗。在內部,NestedSets 也有一些輔助方法,可促進與一般集合類別的互通性;遺憾的是,不小心將 NestedSet 傳遞至其中一個方法會導致複製行為,並重新引入二次記憶體耗用。