Emscriptenで過去に作った物をwasm対応して性能比較してみた

はじめに

表題の通り、以前Emscriptenを使って作っていたライブラリをwasm対応して性能比較してみます。本来ならSION2 HDで比較してみたかったのですが、色々とハマっているうちに時間がなくなってしまったので、SION2 HDの中でも使っているz-music.jsに絞って評価してみようと思います。

なお評価にはChrome 63を使っています。ブラウザ間の比較とかもあれば良かったのかもしれませんが、純粋に時間がなくてやっていません。比較的新しいブラウザは全て実装しているので、いずれ比較・競争は自然と激しくなるでしょう:)

使ったマシンはMac mini (Late 2014)で、性能に関係しそうなプロセッサは3GHz Core i7です。古めのマシンとは言え、シングルコア性能で考えると今でも現役ですね。

一応Chromium Developerです、とか言っておきますが、だからと言ってGoogleやChromium Projectの意見を代弁するものではありませんし、WebAssemblyは完全にユーザーとして趣味で遊んでいるだけですので、深読みとかはしないで下さい。

開発環境と予備調査

emsdk-portable

公式のDownload and installに従い、latestをインストール。自分はCloud9上のUbuntu 14.04という古い環境を使っているため、後述の豆知識に書いた方法を使って無理やりバイナリパッケージを動かしました。コンパイルするのも手ですが、Cloud9ではメモリが足らず。以前は-j1だったらなんとか最後まで通っていたのですが、今回試したら駄目でした。

% ./emsdk update
% ./emsdk install latest
% ./emsdk activate latest
% source ./emsdk_env.sh

生成されるファイル群の構成

htmlを出力形式として選んだ場合

実行に必要となる最終ファイルとして、asm.jsの場合にはhtml、js、memの3種類、WebAssemblyの場合にはhtml、js、wasmの3種類が出力されます。

htmlはどちらの場合も主にデモ用にデザインされたページに標準入出力のための簡単なインタフェースを載せたものです。
wasm_hello.png
こんなやつ。みんな見たことあると思います。htmlの最後のほうにscriptタグでvar Moduleを定義しており、ここで標準入出力のためのフック関数の提供や、実行時の引数の受け渡しなどを行います。単純な処理しかしていないので、このファイルは比較的簡単に自分で1から書くことができますし、コンパイル結果に関わらず再利用可能です。ページの本当に最後の最後、このModule定義のすぐ後ろに別のscriptタグがあり、最終生成ファイルのjsを読み込んでいます。

jsファイルには、asm.jsの時にはコンパイルされた結果とjsで書かれたruntimeがまとめられていますが、WebAssemblyの場合にはruntimeとwasmの読み込みコードのみが含まれます。本来せっかくwasmで出力するのだからjsは使わずに済ませたいところですが、runtime部分がかなり大きく、ここを自分で全て書くのは大変な手間かと思います。wast対応するためのコードとか、Polyfill関係のコードが大量に含まれているので、なるべく多くの環境で動かしたい人は重宝するかもしれません。一方で、最初からモダンなブラウザのみに対象を絞っている場合、多くのコードは不要かもしれません。wasmはサーバ側で正しくMIMEを設定していればstreaming compileを自動的に適用してくれます。設定されていない場合は、consoleに警告を表示し、ArrayBufferから非同期でinstantiationしてくれます。

言うまでもなくwasmはコンパイルされたコードの本体ですね。asm.jsの時に利用されるmemには、疑似メモリ空間の初期化に使うバイナリデータが含まれます。リンク時のフラグでjsに含める事も可能ですが、テキストにエンコードする分、サイズは大きくなります。WebAssemblyではwasm内にバイナリで格納されるため、別途生成されることはありません。

jsを出力形式として選んだ場合

htmlを出力形式として選んだ場合のhtml抜きだと思っていいと思います(違ったら編集リクエストお願いします)。いずれの場合もEmscriptenから出力されたjsはnode.jsからそのまま実行できます。

$ node hello.js
hello
$

wasmを出力形式として選んだ場合

wasmをターゲットにする事も可能なようです。SIDE_MODULEと呼ばれる共有ライブラリをサポートするための仕組みとセットになっているようですが、試した事はありません。詳しくはWebAssembly Standaloneを読んでもらうとして、カレンダーでdlopenについて書いてくれる方がいたような気がしたので、そちらに期待したいと思います:)

コンパイルフラグによる差異の予備評価

hello.c
#include <stdio.h>

int main(int argc, char** argv) {
  puts("hello");
  return 0;
}
コンパイル
$ emcc -o hello.html [-O{3|s|z}] hello.c -s WASM=1

最適化フラグと実行に必要となるファイル群のサイズは以下の通りでした。サイズに関してはなんでも良いからつけとけって感じですね。

