フォレンジックや探偵によって JavaScript のパフォーマンスの謎を解く

はじめに

近年、ウェブ アプリケーションが大幅に高速化されています。今では多くのアプリケーションが高速に動作するため、一部のデベロッパーから「ウェブは十分な速さか」という声が上がっています。アプリケーションによっては高いかもしれませんが、高性能なアプリケーションに取り組むデベロッパーにとっては、処理速度が十分ではないことはわかっています。JavaScript の仮想マシン テクノロジーは目覚ましい進歩を遂げているにもかかわらず、最近の調査では、Google アプリケーションが時間の 50 ~ 70% を V8 内で費やしていることがわかっています。アプリケーションの時間には限りがあります。あるシステムのサイクルを短縮すれば、別のシステムでより多くの処理を実行できます。60 fps で実行されるアプリケーションでは、フレームあたり 16 ミリ秒しかありません。つまり、ジャンクが発生します。ここでは、JavaScript の最適化と JavaScript アプリケーションのプロファイリングについて説明します。Find Your Way to Oz では、V8 チームのパフォーマンス探偵たちが、あいまいなパフォーマンスの問題を突き止める最前線のストーリーをご紹介します。

Google I/O 2013 セッション

この資料は Google I/O 2013 で発表しました。下の動画をご覧ください。

パフォーマンスが重要な理由

CPU サイクルはゼロサムゲームです。システムの一部の使用を減らすことで、別の部分の使用を増やしたり、全体的に動作をよりスムーズにしたりできます。多くの場合、より速く走ってより多くのことをこなす目標は、競合する目標です。ユーザーは新機能を求めつつ、同時にアプリケーションがよりスムーズに動作することを期待しています。JavaScript 仮想マシンは高速化し続けていますが、ウェブ アプリケーションでパフォーマンスの問題に対処している多くのデベロッパーがすでに認識しているように、今日修正できるパフォーマンスの問題を無視する理由はありません。リアルタイムの高フレームレートのアプリケーションでは、ジャンクをなくすことが非常に重要です。Insomniac Games は、ゲームを成功に導くにはフレームレートを安定して維持することが重要であるという調査を行っています。「安定したフレームレートは、よくできたプロの製品の証です。」ウェブ デベロッパーが注目します。

パフォーマンスの問題を解決する

パフォーマンスの問題を解決することは、犯罪の解決に似ています。その証拠を慎重に調べ、疑わしい原因を確認して、さまざまな解決策を試す必要があります。その過程で、実際に問題を修正したことを確認するために、測定を記録する必要があります。この手法と、犯罪刑事が事件を解決する方法との間には、ほとんど違いはありません。刑事は証拠を調査し、容疑者を捜査し、喫煙銃の発見を目指して実験を行います。

V8 CSI: オズ

Find Your Way to Oz』の開発を手掛けた優れた魔法使いたちは、自分たちだけでは解決できないパフォーマンスの問題を抱えることになりました。時折、オンスがフリーズし、ジャンクが発生することがあります。Oz のデベロッパーは、Chrome DevToolsタイムライン パネルを使用して初期調査を行っていました。メモリ使用量を調べると、のこぎり歯のグラフに遭遇しました。1 秒に 1 回、ガベージ コレクタが 10 MB のガベージ コレクションを収集し、それに応じてガベージ コレクションの一時停止も行われました。以下の Chrome DevTools の [Timeline] のスクリーンショットのようになります。

DevTools のタイムライン

V8 の刑事である Jakob と Yang がこれを引き継ぎました。V8 チームとオズチームの Jakob と Yang のやり取りは長いものでした。この問題の突き止めに役立った重要なイベントに絞って話を進めました。

裏付けとなる資料

最初のステップは、最初の証拠を収集して調査することです。

対象となるアプリケーションの種類

Oz デモはインタラクティブな 3D アプリケーションです。このため、ガベージ コレクションによる一時停止の影響を受けやすくなります。なお、60 fps で実行されるインタラクティブ アプリケーションは、JavaScript 処理をすべて 16 ミリ秒以内に完了するので、Chrome がグラフィック呼び出しを処理して画面を描画する時間を確保する必要があります

