はじめに
Daniel Clifford が Google I/O で行った素晴らしい講演では、V8 で JavaScript のパフォーマンスを向上させるためのヒントとコツが紹介されました。Daniel は、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 はこの推論を行うと、タグ付けを使用して値を効率的に表します。これらの型は動的に変更できるためです。ただし、これらの型タグの変更にはコストがかかる場合があるため、数値型を一貫して使用することをおすすめします。一般的には、必要に応じて 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: コンパクトなキーセット用のリニア ストレージ
- 辞書要素: それ以外の場合はハッシュ テーブル ストレージ
配列ストレージが 1 つの型から別の型に切り替わらないようにすることをおすすめします。
これを行うためには、次の手順に従います。
- 配列には 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] の代入によって Array がボックス化されていない double の Array に変換されますが、a[3] の代入によって、任意の値(数値またはオブジェクト)を含むことができる Array に再変換されます。2 つ目のケースでは、コンパイラはリテラル内のすべての要素の型を認識しており、隠しクラスを事前に決定できます。
- 固定サイズの小さな配列には配列リテラルを使用して初期化する
- 小さな配列(64k 未満)は、使用する前に正しいサイズに事前割り当てする
- 数値配列に数値以外の値(オブジェクト)を格納しない
- リテラルを使用せずに初期化する場合は、小さな配列の再変換が発生しないように注意してください。
JavaScript のコンパイル
JavaScript は非常に動的な言語であり、元の実装はインタープリタでしたが、最新の JavaScript ランタイム エンジンはコンパイルを使用します。V8(Chrome の JavaScript)には、実際には 2 つの異なるジャストインタイム(JIT)コンパイラがあります。
- 「フル」コンパイラ。あらゆる JavaScript に対して適切なコードを生成できます。
- 最適化コンパイラ。ほとんどの JavaScript で優れたコードを生成しますが、コンパイルに時間がかかります。
完全なコンパイラ
V8 では、Full コンパイラがすべてのコードで実行され、できるだけ早くコードの実行を開始し、優れたコードを迅速に生成します。このコンパイラは、コンパイル時に型についてほとんど何も想定しません。変数の型は実行時に変更される可能性があると想定しています。Full コンパイラによって生成されたコードは、インライン キャッシュ(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() { // Do performance-sensitive work here }
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 なし)に削減し、それらの指標を使用してボトルネックを特定し、重要なボトルネックを排除します。Daniel の講演(とこの記事)が、V8 が JavaScript を実行する方法を理解するのに役立つことを願っています。ただし、独自のアルゴリズムの最適化にも重点を置いてください。