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