Oz は double 値に対して多くの算術計算を実行し、WebAudio と WebGL を頻繁に呼び出します。

どのようなパフォーマンスの問題が発生していますか?

フレーム落ち(ジャンク)と呼ばれる一時停止が発生しています。これらの一時停止は、ガベージ コレクションの実行と相関しています。

デベロッパーはベスト プラクティスに従っているか?

はい。Oz の開発者は JavaScript VM のパフォーマンスと最適化手法に精通しています。注目すべき点は、Oz のデベロッパーは、ソース言語として CoffeeScript を使用し、CoffeeScript コンパイラを介して JavaScript コードを生成していたことです。そのため、Oz の開発者が作成したコードと V8 が使用するコードとの間に乖離があったため、調査が多少複雑になりました。Chrome DevTools で、ソースマップがサポートされるようになりました。これにより、この点はより簡単になります。

ガベージ コレクタを実行する理由

JavaScript のメモリは、VM によってデベロッパーが自動的に管理される。V8 は、メモリが 2 つ(または複数)のgenerationsに分割される一般的なガベージ コレクション システムを使用します。若い世代は、最近割り当てられたオブジェクトを保持します。残存期間が十分にあるオブジェクトは、古い世代に移行します。

若い世代は、古い世代よりもはるかに高い頻度で収集されます。若い世代のコレクションの方がはるかに安いため、これは意図的なものです。GC の一時停止が頻繁に発生するのは、若い世代のコレクションが原因であると考える方が安全です。

V8 では、若いメモリ空間は、同じサイズの連続する 2 つのメモリブロックに分割されます。この 2 つのメモリブロックのうち、常にどちらか一方だけが使用され、いわゆる「to」スペースと呼ばれます。to のスペースにはメモリが残っていますが、新しいオブジェクトの割り当ては低コストです。to スペースのカーソルが、新しいオブジェクトに必要なバイト数だけ前に移動します。これは、to スペースがなくなるまで続きます。この時点でプログラムは停止し、収集が開始されます。

V8 若いメモリ

この時点で、スペースとスペースが入れ替えられます。「to」スペースと「from」スペースが最初から最後までスキャンされ、まだ存在しているオブジェクトはスペースにコピーされるか、古い世代ヒープにプロモートされます。詳しくは、Cheney's Algorithm をご覧ください。

直観的には、オブジェクトが暗黙的または明示的に(new、[]、または {} の呼び出しによって)割り当てられるたびに、アプリケーションがガベージ コレクションにどんどん近づいてきて、アプリケーションが一時停止するのではないかということを、直感的に理解する必要があります。

このアプリケーションでは 10 MB/秒のガベージ サイズを想定していますか。

要するに、ありません。開発者は 10 MB/秒のガベージ イベントを想定しているわけではありません。

容疑者

調査の次のフェーズは、潜在的な容疑者を特定し、それらを絞り出すことです。

容疑者 1

フレーム中に new を呼び出します。割り当てられるオブジェクトごとに GC の一時停止に近づくことを忘れないでください。特に高フレームレートで実行されるアプリケーションでは、フレームあたりの割り当てをゼロにするよう努力する必要があります。一般的には、用途に応じて慎重に検討したオブジェクトのリサイクル システムが必要になります。V8 の刑事がオズのチームに調べたところ、新たに呼んだことはなかった。実際のところ、オズのチームはすでにこの要件をよく知っていて、「それはおかしいな」と言っていました。リストから削除してみて。

容疑者 2

コンストラクタの外でオブジェクトの「シェイプ」を変更する。これは、コンストラクタ外のオブジェクトに新しいプロパティが追加されるたびに発生します。これにより、オブジェクトに新しい隠しクラスが作成されます。最適化されたコードがこの新しい隠しクラスを検出すると、最適化解除がトリガーされ、コードがホットとして分類されて再び最適化されるまで、最適化されていないコードが実行されます。この最適化の解除と再最適化のチャーンはジャンクにつながりますが、過剰なガベージ作成と厳密には相関しません。コードを慎重に調査した結果、オブジェクトの形状が静的であることが確認されたため、容疑者 #2 は除外されました。

