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 コード用に、最適化されたアセンブリの単一バージョンを生成できます。隠しクラスの発散を引き起こさないようにすればするほど、パフォーマンスは向上します。

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

  • コンストラクタ関数内のすべてのオブジェクト メンバーを初期化する(インスタンスの型が後で変更されないように)
  • オブジェクト メンバーは常に同じ順序で初期化する

数字

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 から始まる連続したキーを使用する
  • 大きな Array(要素数 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];

最初の例では、個々の割り当てが 1 つずつ実行され、a[2] を割り当てると Array がボックス化されていない倍精度浮動小数点の配列に変換されますが、a[3] を代入すると、任意の値(数値またはオブジェクト)を格納できる Array に再変換されるためです。2 つ目のケースでは、コンパイラはリテラル内のすべての要素の型を認識しているため、事前に隠しクラスを決定できます。

  • 固定サイズの小さな配列に対して配列リテラルを使用して初期化する
  • 小さな配列(64,000 未満)を事前に割り当てて、適切なサイズにしてから使用する
  • 数値配列に数値以外の値(オブジェクト)を保存しない
  • リテラルなしで初期化する場合は、小さな配列の再変換が発生しないように注意してください。

JavaScript のコンパイル

JavaScript は非常に動的な言語であり、当初の実装はインタープリタでしたが、最新の JavaScript ランタイム エンジンではコンパイルが使用されます。V8(Chrome の JavaScript)には、実際には 2 種類のジャストインタイム(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 つの理由です)。

スタンドアロンの「d8」バージョンの V8 エンジンを使用して、最適化される内容をログに記録できます。

d8 --trace-opt primes.js

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

ただし、すべての関数を最適化できるわけではありません。一部の機能により、最適化コンパイラが特定の関数で実行できなくなります(「回避」状態)。特に、現在最適化用のコンパイラでは、try {} catch {} ブロックを使用する関数はサポートされていません。

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

  • {} 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 ツール

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

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

デベロッパー ツールのプロファイリングに加えて、d8 を使用してプロファイリングを行うこともできます。

% out/ia32.release/d8 primes.js --prof

ここでは、組み込みのサンプリング プロファイラが使用されます。サンプリング プロファイラはミリ秒ごとにサンプルを取得し、v8.log を書き込みます。

まとめ

パフォーマンスの高い JavaScript をビルドするには、V8 エンジンがコードと連携する仕組みを見極め、理解することが重要です。繰り返しになりますが、基本的なアドバイスは次のとおりです。

  • 問題が発生する(または発生する)前に準備する
  • 次に、問題の根本的な原因を特定し、理解します。
  • 最後に、重要な問題を修正し、

つまり、まず PageSpeed などの他のツールを使用して、JavaScript に問題がないことを確認する必要があります。場合によっては、指標を収集する前に純粋な JavaScript(DOM を使用しない)に減らし、指標を使用してボトルネックを見つけ、重要なものを排除する必要があります。Daniel の講演とこの記事が、V8 による JavaScript の実行方法について理解を深める一助になれば幸いです。ただし、独自のアルゴリズムの最適化にも引き続き注意を払ってください。

参照