このページでは、効率的な Bazel ルールの作成に関する具体的な問題と課題の概要を説明します。
要件の概要
- 前提条件: 正確性、スループット、使いやすさ、レイテンシを目指す
- 前提条件: 大規模なリポジトリ
- 前提条件: BUILD のような説明言語
- 履歴: ロード、分析、実行の間のハード分離は古くなっていますが、API にはまだ影響があります
- 本質的な問題: リモート実行とキャッシュ保存は難しい
- 固有: 変更情報を使用して正確かつ高速な増分ビルドを行うには、異常なコーディング パターンが必要
- 本質的な問題: 2 次の時間とメモリ消費を回避するのは難しい
前提条件
ビルドシステムに関する仮定(正確性、使いやすさ、スループット、大規模なリポジトリの必要性など)を以下に示します。以降のセクションでは、これらの前提条件について説明し、ルールを効果的に記述するためのガイドラインを示します。
正確性、スループット、使いやすさ、レイテンシを目指す
ビルドシステムは、まず増分ビルドに関して正しいことが必要であると想定しています。特定のソースツリーの場合、出力ツリーがどのようなものであっても、同じビルドの出力は常に同じである必要があります。大まかに言うと、Bazel は特定のビルドステップで使用されるすべての入力を把握する必要があります。これにより、入力のいずれかが変更された場合に、そのステップを再実行できます。Bazel はビルドの日時などの情報を漏洩し、ファイル属性の変更などの特定の種類の変更を無視するため、Bazel の正確性には限界があります。サンドボックス化により、未宣言の入力ファイルへの読み取りを防ぎ、正確性を確保します。システムの固有の制限に加えて、いくつかの既知の正しさの問題があります。そのほとんどは Fileset または C++ ルールに関連しており、どちらも難しい問題です。Google は、これらの問題を解決するための長期的な取り組みを行っています。
ビルドシステムの 2 つ目の目標は、高いスループットを実現することです。リモート実行サービスの現在のマシン割り当て内で実行できることの限界を常に押し広げています。リモート実行サービスが過負荷になると、誰も作業を完了できなくなります。
次に使いやすさです。リモート実行サービスのフットプリントが同じ(または類似している)複数の正しいアプローチのうち、使いやすいものを選択します。
レイテンシは、ビルドの開始から目的の結果(合格または不合格のテストのテストログ、または BUILD
ファイルに誤字脱字があるというエラー メッセージなど)が得られるまでの時間を示します。
これらの目標は重複することがよくあります。レイテンシは、リモート実行サービスのスループットの関数であり、使いやすさに関連する正確性も同様です。
大規模なリポジトリ
ビルドシステムは、大規模なリポジトリの規模で動作する必要があります。大規模とは、単一のハードドライブに収まらないことを意味するため、事実上すべてのデベロッパー マシンで完全なチェックアウトを行うことはできません。中規模のビルドでは、数万個の BUILD
ファイルを読み取って解析し、数十万個の glob を評価する必要があります。理論的には 1 台のマシンで BUILD
ファイルをすべて読み取ることは可能ですが、妥当な時間とメモリ内で実現することはまだできていません。そのため、BUILD
ファイルを個別に読み込んで解析できることが重要です。
BUILD のような説明言語
このコンテキストでは、ライブラリとバイナリのルールとその相互依存関係の宣言において、BUILD
ファイルとほぼ同様の構成言語を想定しています。BUILD
ファイルは個別に読み取って解析できます。可能な限り(存在する場合を除く)、ソースファイルを参照しないようにします。
歴史にゆかりがある場所
Bazel のバージョン間には、課題を引き起こす違いがあります。その一部を次のセクションで説明します。
読み込み、分析、実行の厳密な分離は時代遅れですが、API にはまだ影響があります
技術的には、アクションがリモート実行に送信される直前に、アクションの入力ファイルと出力ファイルがルールに認識されていれば十分です。ただし、元の Bazel コードベースでは、パッケージの読み込み、構成(基本的にはコマンドライン フラグ)を使用したルールの分析、アクションの実行が厳密に分離されていました。Bazel のコアではこの区別は不要になりましたが、現在でもルール API の一部として残っています(詳しくは下記をご覧ください)。
つまり、ルール API には、ルール インターフェースの宣言型記述(属性とその型)が必要です。API では、読み込みフェーズ中にカスタムコードを実行して、出力ファイルの暗黙的な名前と属性の暗黙的な値を計算できる例外がいくつかあります。たとえば、'foo' という名前の java_library ルールは、'libfoo.jar' という名前の出力を暗黙的に生成します。この出力は、ビルドグラフ内の他のルールから参照できます。
さらに、ルールの分析では、ソースファイルを読み取ったり、アクションの出力を検査したりすることはできません。代わりに、ルール自体とその依存関係からのみ決定されるビルドステップと出力ファイル名の部分的な有向二部グラフを生成する必要があります。
本質的
ルール作成を困難にする固有のプロパティがいくつかあります。以降のセクションでは、最も一般的なプロパティについて説明します。
リモート実行とキャッシュ保存は難しい
リモート実行とキャッシュ保存により、大規模なリポジトリのビルド時間は、単一のマシンでビルドを実行する場合と比較して、約 2 桁短縮されます。ただし、実行する必要がある規模は驚くべきものです。Google のリモート実行サービスは、1 秒あたりに大量のリクエストを処理するように設計されており、プロトコルは不要なラウンドトリップやサービス側の不要な作業を慎重に回避します。
現時点では、このプロトコルでは、ビルドシステムが特定のアクションのすべての入力を事前に認識している必要があります。ビルドシステムは一意のアクション フィンガープリントを計算し、スケジューラにキャッシュ ヒットを要求します。キャッシュ ヒットが見つかった場合、スケジューラは出力ファイルのダイジェストを返します。ファイル自体は後でダイジェストでアドレス指定されます。ただし、この方法では Bazel ルールに制限が課せられます。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 のバグがいくつか発生しています。
2 次の時間とメモリ消費を回避することは困難です
さらに、Skyframe によって課せられた要件、Java の使用に関する過去の制約、ルール API の古さとは別に、ライブラリとバイナリ ルールに基づくビルドシステムでは、二次時間またはメモリ消費が誤って導入されるという根本的な問題があります。2 つの非常に一般的なパターンで、2 次のメモリ消費(したがって 2 次の時間消費)が発生します。
ライブラリ ルールのチェーン - ライブラリ ルールのチェーン A が B に依存し、B が C に依存するなどの場合を考えます。次に、これらのルールの推移閉包に対して、Java ランタイム クラスパスや各ライブラリの C++ リンカー コマンドなどのプロパティを計算します。単純に標準のリスト実装を使用することもできますが、これではすでに 2 次のメモリ消費が発生します。最初のライブラリにはクラスパスに 1 つのエントリ、2 番目のライブラリには 2 つ、3 番目のライブラリには 3 つというように、合計で 1+2+3+...+N = O(N^2) 個のエントリが含まれます。
同じライブラリ ルールに依存するバイナリ ルール - 同じライブラリ ルールに依存するバイナリのセットがある場合を考えてみましょう。たとえば、同じライブラリ コードをテストするテストルールが複数ある場合などです。N 個のルールのうち、半分がバイナリ ルールで、残りの半分がライブラリ ルールであるとします。ここで、各バイナリが、Java ランタイム クラスパスや C++ リンカー コマンドラインなど、ライブラリ ルールの推移的閉包で計算されたプロパティのコピーを作成するとします。たとえば、C++ リンク アクションのコマンドライン文字列表現を拡張できます。N/2 個の要素の N/2 個のコピーは、O(N^2) のメモリです。
二次複雑性を回避するカスタム コレクション クラス
Bazel はこれらのシナリオの両方に大きな影響を受けるため、各ステップでのコピーを回避することでメモリ内の情報を効果的に圧縮するカスタム コレクション クラスのセットを導入しました。これらのデータ構造のほとんどはセット セマンティクスを備えているため、depset(内部実装では NestedSet
とも呼ばれます)と呼びます。過去数年間に Bazel のメモリ消費量を削減するために行われた変更のほとんどは、以前に使用されていたものに代わって depsets を使用するための変更でした。
残念ながら、depset を使用してもすべての問題が自動的に解決されるわけではありません。特に、各ルールで depset を反復処理するだけでも、二次時間の消費が再び発生します。内部的には、NestedSets には通常のコレクション クラスとの相互運用性を容易にするためのヘルパー メソッドもあります。ただし、これらのメソッドのいずれかに NestedSet を誤って渡すと、コピー動作が発生し、二次関数的なメモリ消費が再び発生します。