ジャスト・イン・タイム (JIT) コンパイラの短期集中コース

この記事は WebAssembly と何が速くしたのかのシリーズの2部です。もしまだ前の記事を読んでいない場合、最初から読むことをお勧めします。JavaScript は最初は遅かったですが、JIT と呼ばれるもののお陰で速くなりました。JIT はどのように機能しているのでしょうか?

JavaScript はブラウザ上でどのように動作しているか

開発者として JavaScript を追加すると、ゴールと問題が出てきます。

ゴール : コンピュータへ何かを伝えたいと思います

問題 : あなたとコンピュータは別の言語を話します

あなたは人間の言語を話し、コンピュータは機械語を話します。JavaScript や他の高レベルのプログラミング言語を人間の言語として考えていなくても、実際にはそうです。これらは人間の認知のために開発されたものであり、コンピュータの認知のために考えられたものではありません。

JavaScript のエンジンの仕事は人間の言葉を機械が理解できるように変換することです。

人間とエイリアンがお互いに話そうとしている映画、「メッセージ」のように考えています。

A person holding a sign with source code on it, and an alien responding in binary

この映画では、人間とエイリアンは一語一語の翻訳をするわけではありません。2つのグループは世界に関して別の考え方を持っています。そして人間と機械に関してもそうです (次の記事でこの映画に関してはもっと説明します) 。

では翻訳はどのように行いますか?

プログラミングでは、機械語に翻訳するには一般的に 2 つの方法があります。

インタープリタでは、この翻訳は直ちに一行一行実施します。

A person standing in front of a whiteboard, translating source code to binary as they go

一方コンパイラは直ちに翻訳を行いません。翻訳を作成して、それを書きとめる前に動作します。

A person holding up a page of translated binary

これらの翻訳処理方法にはそれぞれ長所と短所があります。

インタープリタの長所と短所

インタープリタはすぐに起動して実行できます。コードの実行を開始する前に、コンパイルの手順全体を踏む必要はありません。 最初の行を翻訳して実行するだけです。

このため、インタプリタは JavaScript のようなものに自然にフィットするように見えます。Web 開発者がコードをすぐに実行できるようにすることが重要です。

そしてそれが様々なブラウザが最初から JavaScript インタープリタを使用していた理由です。

しかし同じコードを何度も実行するときに毎回翻訳が必要というインタープリタの問題があります。例えば、ループを使用するときがそうです。この時同じ翻訳を何度も何度も実行する必要があります。

コンパイラの長所と短所

コンパイラは対称的なトレードオフを持っています。

開始時にコンパイルを行うステップを経ないといけないため、起動に少し時間がかかります。 しかし、ループ内のコードパスはそのループを通過するたびに翻訳を繰り返す必要がないため、実行速度が速くなります。

もう一つの違いはコンパイラはコードの中身を確認し、より速く動作するように編集を行う時間があることです。これらの編集は最適化と呼ばれています。

インタープリタは実行時に作業を行っているため、これらの最適化を理解の翻訳フェーズでは時間がかかりません。

ジャスト・イン・タイム コンパイラ : 2つの世界の最も優れたもの

インタプリタの非効率性を取り除く方法として、インタプリタがループを通過するたびにコードの再翻訳を続ける必要がある場合に、ブラウザがコンパイラを混在させることを開始しました。

別のブラウザではこの方法はやや異なっていますが、基本的なアイデアは同じです。これらではモニター (別名プロファイラ) と呼ばれる JavaScript エンジンへ新しい機能を追加しました。そのモニターは実行中のコードを監視し、何回動作したかとどのようなタイプで使われるのかを記録します。

まず最初にモニターはインタープリタを通してすべてを実行します。

Monitor watching code execution and signaling that code should be interpreted

もし同じ行を何度か通った場合、コードのその部分は “warm” と呼ばれます。もし何度も動作する場合、ここは “hot” と呼ばれます。

ベースラインコンパイラ

関数が “warm” な状態になると、JIT はそれを送信しコンパイルします。そしてコンパイル結果を保存します。

Monitor sees function is called multiple times, signals that it should go to the baseline compiler to have a stub created

関数の各行は “スタブ” にコンパイルが行われます。スタブは行番号と変数タイプによって索引付けします (なぜ重要なのかは後で説明します) 。 モニタにより同じ変数の型で同じコードを実行していることが確認した場合、コンパイルしバージョンを取り出します。

それはスピードアップするのに役立ちます。しかし、先程述べたように、コンパイラができることはもっとあります。最適化を行うための最も効率的なやり方を理解するまでには時間がかかることがあります。

ベースラインコンパイラはこれらの最適化の幾つかを行います (以下に例を示します )。長い時間作業を保留にしたくないため、これらに多くの時間をかけたくありません。

しかしコードが本当に “hot” な場合、—もしそれが一杯になったら— このような時にさらなる最適化を実施する時間を余分にとる価値があります。

コンパイラの最適化

コードの一部がとても “hot” な時、モニターは最適化コンパイラに対してそれを送信します。これにより、保存される関数の別のより高速なものが作成されます。

Monitor sees function is called even more times, signals that it should be fully optimized

コードのより高速なバージョンを作るために、最適化コンパイラはいくつかの過程を行う必要があります。

