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 のバグに対しては [こちら](https://github.com/brion/min-wasm-fail) で対策することができます)

つまり、アプリケーションのサポート範囲によってはどうしても 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_MEMORY` に `64KB` とか設定すればそのサイズで初期化してくれるんじゃないか。と考えた方もいらっしゃると思います。実際自分も最初はそう考えていたのですが、事はそう簡単じゃありませんでした。  
Emscripten によって生成された JavaScript ソースコードの中で、`TOTAL_MEMORY` の最小値が `16MB` になるようにハードコーディングされていたのです。  
この設計のため、 `16MB` より小さい値を指定したり、何も指定しなかった場合でも `16MB` の領域を確保するという挙動になっています。  

## 解決方法

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

`pre.js`

```javascript
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](https://github.com/WebAssembly/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` プログラムから生成した成果物のファイルサイズを追っていきたいと思います。

```c
#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` として作成しておくと以下のようなコードで利用することができるようになります。

```javascript
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.js` か `hello.js` を読み込んで実行します。

# 5. まとめ

Emscripten を使って WebAssembly を約1年間本番運用してきた観点から、その問題と解決策の紹介をしました。  
この記事によって今までよりも更に多くの方が WebAssembly に興味を持ち、実際に本番環境で運用していってもらえれば嬉しいです。
また、今回ここで紹介されていない知見で補足などがあれば、ご教示いただければ嬉しいです！