WebAssemblyはなぜ速いのか

本記事はWebAssemblyに関するシリーズの第5回目で、今回のテーマはWebAssemblyが高速な理由です。前の記事をお読みでない方は、初めから目を通される(訳注:原文リンク)ことをお勧めします。

前回の記事(訳注:原文リンク)では、プログラミングにWebAssemblyあるいはJavaScriptを使うかは二者択一の選択ではないことを説明しました。私たちは、WebAssemblyのみのコードベースを書く開発者が膨大な数になるとは思っていません。

ですので、アプリケーションにWebAssemblyとJavaScriptのどちらを使うか選ぶ必要はありません。しかし私たちとしては、開発者がJavaScriptコードの一部をWebAssemblyに置き換えることを期待しています。

例えば、Reactで開発しているチームは、リコンサイラコード(言い換えれば仮想DOM)をWebAssemblyバージョンのものに切り替えることもできるでしょう。Reactを使う側は何もする必要はありません。アプリは、WebAssemblyに由来する恩恵が得られる以外、全く従来どおりに動作するのです。

Reactチームなどの開発者がWebAssemblyに切り替えるといい理由は、WebAssemblyの方が高速だからです。でも、なぜ速いのでしょうか。

現代のJavaScriptのパフォーマンス概観

JavaScriptとWebAssemblyのパフォーマンス差を理解するには、JSエンジンが実行する処理を理解する必要があります。

以下の図は、現代のアプリケーションの起動パフォーマンスを大まかに表したものです。

JSエンジンが以下の各タスクの実行に費やす時間は、そのページが用いているJavaScriptコードによって決まります。この図で意図しているのは、正確なパフォーマンス数値を表すことではなく、同じ機能に対するパフォーマンスがJSとWebAssemblyでどう違うかを大まかに示すことです。

Diagram showing 5 categories of work in current JS engines

各バーは、特定のタスクの実行時間を示しています。

  • パース:ソースコードを処理してインタプリタが動作できる形にするのにかかる時間。
  • コンパイルと最適化:ベースラインコンパイラと最適化コンパイラの処理にかかる時間。最適化コンパイラの処理の中にはメインスレッド上にないものもあり、それはここに含まれない。
  • 再最適化:JITの仮定が誤っていた場合、その再調整にかかる時間。コードの再最適化と、最適化されたコードから抜けてベースラインコードに戻る処理の両方が含まれる。
  • 実行:コードの実行にかかる時間。
  • ガベージコレクション:メモリのクリーンアップにかかる時間。

1つ重要な点があります。これらのタスクは、ばらばらのチャンクや特定のシーケンスとして実行されるのではなく、差し込まれるということです。パースを少し行い、次に実行を少し、次にコンパイルを少し、次にまたパース、次にまた実行といった具合です。

この細分化のおかげで、パフォーマンスは初期のJavaScriptの頃に比べて飛躍的に向上しています。当時のJavaScriptでは以下の図のような感じだったでしょう。

Diagram showing 3 categories of work in past JS engines (parse, execute, and garbage collection) with times being much longer than previous diagram

当初、JavaScriptを実行するのがインタプリタだけだった頃、実行はかなり遅いものでした。JITが導入されたことで、実行時間は劇的に速くなったのです。

その代償として生じたのが、コードのモニタリングとコンパイルのオーバーヘッドです。JavaScript開発者が以前の要領でJavaScriptを書き続けているなら、パースとコンパイルの時間は取るに足りないでしょう。しかし、パフォーマンスが向上したことで、開発者は以前より大規模なJavaScriptアプリケーションを作るようになりました。

つまり、パフォーマンス向上の余地はまだあるのです。

WebAssemblyの場合

以下は、典型的なWebアプリケーションについて、WebAssemblyのパフォーマンスを対比させた概観図です。

Diagram showing 3 categories of work in WebAssembly (decode, compile + optimize, and execute) with times being much shorter than either of the previous diagrams

ブラウザによっては、この各フェーズの処理方法においていくらか異なるパターンもあります。ここでモデルとして使っているのはSpiderMonkey(訳注:Mozillaが作ったJavaScriptエンジン)です。

ファイル取得

これは図に示しませんでしたが、サーバからの単なるファイル取得も、時間を要する処理の1つです。

WebAssemblyはJavaScriptよりコンパクトなため、その取得も短時間で済みます。JavaScriptバンドルのサイズを圧縮アルゴリズムで大幅に減らすことができるとしても、WebAssemblyの圧縮されたバイナリ表現の方がやはりサイズが小さいのです。

そのため、サーバとクライアントの間のファイル転送時間がJavaScriptよりも少なくて済みます。これは、遅いネットワークを介する場合は特に顕著です。

パース

ひとたびブラウザに到達すると、JavaScriptソースはパースされて抽象構文木に変換されます。

多くの場合、ブラウザはこの処理を遅延的に実行します。初めは本当に必要な部分しかパースせず、まだ呼び出されていない関数に対してはスタブを作成するだけです。

そして抽象構文木は、そのJSエンジンに特有の中間表現(バイトコードと呼ばれます)に変換されます。

一方、WebAssemblyはすでに中間表現であることから、WebAssemblyではその変換処理が必要ありません。エラーが含まれていないことを確かめるために、デコードして妥当性を検証するだけでいいのです。

Diagram comparing parsing in current JS engine with decoding in WebAssembly, which is shorter

コンパイルと最適化

JITに関する記事で説明したように、JavaScriptはコードの実行中にコンパイルされます。実行時に用いられる型によって、同じコードの複数バージョンがコンパイルされることもあるでしょう。

