軽量化されたV8
2018年末、私たちはV8のメモリ使用量を劇的に削減することを目指してV8 Liteというプロジェクトを開始しました。このプロジェクトは当初、低メモリモバイルデバイスやメモリ使用量の削減を重視したエンベッダー利用ケース向けに、V8の別のLiteモードとして構想されていました。しかし、この作業の過程で、このLiteモードのために行った多くのメモリ最適化が、通常のV8にも適用可能であり、V8のすべてのユーザーに利益をもたらせることに気付きました。
この投稿では、開発した主要な最適化と、それが実際のワークロードで提供したメモリ削減について説明します。
注: 記事を読むよりもプレゼンテーションを見る方が好きなら、以下のビデオをお楽しみください! そうでない場合は、ビデオを飛ばして記事を読み進めてください。
Liteモード
V8のメモリ使用量を最適化するためには、まずV8がメモリをどのように使用しているのか、どのオブジェクトタイプがV8のヒープサイズに大きな割合を占めているのかを理解する必要がありました。V8のメモリ可視化ツールを使用して、いくつかの典型的なウェブページにおけるヒープ構成を追跡しました。
その結果として、V8のヒープのかなりの部分が、JavaScriptの実行には必須ではないが、JavaScriptの実行を最適化し、例外的な状況に対処するために使用されるオブジェクトに割り当てられていることが分かりました。例として、最適化されたコード、コードの最適化方法を決定するために使用される型フィードバック、C++とJavaScriptオブジェクト間のバインディングに関する冗長なメタデータ、スタックトレースシンボル化のような例外的な状況でのみ必要なメタデータ、ページ読み込み中に数回しか実行されない関数のバイトコードなどがあります。
これを受けて、JavaScriptの実行速度を犠牲にする代わりにこれらのオプションオブジェクトの割り当てを大幅に削減することでメモリの節約を図るV8のLiteモードの開発を開始しました。
Liteモードのいくつかの変更は既存のV8設定を調整することで行うことができました。例えば、V8のTurboFan最適化コンパイラを無効化することが含まれます。しかし、他の変更はV8に対するより大規模な改変を必要としました。
特に、Liteモードではコードの最適化が行われないため、最適化コンパイラに必要な型フィードバックの収集を回避できました。Ignitionインタープリタでコードを実行する際、V8は様々な操作(例えば、+
やo.foo
)に渡されるオペランドの型に関するフィードバックを収集し、それに基づいて後の最適化を調整します。この情報はフィードバックベクタに保存され、V8のヒープメモリ使用量のかなりの部分を占めます。Liteモードではこれらのフィードバックベクタの割り当てを回避できますが、インタープリタとV8のインラインキャッシュインフラの一部がフィードバックベクタを必要とするため、これらのフィードバックなしで実行をサポートするために大幅なリファクタリングが必要でした。
V8 v7.3でリリースされたLiteモードは、コードの最適化を無効化し、フィードバックベクタを割り当てず、あまり実行されないバイトコードのエイジングを行うことで(以下に説明)、V8 v7.1と比較して典型的なウェブページヒープサイズを22%削減しました。これは、パフォーマンスを犠牲にしてメモリ使用量の改善を望むアプリケーションにとって素晴らしい成果です。しかし、この作業を進めるうちに、Liteモードのメモリ節約のほとんどをパフォーマンスへの影響なしに実現するために、V8をより怠惰にすることが可能だと気付きました。
フィードバックの遅延割り当て
フィードバックベクターの割り当てを完全に無効化すると、V8のTurboFanコンパイラーによるコードの最適化が妨げられるだけでなく、V8がIgnitionインタープリター内でのオブジェクトプロパティロードなどの一般的な操作に関するインラインキャッシングを実行することも不可能になります。その結果、これによりV8の実行時間が大幅に低下し、ページ読み込み時間が12%減少し、対話型ウェブページシナリオでV8のCPU使用時間が120%増加するという大きな性能低下を引き起こしました。
これらの多くの節約を通常のV8に持ち込みつつ、この性能退化を避けるために、私たちは代わりに、関数が一定のバイトコード(現在1KB)を実行した後でフィードバックベクターを遅延的に割り当てるアプローチに移行しました。ほとんどの関数は頻繁に実行されないため、ほとんどの場合フィードバックベクターの割り当てを回避できますが、必要な場合には迅速に割り当てることで性能退化を防ぎ、さらにコードの最適化を可能にしています。
このアプローチに関連する追加の複雑性は、フィードバックベクターがツリーを形成するという事実に関連しています。内部関数のフィードバックベクターは、外部関数のフィードバックベクターのエントリとして保持されます。これにより、新しく作成された関数クロージャが、同じ関数で作成された他のすべてのクロージャと同じフィードバックベクター配列を受け取れるようにする必要があります。フィードバックベクターの遅延割り当てを行う場合、フィードバックベクターを使用したこのツリーを形成することはできません。なぜなら、内部関数がそのフィードバックベクターを割り当てる時点までに、外部関数がそのフィードバックベクターを割り当てている保証がないからです。これに対処するために、新しいClosureFeedbackCellArray
を作成し、このツリーを維持する手段を提供し、関数がホットになるとClosureFeedbackCellArray
を完全なFeedbackVector
に置き換えるようにしました。
ラボでの実験とフィールドからのデータにより、デスクトップでの遅延フィードバックに関して性能退化が発生しないことが確認されました。また、モバイルプラットフォームでは、ガベージコレクションの削減により低スペックデバイスで性能向上が観測されました。そのため、V8のすべてのビルドで遅延フィードバック割り当てを有効化しています。これには、メモリの軽微な退化があるものの、実際の性能向上により完全に相殺されたLiteモードも含まれます。
遅延ソースポジション
JavaScriptからバイトコードをコンパイルする際、バイトコードのシーケンスをJavaScriptソースコード内の文字位置に結び付けるソースポジションテーブルが生成されます。しかし、この情報は例外のシンボル化やデバッグといった開発者タスクを行う場合にのみ必要であり、実際にはほとんど使用されません。
この無駄を回避するため、現在ではソースポジションを収集せずにバイトコードをコンパイルします(デバッガーやプロファイラがアタッチされていない場合)。ソースポジションはスタックトレースが実際に生成された場合にのみ収集されます。例えば、Error.stack
を呼び出したり、例外のスタックトレースをコンソールに出力する場合などです。これにはある程度のコストがかかります。ソースポジションの生成には関数の再パースと再コンパイルが必要なためです。しかし、大半のウェブサイトでは本番環境でスタックトレースをシンボル化することがなく、観測可能な性能への影響は見られません。
この作業に取り組む中で対処しなければならなかった課題の1つは、繰り返し可能なバイトコード生成を必要とすることであり、これはこれまで保証されていませんでした。もしV8がソースポジションを収集する際に生成するバイトコードが、元のコードとは異なる場合、ソースポジションが一致せず、スタックトレースが間違ったソースコード位置を指してしまう可能性があります。
特定の状況で、関数が即時コンパイルまたは遅延コンパイルされるかどうかによって、V8が異なるバイトコードを生成することがあります。これは初期の即時パースの段階で失われた一部のパーサー情報が、後の遅延コンパイルでは利用できなくなるためです。これらのミスマッチはほとんどの場合無害であり、たとえば変数が不変であるという事実を追跡できなくなり、それを最適化できなくなるといった程度です。しかし、この作業で明らかになった一部のミスマッチは、特定の状況で誤ったコード実行を引き起こす可能性がありました。その結果として、これらのミスマッチを修正し、機能が即時または遅延コンパイルされる場合でも常に一貫した出力を生成することを確実にするチェックおよびストレスモードを追加しました。これにより、V8のパーサーおよび先解析器の正確性と一貫性に対する信頼を高めることができました。
バイトコードフラッシュ
JavaScriptソースからコンパイルされたバイトコードは、関連するメタデータを含めてV8ヒープスペースのかなりの部分、通常は約15%を占めます。中には初期化中にのみ実行される、またはコンパイル後にほとんど使用されない関数も多数存在します。
そのため、最近実行されていない関数のバイトコードをガベージコレクション中にフラッシュする機能を追加しました。このために、関数のバイトコードの年齢を追跡し、各メジャー(マークコンパクト)ガベージコレクションのたびに年齢を1ずつインクリメントし、関数が実行されるたびにゼロにリセットします。一定の年齢しきい値を超えたバイトコードは、次回のガベージコレクションで収集される対象となります。収集された後に再び実行される場合は、再コンパイルされます。
技術的な課題として、バイトコードが不要になったときのみ消去されるようにする必要があります。例えば、関数A
が別の長時間実行される関数B
を呼び出す場合、関数A
はスタック上に存在しながら古くなる可能性があります。このような場合、長時間実行されている関数B
が終了する際に関数A
に戻る必要があるため、関数A
が古くなった閾値に達してもバイトコードを消去したくありません。そのため、バイトコードは古くなったときには関数から弱く保持されるものとし、スタックや他の場所にある参照によって強く保持されるものとして扱います。強いリンクが残っていない場合にのみコードを消去します。
バイトコードを消去することに加えて、これらの消去された関数に関連付けられているフィードバックベクターも消去します。ただし、フィードバックベクターはバイトコードとは異なるオブジェクトによって保持されるため、同じGCサイクルでは消去できません。バイトコードはネイティブコンテキストに依存しないSharedFunctionInfo
によって保持されますが、フィードバックベクターはネイティブコンテキストに依存するJSFunction
によって保持されます。その結果、フィードバックベクターは次回のGCサイクルで消去されます。
追加の最適化
これらの大きなプロジェクトに加えて、いくつかの非効率性を発見し解決しました。
最初の改善はFunctionTemplateInfo
オブジェクトのサイズを削減することでした。これらのオブジェクトはFunctionTemplate
に関する内部メタデータを保存します。これにより、Chromeのような埋め込みプログラムがJavaScriptコードから呼び出される関数のC++コールバック実装を提供できるようになります。ChromeはDOM Web APIを実装するために多くのFunctionTemplateを導入しており、それによってFunctionTemplateInfo
オブジェクトがV8のヒープサイズに寄与していました。FunctionTemplateの典型的な使用状況を分析した結果、FunctionTemplateInfo
オブジェクトの11フィールドのうち、非デフォルト値に設定されるのは通常3つだけであることがわかりました。そのため、FunctionTemplateInfo
オブジェクトを分割し、稀に使用されるフィールドは必要に応じてのみサイドテーブルに保存されるようにしました。
2つ目の最適化はTurboFan最適化コードからのデオプティマイズに関連しています。TurboFanは推測的な最適化を行いますが、特定の条件が保持できなくなった場合にはインタープリターに戻る(デオプティマイズ)必要があります。各デオプトポイントにはIDがあり、ランタイムがどのバイトコード位置に戻って実行を再開するべきかを決定する助けとなります。以前は、このIDは大きなジャンプテーブル内の特定のオフセットに最適化コードがジャンプし、正しいIDをレジスターにロードした後にランタイムにジャンプしてデオプティマイズを行っていました。この方法は、各デオプトポイントに対して最適化コード内で単一のジャンプ命令のみを必要とする利点がありました。しかし、デオプティマイズジャンプテーブルは事前に割り当てられており、全範囲のデオプティマイズIDをサポートするのに十分な大きさである必要がありました。そのため、TurboFanを変更して最適化コード内のデオプトポイントがランタイムに呼び込む前に直接デオプトIDをロードするようにしました。これにより、この大きなジャンプテーブルを完全に除去することが可能になり、若干の最適化コードサイズの増加を代償として受け入れました。
結果
これらの最適化は過去7回のV8リリースにわたって公開されました。通常は最初にLiteモードで導入され、その後V8のデフォルト設定にも適用されました。
この期間中、V8ヒープサイズを平均して18%削減することができました。これにより、低価格AndroidGoモバイル端末で平均1.5MB減少しました。この削減は、ベンチマークや実際のウェブページのインタラクションで測定されたJavaScriptのパフォーマンスに大きな影響を与えることなく達成されました。
Liteモードは追加のメモリ節約を提供する一方で、関数の最適化を無効にすることでJavaScript実行のスループットに一定の影響を与える可能性があります。平均してLiteモードは22%のメモリ節約を提供し、最大で32%の削減を達成したページもあります。これにより、AndroidGoデバイスのV8ヒープサイズが1.8MB減少します。
各個別の最適化の影響による内訳を分けると、異なるページが異なる比率でそれぞれの最適化から利益を得ていることが明確になります。今後も、JavaScript実行速度を維持しながらV8のメモリ使用量をさらに削減できるような潜在的な最適化を特定し続けます。