V8エンジンでのJavaScriptの機能と最適化コードの書き方に関する5つのベストプラクティス

数週間前に、JavaScriptが実際どのように動いているかを掘り下げて紹介する記事の連載を始めました。JavaScriptがどのような機能で構成されていてそれらがどのように組み合わさって機能していくのかを知ることによって、さらに良いコードやアプリケーションを作ることができるのではないかと思ったからです。

連載の1回目では、エンジンやランタイム、コールスタックについての概要を紹介しました。2回目となる今回は、Google V8 JavaScriptエンジンについて細かく説明していきます。また、より良いJavaScriptコードの書き方、すなわち私たちの開発チームSessionStackがプロダクトを開発する際に意識しているベストプラクティスについても併せて紹介します。

概要

JavaScriptエンジンとはJavaScriptコードを実行するプログラムまたはインタプリタのことです。JavaScriptエンジンはふつうのインタプリタとして実装されることもあれば、JavaScriptを何らかの形式でバイトコードにコンパイルするJITコンパイラとして実装されることもあります。

JavaScriptエンジンが実装されている代表的なプロジェクトは以下の通りです。

  • V8 – Googleによって開発され、C++で記述されたOSS

  • Rhino – Mozilla Foundationによって管理されている、すべてJavaで記述されたOSS

  • SpiderMonkey – 当時Netscape Navigatorで使用されていた世界初のJavaScriptエンジン。現在はFirefoxで使用されている。

  • JavaScriptCore – AppleのSafari向けに開発されたOSS。SafariではNitroと呼ばれている。

  • KJS – KDEのエンジンで、元々はKDEプロジェクトであるKonqueror Webブラウザ向けにHarri Portenによって開発された。

  • Chakara (Jscript9) – Internet Explorer

  • Chakara (JavaScript) – Microsoft Edge

  • Nashorn – OpenJDKの一部で、OracleのJava言語・ツールグループによって記述されたオープンソース。

  • JerryScript – IoT向け軽量エンジン。

V8エンジンが作られた経緯

Googleによって構築されたV8エンジンは、C++で記述されたOSSです。このエンジンはGoogle Chromeで使われていますが、他のエンジンと異なり、よく知られているNode.jsランタイムにも使われています。

V8は当初、Webブラウザで実行されるJavaScriptのパフォーマンス向上を目的に作られました。速度を確保するため、V8はインタプリタを使わずに、JavaScriptをより効率の良い機械語に変換します。SpiderMonkeyやRhino(Mozilla)といった最新のエンジンの多くがそうしているように、JIT(実行時)コンパイラを実装することで、実行時にJavaScriptを機械語にコンパイラします。V8が他のエンジンと異なる主な点は、バイトコードやどんな中間コードも作り出さないという点です。

V8に組み込まれていた2つのコンパイラ

V8 5.9(2017年初期にリリース)がリリースされる前までのバージョンでは、2つのコンパイラが使用されていました。

  • full-codegen  – シンプルで高速なコンパイラ。生成される機械語はシンプルだが速度面では相対的に劣る

  • Crankshaft – より複雑な(実行時)最適化コンパイラで、高度な最適化されたコードを生成

また、V8エンジンは複数のスレッドを使っています。

  • コードをフェッチし、コンパイル、そして実行するといった恐らくあなたの予想通りの動作を行うメインスレッド

  • コンパイル向けの個別スレッド。このスレッドがコードの最適化を行っている最中でも、メインスレッドは継続して動くことができる

  • どのメソッドに長い時間がかかっているのかのランタイムを知ることができるプロファイラのスレッド。これによりCrankshaftがこれらを最適化することができる。

  • ガベージコレクションを取り扱ういくつかのスレッド。

JavaScriptコードを実行すると、V8はまず、full-codegenを使います。full-codegenは、どの変換も使わずにパースされたJavaScriptを機械語に変換することがきます。これにより、機械語を素早く実行することができます。V8は中間表現を用いないので、インタプリタの必要性を取り除くことができます。

コードがある程度の時間実行されたら、プロファイラのスレッドが十分なデータを集め、どのメソッドを最適化すべきかを教えてくれます。

