ねぎ嫌い

始業前に学んだことを小出しに。最近はHacker Newsの人気記事をまとめてみたり。

20170-08-22 How JavaScript works: inside the V8 engine

原文:blog.sessionstack.com

JavaScriptがV8エンジンでどのように動くか、という話。
シリーズの最初はJavaScriptのエンジン、ランタイムおよびコールスタックの概要について。
今回は、GoogleJavaScript V8エンジンについて詳しく知り、良いJavaScriptのコードを書けるようにすることを目的としている。

概要

JavaScriptのエンジンはインタプリタJITコンパイラとして動き、JavaScriptバイトコードに変換している。
以下に主要なJavaScriptのエンジンを列挙してる:

なぜV8エンジンが作られたのか

V8はC++で書かれたGoogle製のオープンソースエンジンであるが、
使用用途はChromeにかぎらず、Node.jsの実行環境としても用いられている。
V8は当初ブラウザ上でのパフォーマンスを向上する目的で設計されていた。
速度を向上させるために、V8エンジンはインタプリタを用いず効率的な機械語コンパイルしている。
V8はJITコンパイラを実装することで実行時に機械語コンパイルしている。
V8の持つ大きな違いは、バイトコードや中間コードを生成しないことにある。

V8は2つのコンパイラを持っていた

バージョン5.8以前のV8は2つのコンパイラを使っていた。

V8はまた内部的にいくつかのスレッドを使っている。

  • メインスレッドはコードの取得、コンパイル、実行を担う
  • コンパイルのためにスレッドを分け、Crankshaftが最適化を行う
  • いくつかのスレッドはGC実行のために使われる

最初にJavaScriptが実行される時、V8はfull-codegenを活用して直接JavaScriptから機械語への変換を行う。
この場合、素早く機械語を実行することが出来る。
V8は中間バイトコード表現を使わないことで、インタプリタを不要としている。

コードが何回も実行される時、プロファイラ用のスレッドがデータを収集し、どのメソッドを最適化するかを決める。

次に、Crankshaftが別のスレッドで最適化を始める。
これはJavaScriptの抽象構文木をHydrogenと呼ばれる高レベルの静的単一代入SSA)に変換し、Hydrogenのグラフを最適化しようとする。
多くの最適化はこのレベルで実施される。

インライン化

最初の最適化は可能な限り多くのコードを予めインライン化することである。
インライン化は、コールサイト(関数を呼びだすコード)を関数の中身で置き換えるプロセス。

隠蔽されたクラス

JavaScriptにはクラスがなく、オブジェクトがクローンのプロセスによって生成されて使われている。
また、JavaScriptは動的な言語で、つまりプロパティはオブジェクトが生成された後、簡単に追加されたり取り除かれたりしている。

多くのJavaScriptインタプリタハッシュ値を基にした辞書のような構造を使用してオブジェクトのプロパティと値をメモリに持っている。
プロパティの値を検索させる構造は、計算上のコストがJavaC#のような非動的言語よりも高くついている。
Javaでは、全てのオブジェクトのプロパティはコンパイル前に予め定義され、実行環境上で動的に追加されたり削除されたりするようなことがない。
結果として、プロパティの値やポインタは固定されたオフセットとともに連続したバッファとしてメモリ上に保存されることが出来る。
このオフセットの長さはプロパティの型で簡単に定義されるが、一方でJavaScriptにおいてプロパティの方は実行環境で変更され得る。

オブジェクトのプロパティによってメモリから値を探し出すために辞書を使うことは非常に非効率的であることから、V8では代わりに別の方法を採用している。
それが、隠蔽されたクラス(hidden class)である。
Hidden classは実行環境で生成されることを除いてJavaのクラスと同じように振る舞う。

実際に、コンストラクタを呼び出すと、V8はC0と呼ばれるhidden classを生成する。
まだ、プロパティになんの値もセットされていないので、C0は空である。

プロパティをセットしようとすると、V8はC1と呼ばれる2つ目のhidden classをC0から生成する。
C1はセットしたプロパティを見つけられるメモリ上の最初のオフセットを述べる。
また、V8は同様にC0のクラス遷移(class transition)を更新し、特定のプロパティがセットされた時に見るべきオブジェクトの指し示す先を持つようにする。

このプロセスは別のプロパティがセットされた時にも同じように動作する。
新たなC2と呼ばれるhidden classが生成され、class transitionがC1に追記され、現在指し示されるオブジェクトがC2に更新される。

隠蔽されたクラスの遷移は、プロパティの追加される順序に依存している。
つまり、同じオブジェクトに対して同じプロパティを追加する場合は、
同じ順序で追加したほうが、hidden classのclass transitionが使いまわせるため、遥かに優れている。

インラインキャッシュ

V8はその他にもインラインキャッシュと呼ばれる動的言語のための最適化技術を利用している。
インラインキャッシュは、同一オブジェクト繰り返し呼び出される傾向のあるメソッドを観察し、その結果を保持している。

ここではインラインキャッシュの一般的な概念にのみ触れる。

