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

はじめに

近年、ウェブ アプリケーションは大幅に高速化されています。現在、多くのアプリケーションは十分に高速に動作するため、一部のデベロッパーから「ウェブは十分に高速なのか?」という疑問の声が上がっています。一部のアプリケーションでは十分かもしれませんが、高性能アプリケーションを開発するデベロッパーにとっては、十分な速度ではないことがわかっています。JavaScript 仮想マシン テクノロジーは驚異的な進歩を遂げていますが、最近の調査によると、Google アプリケーションは V8 内で 50% ~ 70% の時間を費やしています。アプリケーションの処理時間は有限であるため、1 つのシステムのサイクルを短縮すると、別のシステムでより多くの処理を行うことができます。60 fps で動作するアプリケーションでは、1 フレームあたり 16 ミリ秒しかありません。これを超えるとジャンクが発生します。この記事では、JavaScript の最適化と JavaScript アプリケーションのプロファイリングについて説明します。これは、V8 チームのパフォーマンス探偵が Find Your Way to Oz で不明瞭なパフォーマンスの問題を追跡した現場の話です。

Google I/O 2013 セッション

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

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

CPU サイクルはゼロサムゲームです。システムの一部で使用するリソースを減らすことで、別の部分で使用するリソースを増やしたり、全体的な動作をスムーズにしたりできます。高速化と機能の追加は、相反する目標となることがよくあります。ユーザーは新機能を求めると同時に、アプリケーションがよりスムーズに動作することを期待します。JavaScript 仮想マシンは高速化を続けていますが、だからといって、今すぐ修正できるパフォーマンスの問題を無視してよいわけではありません。ウェブ アプリケーションのパフォーマンスの問題に対処している多くのデベロッパーは、すでにそのことを知っています。リアルタイムで高フレームレートのアプリケーションでは、ジャンクをなくすことが最優先事項です。Insomniac Games が作成した調査によると、安定したフレームレートを維持することがゲームの成功に重要であることが示されています。「安定したフレームレートは、プロフェッショナルでよくできた製品の証です。」ウェブ デベロッパーは注意してください。

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

パフォーマンスの問題を解決することは、犯罪を解決することに似ています。証拠を慎重に調べ、疑わしい原因をチェックし、さまざまな解決策を試す必要があります。このプロセス全体を通して、測定値を記録する必要があります。これにより、問題が実際に解決されたことを確認できます。この方法は、刑事事件の捜査とほとんど変わりません。刑事たちは証拠を調べ、容疑者を尋問し、決定的な証拠を見つけるために実験を行います。

V8 CSI: オーストラリア

「Find Your Way to Oz」を構築した素晴らしいウィザードたちが、自分たちでは解決できないパフォーマンスの問題について V8 チームに相談しました。Oz がフリーズしてジャンクが発生することがありました。Oz のデベロッパーは、Chrome DevTools の Timeline パネルを使用して、初期調査を行いました。メモリ使用量を確認すると、恐ろしいノコギリ歯グラフが表示されました。ガベージ コレクタは 1 秒に 1 回、10 MB のガベージを収集しており、ガベージ コレクションの一時停止がジャンクに対応していました。Chrome DevTools のタイムラインの次のスクリーンショットのようになります。

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 つ(またはそれ以上)の世代に分割される一般的なガベージ コレクション システムを使用します。若い世代は、最近割り当てられたオブジェクトを保持します。オブジェクトが十分な期間にわたって存続すると、古い世代に移動されます。

若い世代は、古い世代よりもはるかに高い頻度で収集されます。これは、若い世代のコレクションの方がはるかに低コストであるため、意図的に行われています。多くの場合、頻繁な GC の一時停止は、若い世代のコレクションが原因であると想定しても安全です。

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

V8 young メモリ

この時点で、from 空間と to 空間が入れ替わります。以前は to 空間で、現在は from 空間となっている空間が、最初から最後までスキャンされ、まだ有効なオブジェクトが to 空間にコピーされるか、古い世代のヒープに昇格されます。詳細については、Cheney のアルゴリズムをご覧ください。

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

このアプリケーションで 10 MB/秒のガベージが想定されるか。

簡単に言うと、いいえです。デベロッパーは、10 MB/秒のガベージを想定して何もしていません。

容疑者

調査の次の段階は、容疑者を特定し、絞り込むことです。

容疑者 1

フレーム中に new を呼び出している。割り当てられた各オブジェクトは、GC の一時停止に近づくことを忘れないでください。特に、高いフレームレートで動作するアプリケーションでは、フレームあたりの割り当てをゼロにすることを目指す必要があります。通常、これには、慎重に検討された、アプリケーション固有のオブジェクト リサイクル システムが必要です。V8 探偵が Oz チームに確認したところ、new を呼び出していませんでした。実際、Oz チームはこの要件をすでに十分に認識しており、「それは恥ずかしいことだ」と述べていました。この項目はリストから削除してください。

容疑者 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 に割り当てられます。

Oz は、フレームごとに数千回のオペレーションを行います。これらの計算が最適化されない関数で行われている場合、それがガベージの原因となっている可能性があります。最適化されていない計算では、一時的な結果に対してもメモリが割り当てられるためです。

容疑者 4

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

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

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

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

容疑者 4 の可能性はあります。

フォレンジック

この時点で、探偵は 2 つの容疑者を特定しました。1 つは、ヒープ番号をオブジェクト プロパティとして保存すること、もう 1 つは、最適化されていない関数内で算術計算が行われることです。いよいよラボに向かい、どの容疑者が有罪かを確定する時が来ました。注: このセクションでは、実際の 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 探偵のツールバッグに入っているもう 1 つの重要なツールは、plot-timer-event です。次のように実行できます。

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

実行すると、timer-events.png という名前の png ファイルが現在のディレクトリに作成されます。開くと、次のような画面が表示されます。

タイマー イベント

下部のグラフを除き、データは行に表示されます。X 軸は時間(ミリ秒)です。左側には、各行のラベルが表示されます。

タイマー イベントの Y 軸

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

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

実行されているコードの種類

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

ここまで進んだら、アプリケーションをリファクタリングして v8 デバッグシェル(d8)で実行できるようにすることで、作業を大幅に高速化できることに注目してください。d8 を使用すると、tick-processor ツールと plot-timer-event ツールを使用してイテレーション時間を短縮できます。d8 を使用するもう一つの副次的効果は、実際の問題を分離しやすくなり、データに存在するノイズの量が減ることです。

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

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

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

Oz のソースコードから tick プロセッサの出力を確認すると、最上位の関数(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 のデータを検索すると、次のことがわかります。

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

探偵たちの仮説どおり、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 型の数値が依然としてプロパティとして保存されていることに気づくでしょう。プロファイリングで効果が認められる場合は、位置を double の配列または型付きデータ配列に変更すると、作成されるオブジェクトの数をさらに減らすことができます。

エピローグ

Oz の開発者はそれだけにとどまりませんでした。V8 探偵から共有されたツールと手法を駆使して、最適化解除の地獄に陥っている他の関数をいくつか見つけ、計算コードを最適化されたリーフ関数に組み込むことで、パフォーマンスをさらに向上させることができました。

さあ、パフォーマンスの犯罪を解決しましょう。