WebAssemblyのコンパイルの仕方はブラウザによって異なります。WebAssemblyの実行前にベースラインコンパイルを行うブラウザもあれば、JITを用いるブラウザもあります。

いずれにしても、WebAssemblyははるかに機械コードに近い形で始まります。例えば、WebAssemblyの型はプログラムの一部です。その方が高速なのにはいくつか理由があります。

  1. コンパイラは、最適化されたコードのコンパイルを始める前に、どの型が使われているか観察するためにコードを実行する必要がなく、その分の時間が節約される。
  2. コンパイラは、観察した各型に基づいて同一コードの別バージョンをコンパイルする必要がない。
  3. 事前にLLVMでさらなる最適化がすでに行われている。よって、コンパイルと最適化に必要な処理が少なくなる。

Diagram comparing compiling + optimizing, with WebAssembly being shorter

再最適化

JITは時に、コードの最適化されたバージョンを破棄して処理をやり直さなければならないこともあります。

それが起こるのは、コードの実行に基づいたJITの仮定が誤りだと分かった時です。例えば、ループに入ってくる変数が前の反復時と異なる場合や、プロトタイプチェーンに新しい関数が挿入される場合に、脱最適化が起こります。

脱最適化は2つのコストを伴います。1つは、最適化されたコードから抜けてベースラインバージョンに戻るための時間がかかることです。もう1つは、その関数がまだ何度も呼び出されている場合、JITは最適化コンパイラにそれを再び送ることにするかもしれず、もう一度コンパイルするコストが生じることです。

WebAssemblyでは、型などは明示的なので、JITは実行時に採取したデータに基づいて型に関する仮定を置く必要はありません。つまり、再最適化のサイクルを経る必要がないのです。

Diagram showing that reoptimization happens in JS, but is not required for WebAssembly

実行

優れたパフォーマンスで実行できるJavaScriptコードを書くことは可能です。そのためには、JITが行う最適化について知る必要があります。例えば、JITに関する記事で説明したように、コンパイラが型を特定できるようなコードの書き方を知る必要があるのです。

しかし、大抵の開発者はJITの内部構造について知りません。それを知っている開発者でさえ、JITの効果をうまく引き出すのは時として難しいことです。コードをより読みやすくするために開発者が取るコーディングパターン(共通のタスクを、型を越えて機能する関数に抽象化するなど)は、コードを最適化しようとしている時のコンパイラを邪魔するものも少なくありません。

その上、JITが用いる最適化はブラウザによって異なるので、あるブラウザの内部構造に合わせたコーディングは、別のブラウザではパフォーマンスが落ちてしまうこともあります。

このため、WebAssemblyでコードを実行する方が概して高速です。JITがJavaScriptに対して行う最適化(型の特定など)の多くは、WebAssemblyでは全く必要ありません。

しかも、WebAssemblyはコンパイラターゲットとして設計されました。すなわち、人間のプログラマが書くものではなく、コンパイラが生成するものとして設計されたのです。

人間のプログラマが直接プログラミングする必要がないので、WebAssemblyは機械にとってより適した命令セットを提供することができます。コードが実行する処理の種類によりますが、そうした命令の方が10~800%ぐらい速く動作します。

Diagram comparing execution, with WebAssembly being shorter

ガベージコレクション

JavaScriptの場合、開発者は、必要のなくなった古い変数をメモリから消去することについて気にする必要はありません。代わりにJSエンジンが、ガベージコレクタと呼ばれる機能を使ってその作業を自動的に行ってくれます。

しかし、これは予測可能なパフォーマンスを必要とする場合には問題となることがあります。ガベージコレクタが処理をいつ実行するかは制御されないため、都合の悪い時に起こるかもしれません。大抵のブラウザではそのスケジューリングがかなりうまく行われるようになりましたが、それでもコードの実行を時に妨げるオーバーヘッドとなります。

少なくとも今のところ、WebAssemblyはガベージコレクションを全くサポートしていません。メモリは(CやC++の場合のように)手動で管理されます。このため、開発者にとってプログラミングが難しくなることもある一方、パフォーマンスの一貫性は高まっているのです。

Diagram showing that garbage collection happens in JS, but is not required for WebAssembly

まとめ

WebAssemblyが多くのケースでJavaScriptより高速な理由は以下のとおりです。

  • WebAssemblyはJavaScriptよりコンパクトなため、その取得にかかる時間はJavaScriptより短い。圧縮されたJavaScriptと比べても同様である。
  • WebAssemblyのデコードは、JavaScriptのパースより所要時間が短い。
  • WebAssemblyはJavaScriptに比べて機械コードに近く、サーバ側での最適化がすでに行われているため、コンパイルと最適化の所要時間がJavaScriptより短い。
  • WebAssemblyの場合、型や他の情報が組み込まれているので、JSエンジンがJavaScriptの場合のような最適化時の仮定を必要としないため、再最適化の必要がない。
  • パフォーマンスが一貫して優れたコードを書くために開発者が知っておくべきコンパイラ向けのコツや注意事項が少ないので、実行は多くの場合、JavaScriptより短時間で済む。さらに、WebAssemblyの命令セットは機械にとってより適している。
  • メモリは手動で管理されるので、ガベージコレクションの必要がない。

このようなわけで、多くの場合、同じタスクの実行においてWebAssemblyがJavaScriptより優れたパフォーマンスを示すのです。

WebAssemblyが期待されたほどのパフォーマンスを見せないケースもあります。そして、WebAssemblyのさらなる高速化を可能にするような変化の兆しもいくつか見えています。それらについては、次回の記事で取り上げましょう。

編注: このシリーズ全編の翻訳を望まれる方は、こちらよりissueをお願いします!
(すでにある場合は+1をお願いします)