フラグ hello.html hello.js hello.wasm
102,727 101,912 23,487
-O3 102,727 42,393 11,743
-Os 102,727 42,393 11,632
-Oz 102,727 42,393 11,644

ちなみにasm.jsでの結果は以下のようになります。最適化フラグによって差がついていないのはコピペミスではなく、本当に同じサイズでした。コンパイル対象があまりにも単純すぎたか。

コンパイル
$ emcc -o hello.html [-O{3|s|z}] hello.c
フラグ hello.html hello.js hello.html.mem
102,727 185,217 381
-O3 103,461 56,903 381
-Os 103,461 56,903 381
-Oz 103,461 56,903 381

簡単なコードに関してはむしろjsとwasmに分かれる上にjs側のglueコードの多いWebAssemblyが不利なようです。

z-music.jsを使った比較

比較に用いたプログラムについて

X68000というパソコンに搭載されていたFM音源(OPM)とADPCM音源(正確にはPCM8相当)のエミュレーション、CPU(68000)のエミュレーションを行い、その上でX68000用のBGM演奏用の常駐プログラムZMUSIC.Xを実行します。Web Audioと連携する事でX68000用に作られたZMUSIC.X用の曲をリアルタイムレンダリングしながら再生する事ができます。

音源部分のエミュレーションはX68Sound.dllをベースにしています。Windows用のネイティブ版が193KBでした。CPU部はRun68をベースにしています。こちらは143KB程度。

ソースコードの規模でいうと、ヘッダも含めて23Kラインってところです。

サイズ

z-music.jsではjsターゲットで出力させています。ファイルシステムのデータもjsに埋め込まれている(--embed-fileを使用している)ため、ZMUSIC110.XとZMUSIC208.Xのためにそれぞれ48K、55Kのバイナリをテキストエンコードした物が含まれています。またC++も使っているのでruntimeを含めるとやや大きめです。asm.js出力の際には.memもjsに埋め込む形(--memory-init-file 0)で出力しています。

asm.jsでは全てが1ファイルに集約されてしまっていますが、WebAssemblyの場合、エミュレーションコアのサイズを素直に表しているのがwasm側のサイズと考えて良いと思います。最適化の影響はそちらに集中しています。Windows用のネイティブ版のサイズである193KB+143KBにファイルシステムを加えると500KBほどになりますが、それと比べてもまずまずの数字かと思います。元が別々のバイナリだったので純粋に足すのはフェアではありませんが。

モード zmusic.js zmusic.wasm
asm.js 2,345,857 -
asm.js -O3 1,011,956 -
asm.js -Os 983,853 -
asm.js -Oz 981,183 -
wasm 721,875 421,663
wasm -O3 471,672 250,038
wasm -Os 471,672 228,749
wasm -Oz 471,672 226,961

サイズだけ見た場合、単純なデモと同様に最適化オプションをつけてさえいれば、大きな差はないようです。このくらいの規模になれば合計サイズでもWebAssemblyが有利です。

速度比較

波形レンダリング時に定期的に呼び出される音源とCPUのエミュレーションが走る時間を計測してみました。サイズ2048のFloat32Arrayで波形をレンダリングしていきます。44.1kHzで考えると46msec程度の間隔で呼び出されますので音源エミュレーションとしては長めのバッファでレンダリングしています。

wasm_devtools.png

DevToolsのPerformanceで見るとこんな感じです。Timer起点になっているのは更にダブルバッファリングをしているからです。Web Audioから要求があったら即座にレンダリング済みのデータを渡し、次のレンダリングをSetTimeoutを経由して行っています。レンダリングの手前に細長くみえる方がWeb Audioからの呼び出しです。これにより60fpsでゲームなどをカツカツに動かしていても、なんとかレンダリングを滑り込ませられる確率が大幅にあがります。遅延としては100msec近くなってしまいますが、BGMメインなら問題ないでしょう(とか言いつつも効果音にも使われているのですが)。

レンダリングの負荷は演奏内容に応じて前後するため、同じ曲の演奏開始から最初の1024回の呼び出しを平均して比較してみます。有効数字2桁まで書いてはみたものの、0.1 msecの計測精度は出てません。回数増やすと時間がかかるので、ざっくりとした比較ですがご容赦ください。検討材料としては十分かと思います。

モード 平均処理時間 (msec)
asm.js 6.5
asm.js -O3 2.4
asm.js -Os 3.2
asm.js -Oz 2.7
wasm 3.6
wasm -O3 2.5
wasm -Os 3.0
wasm -Oz 2.5

速度も含めて考えると、-Ozが最強のようです。速度・サイズともに最強なのでトレードオフすらありませんね。ただ、asm.jsの-O3が最速というのは想定外でした。WebAssemblyもまだまだ最適化の余地があるということでしょうか。asm.jsと比べフラグによる差異が小さいことからも最適化がまだあまり進んでいないのかな、と今後に期待してしまいます。

