編寫規則的挑戰

回報問題 查看來源

這個頁面會概略說明編寫有效 Bazel 規則時會發生的特定問題和挑戰。

摘要規定

  • 假設:以正確性、處理量、使用難易度和延遲為目標
  • 假設:大型擴充性存放區
  • 假設:類似建構型的描述語言
  • 歷史事件:載入、分析和執行之間的硬性分離已過時,但仍會影響 API
  • 內建函式:遠端執行和快取作業會很困難
  • 內建函式:使用變更資訊以正確且快速的漸進式版本需要有異常的編碼模式
  • 內建函式:避免一連串時間與記憶體耗用量

假設

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

著重正確性、處理量、易用性和延遲

我們假設建構系統必須優先執行,而且對漸進式版本抱持最正確的看法。無論輸出樹狀結構的呈現方式為何,同一個版本的輸出內容都應一律相同。在第一個概略位置中,這表示 Bazel 必須知道特定建構步驟中的每一個輸入內容,以便在任何輸入內容變更時重新執行該步驟。Bazel 的正確存取方式設有限制,因為這樣會洩漏部分資訊,例如建構的日期 / 時間,並忽略某些類型的變更,例如檔案屬性變更。沙箱功能有助於避免讀取未宣告的輸入檔案,確保內容正確。除了系統的內建限制以外,還有一些已知的正確性問題,其中大部分與 Fileset 或 C++ 規則有關,都會造成困難。我們採取了長期修正措施,

建構系統的第二個目標是擁有高處理量;在目前的機器配置中,為遠端執行服務可達成的目標,會持續推進界限。如果遠端執行服務超載,則任何人都無法執行作業。

易用性原則如下。有許多相同 (或類似) 的遠端執行服務採用的正確方法,我們會選擇較易使用的方法。

延遲時間表示從開始建構到取得預期結果所需的時間,無論是通過測試或失敗測試的測試記錄,或是 BUILD 檔案含有錯字的錯誤訊息。

請注意,這些目標通常會重疊;延遲時間如同遠端執行服務的正確性,其對方便使用程度一樣有效。

大型存放區

建構系統必須大規模執行大型存放區,因為其中大規模的意味著其無法存放於單一硬碟,所以幾乎所有的開發人員機器都無法完整結帳。中型版本必須讀取及剖析數萬個 BUILD 檔案,並評估數百個 glob。雖然理論上可以讀取單一機器上的所有 BUILD 檔案,但我們還無法在合理的時間和記憶體內進行這項作業。因此,請務必獨立載入及剖析 BUILD 檔案。

類似 UI 的說明語言

在此情況下,我們假設程式庫和二進位檔規則的宣告及其依附元件大致與 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、依附 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 記憶體用量所做的大多數變更,都已改用 Depset ,而不是之前使用的任何變更。

遺憾的是,使用依附元件並不會自動解決所有問題;尤其是,即使只是在每個規則中的某個依附元件疊代,也會重新產生四次時間消耗。在內部,NestedSet 也有一些輔助方法,可協助促進與一般集合類別的互通性;不幸地,意外將 NestedSet 傳遞至這些方法,會導致複製行為,並重新引入二次記憶體使用量。