容疑者 3

最適化されていないコードでの算術。最適化されていないコードでは、すべての計算の結果、実際のオブジェクトが割り当てられます。たとえば、次のスニペットがあるとします。

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

5 つの HeapNumber オブジェクトが作成されます。最初の 3 つは変数 a、b、c 用です。4 つ目は匿名値(a * b)で、5 つ目は #4 * c です。5 つ目は、最終的に point.x に割り当てられます。

オズは、1 フレームあたり数千回これらの処理を行います。これらの計算のいずれかが最適化されていない関数で発生する場合、それがガベージの原因になる可能性があります。最適化されていない計算では、一時的な結果であってもメモリが割り当てられるためです。

容疑者 4

倍精度数をプロパティに格納する。数値を格納する HeapNumber オブジェクトを作成し、この新しいオブジェクトを指すようにプロパティを変更します。HeapNumber を指すようにプロパティを変更しても、ガベージが生成されることはありません。ただし、倍精度の数値がオブジェクトのプロパティとして多数保存されている可能性があります。コードには、次のようなステートメントがたくさん入っています。

sprite.position.x += 0.5 * (dt);

最適化されたコードでは、x に新たに計算された値(一見無害なステートメント)が割り当てられるたびに、新しい HeapNumber オブジェクトが暗黙的に割り当てられるため、ガベージ コレクションの一時停止に近づきます。

型付き配列(または double のみを保持する通常の配列)を使用すると、この特定の問題を完全に回避できます。倍精度数のストレージは 1 回だけ割り当てられ、値を繰り返し変更しても新しいストレージを割り当てる必要はありません。

容疑者 4 は可能性がある。

フォレンジック

この時点で、探偵は 2 つの疑いがあります。ヒープ番号をオブジェクト プロパティとして保存することと、最適化されていない関数内で算術計算を行うことです。そこで、研究室に向かい、どなたが有罪判決を下したのかをはっきりさせるときが来ました。注: このセクションでは、実際の Oz ソースコードで見つかった問題を再現します。この再現は元のコードよりも桁違いに小さいため、推測しやすくなります。

テスト #1

問題 3(最適化されていない関数内の算術計算)をチェック。V8 JavaScript エンジンにはロギング システムが組み込まれており、内部で何が起こっているかを詳細に把握することができます。

Chrome がまったく実行されない状態で、フラグを指定して Chrome を起動します。

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

Chrome を完全に終了すると、現在のディレクトリに v8.log ファイルが作成されます。

v8.log の内容を解釈するには、Chrome で使用しているのと同じバージョンの v8 をダウンロードして(about:version を確認)し、ビルドする必要があります。

v8 を正常にビルドしたら、ティック プロセッサを使用してログを処理できます。

$ tools/linux-tick-processor /path/to/v8.log

(プラットフォームに応じて、Linux の代わりに mac または Windows を使用してください)。(このツールは、v8 の最上位のソース ディレクトリから実行する必要があります)。

ティック プロセッサは、ティックが最も多い JavaScript 関数のテキストベースのテーブルを表示します。

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

demo.js には、opt、unopt、main という 3 つの関数があることがわかります。最適化された関数には、名前の横にアスタリスク(*)が付いています。関数の opt が最適化されていて、unopt が最適化されていないことを確認します。

V8 探偵のツールバッグに含まれるもう一つの重要なツールは、plot-timer-event です。次のように実行できます。

$ tools/plot-timer-event /path/to/v8.log

実行すると、timer-events.png という png ファイルが現在のディレクトリに配置されます。ファイルを開くと、次のように表示されます。

タイマー イベント

下部のグラフとは別に、データが行で表示されます。X 軸は時間(ms)です。左側には、各行のラベルが含まれています。

タイマー イベントの Y 軸

