編寫規則的挑戰

回報問題 查看來源

本頁概略說明編寫高效率 Bazel 規則的特定問題和挑戰。

摘要規定

  • 假設:以正確性、處理量、易用性和延遲為目標
  • 假設:大型存放區
  • 假設:類似 BUILD 的說明語言
  • 歷史悠久:載入、分析和執行作業之間的硬性區隔性過時,但仍會影響 API
  • 內建函式:遠端執行和快取是硬的
  • 本質:使用變更資訊來正確快速地建構,需要異常的程式設計模式
  • 本質上:避免二次耗時和記憶體用量很困難

假設

以下是關於建構系統的假設,例如對正確性、易用性、處理量和大規模存放區的需求。以下各節將說明這些假設並提供指引,確保以有效方式編寫規則。

以正確性、處理量、易用性和延遲為考量

我們假設建構系統需遵循漸進式建構,因此最為正確。無論輸出樹狀結構的樣子為何,特定來源樹狀結構的輸出內容一律必須相同。在第一個估算作業中,這意味 Bazel 必須知道每個進入特定建構步驟的輸入項目,這樣只要任何輸入內容有變化,就能重新執行該步驟。正確的 Bazel 會獲取部分資訊 (例如建構的日期 / 時間),且會忽略某些類型的變更 (例如檔案屬性變更),因此正確的預期方法有限。沙箱機制會防止讀取未宣告的輸入檔案,協助確保正確性。除了系統的內建限制之外,還有一些已知的正確問題,其中大部分都與 Fileset 或 C++ 規則有關,這些都是棘手的問題。我們長期以來會努力修正這些問題。

建構系統的第二個目標是擁有高處理量;我們將持續推展在目前機器分配中,為遠端執行服務所完成的作業範圍。如果遠端執行服務超載,任何人都無法完成工作。

接下來是讓客戶更容易使用。在受多個遠端執行服務足跡相同 (或類似的) 的正確方法中,我們會選擇較易於使用的方法。

延遲時間代表從啟動建構到取得預期結果所花費的時間 (無論是來自通過或失敗的測試的測試記錄,或 BUILD 檔案有錯字的錯誤訊息)。

請注意,這些目標通常會重疊;延遲時間相當於遠端執行服務的處理量,以方便使用上的正確性為準。

大型存放區

建構系統需要在大型存放區中進行大規模運作,而大規模存放區代表不能單一硬碟,因此很難在所有開發人員電腦上完成完整結帳。中型建構作業需要讀取及剖析數萬個 BUILD 檔案,且必須評估數十萬 glob。雖然理論上來說,您可以在單一機器上讀取所有 BUILD 檔案,但在合理的時間和記憶體中,還是無法這麼做。因此,請務必單獨載入及剖析 BUILD 檔案。

類似於建構的說明語言

在此情境中,我們假設的設定語言大致與程式庫和二進位規則宣告中的 BUILD 檔案及其相互依附元件相似。BUILD 檔案可以獨立讀取及剖析,而且我們甚至會盡可能避免查看來源檔案 (除非存在)。

歷史古蹟

Bazel 版本之間存在差異,因而造成困難,以下章節將概述部分版本。

載入、分析和執行作業之間的硬性區隔已過時,但仍會影響 API

從技術層面來說,在將動作傳送至遠端執行前,規則就足以讓規則知道動作的輸入和輸出檔案。不過,原本的 Bazel 程式碼集對載入套件有嚴格區隔性,接著使用設定 (基本上就是指令列標記) 分析規則,然後只執行任何動作。即使 Bazel 的核心不再需要這些差異 (詳情請見下文),但目前這項區別仍是規則 API 的一部分。

這表示規則 API 需要規則介面 (其擁有的屬性、屬性類型) 的宣告說明。在某些例外情況下,API 允許自訂程式碼在載入階段執行,以計算輸出檔案的隱含名稱和屬性的隱含值。舉例來說,名為「foo」的 java_library 規則隱含產生名為「libfoo.jar」的輸出內容,可從建構圖表中的其他規則參照。

此外,規則的分析無法讀取任何來源檔案或檢查動作的輸出,而需要產生部分有向的建構步驟圖,並輸出僅從規則本身及其依附元件決定的檔案名稱。

本質上可解釋

有些內建屬性會讓編寫規則具有挑戰性,而最常見的屬性會在以下各節中說明。

遠端執行和快取很困難

與在單一機器上執行版本相比,遠端執行與快取可將大型存放區中的建構時間縮短約兩大大小。然而,需要執行的規模卻變差:Google 的遠端執行服務每秒處理大量要求,且通訊協定會仔細避免不必要的往返作業,服務端不需要進行不必要的工作。

此時,通訊協定需要建構系統預先知道特定動作的所有輸入內容;建構系統會計算不重複的動作指紋,並要求排程器在快取中找到了所需資料。如果找到快取命中,排程器會回覆輸出檔案的摘要;檔案本身稍後會由摘要進行處理。不過,這會對 Bazel 規則施加限制,因此需要事先宣告所有輸入檔案。