次に、別のスレッドでCrankshaftの最適化が始まります。このコンパイラは、JavaScriptの抽象構文木をHydrogenと呼ばれる高度なSSA形式に変換し、Hydrogenグラフを最適化しようとします。この時点でほとんどの最適化が完了します。

インライン化

最初の最適化では、あらかじめ可能な限り多くのコードをインライン化します。インライン化とは、コールサイト(関数が呼び出されるコードの行位置)を呼び出された関数の本体で置き換える処理のことです。この簡単なステップにより、以下の最適化がより重要な意味を持つようになります。

Hidden class

JavaScriptはプロトタイプベースの言語なので、クラスは存在せずオブジェクトはプロセスをコピーすることで作成されます。また、JavaScriptは動的プログラミング言語でもありますので、インスタンスが生成された後でもプロパティは簡単に追加したり削除したりすることができます。

JavaScriptのほとんどのインタプリタは、辞書のような構造(ハッシュ関数ベース)を使ってメモリにあるオブジェクトプロパティ値のロケーションを保存します。この構造によって、JavaScriptではプロパティ値の抽出がJavaやC#といった動的プログラミングではない言語と比べて計算コストが高くなります。Javaでは、コンパイルする前に固定のオブジェクトレイアウトによってすべてのオブジェクトプロパティが断定され、ランタイム時に動的に追加や削除がされません(C#は動的型を持っているので、これはまた別の話です)。これによって、プロパティ値(またはこれらのプロパティへのポインタ)は、連続したバッファとして各バッファ間にある固定オフセットとともにメモリに保存することができます。オフセットの長さは、プロパティの型をもとに簡単に判断することができますが、JavaScriptの場合、ランタイムを行っている間にプロパティの型が変化するので、オフセットの長さを判断することができません。

メモリにあるオブジェクトプロパティのロケーションを見つけるために辞書を使うことは非常に非効率なので、V8ではhidden classと呼ばれる異なるメソッドを使っています。Hidden classは、Javaなどの言語で使われる固定のオブジェクトレイアウト(クラス)と同じような働きをしますが、唯一異なる点は、hidden classはランタイム時に作成されるという点です。では、実際のコードを見てみましょう。

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

“new Point(1,2)”が起動されると、V8は”C0″と呼ばれるhidden classを作成します。

この時点でPointのプロパティは識別されていないので、”C0″は空です。

最初のステートメント”this.x = x”が(”Point”関数内で)実行されたら、V8は”C0″をもとに、”C1″と呼ばれる2つ目のhidden classを作成します。”C1″は(オブジェクトポインタからの相対位置として)プロパティxがあるメモリ内のロケーションを表します。この場合、”x”はオフセット0に保存されます。これはつまり、継続したバッファとしてPointオブジェクトをメモリ内に表示させると、最初のオフセットはプロパティ”x”に対応するということです。またV8は、プロパティ”x”がPointオブジェクトに追加された場合にhidden classが”C0″から”C1″に変更されることを指示する”class transition”を使って”C0″も更新します。以下のPointオブジェクトのhidden classは”C1″になりました。

(図の説明:新しいプロパティがオブジェクトに追加されるたびに、古いhidden classはtransitionパスによって、新しいhidden classに更新されます。Hidden classのtransitionは、hidden classに対して同様に作成された他のオブジェクトを共有できるようにさせるので、とても重要です。もし2つのオブジェクトが1つのhidden classを共有しており、両方に同じプロパティが追加された場合、transitionは両方のオブジェクトが、同じ新しいhidden classとそれに付随するすべての最適化されたコードを受け取るかを確認します。)

このプロセスは、”this.y = y”が実行された場合も同じことを繰り返します(繰り返しますが、”this.x = x”ステートメントの後にあるPoint関数内です)。

新しいhidden class”C2″が作成され、プロパティ”y”が(すでにプロパティ”y”を含んでいる)Pointオブジェクトに追加された場合にhidden classが”C2″に変更されることを指示するclass transitionが”C1″に追加されると、Pointオブジェクトのhidden classは”C2″に更新されます。

Hidden classのtransitionはプロパティがオブジェクトに追加された順番に左右されます。以下のコードスニペットを見てください。

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

