HTML5Experts.jp

JSエンジン「V8」はバージョン6で世代移行を終える ── Google I/O 2017レポート

連載: Google I/O 2017特集 (3)

ChromeやNode.jsで利用されているJavaScriptエンジン「V8」に、8年の歴史の中でも大きな変化が訪れました。8月3日にリリースされたバージョン6.1で、数年かけて進めてきたJavaScriptコンパイラーが世代交代を終えました。詳しい話は、V8のブログでも語られていますが、ここでは大きなトピックであるコンパイラーの世代交代についてお話します。

なお、この動きについては、昨年に開かれたカンファレンス「BlinkOn 6」でも語られており、Google I/O 2017でも、Seth Thompsonによるセッション「V8, Advanced JavaScript, & the next performance frontier」によって紹介されています。本記事では、これらの発表内容を補足してご紹介します。

V8のミッションとは?

Chromeで使われているJavaScriptエンジン「V8」ですが、そのミッションはとてもシンプルです。

「Speed up real-world performance for modern JavaScript, and enable developers to build a faster future web.(実世界での要求にあわせてモダンなJavaScriptのパフォーマンスを向上させること。そして、今後の変化していくWebに早く追従し、開発者がそれらをビルドできるようにすること)」

V8のパフォーマンス改善につきまとう2つのトレードオフ

V8とはなんでしょう?JIT(just-in-time compile)で動作させることができるJavaScriptのエンジンです。ブラウザがJavaScriptを受け取ると、ブラウザはそのコードをトランスレートして実行したり。また、コードを翻訳してコンピューターが理解できるネイティブコードにコンパイルしてから実行します。

ただ、そこにはいくつかのトレードオフが存在します。

  1. コンパイルして生成されたネイティブコードは、実行されるピーク時の速度が早い一方で、コードの量が多いほど、最初に実行されるまでに遅延が発生する。一方で、トランスレーターを使った場合は、実行されるまでの遅延は小さいけど、実行時の速度が遅い。
  2. JavaScriptエンジンは、パフォーマンスを良くしようとするとメモリの消費量が多くなる。一方で、省メモリを進めようとするとパフォーマンスは悪化する。

例えば、以下のような一行のfunctionを呼び出すコードについて考えてみましょう。

このコードでは、 function foo() は一度しか呼び出されていませんよね。このケースでは、実行されるまでの速さ(Fast Startup)を重視し、実行時のパフォーマンス(Peak Perf)は重要視しません。

しかし、以下のケース。

1万回も function foo() を呼び出しています。このケースでは、Fast Startupが遅れてでも、Peak Perfを重視する必要がでてきます。 foo() はネイティブコードにコンパイルされていなくてはいけません。そしてそれがデスクトップ型のコンピューターであれば、メモリも潤沢なので、大量のメモリ消費を犠牲にネイティブコードをさらに幾度に渡り最適化させます。

ただ、これはあくまでデスクトップ型コンピューターの場合。同じ1万回 foo() を呼び出すコードであっても、モバイルの場合は同じアプローチにはなりません。Androidデバイスはモバイルなので、デスクトップと同じようにメモリを扱うことはできません。

ネイティブのコードへコンパイルはされますが、メモリの消費量は抑えられるよう最適化は制限されます。

別のパターンについても考えてみましょう。それが以下のコード。

これはNode上で実行されているコードです。一度起動されたら、ずっと利用されるわけですから、当然、Fast Startupを犠牲にしてPeak Perfを改善しようとしますね。しかし…

IoTデバイス上で動作するとなると、同じ状況とは言えません。サーバー用マシンとは異なり、メモリ消費は抑えなくてはいけません。Peak Perfを上げるようにはしますが、それはメモリ消費が少なくて済むようなアプローチで進めます。

同じ一行のfunction呼び出しであっても、その最適化方法は無数にあり、コンテキストやデバイスの状況に強く依存するのです。そしてそれは、Fast Startupなのか、あるいはPeak Perfに最適化していくのか、メモリを富豪的に扱うのか、それとも小さくなるよう努力するのか、解決しなくてはいけません。

8年前のリリース時点から全てが変わろうとしているV8

V8エンジンは、ここまでに説明したあらゆる状況で最適化できるようにしたり、また新しいパターンのJavaScript(asm.js, WebAssembly等)に対応できるよう、2〜3年かけて実行パイプラインを作り変えました。

過去の変遷を見てみると。

2008年のリリース時点の段階では、シンプルなコードジェネレーターがあって、そこからやや最適化を行ったマシンコードが生成されるだけでした。あらゆるJavaScriptのコードは、この仕組を使って実行されます。

ただ、最適化には計算が必要なため、コードを実行できる状態にするまでに多くの時間を要します。大量のJavaScriptコードを扱う場合は、Startup Time短縮するために最適化の処理をスキップすることが求められます。

2010年にコンパイラ「Crankshaft」が追加されます。計算コストの高い最適化処理を分離し、Crankshaftにその責務が委譲されました。コードの生成だけであれば、Full-codegenは最小のコストでマシンコードを吐き出すため高速なため、Startup Timeが最適化されます。そして、CrankShaftを使ってコンパイルすれば、計算コストを犠牲にPeak Perfにとって最適なマシンコードが生成されます。

実行時に、CrankShaftが吐き出したコードを、Full-codegenが吐き出したコードへ置き換えることで最適化を図ります。

2015年。Crankshaftには限界が来ます。JavaScriptの仕様そのものが大きく変化する時代になりましたが、Crankshaftはその設計上、JavaScriptの機能をサポートしきれず、また新しいパターンのJavaScript(asm.js, WebAssembly等)にも十分に対応できませんでした。当時のフロントエンドエンジニアなら、V8のECMAScript標準への対応の遅れに苛立ちを感じたはずです。

V8開発チームは、それを改善しようと新たなコンパイラ「TurboFan」を追加します。

2016年。「Ignition」と呼ばれる仕組みを追加しました。Ignitionには、ソースコードをいきなりマシンコードにするのではなく、バイトコード(マシンコードほどハードウェアに依存しない「中間コード」と呼ばれるもの)を生成するコンパイラーと、それを実行するインタプリターが内包されています。

そして、TurboFanはバイトコードを扱う形へと作り変えられました。そして現在、最新のVersionである5.9では、この2つの仕組みは既に使われていません。内部にコードこそ存在していますが、バイトコードの生成と解釈するインタープリターIgnitionと、PeakPerfを改善するための処理をするコンパイラーTurboFanだけで動作しているという状況です。

そして、これからまもなくリリースされる6.0では、コードとして完全にV8から削除されます。

「Ignition Interpreter」とは?

中間コードを生成して実行するインタプリター。メモリ消費が小さく動く、Fast Startupも最適という特徴があります。

「TurboFan」とは?

主として、最適化を行うことを目的としたコンパイラー。

まとめ

V8について、メモリ管理向けガーベジコレクター「Orinoco」など、さまざまな技術要素が進化し、語り尽くせないほどのトピックがあるのですが、それはまた別の機会にお話しましょう。TurboFanへの完全移行、実態としてすでにほとんど終わった状況といっても過言ではありませんが、8年前とは全く異なる、Web・JavaScriptの変化に追従する大きな変化です。今後も楽しみですね。