46msecに対して2.5msec程度の実行時間だと、1コアのCPU利用率で考えると5%ってところです。処理内容を考えると遅くはないと思います。

メモリとGCについて

Emscriptenから生成されたasm.jsのコードは大量の一時変数を作るという特徴がありました。SSAが一時代入も含めて全て個別の変数を用意するため、大量の変数が確保されるのですが「確保された変数を可能な限り共有してスタック消費を節約する」という最適化なしにコードを吐き出すからさぁ大変。変数内に大量のvar宣言が散らばることに。特にゲームのコードでは毎フレーム呼び出され、その度に大量の一時変数が作られるので、GCに凄い負担がかかっていました。そんな事もあり、WebAssemblyにはGC負担軽減の意味でも期待していたのですが……今回はasm.jsでも問題になるようなGCは観測されませんでした。改善済みなのかもしれません。

asm.jsの場合

wasm_gc_js.png

こちらがasm.jsのケース。ページ読み込み直後に3秒ほどリソース消費が荒れますね。JS Heapは最高値の17.1MBを記録した後、GCで回収され8.1〜9.5MB程度の低空飛行を続けます。GCの間隔も30秒弱に落ち着いています。

wasm_heap_js.png

Memoryでスナップショットの統計を見るとこんな感じです。

WebAssemblyの場合

wasm_gc_wasm.png

こちらがWebAssemblyのケース。ページ読み込み直後も比較的安定。JS Heapの最高値は16.2MBで若干低く押さえられました。その後は7.8〜9.0MB程度の低空飛行を30秒弱のGC間隔で維持します。まぁ、若干いいかな、という程度。

wasm_heap_wasm.png
Memoryでスナップショットの統計を見ると、確かにCodeとStringsが減っています。Codeの400KB近い差分はコンパイルのサイズ結果と整合性が取れているように見えます。一方でTotalは少しだけ増えているので、白いその他の部分にWebAssemblyの消費が積まれているようです。

まぁ、今回はTyped Arraysの16MBが支配的ですね。Emscripten内のC/C++から見える疑似メモリ空間に割り当てられているものだと思います。

コンパイル時間について

wasmのコンパイルについてChromeが遅いという話があったので試してみたかったのですが、いかんせん数百KBではまだまだ小さすぎるようです。ゲームエンジンでも使わないと100MBを越えるような規模のバイナリはなかなか作れませんね。

今回のケースではプロファイラに出てくる数字としてはcompileに187.58msecでした。1.2MB/sec程度のスループットでしょうか。ユーザーが待ってくれるのが3秒までだと考えると、3.6MB程度までのwasmしかさばけないのはいただけませんね。いずれ内部的にコンパイル結果、検証結果をキャッシュする、最初の最適化はほどほどにする、などといった最適化が入ってくるのではないでしょうか。

まとめ

そこそこな規模のライブラリをWebAssembly対応して、asm.jsと比較してみました。-Oz 最強! WebAssembly確かに利点多いけど、どれもまだ僅差。まだまだ速くなって欲しいなぁ……ってところでしょうか。まぁ、今時のJavaScriptが速すぎるのだ。限界まで最適化された既存の物に、全くの新しい物が勝つってのは凄い大変な事で。ひとまず今までより良い結果が出るってのは重要なマイルストーンです。

おまけの豆知識

precompiled packages on Ubuntu 14.04

Toolchainをemsdk-portableからインストールする際、Linux向けのprecompiled packagesはUbuntu 16.04を想定しています。が、諸般の事情で賞味期限切れの14.04を使っている場合、16.04から/usr/lib/x86_64-linux-gnu/libstdc++.so.6をコピーしてくる事で一応動きます。クラウドIDEのCloud9が未だに14.04なんですよね、残念。

sdk-master / sdk-incoming

最新版をbuildして使おうとすると、wasmに埋め込まれたバージョンが1ではなくなり、ブラウザで実行できません。また、wasmを読み込む部分のjsコードがなぜかsynchronousにcompileしようとして、4KB以上のwasmがChromeで実行できないという事態に。Promise化しようにもcall siteを何段か非同期化しないと動かないので対応が面倒。precompiled packagesにはasynchronous対応のコードが入っているので、おそらくはbuild flagが何かが足りないと思うのですが……詳しく調査できていません。情報求む。

streaming compile

サーバーが正しいMIMEタイプ(application/wasm)をセットしていないと、streaming compileに失敗します。今時サーバのMIME設定とか触ったことない人の方が多そうですが、大きめのバイナリには効くと思うのでサーバ毎に調べて設定入れておくのか吉。