JavaScript のパフォーマンスに関するヒント(バージョン 8)

Chris Wilson
Chris Wilson

はじめに

Daniel Clifford は、V8 で JavaScript のパフォーマンスを改善するためのヒントとコツについて、Google I/O で素晴らしい講演をしていただきました。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 種類の配列ストレージがあります。

  • 高速要素: コンパクトなキーセット用のリニア ストレージ
  • 辞書の要素: それ以外の場合はハッシュ テーブル ストレージ

アレイ ストレージのタイプが切り替わらないようにすることをおすすめします。

これを行うためには、次の手順に従います。

  • 配列には 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 種類のジャストインタイム(JIT)コンパイラがあります。

  • 「Full」あらゆる 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 では単相が推奨されるもう一つの理由です)。

スタンドアロンの「d8」を使用すると、最適化対象をログに記録できます。説明します。

d8 --trace-opt primes.js

(最適化された関数の名前が stdout に記録されます)。

ただし、すべての関数を最適化できるわけではありません。特定の関数に対して最適化コンパイラを実行できない機能もあります(「ベイルアウト」)。特に、最適化コンパイラは現在、try {} catch {} ブロックを使用して関数の実行を中止します。

これを行うためには、次の手順に従います。

  • {} catch {} ブロックを試行した場合は、perf 依存型のコードをネスト関数に入れます。 ```js function perf_sensitive() { // ここでパフォーマンス重視の作業を行う }

try { perf_sensitive() } catch (e) { // ここで例外を処理 } ```

このガイダンスは将来、最適化コンパイラで try/catch ブロックを有効にするため、変更される可能性があります。「--trace-opt」コマンドを使用すると、最適化コンパイラが関数をどのように処理しなくなるかを調べることができます。オプションを d8 に置き換えると、どの関数が無効化されたかに関する詳細情報がわかります。

d8 --trace-opt primes.js

最適化解除

最後に、このコンパイラによって実行される最適化は予測的なものであり、うまくいかないことがあるため、バックオフします。「最適化解除」のプロセス最適化されたコードを捨て、フルスケールの適切な場所で実行を再開コンパイルします。再最適化は後で再びトリガーされる可能性がありますが、短期的には実行速度が低下します。特に、関数が最適化された後に変数の非表示クラスに変化を発生させると、この最適化解除が発生します。

これを行うためには、次の手順に従います。

  • 最適化後に関数の隠れたクラスの変更を回避する

他の最適化と同様に、ロギングフラグを使用して、V8 で最適化解除が必要だった関数のログを取得できます。

d8 --trace-deopt primes.js

その他の V8 ツール

なお、Chrome の起動時に V8 トレース オプションを渡すこともできます。

"/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 を実行する仕組みについての理解を深める一助となれば幸いです。ただし、独自のアルゴリズムの最適化にもぜひ取り組んでください。

参照