ルール記述の課題

このページでは、効率的な Bazel ルールの作成に関する具体的な問題と課題の概要について説明します。

要件の概要

  • 前提条件: 正確性、スループット、使いやすさ、レイテンシを重視する
  • 前提条件: 大規模なリポジトリ
  • 前提条件: BUILD に似た記述言語
  • 履歴: ロード、分析、実行の厳密な分離は古くなっていますが、API にはまだ影響があります
  • 本質的: リモート実行とキャッシュは難しい
  • 本質的: 正確で高速な増分ビルドに変化情報を使用するには、特殊なコーディング パターンが必要
  • 本質的: 2 次の時間とメモリの消費を回避するのは難しい

前提条件

ビルドシステムに関する前提条件をいくつか示します。正確性、使いやすさ、スループット、大規模なリポジトリの必要性などです。以降のセクションでは、これらの前提条件について説明し、ルールを効果的に作成するためのガイドラインを示します。

正確性、スループット、使いやすさ、レイテンシを重視する

ビルドシステムは、何よりも増分ビルドに関して正確である必要があります。特定のソースツリーの場合、出力ツリーがどのように見えるかに関係なく、同じビルドの出力は常に同じである必要があります。概算では、Bazel は特定のビルドステップに入るすべての入力を認識する必要があります。これにより、入力のいずれかが変更された場合にそのステップを再実行できます。Bazel の正確性には限界があります。ビルドの日時などの情報が漏洩し、ファイル属性の変更など、特定の種類の変更が無視されます。サンドボックス化 は、宣言されていない入力ファイルへの読み取りを防ぐことで正確性を確保します。システムの固有の制限に加えて、Fileset または C++ ルールに関連する既知の正確性の問題がいくつかあります。これらはどちらも難しい問題です。Google では、これらの問題を解決するための長期的な取り組みを行っています。

ビルドシステムの 2 番目の目標は、高スループットを実現することです。Google は、リモート実行サービスに現在割り当てられているマシン内で実行できることの限界を常に押し広げています。リモート実行サービスが過負荷になると、誰も作業を完了できなくなります。

次に使いやすさが重要になります。リモート実行サービスのフットプリントが同じ(または類似)の複数の正しいアプローチの中から、使いやすいものを選択します。

レイテンシとは、ビルドの開始から目的の結果が得られるまでの時間を指します。これは、合格または不合格のテストのテストログ、または 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 がビルドステップに入るすべての入力ファイルを認識して、そのビルドステップが最新の状態であるかどうかを検出する必要があります。パッケージのロードとルール分析についても同様です。Google は、これを一般的に処理するように 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 バグのいくつかは、Bazel チームや他のドメイン エキスパートが作成したルールであっても、安全でない API を使用したルールが原因でした。

2 次の時間とメモリの消費を回避するのは難しい

さらに悪いことに、Skyframe によって課される要件、Java の使用に関する過去の制約、ルール API の古さとは別に、ライブラリ ルールとバイナリ ルールに基づくビルドシステムでは、2 次の時間またはメモリの消費を誤って導入することが根本的な問題です。2 次のメモリ消費(したがって 2 次の時間消費)を引き起こすパターンは 2 つあります。

  1. ライブラリ ルールのチェーン - ライブラリ ルールのチェーン A が B に依存し、B が C に依存する場合を考えてみましょう。次に、これらのルールの推移的閉包に対して、Java ランタイム クラスパスや各ライブラリの C++ リンカー コマンドなどのプロパティを計算します。単純に標準のリスト実装を使用すると、2 次のメモリ消費が発生します。最初のライブラリにはクラスパスに 1 つのエントリ、2 番目のライブラリには 2 つのエントリ、3 番目のライブラリには 3 つのエントリが含まれます。合計で 1+2+3+...+N = O(N^2) エントリになります。

  2. 同じライブラリ ルールに依存するバイナリ ルール - 同じライブラリ ルールに依存するバイナリのセットがある場合を考えてみましょう。たとえば、同じライブラリ コードをテストするテストルールが多数ある場合などです。N 個のルールのうち、半分のルールがバイナリ ルールで、残りの半分がライブラリ ルールであるとします。各バイナリは、ライブラリ ルールの推移的閉包に対して計算されたプロパティ(Java ランタイム クラスパスや C++ リンカー コマンドラインなど)のコピーを作成します。たとえば、C++ リンク アクションのコマンドライン文字列表現を拡張できます。N/2 個の N/2 要素のコピーは O(N^2) メモリです。

2 次の複雑さを回避するカスタム コレクション クラス

Bazel はこれらのシナリオの両方に大きな影響を受けるため、各ステップでのコピーを回避することでメモリ内の情報を効果的に圧縮するカスタム コレクション クラスのセットを導入しました。これらのデータ構造のほとんどはセット セマンティクスを持っているため、 depset と呼びました(内部実装では NestedSet とも呼ばれます)。過去数年間に Bazel のメモリ消費量を削減するための変更のほとんどは、以前使用されていたものから depset を使用するように変更したものです。

残念ながら、depset の使用ではすべての問題が自動的に解決されるわけではありません。特に、各ルールで depset を反復処理するだけでも、2 次の時間消費が再導入されます。内部的には、NestedSets には通常のコレクションクラスとの相互運用を容易にするヘルパー メソッドもありますが、誤って NestedSet をこれらのメソッドのいずれかに渡すと、コピー動作が発生し、2 次のメモリ消費が再導入されます。