Edited at

1年間本番環境で WebAssembly ( by Emscripten )を使ってきた中で生じた問題とその解決策


1. 今 WebAssembly を生成するなら何から作るか

WebAssembly の人気がとどまることを知らない昨今ですが、みなさんはどうやって WebAssembly を生成しているでしょうか。


自分の観測範囲では、Rust や Go で書いたコードから生成したり、 AssemblyScript を使って TypeScript などから作ったり、 Emscripten を利用して C/C++ で書いたコードから作ったりしているのが多い印象です。

このうち、本番環境で長らく WebAssembly を運用してきた身としては、今の段階では Emscripten、または自分では使っていませんが AssemblyScript を利用する方法が良い気がしています。

というのも、主要ブラウザが WebAssembly をサポートしてくれているとはいえ、まだまだ WebAssembly がサポートされていない環境を考えて運用しなければならないという印象をもっています。例えば Chrome 60 , Safari 11.2.X 系では WebAssembly を実装していると銘打っているバージョンではありますが動作してくれません。 ( Safari のバグに対しては こちら で対策することができます)

つまり、アプリケーションのサポート範囲によってはどうしても WebAssembly ありきで考えられない場合があり、そういった場合は WebAssembly を使わないものにフォールバックする必要があり、その観点では、フォールバック先を用意できる AssemblyScript や Emscripten が良いのではないかと考えています(前者は AssemblyScript がというよりは、コンパイル対象が TypeScript であったりするので、そもそもそのまま純粋な JavaScript ソースを生成する経路がありますし、 Emscripten は WASM=0 オプションをコンパイル時に渡すだけで簡単に asm.js を利用した JavaScript ソースコードを生成することができます )。


逆にいえば、管理ツールなどの社内ツールを作る場合には WebAssembly が実行できる環境を前提としても良いことが多いと思うので、そういった場合は Go や Rust などを利用することも十分にありだと思います。

少し弊社での WebAssembly の使い方について説明すると、弊社で開発しているブラウザゲームでは、インゲームのロジックをC++で記述し、それをサーバー側では Go から cgo を経由して利用し、クライアント側では Emscripten を用いて asm.js 版、 WebAssembly 版 それぞれを生成し、状況によって WebAssembly版 => asm.js版にフォールバックしながら利用しています。 ( 同じゲームロジックをクライアント・サーバーそれぞれで動かすことによって、低遅延でのインタラクションとチート対策を両立するモデルですが、このトピックに関してはたくさんネタがあるので、機会があれば別の記事で詳細について触れたいと思います)


他にも、WebAssembly(バイナリ) やasm.js を利用したソースコードが解析しづらいことを利用し、アプリケーションソースコードの暗号化やサーバーとの通信の暗号化・復号の仕組み、アセットパスの難読化などに利用しています。( こちらも機会があればウェブゲームのセキュリティ対策といったトピックで紹介したいです )

これらを実装していた当時は Go の WebAssembly 対応が本格的に行われる前だったので、 Go で書いたソースを WebAssembly化してクライアント側で動かす。といったアプローチは考えませんでした。ですが、先に挙げたように WebAssembly が動作しない環境を実際に目にし、Emscripten を選択してよかったと思っています。

ですが、 Emscripten を利用する上で問題もありました。ひとつは asm.js 版、 WebAssembly 版 それぞれの成果物のファイルサイズが大きいこと。もうひとつはメモリの使用量が多いことです。以降では、これらの問題をどう解決したのかという点について触れたいと思います。


2. メモリの最低使用量16MB問題

Emscripten を一度でも使ったことがある方はニヤリとしてしまう話だと思います。


これは Hello World を出力するだけの小さなプログラムであったとしても、Emscripten で出力されたモジュールを読み込むと 16MB もの領域の ArrayBuffer を確保してしまうという問題です。


この仕様のせいで、小さなモジュールを Emscripten を使ってたくさん作って動かす。といったことがしづらくなっています。


Emscriptenのメモリアロケーション戦略

そもそも 16MB もの領域を確保してしまう理由はなんでしょうか?

Emscripten には TOTAL_MEMORY というパラメータがあり、ビルド時に任意の値を指定することができます。これはモジュール初期化時にアロケーションするサイズを表しており、初期化時のタイミングで確保した ArrayBuffer のメモリ領域をヒープの総量として扱いながら動作します。ではこのサイズを超えてメモリを確保したくなったらどうするかというと、 ALLOW_MEMORY_GROWTH=1 というオプションをつけてビルドします。こうすると、もし TOTAL_MEMORY で指定されたサイズを超えてメモリを確保したくなった場合に、 ArrayBuffer で確保している領域を伸長して対応してくれます。(オプションを付けない場合はエラーになります)


