ルール記述の課題

問題を報告 ソースを表示

このページでは、効率的な Bazel ルールを作成するための具体的な問題と課題の概要を説明します。

要件のまとめ

  • 前提条件: 正確性、スループット、使いやすさ、レイテンシを目指す
  • 前提条件: 大規模リポジトリ
  • 前提条件: BUILD に似た記述言語
  • 履歴: 読み込み、分析、実行のハード分離は廃止されましたが、API には影響があります
  • 本質的: リモート実行とキャッシュは難しい
  • 本質的: 正しく高速な増分ビルドで変更情報を使用するには、異常なコーディング パターンが必要
  • 本質的: 二次時間とメモリ消費を回避することは困難

前提条件

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

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

増分ビルドに関して、ビルドシステムが何よりもまず正確である必要があることを前提としています。特定のソースツリーでは、出力ツリーがどのようなものであっても、同じビルドの出力が常に同じである必要があります。最初の近似では、Bazel は特定のビルドステップに入るすべての入力を認識し、入力のいずれかが変更された場合にそのステップを再実行できるようにする必要があります。Bazel による正確な取得には制限があります。ビルドの日時などの一部の情報が漏洩し、ファイル属性の変更などの特定の種類の変更が無視されるためです。サンドボックス化により、宣言されていない入力ファイルの読み取りを防ぐことで、正確性を確保します。システムに内在する制限以外にも、正確性に関する既知の問題がいくつかあります。そのほとんどは、ファイルセットや C++ のルールに関連するものであり、どちらも難しい問題です。Google はこの問題の修正に長期的に取り組んでおり、

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

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

レイテンシは、ビルドの開始から目的の結果を取得するまでの時間を示します。これは、テストの合格または不合格の結果のテストログや、BUILD ファイルに入力ミスがあるというエラー メッセージです。

多くの場合、これらの目標は重なり合っています。レイテンシは、使いやすさと同様に、リモート実行サービスのスループットの関数と同じくらい重要です。

大規模なリポジトリ

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

BUILD に似た説明言語

ここでは、ライブラリ ルールとバイナリルール、およびそれらの相互依存関係の宣言で、BUILD ファイルにおおむねよく似た構成言語を想定しています。BUILD ファイルは個別に読み取り、解析でき、可能な限りソースファイルを参照しないようにします(ただし、存在する場合を除く)。

歴史にゆかりがある場所

問題となる Bazel のバージョンには違いがあります。以下ではその一部について説明します。

読み込み、分析、実行を厳密に分離しているが、まだ API には影響しない

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

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

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

本質的

ルールの記述を難しくする固有の特性がありますが、これらの特性のうち最も一般的なものについては、以降のセクションで説明します。

リモート実行とキャッシュ保存は困難

リモート実行とキャッシュを使用すると、1 台のマシンでビルドを実行する場合と比較して、大規模なリポジトリでのビルド時間を約 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 を誤って使用する危険性は、非常に大きなものです。以前は、危険な API を使用するルールによって Bazel のバグがいくつか発生していました。ただし、ルールは Bazel チームや他のドメイン専門家によって作成されていました。

二次関数的な時間とメモリの消費を回避することは困難

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

  1. ライブラリ ルールのチェーン - ライブラリ ルールのチェーン A が B に依存し、C に依存している、という場合について考えてみましょう。次に、Java ランタイム クラスパスや各ライブラリの C++ リンカー コマンドなど、これらのルールの推移的クロージャに対するプロパティを計算します。簡単に言うと、標準的なリスト実装を採用できますが、これはすでに二次的なメモリ消費を導入しています。最初のライブラリにはクラスパス上の 1 つのエントリが含まれ、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) メモリです。

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

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

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