はじめに
近年、ウェブ アプリケーションの速度は大幅に向上しています。多くのアプリケーションが十分な速度で動作するようになったため、一部のデベロッパーは「ウェブは十分に高速か?」と疑問を抱いているようです。一部のアプリケーションでは十分かもしれませんが、高パフォーマンス アプリケーションを開発しているデベロッパーにとっては十分ではありません。JavaScript 仮想マシン技術は驚異的な進歩を遂げていますが、最近の調査によると、Google アプリケーションは実行時間の 50 ~ 70% を V8 内で費やしています。アプリケーションには限られた時間があります。1 つのシステムのサイクル数を削減すると、別のシステムでより多くの処理を行うことができます。60 fps で実行されるアプリには、フレームあたり 16 ms しかありません。それを超えると、ジャンクが発生します。JavaScript の最適化と JavaScript アプリケーションのプロファイリングについて詳しくは、Find Your Way to Oz で、V8 チームのパフォーマンス ディテクティブが不明瞭なパフォーマンスの問題を特定する様子をご覧ください。
Google I/O 2013 セッション
この資料は Google I/O 2013 で発表しました。以下の動画をご覧ください。
パフォーマンスが重要な理由
CPU サイクルはゼロサムゲームです。システムの一部で使用量を減らすと、別の部分でより多くのリソースを使用したり、全体的にスムーズに動作したりできます。多くの場合、高速化と機能の拡張は競合する目標です。ユーザーは新機能を要求する一方で、アプリケーションがスムーズに動作することを期待しています。JavaScript 仮想マシンはますます高速化していますが、ウェブ アプリケーションのパフォーマンスの問題に取り組んでいる多くのデベロッパーがすでに知っているように、今すぐ修正できるパフォーマンスの問題を無視する理由にはなりません。リアルタイムで高フレームレートのアプリでは、ジャンクのない状態を維持することが最も重要です。Insomniac Games が行った調査では、安定したフレームレートを維持することがゲームの成功に重要であることが示されています。「安定したフレームレートは、プロフェッショナルでよくできたプロダクトの証です。」ウェブ デベロッパーは注意してください。
パフォーマンスの問題を解決する
パフォーマンスの問題を解決することは、犯罪を解決するようなものです。証拠を慎重に調べ、疑わしい原因をチェックし、さまざまな解決策を試す必要があります。問題が実際に解決されたことを確認できるように、測定結果をすべて記録する必要があります。この方法は、刑事事件の捜査方法とほとんど変わりません。刑事は証拠を調べ、容疑者を尋問し、決定的な証拠を見つけるためにテストを実施します。
V8 CSI: Oz
Find Your Way to Oz を開発している優秀なウィザードが、自力で解決できないパフォーマンスの問題について V8 チームに問い合わせてきました。まれに Oz がフリーズし、ジャンクが発生する。Oz のデベロッパーは、Chrome DevTools の [タイムライン パネル] を使用して初期調査を実施しました。メモリ使用量を調べたところ、恐ろしい鋸歯状のグラフが見つかりました。ガベージ コレクタは 1 秒に 1 回 10 MB のガベージを収集しており、ガベージ コレクションの一時停止がジャンクに対応していました。Chrome DevTools のタイムラインの次のスクリーンショットのようなものです。
V8 の探偵である Jakob と Yang がこのケースを引き継ぎました。V8 チームの Jakob と Yang と Oz チームとの間で、長いやり取りが続きました。この会話から、この問題の追跡に役立った重要なイベントを抜粋しました。
裏付けとなる資料
最初のステップは、初期の証拠を収集して調査することです。
どのような種類のアプリケーションを検討していますか?
Oz デモはインタラクティブな 3D アプリケーションです。このため、ガベージ コレクションによる一時停止に非常に敏感です。60 fps で実行されるインタラクティブ アプリケーションには、JavaScript のすべての処理を行うのに 16 ミリ秒しかありません。そのうちの一部は、Chrome がグラフィック呼び出しを処理して画面を描画するために残しておく必要があります。
Oz は、浮動小数点値に対して多くの算術演算を行い、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 スペースが使い果たされるまで続きます。この時点でプログラムが停止し、収集が開始されます。
この時点で、送信元スペースと送信先スペースが入れ替わります。転送元スペースだったものが転送元スペースになり、最初から最後までスキャンされ、まだ存続しているオブジェクトは転送元スペースにコピーされるか、古い世代のヒープへと昇格されます。詳しくは、Cheney のアルゴリズムをご覧ください。
オブジェクトが暗黙的または明示的に(new、[]、{} の呼び出しを介して)割り当てられるたびに、アプリはガベージ コレクションと恐ろしいアプリの一時停止に近づいていることを直感的に理解する必要があります。
このアプリケーションで 10 MB/秒のガベージが発生することは想定されていますか?
簡単に言えば、いいえ。デベロッパーは、10 MB/秒のガベージを想定して何もしていません。
容疑者
調査の次の段階は、疑わしい人物を特定し、絞り込むことです。
容疑者 1
フレーム内で new を呼び出す。割り当てられるオブジェクトが増えるにつれて、GC の停止に近づいていくことを覚えておいてください。特にフレームレートが高いアプリケーションでは、フレームあたりの割り当てをゼロにする必要があります。通常、これは、アプリケーション固有のオブジェクト再利用システムを慎重に検討する必要があります。V8 の担当者が 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 つの疑わしい原因を特定できます。ヒープ数をオブジェクト プロパティとして保存することと、最適化されていない関数内で行われる算術演算です。ラボに行き、どの容疑者が有罪かを明確に判断する時が来ました。注: このセクションでは、実際の 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 軸は時間(ミリ秒)です。左側には、各行のラベルが表示されます。
V8.Execute 行には、V8 が JavaScript コードを実行していたプロファイル ティックごとに黒い縦線が引かれています。V8.GCScavenger には、V8 が新しい世代の収集を実行したプロファイル ティックごとに青い垂直線が引かれています。他の V8 の状態についても同様です。
最も重要な行の一つは「実行中のコードの種類」です。最適化されたコードが実行されているときは緑色になり、最適化されていないコードが実行されているときは赤と青が混在します。次のスクリーンショットは、最適化されたコードから最適化されていないコードに移行し、その後再び最適化されたコードに戻った様子を示しています。
理想的には、この線はすぐに緑色になりますが、すぐにはなりません。つまり、プログラムが最適化された安定状態に移行したことを意味します。最適化されていないコードは、最適化されたコードよりも常に遅くなります。
ここまで手順を踏んだのであれば、v8 デバッグシェル(d8)で実行できるようにアプリケーションをリファクタリングすることで、作業を大幅に効率化できます。d8 を使用すると、tick-processor ツールと plot-timer-event ツールでイテレーション時間が短縮されます。d8 を使用するもう 1 つの副作用として、実際の問題を特定しやすくなり、データ内のノイズの量を減らすことができます。
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 の一時停止頻度が低下します。これは、新しいコードで同じテストを実行することで簡単に確認できます。注意深く読むと、浮動小数点数は引き続きプロパティとして保存されていることがわかります。プロファイリングでその価値が示された場合は、位置をダブル配列または型付きデータ配列に変更すると、作成されるオブジェクトの数をさらに減らすことができます。
エピローグ
Oz のデベロッパーはそこで終わりませんでした。V8 デテクティブから共有されたツールと手法を使って、デオプティマイゼーション ヘルに陥っていた他のいくつかの関数を見つけ、計算コードを最適化されたリーフ関数に分割し、パフォーマンスをさらに向上させました。
さあ、パフォーマンス犯罪を解決しましょう。