では毎回これをつければいいじゃないかと思うのですが、メモリの伸長には無視できないレベルのオーバーヘッドが発生することも少なくないので、もし使用するメモリ量があらかじめ分かっている場合は、TOTAL_MEMORY のチューニングだけで済ませたほうが良いことになります。ただし、 WebAssembly版ではオーバーヘッドを抑えてメモリの伸長ができる とのことで、 WASM=1 を設定する場合は ALLOW_MEMORY_GROWTH=1 もセットで指定するのが良いでしょう。

ここまで読んで、 TOTAL_MEMORY64KB とか設定すればそのサイズで初期化してくれるんじゃないか。と考えた方もいらっしゃると思います。実際自分も最初はそう考えていたのですが、事はそう簡単じゃありませんでした。


Emscripten によって生成された JavaScript ソースコードの中で、TOTAL_MEMORY の最小値が 16MB になるようにハードコーディングされていたのです。


この設計のため、 16MB より小さい値を指定したり、何も指定しなかった場合でも 16MB の領域を確保するという挙動になっています。


解決方法

ではどうするか、 Emscripten には --pre-js というオプションで、モジュール初期化時に実行するスクリプトを差し込むことができる機能があります。これを利用して下記のようなソースを pre.js として保存し、 --pre-js=pre.js としてビルド時に差し込むようにすることで TOTAL_MEMORY の値を上書きすることができます。

pre.js

Module['TOTAL_MEMORY'] = 65536;

Module['TOTAL_STACK'] = 32768;

Module['preInit'] = function() {
ASMJS_PAGE_SIZE = 65536;
MIN_TOTAL_MEMORY = 65536;
};

ここで TOTAL_MEMORY のサイズを 65536 = 64 * 1024 ( 64KB ) としているのには理由があり、WebAssembly 版のメモリ使用量が page 単位で決まり、1 page ぶんのサイズが 64KB なため、最小サイズが 1 page = 64KB になるからです。


これで asm.js 版であれば 64KB のメモリ使用量でモジュールを初期化することができるようになります。


ですが、WebAssembly版の対策はこれで終わりではありません。生成された wasm ファイル側にもアロケーションするpageサイズがハードコーディングされているため、こちらも変更する必要があります。

自分がとったアプローチは、泥臭いやり方ですが wabt を使って wasm ファイルを wast ファイルに変換し、冒頭にある

(import "env" "memory" (memory (;0;) 256))

256 pageぶんの領域 (つまり 16MB) を確保する。という部分を

(import "env" "memory" (memory (;0;) 1))

に文字列置換で置き換えるというものです。

最後に、置き換え後の wast ファイルをもう一度 wabt を使って wasm に変換し直して終了です。


3. 成果物のファイルサイズでかすぎ問題

asm.js 版も WebAssembly 版も、何も対策しないと結構でかいファイルが生成されてしまいます。


そこでここではファイルサイズを減らすために使えるオプションをいくつか紹介し、最後の仕上げとして WebAssembly をgzip圧縮して配信する流れについて触れたいと思います。


3.1 ファイルサイズ削減に効果のあるビルドオプションの紹介

例として以下の Hello World プログラムから生成した成果物のファイルサイズを追っていきたいと思います。

#include <stdio.h>


int main(int argc, char **argv)
{
printf("hello, world!\n");
return 0;
}


3.1.1. 最適化なし

$ emcc hello.c -o hello.js

[ 成果物 ]

- hello.js : 102K

- hello.wasm : 44K


3.1.2. -Oz オプションの追加

リリース版の最適化オプションはこれでいきましょう。 -O2(3) とか指定するよりもこっちがオススメです

$ emcc -Oz hello.c -o hello.js

[ 成果物 ]

- hello.js : 21K

- hello.wasm : 2.3K


3.1.3 --closure 1 オプションの追加

Closure Compiler による最適化をおこなってくれます。ビルド環境にJava環境も作らないといけないのが手間ですが、それに見合うリターンがあるので必ずやりましょう

$ emcc -Oz --closure 1 hello.c -o hello.js

[ 成果物 ]

