ルール記述の課題

問題を報告 ソースを表示

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

要件の概要

  • 前提条件: 正確性、スループット、使いやすさ、レイテンシを目指す
  • 前提: 大規模なリポジトリ
  • 前提: BUILD 形式の説明言語
  • 過去: 読み込み、分析、実行の間のハードな分離は古いが、API には引き続き影響する
  • 本質的: リモート実行とキャッシュ保存は難しい
  • 本質的: 変更情報を使用して正確で高速な増分ビルドを行うには、異常なコーディング パターンが必要
  • 本質的: 二次時間とメモリ消費の回避は困難

前提条件

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

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

増分ビルドに関しては、ビルドシステムが何よりもまず正しいものである必要があると想定しています。特定のソースツリーについては、出力ツリーの内容に関係なく、同じビルドの出力は常に同じである必要があります。最初の近似では、Bazel は特定のビルドステップに入るすべての入力を把握して、いずれかの入力が変更された場合にそのステップを再実行できるようにする必要があります。Bazel ではビルドの日時などの情報が漏洩し、ファイル属性の変更など特定の種類の変更が無視されるため、Bazel の精度には制限があります。サンドボックス化により、宣言されていない入力ファイルの読み取りを防ぐことで、正確性を確保できます。システムの本質的な制限に加えて、正確性に関する問題がいくつかあります。そのほとんどは Fileset ルールまたは C++ ルールに関連しており、どちらも難しい問題です。YouTube では、この問題の修正に長期的な取り組みを続けています。

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

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

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

多くの場合、これらの目標は重複します。レイテンシは、使いやすさに関連する正確性に関連するのと同時に、リモート実行サービスのスループットの関数です。

大規模なリポジトリ

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

BUILD 形式の説明言語

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

歴史にゆかりがある場所

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

読み込み、分析、実行のハードな分離は時代遅れだが API に影響する

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

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

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

本質的

ルールの作成を困難にする固有のプロパティがあります。以下では、その最も一般的なプロパティについて説明します。

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

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

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

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

上記では、ビルドステップが最新かどうかを検出するために、ビルドステップに入るすべての入力ファイルを Bazel が把握する必要があると主張しました。パッケージの読み込みとルール分析にも同じことが言えます。Google では、これを一般的に処理できるように 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 を誤って使用するリスクは大きすぎます。過去には、安全でない API を使用するルールが原因で Bazel バグが発生していました。そのルールが Bazel チームや他のドメインエキスパートによって作成されていました。

二次的な時間とメモリ使用量の回避が困難

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

  1. ライブラリ ルールのチェーン - ライブラリ ルール A のチェーンが B に依存し、C に依存(以下同様)する場合を考えてみましょう。次に、Java ランタイム クラスパスや、各ライブラリの C++ リンカー コマンドなど、これらのルールの推移的クロージャに対してなんらかのプロパティを計算します。単純に言うと、標準的なリストの実装を採用することもできますが、これではすでに 2 次メモリ消費量が導入されています。最初のライブラリにはクラスパスに 1 つのエントリ、2 番目、2 番目、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 をこれらのメソッドのいずれかに誤って渡すと、コピー動作が発生し、メモリ消費量が二次的になります。