使用變更資訊來正確快速進行漸進式建構作業,需要不尋常的程式設計模式

上面的論述指出,Bazel 需要知道建構步驟中的所有輸入檔案,才能偵測建構步驟是否仍在最新狀態。套件載入與規則分析也是如此,而我們設計了 SkyFrame 來處理一般情況。SkyFrame 是一種圖表程式庫和評估架構,會使用目標節點 (例如「build //foo with these options」) 並拆解成其組成部分,然後進行評估和組合以產生此結果。在這個程序中,SkyFrame 會讀取套件、分析規則和執行動作。

在每個節點中,SkyFrame 會準確追蹤任何使用特定節點計算自己的輸出內容的節點,從目標節點到輸入檔案 (也就是 SkyFrame 節點)。在記憶體中明確表示這張圖表可讓建構系統識別出受到輸入檔案變更 (包括建立或刪除輸入檔案) 而確切影響的節點,並執行最基本的工作,將輸出樹狀結構還原至預期狀態。

過程中,每個節點都會執行依附元件探索程序。每個節點都可以宣告依附元件,然後使用這些依附元件的內容宣告進一步的依附元件。原則上,這可對應至每個節點的執行緒模型。不過,中型建構作業包含數十萬個 SkyFrame 節點,這在目前的 Java 技術中不容易達成 (由於歷史上,我們目前連結的是 Java,因此沒有輕量的執行緒,也沒有接續運作)。

而 Bazel 會使用固定大小的執行緒集區。但是,這表示如果節點宣告的依附元件尚未可用,我們可能必須在依附元件可用時取消評估,並在其他執行緒中重新啟動評估作業。反之,這表示節點不應過度執行此操作;宣告 N 依附元件的節點有可能被依序重新啟動 N 次,從而減少 O(N^2) 時間。我們的目標是提前大量宣告依附元件,有時為了限制重新啟動次數,有時需要重新整理程式碼,甚至將節點分割為多個節點。

請注意,Rules API 目前並未提供這項技術;相反地,規則 API 仍在使用載入、分析和執行階段的舊版概念定義。不過,基本限制是,其他節點的存取一律都必須經過架構,才能追蹤對應的依附元件。無論建構系統採用哪種語言,或編寫規則時使用的語言 (兩者不必相同),規則作者都不得使用能避開 SkyFrame 的標準程式庫或模式。以 Java 來說,這代表避免使用 java.io.File 與任何形式的反射,也避免使用這兩者的程式庫。支援插入這些低層級介面的程式庫仍需要正確設定 SkyFrame。

強烈建議您避免一開始就將規則作者暴露於全語言執行階段中。意外使用這類 API 的危險會過於龐大,即使這些規則是由 Bazel 團隊或其他領域專家撰寫,但過去某些 Bazel 錯誤是由使用不安全的 API 所導致。

要避免二次工程時間和記憶體使用量相當困難

更糟的是,除了 SkyFrame 的限制、使用 Java 的歷史限制,以及規則 API 的過時程度外,在任何以程式庫和二進位檔規則為基礎的建構系統中,意外導入二次時間或記憶體用量都是根本問題。有兩個非常常見的模式,都會造成二次記憶體使用 (進而導致四次消耗量)。

  1. 程式庫規則鏈結 - 假設程式庫規則 A 的鏈結位於 B 依附於 B、依附於 C,依此類推。然後,計算這些規則的遞移期間計算某些屬性,例如 Java 執行階段類別路徑或每個程式庫的 C++ 連結器指令。我們可以採用標準清單實作方式;然而,這已經實現二次記憶體消耗:第一個程式庫在類別路徑上包含一個項目,後二、第三個三個,依此類推,總共 1+2+3+...+N = O(N^2) 個項目。

  2. 根據相同程式庫規則而定的二進位檔規則 - 請考慮使用一組依附相同程式庫規則的二進位檔,例如您有多項測試規則來測試相同程式庫程式碼時。假設在 N 規則中,一半的規則為二進位規則,其他一半的程式庫規則。現在,請思考每個二進位檔會建立藉由程式庫規則遞移性閉包計算的部分屬性副本,例如 Java 執行階段類別路徑或 C++ 連結器指令列。舉例來說,這個 API 可以展開 C++ 連結動作的指令列字串表示法。N/2 元素的 N/2 副本是 O(N^2) 記憶體。

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

Bazel 在上述兩種情況下都會受到嚴重影響,因此我們推出了一組自訂集合類別,透過避免每個步驟的複製功能,有效壓縮記憶體中的資訊。幾乎所有的資料結構都有設定語意,因此我們將其稱為 depset (在內部實作中也稱為 NestedSet)。過去幾年來降低 Bazel 記憶體消耗的大部分變更,都是改用 Depset,而非之前使用過的任何變更。

遺憾的是,使用 Depset 並不會自動解決所有問題;特別是即使只是在每個規則中疊代偏移,都會重新產生二次使用。在內部,NestedSet 也有一些輔助方法,可促進與一般集合類別之間的互通;遺憾的是,將 NestedSet 因意外傳遞至其中一種方法會導致複製行為,並重新造成二次記憶體消耗量。