- hello.js : 11K

- hello.wasm : 2.3K


3.1.4 --llvm-lto 1 オプションの追加

lto というのは LLVM link-time optimizations の略で、リンク時に行う最適化に関するオプションです。

1 を指定することで有効になります。付けましょう。

$ emcc -Oz --closure 1 --llvm-lto 1 hello.c -o hello.js

[ 成果物 ]

- hello.js : 11K

- hello.wasm : 2.0K


3.1.5 -fno-exceptions オプションの追加

今回の例では関係ありませんが、 C++ で書いていてかつ exception 機構を使っていない場合は付けましょう。


3.1.6 NO_FILESYSTEM=1 オプションの追加

今回の例では効果がなかったため結果は載せませんが、 ファイルの読み書きを行わないようなプログラムの場合はこちらのオプションも試してみる価値ありです。


3.2 WebAssembly を gzip 圧縮して配信する

3.1 で紹介したオプションをつけてビルドすることで、最終的に



  • hello.js : 102K => 11K


  • hello.wasm : 44K => 2.0K

まで最適化できました。実際にはこれらのファイルを CDN 等を通してクライアントに配信するわけですが、その際 JavaScript であれば gzip 圧縮して配信するのが普通です。なので実際は

$ gzip hello.js



  • hello.js.gz : 4.5K

ほどのサイズになっているはずです。いっぽう wasm ファイルの方は、CloudFront などで gzip 圧縮対象になっていないため、 あらかじめ wasm ファイルを gzip 圧縮しておき、そのファイルを Content-Encoding: gzip ヘッダをつけてオリジンにアップロード。 ブラウザからは Accept-Encoding: gzip 付きでリクエストされるため、 CDN が wasm に対し無圧縮でパススルーしたとしても、ブラウザには gzip 圧縮済み、かつ Content-Encoding: gzip ヘッダがついている wasm ファイルが届いて、晴れてブラウザが解凍してくれる流れとなります。

上記のテクニックを前提とするならば、 wasm ファイルも gzip 圧縮できるため

$ gzip hello.wasm



  • hello.wasm.gz : 1.2K

までデータサイズを削減することができます。

例が単純すぎたため、あまり効果が感じられないように見えてしまうかもしれませんが、ぜひ本番用のアプリケーションコードで試して、サイズの違いを確かめていただきたいなと思います。


4. これまでに紹介した解決方法をサクッと試せる環境の紹介

https://github.com/knocknote/wasm というリポジトリに 2 や 3 で挙げた方法を簡単に行うための仕組みをまとめました。

リポジトリの中にある Dockerfile をビルドした image を https://hub.docker.com/r/knocknote/wasm で公開しており、docker pull すれば emscripten のビルド環境や --closure 1 が使える Javaの環境、 16MB 問題を解決するために作った wasm-builder というバイナリが利用できます。 これらを使って

wasm-builder --name hello -- \

emcc -Oz \
--closure 1 \
--memory-init-file 0 \
-fno-exceptions \
--llvm-lto 1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s MODULARIZE=1 \
-s NO_FILESYSTEM=1 \
--pre-js /src/emscripten/pre.js \
--bind \
hello.c -o hello.js

のように実行することで、 メモリ使用量を 64KB に抑えられる WebAssembly が生成されます。

また、WebAssemblyが動作する環境であればそちらを、そうでなければ asm.js 版を利用することができる WebAssembly ローダを npm install @knocknote/wasm-loader でインストールすることができます。

あらかじめ、 asm.js 版の JavaScript を hello-wasm.js という名前で作成、 WebAssembly 版の初期化スクリプトを hello.js として作成しておくと以下のようなコードで利用することができるようになります。

import WasmLoader from 'wasm-loader';

const loader = new WasmLoader();
loader.load(
() => import('./hello-wasm'),
() => import('./hello')
).then(module => {
console.log('loaded module', module);
module.hello();
});

WasmLoader の中では 先に上げた Safari のバグなどを判定しており、実行環境に応じて hello-wasm.jshello.js を読み込んで実行します。


5. まとめ

Emscripten を使って WebAssembly を約1年間本番運用してきた観点から、その問題と解決策の紹介をしました。


この記事によって今までよりも更に多くの方が WebAssembly に興味を持ち、実際に本番環境で運用していってもらえれば嬉しいです。

また、今回ここで紹介されていない知見で補足などがあれば、ご教示いただければ嬉しいです!