例えば、特定のコンストラクタによって作成されたすべてのオブジェクトが同じ形状を持っていると仮定することができます。つまり、それらは常に同じプロパティ名を持ち、それらのプロパティは同じ順序で追加され、それに基づいていくつかのコーナーをカットすることができます。

最適化コンパイラはモニターが監視したコードの実行状態の情報を使用します。ループが通る前のすべてのパスが真だった場合、それらは引き続き真だとみなされます。

もちろん JavaScript を使用した場合、それらの保証はありません。あなたが 99 個の同じ形状のオブジェクトを持ってしたとしても、100 個目のオブジェクトはプロパティが無いかもしれません。

従ってコンパイルされたコードは仮定が正しいかどうかを動作させる前に確認する必要があります。もし正しければコンパイルされたコードを動作させます。もし正しくなければ、JIT は誤った仮定とみなし、最適化したコードを破棄します。

Monitor sees that types don't match expectations, and signals to go back to interpreter. Optimizer throws out optimized code

その後、実行するコードはインタプリタまたはベースラインのコンパイルされたバージョンに戻ります。このプロセスは最適化解除 (または救済) と呼ばれます。

たいていの最適化コンパイラはコードを速く動くようにしますが、時々期待されないパフォーマンスの問題が発生します。最適化を続けて最適化を行わないコードがある場合、ベースラインのコンパイル済みバージョンを実行するよりも遅くなります。

ほとんどのブラウザでは最適化/最適化解除のサイクルに問題が発生した時、終了させる制限を加えています。もし JIT が最適化を 10 回以上試行しそれを捨て続けた場合、試行をやめます。

最適化の例 : 型の特殊化

さまざまな種類の最適化がありますが、1 つの型を見て最適化がどのように起こるかを感じることができます。コンパイラの最適化で最も大きな成果を得たのは、型の特殊と呼ばれるものです。

JavaScriptが利用する動的型システムでは、実行時に余分な作業が必要です。例えば、次のコードで考えてみましょう。

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr;
  }
}
ループ内の += ステップは単純に思えるかもしれません。これを 1 ステップで計算できるように思えるかもしれませんが、動的な型付けのために予想以上に多くのステップが必要となります。

arr が 100 個の整数の配列であるとしましょう。コードが “warm up” すると、ベースラインコンパイラは関数内の各演算のスタブを作成します。そのため sum += arr のスタブがあり、これは += 演算を整数加算として扱います。

しかし、sum arr は整数であるとは限りません。 JavaScript では型が動的なので、ループの後の反復で、arr が文字列になる可能性があります。 整数の加算と文字列の連結は非常に異なる2つの操作なので、非常に異なる機械語にコンパイルされます。

JIT がこれを処理する方法は、複数のベースライン・スタブをコンパイルすることです。 コードの一部が単調である (つまり、常に同じ型で呼び出される) 場合、それは 1 つのスタブを取得します。 それがポリモーフィックである場合 (あるコードから別のコードへと異なるタイプで呼び出されます) 、その操作を経たタイプの各組み合わせに対してスタブを取得します。

これは JIT はスタブを選ぶ前に多くの質問をする必要が有ることを意味します。

Decision tree showing 4 type checks

コードの各行はベースラインコンパイラに独自のスタブセットを持つため、JIT はコードの各行が実行されるたびに型のチェックを実施し続ける必要があります。そしてループを通るそれぞれのイテレーションのために、同じ質問をする必要があります。

Code looping with JIT asking what types are being used in each loop

JIT がこれらのチェックを繰り返す必要がない場合、コードはより高速に実行されます。 そして、これは最適化コンパイラが行うことの 1 つです。

最適化コンパイラでは、関数全体が一緒にコンパイルされます。 型チェックは、ループの前に実施するように移動されます。

Code looping with questions being asked ahead of time

一部の JIT ではこれをさらに最適化します。 例えば、Firefox には、整数だけを含む配列の特別な分類があります。 arr がこれらの配列のいずれかである場合、JIT は arr が整数かどうかを確認する必要はありません。これは、JIT がループに入る前にすべての型チェックを実行できることを意味します。

結論

それを簡潔に言えば JIT です。コードを実行しながらコードを監視し、”hot” なコードパスを送信して最適化することで、JavaScript をより速く実行できます。 これにより、ほとんどの JavaScript アプリケーションのパフォーマンスが数倍に向上しました。

しかし、これらの改良によっても、JavaScript のパフォーマンスは予測できません。さらに高速化するために、JIT は実行時にいくつかのオーバーヘッドが増えています :

  • 最適化と最適化解除
  • 救済措置が起こったときのモニタの記録と復旧情報に使用されるメモリ
  • 関数のベースラインバージョンと最適化バージョンを格納するために使用されるメモリ
ここに改善の余地があります : オーバーヘッドが取り除かれ、パフォーマンスが予測可能になります。それが WebAssembly が行うことの 1 つです。

次の記事では、アセンブリとコンパイラがどのように動作するかについて、さらに詳しく説明します。

Lin Clark に関して

Lin は Mozilla Developer Relations チームのエンジニアです。 彼女は JavaScript、WebAssembly、Rust、Servo を使っています。また、コードの漫画を描きます。

Lin Clark によるその他の記事はこちら…

コメントを投稿する