p1とp2が同じhidden classでこれらにtransitionが使われたと思うかもしれませんが、そうではありません。”p1″の場合、最初に追加されたプロパティは”a”で次に”b”です。これに対し”p2″には、最初に”b”そして次に”a”が追加されています。つまり、”p1″と”p2″は、異なるtransitionパスであることから、異なるhidden classを持つことになります。この場合、動的プロパティを同じ順番で初期化すれば、hidden classを再度利用することができます。

インライン・キャッシング

V8には、インライン・キャッシングという最適化された動的プログラミング言語に用いられる別のテクニックがあります。インライン・キャッシングは、あるメソッド呼び出しが繰り返し発生する状況というのが、同じ型のオブジェクトに起こりがちであるという観測に基づいています。インライン・キャッシングに関する詳細を知りたい方は、こちらをご覧ください。

ここでは、(上記のリンクから詳細を確認する時間がない人のために)インライン・キャッシングの一般概念についてだけ説明します。

さてインライン・キャッシングはどのように動くのでしょうか。V8は、最も最近呼び出されたメソッドに渡されたオブジェクトの型のキャッシュを管理し、この情報を将来パラメータとして通過するであろうオブジェクトの型を推測するのに活用します。V8がメソッドに追加するオブジェクトの型をほぼ正しく推測することができれば、オブジェクトのプロパティへのアクセス方法を突き止めるプロセスを省くことができ、直前のオブジェクトのhidden classの検索から得た記録情報を使うことができます。

では、hidden classとインライン・キャッシングの概念はどのように関連しているのでしょう?特定のオブジェクト上でメソッドが呼び出されるときはいつも、V8エンジンは特定のプロパティにアクセスするためのオフセットを断定するために、特定オブジェクトのhidden classを検索しなくてはなりません。同じhidden classに対して同じメソッドの呼び出しがうまくいくと、V8はhidden classの検索を省略し、単にオブジェクトポインタに対しプロパティのオフセットを追加します。今後、このメソッドに対して呼び出しが行われる場合、V8エンジンはhidden classは変更されていないと推測し、直前の検索で保存されたオフセットを使って、特定のプロパティのメモリアドレスに直接移動します。これにより実行速度が一段と向上します。

インライン・キャッシングはまた、hidden classが同じ型のオブジェクトを共有することが極めて重要であることの理由でもあります。同じ型だが異なるhidden classを持つ2つのオブジェクトを作成した場合(前述で示した例のとおり)、V8はインライン・キャッシングを使うことができません。というのも、2つのオブジェクトの型が同じであってもそれに応えるhidden classは、プロパティに対して異なるオフセットを割り当てるからです。

(図の説明:2つのオブジェクトは基本的には同じですが”a”と”b”プロパティは異なる順番で作成されています。)

機械語へのコンパイル

Hydrogenグラフが最適化されたら、Crankshaftは、それをLithiumと呼ばれるlower-levelの中間表現へと変換します。ほとんどの場合、Lithiumの実装はアーキテクチャ固有となっており、このタイミングでレジスタアロケーションが発生します。

その後最終的にLithiumが機械語にコンパイルされると、OSR(on-stack replacement)と呼ばれる事象が起こります。明らかに長い時間実行されるであろうメソッドをコンパイルしたり最適化しなければ、私たちはただそれらを実行しようとします。V8は最適化されたバージョンを使って再実行できるように、ゆっくりと実行された事象を忘れはしません。それどころか、持っているすべてのコンテキスト(スタック、レジスタなど)を変換し、実行中でも最適化されたバージョンに切り替えることを可能にします。これは非常に複雑なタスクです。いくつかの最適化の中でも、V8は最初にコードをインライン化していることを忘れないでください。これが可能なエンジンはV8だけではありません。

Deoptimizationと呼ばれるセーフガードがあり、これはエンジンの推測が当たらなくなった場合に正反対の変換を行い、最適化されていないコードへと戻すことができます。

ガベージコレクション

ガベージコレクションにおいて、V8は古い世代を取り除くためにマーク・アンド・スイープという昔ながらの世代別管理手法を使っています。マークフェーズでは、JavaScriptの実行を停止します。GCコストを管理し、実行をより安定させるために、V8ではインクリメンタルマーキングを使います。全体のヒープを確認し、可能性のあるすべてのオブジェクトをマークする代わりに、一部のヒープだけを確認し、通常の実行を再開します。次のGCの停止は、直前のヒープウォークが停止した時点から開始されます。こうすることで、通常の実行時の中断時間を短くすることができます。前述のとおり、スイープフェーズは異なるスレッドで処理されます。

