はじめに
Daniel Clifford は、V8 で JavaScript のパフォーマンスを改善するためのヒントとコツについて、Google I/O で優れた講演を行いました。ダニエルは、「より速く」を求めるよう、つまり C++ と JavaScript のパフォーマンスの違いを慎重に分析し、JavaScript の仕組みを念頭に置いてコードを記述するよう、参加者に促しました。この記事には Daniel の講演の要点をまとめたものです。また、パフォーマンス ガイダンスの変更があったらこの記事も随時更新していきます。
最も重要なアドバイス
パフォーマンスに関するアドバイスは、状況に応じて判断することが重要です。パフォーマンスに関するアドバイスは中毒性があり、最初に詳細なアドバイスに集中すると、本当の問題から注意をそらすことがあります。ウェブ アプリケーションのパフォーマンスを総合的に把握する必要があります。これらのパフォーマンスに関するヒントに注目する前に、PageSpeed などのツールでコードを分析し、スコアを高めることをおすすめします。これにより、早期の最適化を回避できます。
ウェブ アプリケーションで優れたパフォーマンスを得るための基本的なアドバイスは次のとおりです。
- 問題が発生する(または気付く)前に準備する
- 次に、問題の核心を特定し、理解します。
- 最後に、重要な問題を修正します
これらのステップを実行するには、V8 が JS を最適化する方法を理解し、JS ランタイム設計に配慮したコードを記述することが重要です。また、利用可能なツールとその活用方法についても学ぶことが重要です。Daniel は講演の中で、デベロッパー ツールの使用方法についてもう少し詳しく説明しています。このドキュメントは、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 がこの推論を行った後、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.
}
また、double の配列は高速です。配列の非表示クラスは要素の型を追跡し、double のみを含む配列はボックス化されません(これにより、非表示のクラスが変更されます)。ただし、配列を不注意に操作すると、ボックス化とボックス化解除によって余分な作業が発生する可能性があります。
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 では単相が推奨されるもう一つの理由です)。
スタンドアロンの「d8」バージョンの V8 エンジンを使用すると、最適化の対象をログに記録できます。
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 を実行する仕組みを理解する一助となれば幸いです。ただし、独自のアルゴリズムの最適化にもぜひ取り組んでください。