メインコンテンツまでスキップ

V8アーキテクチャの複雑さを飼いならす — CodeStubAssembler

· 約13分
[ダニエル・クリフォード](https://twitter.com/expatdanno)、CodeStubAssembler アセンブラ

この投稿では、CodeStubAssembler(CSA)を紹介します。これは、最近の複数のV8リリースでいくつかの大きな パフォーマンス 向上を達成するために非常に役立ったV8のコンポーネントです。CSAはまた、V8チームが高い信頼性で低レベルのJavaScript機能を迅速に最適化する能力を大幅に向上させ、開発のスピードを向上させました。

V8におけるビルトインと手書きアセンブリの簡略な歴史

CSAの役割を理解するには、その開発に至った文脈と歴史を少し理解することが重要です。

V8は、さまざまな手法を組み合わせてJavaScriptのパフォーマンスを引き出します。長時間実行されるJavaScriptコードに対しては、V8のTurbofan最適化コンパイラが、ES2015+の全範囲の機能をピークパフォーマンスで高速化する素晴らしい仕事をします。しかし、V8は、短期間実行されるJavaScriptも効率的に実行する必要があります。これは特に、ECMAScript仕様で定義された、すべてのJavaScriptプログラムが利用できるあらかじめ定義されたオブジェクトのビルトイン関数において重要です。

歴史的には、これらのビルトイン関数の多くはセルフホスティングされており、V8開発者がJavaScriptで記述していました(ただし、特別なV8内部の方言で書かれています)。良好なパフォーマンスを達成するために、これらのセルフホスティングされたビルトインは、V8がユーザーが提供するJavaScriptを最適化するために使用するのと同じメカニズムを利用します。ユーザー提供のコードと同様に、セルフホスティングされたビルトインも型フィードバックを収集する予熱フェーズが必要で、最適化コンパイラによってコンパイルされる必要があります。

この手法は特定の状況で良好なビルトインのパフォーマンスを提供しますが、さらに良い方法があります。Array.prototypeのあらかじめ定義された関数の正確な意味は、仕様内で詳細に記述されています。重要で一般的な特別なケースについては、仕様を理解することにより、V8の実装者はこれらのビルトイン関数がどのように動作すべきかを事前に正確に把握し、この知識を使用して慎重にカスタムの手作業でチューニングされたバージョンを最初から作成します。これらの_最適化されたビルトイン_は、予熱や最適化コンパイラを呼び出す必要なしに、最初の呼び出しからすでに基盤パフォーマンスが最適であるため、一般的なケースを処理します。

手書きの組み込みJavaScript関数(およびその他のV8コードの他の高速パス、ビルトインと呼ばれることもあります)から最高のパフォーマンスを引き出すために、V8の開発者は伝統的にアセンブリ言語で最適化されたビルトインを書きました。アセンブリを使用することで、V8のC++コードをトランポリンを介して呼び出す際の高コストを避け、V8が内部的に使用するカスタムなレジスタベースのABIを活用することで、手書きのビルトイン関数は特に高速になります。

手書きアセンブリの利点により、V8は何年にもわたって各プラットフォームごとに何万行もの手書きアセンブリコードをビルトインのために蓄積しました。これらの手書きアセンブリビルトインはパフォーマンスを向上させるのに素晴らしいものでしたが、新しい言語機能が常に標準化されており、この手書きアセンブリを保守し拡張することは労力がかかり間違いやすいものでした。

CodeStubAssemblerの登場

V8の開発者は長年、手書きアセンブリの利点を持ちながらも、壊れやすく保守が難しいものではないビルトインを作成することが可能かどうかというジレンマに直面していました。

TurboFanが登場したことで、この質問への答えはついに「はい」となりました。TurboFanのバックエンドは、低レベルのマシン操作用のクロスプラットフォーム中間表現 (IR)を使用します。この低レベルマシンIRは、命令セレクター、レジスター割り当て、命令スケジューラー、コード生成器に入力され、すべてのプラットフォームで非常に効率的なコードを生成します。バックエンドはまた、V8の手書きアセンブリのビルトインで使用される多くのトリックを知っています。たとえば、カスタムレジスターをベースにしたABIを使用して呼び出す方法、マシンレベルの末尾呼び出しをサポートする方法、葉関数でスタックフレームの構築を省略する方法などです。この知識により、TurboFanのバックエンドは高速で効率的なコードを生成し、V8全体とよく統合されるように特に適しています。

この機能の組み合わせにより、手書きのアセンブリビルトインに対する堅牢で保守性の高い代替案が初めて現実的になりました。チームは新しいV8コンポーネントを構築しました—CodeStubAssemblerまたはCSAと命名され—TurboFanのバックエンド上に構築されたポータブルアセンブリ言語を定義します。CSAはAPIを追加し、TurboFanのJavaScript固有の最適化を適用したり、JavaScriptを記述および解析したりする必要なく、TurboFanのマシンレベルIRを直接生成できます。この高速なコード生成へのパスは、V8開発者のみがV8エンジンを内部的に高速化するために使用するものですが、CSAで構築された組み込みにおけるクロスプラットフォーム方式で最適化されたアセンブリコード生成の効率的な経路が、すべての開発者のJavaScriptコードに直接利益をもたらします。これには、V8のインタプリタIgnitionの性能クリティカルなバイトコードハンドラが含まれます。

CSAとJavaScriptのコンパイルパイプライン

CSAインターフェースには、非常に低レベルでアセンブリコードを記述したことがある人なら誰でも馴染みのある操作が含まれています。たとえば、「このオブジェクトポインタを指定されたアドレスから読み込む」や「これらの2つの32ビット数値を掛ける」といった機能を含んでいます。CSAにはIRレベルで型検証があり、ランタイムではなくコンパイル時に多くの正確性バグをキャッチできます。たとえば、メモリから読み込まれたオブジェクトポインタを32ビットの掛け算の入力として誤って使用しないことを保証できます。この種の型検証は、手書きのアセンブリスタブでは実現できません。

CSAの試用

CSAが提供するものをより明確に理解するために、簡単な例を見てみましょう。V8に新しい内部ビルトインを追加し、オブジェクトが文字列(String)である場合にはその文字列の長さを返し、入力オブジェクトが文字列でない場合にはundefinedを返します。

まず、V8のbuiltin-definitions.hファイル内のBUILTIN_LIST_BASEマクロに新しいビルトインGetStringLengthを宣言する行を追加し、定数kInputObjectで識別される単一の入力パラメータがあることを指定します。

TFS(GetStringLength, kInputObject)

TFSマクロはビルトインを標準的なCodeStubリンケージを使用するTurboFanビルトインとして宣言します。これは単にCSAを使用してそのコードを生成し、パラメータがレジスターを介して渡されることを期待するという意味です。

次に、builtins-string-gen.ccでビルトインの内容を定義できます。

TF_BUILTIN(GetStringLength, CodeStubAssembler) {
Label not_string(this);

// 定義された定数を使用して受信オブジェクトを取得する。
Node* const maybe_string = Parameter(Descriptor::kInputObject);

// 入力がSmi(小さな数値の特別な表現)であるかどうかを確認する。
// これは下のIsStringチェックの前に行う必要がある。
// なぜなら、IsStringは引数がオブジェクトポインタでありSmiでないことを前提としているためである。
// もし引数がSmiである場合、|not_string|ラベルにジャンプする。
GotoIf(TaggedIsSmi(maybe_string), &not_string);

// 入力オブジェクトが文字列であるか確認する。
// もしそうでない場合、|not_string|ラベルにジャンプする。
GotoIfNot(IsString(maybe_string), &not_string);

// 文字列であることを確認した後、文字列の長さを読み込み、CSAの「マクロ」LoadStringLengthを使用してそれを返す。
Return(LoadStringLength(maybe_string));

// 上記のIsStringチェックに失敗したターゲットラベルの位置を定義する。
BIND(&not_string);

// 入力オブジェクトが文字列ではない場合、JavaScriptのundefined定数を返す。
Return(UndefinedConstant());
}

上記の例では、使用されている命令には2つのタイプがあります。GotoIfReturnのように1つまたは2つのアセンブリ命令に直接変換される_プリミティブ_なCSA命令。そして、LoadStringLengthTaggedIsSmiIsStringのような_マクロ_命令です。これらはプリミティブまたはマクロ命令をインラインで出力するための便利な関数です。マクロ命令はよく使用されるV8の実装イディオムを簡単に再利用するためにカプセル化されたものです。任意の長さで使用可能で、必要に応じてV8開発者が新しいマクロ命令を簡単に定義することができます。

上述の変更でV8をコンパイルした後、mksnapshotというツールを--print-codeコマンドラインオプションと共に実行できます。このオプションは、各ビルトインの生成されたアセンブリコードを出力します。出力内でGetStringLengthgrepすると、x64で次のような結果が得られます(コード出力を若干整理して読みやすくしています):

  test al,0x1
jz not_string
movq rbx,[rax-0x1]
cmpb [rbx+0xb],0x80
jnc not_string
movq rax,[rax+0xf]
retl
not_string:
movq rax,[r13-0x60]
retl

32ビットARMプラットフォームでは、mksnapshotによって次のコードが生成されます:

  tst r0, #1
beq +28 -> not_string
ldr r1, [r0, #-1]
ldrb r1, [r1, #+7]
cmp r1, #128
bge +12 -> not_string
ldr r0, [r0, #+7]
bx lr
not_string:
ldr r0, [r10, #+16]
bx lr

新しいビルトインが非標準の(少なくとも非C++の)呼び出し規約を使用するとはいえ、このためのテストケースを書くことは可能です。以下のコードをtest-run-stubs.ccに追加することで、全プラットフォームでビルトインをテストできます:

TEST(GetStringLength) {
HandleAndZoneScope scope;
Isolate* isolate = scope.main_isolate();
Heap* heap = isolate->heap();
Zone* zone = scope.main_zone();

// 入力が文字列の場合のテスト
StubTester tester(isolate, zone, Builtins::kGetStringLength);
Handle<String> input_string(
isolate->factory()->
NewStringFromAsciiChecked("Oktoberfest"));
Handle<Object> result1 = tester.Call(input_string);
CHECK_EQ(11, Handle<Smi>::cast(result1)->value());

// 入力が文字列でない場合のテスト(例:undefined)
Handle<Object> result2 =
tester.Call(factory->undefined_value());
CHECK(result2->IsUndefined(isolate));
}

CSAを使用して異なる種類のビルトインを作成する方法やさらなる例についてはこのWikiページを参照してください。

V8開発者の効率を大幅に向上させるツール

CSAは単なるマルチプラットフォーム向けの普遍的なアセンブリ言語ではありません。新しい機能を実装する際の効率を大幅に向上させ、以前のように各アーキテクチャごとにコードを手書きで記述する必要がなくなります。以下のような恩恵を提供することで、CSAはハンドライティングアセンブリの利点を提供しつつ、最も厄介な落とし穴から開発者を守っています:

  • CSAを使用すると、開発者は各プラットフォームに適切に変換される低レベルのプリミティブを用いてビルトインコードを書くことができます。CSAの命令セレクタは、V8がターゲットとするすべてのプラットフォームで最適なコードを生成します。そのため、V8開発者が各プラットフォームのアセンブリ言語の専門家である必要がありません。
  • CSAのインターフェースにはオプション型があり、生成された低レベルのアセンブリで操作される値がコード作成者が期待する型であることを保証します。
  • アセンブリ命令間のレジスタの割り当てはCSAが自動的に行います。また、ビルトインが多くのレジスタを使用する場合や呼び出しを行う場合には、スタックフレームを構築してスタックに値を保持することも含めて自動的に処理します。これにより、従来の手書きのアセンブリビルトインで頻発していた微妙で発見が難しいバグを排除します。生成されたコードをより堅牢にすることで、正しい低レベルビルトインを書くために必要な時間を大幅に削減します。
  • CSAはABI呼び出し規約を理解しています(標準C++とV8内部レジスタベースの両方)。そのため、CSA生成コードとV8の他の部分との間で容易に相互運用が可能です。
  • CSAコードはC++なので、共通のコード生成パターンをマクロでカプセル化し、多くのビルトインで簡単に再利用できます。
  • V8ではCSAを使用してIgnitionのバイトコードハンドラーを生成しているため、CSAベースのビルトインの機能をハンドラーに直接インライン化してインタプリタの性能を向上させることが非常に簡単です。
  • V8のテストフレームワークは、CSA機能とCSA生成のビルトインをC++からテストすることをサポートしており、アセンブリアダプターを書く必要がありません。

全体として、CSAはV8開発に革命をもたらしました。チームのV8最適化能力を大幅に向上させ、V8の埋め込み先のためにJavaScript言語のより多くの部分をより速く最適化できるようになりました。