IgnitionとTurboFan

2017初期にリリースされたV8 5.9には、新しい実行パイプラインが導入されました。この新しいパイプラインの導入により、パフォーマンスが飛躍的に向上し、現実世界のJavaScriptアプリケーションにおいてメモリ使用量も抑えることができています。

新しい実行パイプラインは、V8のインタプリタであるIgnitionと、V8の新しい最適化コンパイラであるTurboFanとで成り立っています。

V8 5.9バージョンでは、full-codegenとCrankshaft(2010年以来、V8で使用されていたコンパイラ)はJavaScriptの実行に使われていません。これは、新しいJavaScript言語の機能やこれらの機能に必要とされる最適化に、V8チームがついていくのが難しいと考えたからです。

つまり簡単にいうと、今後V8はよりシンプルで、より管理しやすいアーキテクチャになるということです。

(図の説明:WebならびにNode.jsベンチマークの向上率)

これらの伸びはまだ序の口です。新たに登場したIgnitionとTurboFanパイプラインは、JavaScriptのパフォーマンスを向上させる最適化の新しい道を切り開き、今後ChromeやNode.jsでのV8のメモリ使用量は少なくなっていくでしょう。

最後に、より良く最適化されたベターなJavaScriptの書き方について、いくつかアドバイスをしたいと思います。詳しいことは記事内で説明していますが、以下に要約したものを記載します。

最適化されたJavaScriptの書き方

  1. オブジェクトプロパティの順番:常に同じ順番を持つオブジェクトプロパティのインスタンスを作成する。そうすることで、今後hidden classや最適化コードの共有が可能になる。

  2. 動的プロパティ:インスタンス生成後のオブジェクトにプロパティを追加してしまうと、hidden classの変更が発生し、既に最適化されていた直前のhidden classのメソッドを遅らせてしまう。代わりに、すべてのオブジェクトのプロパティを前もってそれぞれのコンストラクタで割り当ててしまう。

  3. メソッド:同じメソッドを繰り返し実行するコードの方が、異なる多数のメソッドを1度だけ実行するコードよりも速い(これはインライン・キャッチングによる結果)。

  4. 配列:キーがインクリメントな数値ではない疎な配列を避ける。行列にすべての要素が含まれていないスパース配列は、ハッシュテーブルである。このような行列内の要素は、アクセスするのにコストがかかる。また事前に割り当てられた大きな行列も避けるべきだ。自然に大きくさせるようにする。最後に、行列内の要素を削除しない。キーを疎にしてしまう。

  5. タグされた値:V8はオブジェクトと数値を32ビットで表現します。そのうち1ビットは、オブジェクト(flag = 1)または31ビットであるSMI(小整数)と呼ばれる整数(flag = 0)であるかの判断に使用されます。もし数値が31ビットよりも大きければ、V8は数値を囲み倍の値にしてから、数値を内部に置くために新しいオブジェクトを作成します。コストのかかるJSオブジェクトへのボックス操作を避けるために、どんな場合でも極力、符号のついた31ビットの数値を使うようにする。

SessionStackでは、高度な最適化JavaScriptコードを書く際、これらのベストプラクティスに沿うよう努めています。なぜなら、ひとたびSessionStackをあなたのWebアプリの本番環境に組み込むと、DOM変更やユーザインタラクション、JavaScriptの例外、スタックトレース、失敗したネットワーク要求、デバッグメッセージといったすべてを記録する動作を行うためです。
SessionStackを使えば、Webアプリで起こった問題を動画で返信することができ、私たちはユーザに起こったすべてを確認することができます。しかもWebアプリのパフォーマンスに影響を及ぼすことはありません。
まずは無料で開始できるプランを検討してみてください。

参照

https://docs.google.com/document/u/1/d/1hOaE7vbwdLLXWj3C8hTnnkpE0qSa2P–dtDvwXXEeD0/pub
https://github.com/thlorenz/v8-perf
http://code.google.com/p/v8/wiki/UsingGit
http://mrale.ph/v8/resources.html

https://www.youtube.com/watch?v=hWhMKalEicY