Calls between JavaScript and WebAssembly are finally fastを読む
昔と比べてjs - wasm間の呼び出しはめっちゃ早くなった
この記事ではどうやって早くしたのかが書かれている
関数呼び出しの基本の仕組み
関数はJavaScriptで書かれたコードの大半を閉める
関数は呼び出しをネスト状態で行う
そして関数の処理が完了したらネストを順々に元に戻っていく
ただこの元に戻っていく作業には様々な情報を記録しておく必要がある。そういったものを記録するためにスタックフレームというものを使用する。
そしてそのスタックフレームはスタック形式で積み重なっており一つの関数の処理が終わるごとに次の関数へ行く、このスタックフレームのスタックをコールスタックと呼ぶ
wasm-js間の呼び出しをどのように早くしたか
以下の三つの呼び出しを早くした
- JSからwasm
- wasmからjs
- wasmからビルドインの呼び出し
どうやって?
- ブックキーピングの削減
- スタックフレームを整理するための不必要な作業を無くした
- 中間処理の削減
- 関数間の最も直接的なパスを通るようにした
wasmからjs 呼び出しの最適化
まずJSの内部処理について説明する
JSはJITにより生成されたマシンコードを実行する方式ととインタプリタでバイトコードを実行する方式がある。この二つの方式間で呼び出しをする毎に新しくコールスタックと呼び出し元のコードの位置を記録しないといけない(これらの処理をSpiderMonkeyではアクティベーションと呼ばれている)、
そしてこのアクティベーションの処理はC++を経由しておりそれに大きなコストがかかっている。(メモ: C++は速いでしょと思ったけどまずJITは機械語を生成するのでそれと比べるとC++遅い)
ちなみにこれをトランポリンと呼んだりする
ではwasmの場合は?
どちらも同じ機械語を話しているにも関わらず以前までwasmはJIT形式のJSと別のコールスタックがあり別々の言語を話しているように振る舞っていた。
これには以下の二つの不必要なコストがかかっていた
- コールスタックを新しく作り、そしてそれを削除するコスト
- C++を使ってトランポリンをする必要があるコスト
これを解決するためJIT化されたJSとwasmを同一のコールスタックで扱うようにした。
これによりwasmからjsを呼び出す処理はJSからJSを呼び出す処理とほぼ同じ速度になった。
JSからwasmを呼び出す処理の最適化
まずJSについて
JSは動的型付け言語であり実行時に型を把握する必要がある。
そして動的な型を扱うためにJSはboxingという処理を行っている
例えば 3の場合、
3は2進数で0011だが更に整数型であることを表すため最後に1という符号になる数字をつけてboxingする(つまり00111になる)
JSはadd計算などの処理を実行するとき`boxを削除 → 処理 → 処理結果にbox追加`というフローを行う
※JSのJITでは多くの場合、boxing/unboxingの処理を避けることができるけど、関数呼び出しのような汎用的な操作ではJSはboxingをする必要がある。
ただwasmは静的型付けされている。そのためwasmに引数を渡す場合はunboxする必要がある。
またそれに加えて、wasmは値が特定のレジスタで渡されることを期待する。
そのためwasmに値を渡すときは、unboxingして更に特定のレジスタに入れる処理をする必要がある(エントリースタブと呼ぶ)
今まではこのエントリースタブの処理をする度にC++が呼ばれており、この中間処理が大きなコストになっていた。
そこで今までC++で実行していたエントリースタブをJITコードから(おそらく機械語で)直接呼び出せるようにした!
※実際のところ、jsからのwasmの呼び出しはもっと速くすることができて、場合によってはJSからJSの呼び出しよりも速くなることがある。
JSからwasmのmonomorphicな場面での更に速い呼び出し
JSが他の関数を呼び出す時、他の関数がどんな種類の値を渡して欲しいか呼び出し元の関数は知りません。
しかし、呼び出し元の関数が特定の関数を呼び出す時に毎回同じ種類の引数で呼び出していることを知っている場合、JITで最適化がかかり値をboxingしなくなります。
これは"type specialization"(型の特殊化)と呼ばれるJITの最適化処理です。
またこのように毎回同じ関数を呼び出すことを"monomorphic call"と呼びます。
JSの場合、呼び出しがmonomorphicであるには毎回同じ種類の引数で関数を呼び出す必要があります。
しかしWebAssemblyは明示的な型を持っているため途中で型の変更が強制されます。
これにより、JSで常に同じwasm関数に同じ型を渡すコードを書けば、呼び出しは非常に高速になります。実際こういった呼び出しの多くはJSからJSへの呼び出しよりも高速です。
今後の課題
ただしJSからwasmの呼び出しがjsからjsの呼び出しより速くならないケースが一つあります。
それはJSが関数をインライン化している場合です。
関数のインライン化はある関数が頻繁に実行されており、呼び出している関数が比較的小さい時に行われます。
そのうち、wasmでも関数のインライン化ができるようにしたいと考えています。
wasmからbuilt-in関数の呼び出し
built-in関数はC++で実装されたものやJSで書かれたもの、JSとC++二つの言語で書かれたものがある。(ブラウザによってどちらの言語で書かれるかは違う)
このbuilt-in関数がJSで書かれている場合、上記で説明されている全ての最適化をかけることができる、しかしC++で書かれている場合、またトランポリンが起こってしまう。
このトランポリンを回避するためSpiderMonkeyはbuilt-in関数を機械語から呼び出せるためのファストパスを開発した。
今後どうしたいか?
現在サポートしているbuilt-inはほとんどMath built-inに限られている。なぜならwasmが現在サポートしているのはintとfloatのみだから
そのほかのbuilt-inを使いたい場合はwasm-bindgenを使う。
計画としてこれから、他のbuilt-inもMath関数のようにファストパスが整備されていくみたい