ルール記述の課題

問題を報告する ソースを表示 ナイトリー · 8.0 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

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

要件の概要

  • 前提条件: 正確性、スループット、使いやすさ、レイテンシを重視
  • 前提条件: 大規模なリポジトリ
  • 前提条件: BUILD に似た記述言語
  • 過去: 読み込み、分析、実行の厳格な分離は古くなっていますが、API に引き続き影響します
  • 組み込み: リモート実行とキャッシュ保存が難しい
  • 本質的: 正確で高速な増分ビルドに変更情報を使用するには、通常とは異なるコーディング パターンが必要
  • 本質的: 時間とメモリの消費量が二次関数的に増加するのを回避するのは難しい

前提条件

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

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

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

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

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

レイテンシは、ビルドの開始から目的の結果が得られるまでの時間を表します。これは、成功または失敗したテストのテストログ、または BUILD ファイルに誤字脱字があるというエラー メッセージのいずれかです。

これらの目標はしばしば重複します。レイテンシは、利便性に関連する正確性と同様に、リモート実行サービスのスループットの関数です。

大規模なリポジトリ

ビルドシステムは、大規模なリポジトリの規模で動作する必要があります。大規模とは、単一のハードドライブに収まらないことを意味します。そのため、ほとんどすべてのデベロッパー マシンで完全なチェックアウトを行うことはできません。中規模のビルドでは、数十万の BUILD ファイルを読み取って解析し、数十万のグロブを評価する必要があります。理論的には、1 台のマシンですべての BUILD ファイルを読み取ることは可能ですが、妥当な時間とメモリ内で実行することはまだできません。そのため、BUILD ファイルを個別に読み込んで解析できることが重要です。

BUILD のような記述言語

このコンテキストでは、ライブラリとバイナリ ルールとそれらの相互依存関係の宣言で BUILD ファイルにほぼ類似した構成言語を前提としています。BUILD ファイルは個別に読み取られて解析されます。また、可能な限りソースファイルを確認することさえ避けています(存在を除く)。

歴史にゆかりがある場所

Bazel のバージョンには、問題を引き起こす違いがあります。これらの違いの一部については、次のセクションで説明します。

読み込み、分析、実行の厳密な分離は時代遅れですが、API に影響を与えています

技術的には、アクションがリモート実行に送信される直前に、アクションの入力ファイルと出力ファイルをルールが把握していれば十分です。ただし、元の Bazel コードベースでは、パッケージの読み込み、構成(基本的にはコマンドライン フラグ)を使用したルールの分析、アクションの実行が厳密に分離されていました。Bazel のコアでは不要になったものの、この区別は現在も rules API の一部となっています(詳細は後述)。

つまり、rules API では、ルール インターフェースの宣言型の記述(ルールに含まれる属性、属性のタイプ)が必要です。ただし、API で読み込みフェーズ中にカスタムコードを実行して、出力ファイルの暗黙的な名前と属性の暗黙的な値を計算できる例外もあります。たとえば、「foo」という名前の java_library ルールは、「libfoo.jar」という名前の出力を暗黙的に生成します。この出力は、ビルドグラフ内の他のルールから参照できます。

さらに、ルールの分析では、ソースファイルを読み取ったり、アクションの出力を検査したりすることはできません。代わりに、ルール自体とその依存関係からのみ決定される、ビルドステップと出力ファイル名の部分的な有向 2 分グラフを生成する必要があります。

本質的

ルールの作成を困難にする固有のプロパティがいくつかあります。以降のセクションでは、最も一般的なプロパティについて説明します。

リモート実行とキャッシュが難しい

リモート実行とキャッシュにより、単一マシンでビルドを実行する場合と比較して、大規模なリポジトリでのビルド時間が約 2 桁短縮されます。ただし、実行する必要があるスケールは驚異的です。Google のリモート実行サービスは、1 秒あたり大量のリクエストを処理するように設計されており、プロトコルは不要なラウンドトリップやサービス側の不要な作業を慎重に回避します。

