遅延デシリアライズ
要約: 遅延デシリアライズが最近V8 v6.4でデフォルトで有効化され、V8のメモリ消費を平均でブラウザタブあたり500 KB以上削減しました。詳しくは以下をご覧ください!
V8スナップショットの紹介
まずは一歩下がって、V8が新しいIsolate(大まかに言えばChromeのブラウザタブに対応)を作成する際にどのようにヒープスナップショットを使用して速度を向上させているのか見てみましょう。私の同僚のYang Guoがカスタムスタートアップスナップショットの記事でこの点に関する優れた解説をしています:
JavaScript仕様には、多くの組み込み機能が含まれています。これには数学関数から完全な正規表現エンジンまで含まれます。新しく作成されたすべてのV8コンテキストには、最初からこれらの機能が利用可能です。これを実現するために、グローバルオブジェクト(例えばブラウザ内の
window
オブジェクト)やすべての組み込み機能が設定され、コンテキストが作成される時にV8のヒープに初期化される必要があります。これをゼロから行うにはかなりの時間がかかります。幸いなことに、V8はこれを高速化するためのショートカットを使用します。ちょうど冷凍ピザを解凍して素早く夕食を用意するように、事前に準備されたスナップショットをヒープに直接デシリアライズして初期化済みコンテキストを取得します。通常のデスクトップコンピュータでは、コンテキスト作成時間を40 msから2 ms未満に短縮できます。平均的なモバイル電話でも、270 msと10 msの違いを生み出す可能性があります。
要約すると: スナップショットはスタートアップ性能にとって非常に重要であり、各IsolateのためにV8のヒープの初期状態を作るためにデシリアライズされます。したがって、スナップショットのサイズがV8ヒープの最小サイズを決定し、スナップショットが大きいほど各Isolateのメモリ消費が増加します。
スナップショットには、新しいIsolateを完全に初期化するために必要なすべてが含まれています。例えば言語定数(undefined
値など)、インタープリタによって使用される内部バイトコードハンドラ、組み込みオブジェクト(String
など)、および組み込みオブジェクトにインストールされた関数(String.prototype.replace
など)とそれらの実行可能Code
オブジェクトが含まれます。
過去2年間でスナップショットサイズはほぼ3倍に増加し、2016年初頭の約600 KBから現在では1500 KB以上になっています。この増加の大部分はシリアライズされたCode
オブジェクトに起因しており、それらの数(例えばJavaScript言語の仕様進化に伴って追加されたもの)やサイズ(新しいCodeStubAssemblerパイプラインによって生成されるビルドインネイティブコードが、よりコンパクトなバイトコードや最小化されたJS形式ではなく)によって増加しています。
これは困った問題で、メモリ消費をできるだけ少なく抑えたいと望んでいる中でのニュースです。
遅延デシリアライズ
主な課題の1つは、以前はスナップショット全体の内容を各Isolateにコピーしていたことです。この操作は、特に組み込み関数に対して非常に無駄であり、これらはすべて無条件にロードされていたにもかかわらず、実際には使用されない可能性が高かったのです。
ここで遅延デシリアライズが登場します。概念は非常に単純です:組み込み関数を呼び出す直前にのみデシリアライズするようにしたらどうでしょうか?
いくつかの主要なウェブサイトを調査したところ、このアプローチは非常に魅力的であることが分かりました。平均すると、すべての組み込み関数のうちわずか30%が使用されており、いくつかのサイトではわずか16%しか使用されていませんでした。この結果は非常に期待が持てるもので、これらのサイトの多くがJSを大量に使用している点を考えると、これらの数値はウェブ全体の潜在的なメモリ節約の(やや不明瞭ではあるものの)下限と見なすことができるでしょう。
この方向に作業を進め始めたところ、遅延デシリアライズがV8のアーキテクチャと非常によく統合されており、運用を開始するために必要な設計変更もほとんどなく、侵襲性も少ないことがわかりました:
- スナップショット内の既知の位置 遅延デシリアライズ以前では、シリアライズされたスナップショット内のオブジェクト順序は無関係でした。なぜならヒープ全体を一度にデシリアライズするだけだったからです。遅延デシリアライズでは、任意の組み込み関数を個別にデシリアライズできる必要があるため、それがスナップショット内のどこにあるかを知る必要があります。
- シングルオブジェクトのデシリアライズ V8のスナップショットは元々ヒープ全体のデシリアライズを目的として設計されており、シングルオブジェクトのデシリアライズのサポートを追加する際には、非連続的なスナップショットレイアウト(1つのオブジェクトに対するシリアライズデータが他のオブジェクトのデータと混在する可能性)やバックリファレンス(現在の実行内でデシリアライズ済みのオブジェクトを直接参照すること)のような特異点に対処する必要がありました。
- 遅延デシリアライズメカニズムそのもの 実行時、遅延デシリアライズハンドラーはa)どのコードオブジェクトをデシリアライズするかを判断し、b)実際のデシリアライズを実行し、c)シリアライズされたコードオブジェクトを関連するすべての関数に付加しなければならない。
最初の2つの問題に対する解決策として、スナップショット内に新しい専用のビルトイン領域を追加しました。この領域にはシリアライズされたコードオブジェクトのみを含めることができます。シリアライズは明確に定義された順序で行われ、それぞれのCode
オブジェクトの開始オフセットはビルトインスナップショット領域内の専用セクションに保持されます。バックリファレンスや混在したオブジェクトデータは許可されていません。
遅延ビルトインデシリアライズは、適切に名付けられたDeserializeLazy
ビルトインによって処理されます。このデシリアライズハンドラーは、すべての遅延ビルトイン関数にデシリアライズ時にインストールされます。実行時に呼び出されると、関連するCode
オブジェクトをデシリアライズし、最終的にそれをJSFunction
(関数オブジェクトを表す)およびSharedFunctionInfo
(同じ関数リテラルから生成された関数間で共有される)にインストールします。各ビルトイン関数は最大でも1回しかデシリアライズされません。
ビルトイン関数に加え、バイトコードハンドラーの遅延デシリアライズも実装しました。バイトコードハンドラーはV8のIgnitionインタープリタ内で各バイトコードを実行するロジックを含むコードオブジェクトです。ビルトインとは異なり、これらには付属のJSFunction
やSharedFunctionInfo
がありません。代わりに、これらのコードオブジェクトはディスパッチテーブルに直接格納され、インタープリタが次のバイトコードハンドラーにディスパッチする際にインデックス付けされます。遅延デシリアライズのプロセスはビルトインの場合と類似しています: DeserializeLazy
ハンドラーはバイトコード配列を調査してどのハンドラーをデシリアライズするかを判断し、コードオブジェクトをデシリアライズし、最終的にデシリアライズされたハンドラーをディスパッチテーブルに格納します。ここでも、各ハンドラーは最大で1回しかデシリアライズされません。
結果
Androidデバイス上でChrome 65を用いて最も人気のあるウェブサイト上位1000件を読み込み、遅延デシリアライズの有無でメモリ節約を評価しました。
平均して、V8のヒープサイズは540 KB減少し、テストしたサイトの25%が620 KB以上節約、50%が540 KB以上節約、75%が420 KB以上節約しました。
遅延デシリアライズによる実行時のパフォーマンス(Speedometerのような標準JSベンチマークや人気ウェブサイトの幅広い選択を基に測定)は影響を受けていません。
次のステップ
遅延デシリアライズにより、各Isolateは実際に使用されるビルトインコードオブジェクトのみをロードするようになりました。これはすでに大きな成果ですが、さらに一歩進み、各Isolateのビルトイン関連のコストを実質ゼロに近づけることが可能だと考えています。
今年後半にこの分野でのアップデートをお届けできることを期待しています。お楽しみに!