V8.Execute 行には、V8 が JavaScript コードを実行していたプロファイル ティックごとに黒い縦線が描画されます。V8.GCScavenger では、V8 が新世代のコレクションを実行していたプロファイルの目盛りごとに青い縦線が引かれています。V8 の他の状態についても同様です。

最も重要な行の一つは「実行中のコードの種類」です。最適化されたコードが実行されるときは常に緑色になり、最適化されていないコードが実行されているときは赤色と青色が混在します。次のスクリーンショットは、最適化されたコードから最適化されていないコードへ、そして最適化されたコードに戻る様子を示しています。

実行されるコードの種類

理想的には、この線は緑色で点灯しますが、すぐには表示されなくなります。これは、プログラムが最適化された定常状態に移行したことを意味します。最適化されていないコードは、最適化したコードよりも常に実行速度が遅くなります。

この長さに達した場合、アプリケーションをリファクタリングして、v8 デバッグシェル(d8)で実行できるようにすることで、作業を大幅に迅速化できることに注意してください。d8 を使用すると、ティック プロセッサとプロット タイマー イベント ツールでの反復処理時間を短縮できます。d8 を使用することのもう一つの副作用は、実際の問題の特定が容易になり、データに存在するノイズの量を減らすことです。

Oz のソースコードのタイマー イベント プロットを見ると、最適化されたコードから最適化されていないコードへの遷移が示されています。また、最適化されていないコードの実行中に、次のスクリーンショットのように、多くの新しい世代コレクションがトリガーされています(中央の時間は削除されています)。

タイマー イベントのプロット

よく見ると、V8 が JavaScript コードを実行しているタイミングを示す黒い線が、新しい世代のコレクションとまったく同じプロファイルの目盛り時間(青い線)で欠落していることがわかります。これは、ガベージ コレクションの収集中にスクリプトが一時停止することを示しています。

Oz ソースコードのティック プロセッサ出力を見ると、トップ関数(updateSprites)は最適化されていませんでした。言い換えれば、プログラムが最も時間を費やした関数も最適化されていないということです。このことは、容疑者 3 が犯人であることを明確に示しています。updateSprites のソースには、次のようなループが含まれていました。

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

V8 の存在を知っていた同社は、for-i-in ループ構造が V8 によって最適化されない場合があることを即座に認識しました。つまり、関数に for-i-in ループ構造が含まれていると、最適化できない場合があります。これは現在では特殊なケースであり、将来的に変更される可能性があります。つまり、いつの日か V8 でこのループ コンストラクトを最適化する可能性があります。私たちは V8 の探偵ではなく、V8 についても知ることができません。では、updateSprites が最適化されなかった理由をどうやって判断すればよいのでしょうか。

テスト #2

このフラグを指定して Chrome を実行:

--js-flags="--trace-deopt --trace-opt-verbose"

最適化と最適化解除のデータの詳細ログを表示します。updateSprites のデータを検索すると、次のことがわかりました。

[updateSprites の最適化を無効にしました。理由: ForInStatement は迅速なケースではありません]

探偵が仮説したように、for-i-in ループ構造が理由でした。

クローズされたケース

updateSprites が最適化されていない理由が判明した後、修正は簡単で、計算をその独自の関数に移動します。つまり、

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite が最適化され、HeapNumber オブジェクトが大幅に減り、GC の一時停止頻度が下がります。新しいコードで同じテストを行うことで、簡単に確認できます。注意深く読めば、倍精度数がまだプロパティとして保存されていることに気付くでしょう。プロファイリングで価値があると判断できる場合は、位置を double の配列または型付きデータ配列に変更することで、作成されるオブジェクトの数をさらに減らすことができます。

エピローグ

オズのデベロッパーもそれだけではありません。V8 の探偵によって共有されたツールと手法により、最適化解除の地獄に行き詰っている他の関数をいくつか見つけることができ、計算コードを最適化されたリーフ関数に分解し、パフォーマンスをさらに向上させることができました。

外に出てパフォーマンス犯罪の解決を始めましょう!