現時点では、このプロトコルでは、ビルドシステムが特定のアクションへのすべての入力を事前に把握している必要があります。ビルドシステムは、一意のアクション フィンガープリントを計算し、スケジューラにキャッシュヒットを要求します。キャッシュヒットが見つかると、スケジューラは出力ファイルのダイジェストで応答します。ファイル自体は後でダイジェストで参照されます。ただし、これにより Bazel ルールに制限が加わり、すべての入力ファイルを事前に宣言する必要があります。

変更情報を使用して正確かつ高速な増分ビルドを行うには、通常とは異なるコーディング パターンが必要

上記では、Bazel が正しく動作するためには、ビルドステップに入力されるすべての入力ファイルを把握し、そのビルドステップがまだ最新の状態かどうかを検出する必要があることを説明しました。パッケージの読み込みとルール分析も同様です。Google は、一般にこれを処理するように Skyframe を設計しています。Skyframe は、目標ノード(「これらのオプションで //foo をビルド」など)を受け取り、それを構成要素に分解し、評価して組み合わせて結果を生成します。これはグラフ ライブラリと評価フレームワークです。このプロセスの一環として、Skyframe はパッケージを読み取り、ルールを分析し、アクションを実行します。

Skyframe は各ノードで、特定のノードが独自の出力の計算に使用したノード(ゴールノードから入力ファイル(Skyframe ノードでもある)まで)を正確に追跡します。このグラフをメモリ内に明示的に表現することで、ビルドシステムは入力ファイルに対する特定の変更(入力ファイルの作成や削除を含む)によって影響を受けるノードを正確に特定し、出力ツリーを目的の状態に復元するために最小限の作業を行うことができます。

このプロセスの一環として、各ノードは依存関係の検出プロセスを実行します。各ノードは依存関係を宣言し、それらの依存関係の内容を使用してさらに依存関係を宣言できます。原則として、これはノードあたりのスレッドモデルに適しています。ただし、中規模のビルドには数十万の Skyframe ノードが含まれており、これは現在の Java テクノロジーで簡単には実現できません(歴史的な理由から、現在は Java の使用に縛られているため、軽量スレッドや継続はありません)。

代わりに、Bazel は固定サイズのスレッドプールを使用します。ただし、ノードがまだ使用できない依存関係を宣言した場合、その依存関係が使用可能になったときに、その評価を中止して(別のスレッドで)再開しなければならない場合があります。つまり、ノードはこれを過度に行わない必要があります。N 個の依存関係をシリアルで宣言するノードは、N 回再起動される可能性があり、O(N^2) の時間がかかります。代わりに、依存関係を事前に一括で宣言することを目標としています。そのため、コードの再編成や、再起動回数を制限するためにノードを複数のノードに分割することが必要になる場合があります。

なお、このテクノロジーは現在、rules API では使用できません。rules API は、読み込みフェーズ、分析フェーズ、実行フェーズの従来の概念を使用して定義されています。ただし、基本的な制限として、他のノードへのすべてのアクセスはフレームワークを経由して、対応する依存関係を追跡できるようにする必要があります。ビルドシステムの実装言語やルールの記述言語に関係なく(同じである必要はありません)、ルール作成者は Skyframe をバイパスする標準ライブラリやパターンを使用しないでください。Java の場合、java.io.File やあらゆる形式のリフレクション、およびどちらかを行うライブラリは使用しないことを意味します。これらの低レベル インターフェースの依存関係挿入をサポートするライブラリは、Skyframe 用に正しく設定する必要があります。

そのため、ルール作成者が完全な言語ランタイムにさらされることを最初から避けることを強くおすすめします。このような API が誤って使用される危険性は非常に大きいです。過去に発生した Bazel のバグのいくつかは、Bazel チームや他のドメイン エキスパートによって記述されたルールであっても、安全でない API を使用したルールによって発生しています。

時間とメモリ使用量の二次関数的な増加を回避するのは難しい

さらに、Skyframe が課す要件、Java の使用に関する歴史的な制約、ルール API の古さに加えて、ライブラリとバイナリ ルールに基づくすべてのビルドシステムでは、時間やメモリの消費量が二次的に増加することが根本的な問題となっています。メモリ消費が二次関数的に増加する(つまり時間消費も二次関数的に増加する)一般的なパターンが 2 つあります。

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

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

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

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

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