はじめに
Daniel Clifford は、V8 で JavaScript のパフォーマンスを改善するためのヒントとコツについて、Google I/O で優れた講演を行いました。ダニエルは、「より速く」を求めるよう、つまり C++ と JavaScript のパフォーマンスの違いを慎重に分析し、JavaScript の仕組みを念頭に置いてコードを記述するよう、参加者に促しました。ダニエルの講演の最も重要なポイントの概要はこの記事に記載されています。また、パフォーマンスに関するガイダンスが変更されるたびに、この記事も更新されます。
最も重要なアドバイス
パフォーマンスに関するアドバイスは、状況に応じて判断することが重要です。パフォーマンスに関するアドバイスは中毒性があり、最初に詳細なアドバイスに集中すると、本当の問題から注意が逸れることがあります。ウェブ アプリケーションのパフォーマンスを総合的に把握する必要があります。これらのパフォーマンスに関するヒントに重点を置く前に、PageSpeed などのツールでコードを分析し、スコアを高めることをおすすめします。これにより、早期最適化を回避できます。
ウェブ アプリケーションで優れたパフォーマンスを得るための基本的なアドバイスは次のとおりです。
- 問題が発生する(または気付く)前に準備する
- 次に、問題の核心を特定して理解します。
- 最後に、重要な問題を修正する
これらのステップを実行するには、V8 が JS を最適化する方法を理解し、JS ランタイム設計に配慮したコードを記述することが重要です。また、利用可能なツールとその活用方法についても学ぶことが重要です。デベロッパー ツールの使用方法については、ダニエルの講演で詳しく説明されています。このドキュメントでは、V8 エンジン設計の最も重要なポイントをいくつか取り上げています。
それでは、V8 に関するヒントをご紹介しましょう。
非表示のクラス
JavaScript には、コンパイル時の型情報が限定されています。型は実行時に変更できるため、コンパイル時に JS 型を推論するのはコストが高いと予想されます。これで、JavaScript のパフォーマンスが C++ に近づく可能性があるのか疑問に思うかもしれません。しかし、V8 には、実行時にオブジェクト用に内部で作成される非公開型があります。同じ非公開クラスを持つオブジェクトは、同じ最適化された生成コードを使用できます。
次に例を示します。
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```
オブジェクト インスタンス p2 にメンバー「.z」が追加されるまで、p1 と p2 は内部で同じ非公開クラスを持つため、V8 は p1 または p2 のいずれかを操作する JavaScript コード用に最適化されたアセンブリの単一バージョンを生成できます。非表示クラスの分散を回避できるほど、パフォーマンスは向上します。
これを行うためには、次の手順に従います。
- コンストラクタ関数ですべてのオブジェクト メンバーを初期化します(インスタンスの型が後で変更されないようにするため)
- オブジェクト メンバーは常に同じ順序で初期化する
Numbers
V8 は、型が変更される可能性がある場合にタグ付けを使用して値を効率的に表します。V8 は、使用する値から、扱っている数値型を推測します。V8 は、この推論を行った後、タグ付けを使用して値を効率的に表現します。これらの型は動的に変更される可能性があるためです。ただし、これらの型タグを変更するとコストが発生することがあるため、数値型を一貫して使用することをおすすめします。一般的には、必要に応じて 31 ビットの符号付き整数を使用するのが最適です。
次に例を示します。
var i = 42; // this is a 31-bit signed integer
var j = 4.2; // this is a double-precision floating point number```
これを行うためには、次の手順に従います。
- 31 ビットの符号付き整数として表せる数値を優先します。
配列
大規模でスパースな配列を処理するために、内部には次の 2 種類の配列ストレージがあります。
- Fast Elements: コンパクトな鍵セット用のリニア ストレージ
- 辞書要素: ハッシュ テーブル ストレージ(それ以外の場合)
配列ストレージをあるタイプから別のタイプに切り替えないようにすることをおすすめします。
これを行うためには、次の手順に従います。
- 配列には 0 から始まる連続したキーを使用する
- 大きな配列(64, 000 要素を超える配列など)を最大サイズに事前割り当てしないでください。代わりに、必要に応じて増やしてください。
- 配列内の要素(特に数値配列)を削除しない
- 初期化されていない要素や削除された要素は読み込まない。
for (var b = 0; b < 10; b++) {
a[0] |= b; // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
a[0] |= b; // Much better! 2x faster.
}
また、配列のダブルは高速です。配列の非公開クラスは要素の型を追跡し、ダブルのみを含む配列はアンボックスされます(これにより非公開クラスが変更されます)。ただし、配列を不用意に操作すると、ボックス化とアンボックス化によって余分な作業が発生する可能性があります。
var a = new Array();
a[0] = 77; // Allocates
a[1] = 88;
a[2] = 0.5; // Allocates, converts
a[3] = true; // Allocates, converts```
次の方法よりも効率が低い
var a = [77, 88, 0.5, true];
最初の例では、個々の代入が順番に実行され、a[2]
の代入によって配列がボックス化されていない Double の配列に変換されますが、a[3]
の代入によって、任意の値(数値またはオブジェクト)を含む配列に再変換されるためです。2 番目のケースでは、コンパイラはリテラル内のすべての要素の型を認識しているため、非表示クラスを事前に決定できます。
- 小さな固定サイズの配列に配列リテラルを使用して初期化する
- 小さな配列(64,000 バイト未満)を事前に割り当て、使用前にサイズを修正する
- 数値以外の値(オブジェクト)を数値配列に格納しない
- リテラルなしで初期化する場合は、小さな配列が再変換されないように注意してください。
JavaScript コンパイル
JavaScript は非常に動的言語であり、元の実装はインタープリタでしたが、最新の JavaScript ランタイム エンジンはコンパイルを使用します。V8(Chrome の JavaScript)には、実際には 2 つの異なる Just-In-Time(JIT)コンパイラがあります。
- 「完全」コンパイラ: あらゆる JavaScript に対して優れたコードを生成できます。
- 最適化コンパイラ: ほとんどの JavaScript で優れたコードを生成しますが、コンパイル時間が長くなります。
完全なコンパイラ
V8 では、フルコンパイラがすべてのコードで実行され、できるだけ早くコードの実行が開始されます。これにより、優れたコードではなく、十分なコードが迅速に生成されます。このコンパイラは、コンパイル時に型についてほとんど何も想定しません。変数の型は実行時に変更される可能性があると想定しています。フルコンパイラによって生成されたコードは、インライン キャッシュ(IC)を使用して、プログラムの実行中に型に関する知識を洗練し、効率を即座に改善します。
インライン キャッシュの目的は、オペレーションの型依存コードをキャッシュに保存することで、型を効率的に処理することです。コードの実行時に、まず型の仮定を検証し、次にインライン キャッシュを使用してオペレーションをショートカットします。ただし、複数の型を受け入れるオペレーションのパフォーマンスは低下します。
これを行うためには、次の手順に従います。
- ポリモーフィック オペレーションよりも、オペレーションの単一形態の使用が推奨される
入力の非表示クラスが常に同じである場合、オペレーションは単一形態です。それ以外の場合は多形です。つまり、オペレーションの呼び出しごとに引数の一部の型が変更される可能性があります。たとえば、この例の 2 番目の add() 呼び出しはポリモーフィズムを引き起こします。
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
最適化コンパイラ
V8 は、完全なコンパイラと並行して、最適化コンパイラを使用して「ホット」関数(つまり、何度も実行される関数)を再コンパイルします。このコンパイラは、型フィードバックを使用してコンパイルされたコードを高速化します。実際、先ほど説明した IC から取得した型を使用します。
最適化コンパイラでは、オペレーションが推測的にインライン化されます(呼び出される場所に直接配置されます)。これにより、(メモリ フットプリントを犠牲にして)実行速度が向上しますが、他の最適化も可能になります。単一形関数とコンストラクタは完全にインライン化できます(これは、V8 で単一形が推奨される理由の 1 つです)。
V8 エンジンのスタンドアロンの「d8」バージョンを使用して、最適化された内容をログに記録できます。
d8 --trace-opt primes.js
(これにより、最適化された関数の名前が stdout にログに記録されます)。
ただし、すべての関数を最適化できるわけではありません。一部の機能では、最適化コンパイラが特定の関数で実行されないようにします(「バイアウト」)。特に、現在、最適化コンパイラは try {} catch {} ブロックを含む関数でバイアウトします。
これを行うためには、次の手順に従います。
- try {} catch {} ブロックがある場合は、パフォーマンスに影響するコードをネストされた関数に配置します。 ```js function perf_sensitive() { // パフォーマンスに影響する処理をここで行う }
try { perf_sensitive() } catch (e) { // 例外をここで処理 } ```
最適化コンパイラで try/catch ブロックが有効になるため、このガイダンスは今後変更される可能性があります。最適化コンパイラが関数をバイアウトする方法を確認するには、上記のように d8 で「--trace-opt」オプションを使用します。これにより、どの関数がバイアウトされたかについて詳細な情報を確認できます。
d8 --trace-opt primes.js
最適化解除
最後に、このコンパイラによって実行される最適化は推測的です。うまくいかず、バックオフすることもあります。「デオプティマイゼーション」プロセスでは、最適化されたコードが破棄され、「完全な」コンパイラ コードの適切な場所で実行が再開されます。再最適化は後で再びトリガーされる可能性がありますが、短期的には実行速度が低下します。特に、関数が最適化された後に変数の非表示クラスを変更すると、この最適化解除が発生します。
これを行うためには、次の手順に従います。
- 最適化後に関数でクラスが変更されないようにする
他の最適化と同様に、ロギング フラグを使用して、V8 が最適化解除しなければならなかった関数のログを取得できます。
d8 --trace-deopt primes.js
その他の V8 ツール
なお、起動時に V8 トレース オプションを Chrome に渡すこともできます。
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
デベロッパー ツールのプロファイリングに加えて、d8 を使用してプロファイリングすることもできます。
% out/ia32.release/d8 primes.js --prof
これには、組み込みのサンプリング プロファイラが使用されます。このプロファイラは、1 ミリ秒ごとにサンプルを取得し、v8.log に書き込みます。
要約
パフォーマンスの高い JavaScript を構築する準備として、V8 エンジンがコードとどのように連携するかを特定して理解することが重要です。基本的なアドバイスは次のとおりです。
- 問題が発生する(または気付く)前に準備する
- 次に、問題の核心を特定して理解します。
- 最後に、重要な問題を修正する
つまり、まず PageSpeed などの他のツールを使用して、問題が JavaScript にあることを確認する必要があります。指標を収集する前に、JavaScript のみ(DOM なし)に減らして、それらの指標を使用してボトルネックを特定し、重要なボトルネックを排除します。ダニエルの講演(およびこの記事)が、V8 が JavaScript を実行する仕組みを理解する一助となれば幸いです。ただし、独自のアルゴリズムの最適化にもぜひ取り組んでください。