V8は、最近のメソッド呼び出しで渡されたパラメーターの型のキャッシュをメンテナンスし、
その情報を用いて将来的に使用される可能性があるパラメーターを推測する。
もしV8がメソッドに渡されるパラメーターの型について良い仮定を作れたら、
オブジェクトのプロパティへのアクセス方法を考え出すプロセスを迂回し、
代わりにオブジェクトのhidden classが保持している以前の検索結果を使用する事ができる。

特定のオブジェクトからメソッドが呼ばれるたびに、V8は特定のプロパティにアクセスするオフセットを定めるためにオブジェクトのhidden classの検索を実行する。
同じhidden classに対する同じメソッド呼び出しが2回成功すると、V8はhidden classの検索を省略し、単純にプロパティのオフセットをそのオブジェクトのポインタ自身に追加する。
そのメソッドの将来的な全ての呼び出しでは、V8はhidden classが変更されていないと想定し、
以前の検索結果から保持しているオフセットを使って特定のプロパティに直接メモリアドレスにアクセスする。
これによって大幅な速度改善を図っている。

インラインキャッシュにとって、同じ型のオブジェクトがhidden classを共有していることもかなり重要である。
同じ型の2つのオブジェクトを作り、それが違うhidden classを有している場合に、
例え2つが同じ型であったとしても、hidden classのオフセットへの割当が異なるためインラインキャッシュを使用することが出来ない。

機械語へのコンパイル

一度Hydrogenのグラフが最適化されると、Crankshaftは低レベルのLithiumと呼ばれる表現に引き下げる。
多くのLithiumの実装はアーキテクチャ固有である。
レジスタの割当はこの層で行われる。

最終的に、Lithiumは機械語コンパイルされる。
そうすると、OSR(on-stack replacement)と呼ばれる他のものが起こる。
明らかに実行時間の長いメソッドのコンパイルと最適化を始める前に、それを実行する可能性があった。
V8は遅く実行されたことを忘れず、最適化されたバージョンで実行し直す。
代わりに、それはスタックやレジスタなどの全てのコンテキストを変換するので、
実行中に最適化されたバージョンに切り替えることが出来る。
非常に複雑なタスクであり、他の最適化中であることを念頭に置いて、V8は最初にコードのインライン化を行う。
V8だけがそれを出来る唯一のエンジンということではない。

非最適化と呼ばれるセーフガードがあり、これはエンジンが作成したもはや正しいとは言えない仮定を非最適化されたコードに戻したり逆の変換をする。

ガベージコレクション

V8は古い世代をきれいにするために、伝統的で一般的なマーク・アンド・スイープのアプローチをガベージコレクションに使っている。
マーキングの段階では、JavaScriptの実行を停止するようになっている。
安定した実行とGCのコストを制御するために、V8はインクリメンタルマーキングを使用している。
(全てのヒープを探索する代わりに、全てのあり得るオブジェクトにマークをつけ、そのヒープの一部を探索し、通常の実行を再開する。)
次のGCの停止は以前のヒープ探索が停止したところから再開する。
これによって、通常の実行の最中で非常に短い停止時間を達成できる。
以前に述べたように、スイープの段階は別のスレッドによって処理される。

IgnitionとTurboFan

V8エンジンのバージョン5.9から、新しい実行パイプラインが導入された。
このパイプラインはさらに大きなパフォーマンス向上と素晴らしいメモリの節約を達成している。

このパイプラインはIgnitionと呼ばれるV8のインタプリタ、TurboFanと呼ばれる最適化コンパイラの上に構築されている。

どのように最適化されたJavaScriptを書くか

  1. プロパティの順序
    オブジェクトのプロパティを初期化するときは常に同じ順序で行うこと。
    これによって、hidden classと最適化されたコードが共有される。
  2. 動的プロパティ
    初期化されたオブジェクトにプロパティを追加することは、hidden classに対して変更を強制し、
    最適化された以前のhidden classの幾つかのメソッドを低速化させる。
    代わりに、コンストラクタでプロパティの割当を行うこと。
  3. メソッド
    インラインキャッシュが使用できるため、同じメソッドを繰り返し実行するコードは
    一度しか実行されない異なるメソッドを実行するコードよりも速い。
  4. 配列
    キーがインクリメント番号ではないまばらな配列を避けること。
    内部になんの要素も持たないまばらな配列はハッシュテーブルである。
    そのような配列にある要素にアクセスするのは高コストである。
    また、大きな事前割当てを行うような配列も避けること。
    実装の中で割当を増加させていくほうが良い。
    最後に、配列から要素を削除しないこと、これはキーをまばらにしてしまう。
  5. タグ付きの値
    V8はオブジェクトと数値を32bitで表現する。
    V8は1ビットを使用してオブジェクトか、SMIと呼ばれる31bitの数値かを知る。
    なので、数値が31bitよりも大きい場合は、数値をdouble型にして、新たなオブジェクトを生成しその中に数値を格納する。
    31bitの符号付き数値を可能な限り用いて、JSオブジェクトへの高コストなボクシングを避けるようにすること。