ウェブの外: Emscripten を使ったスタンドアローンの WebAssembly (翻訳)
Emscripten はウェブや Node といった JavaScript 環境へのコンパイルを何よりも一番に考えてきました。しかし WebAssembly は JavaScript を使わずに利用されはじめ、新たなユースケースが登場しています。そこで私たちは、Emscripten の JS ランタイムに依存しないスタンドアローンの Wasm ファイルを Emscripten から生成するための作業を行ってきました! 本記事ではこれが面白い理由を説明します。
Emscripten をスタンドアローンモードで使う
まず、この新しい機能で何ができるかを見ましょう! この記事と同じように、まずは二つの数を足す関数を一つエクスポートする "hello world" プログラムから始めます:
// add.c
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int add(int x, int y) {
return x + y;
}
普通であればこのコードを emcc -O3 add.c -o add.js
などでコンパイルし、結果として add.js
と add.wasm
が生成されます。ここではそうではなくて、Wasm を生成するよう emcc
に指示しましょう:
emcc -O3 add.c -o add.wasm
Wasm だけが必要なことを emcc
が検出すると、生成ファイルは「スタンドアローン」になります。つまり Wasm ファイルは (できる限り) それ自身で実行可能であり、Emscripten からの JavaScript ランタイムは一切必要ありません。
Wasm ファイルを逆アセンブルすると、とても小さいことが分かります たった 87 バイトです! 簡単な add
関数が含まれます:
(func $add (param $0 i32) (param $1 i32) (result i32)
(i32.add
(local.get $0)
(local.get $1)
)
)
それからもう一つ、_start
関数もあります:
(func $_start
(nop)
)
_start
は WASI 仕様の一部であり、Emscripten はコードが WASI ランタイムでも実行できるようこの関数を生成します (_start
は通常グローバル変数の初期化を行いますが、今の例では必要ないのでこの関数は空です)。
JavaScript ローダーを自分で書く
スタンドアローンの Wasm ファイルに関して素敵なのが、このファイルを読み込んで実行する JavaScript を自分で書ける点です。この JavaScript は使用例によっては非常に小さくなります。例えば Node.js では次のようにできます:
// load-add.js
const binary = require('fs').readFileSync('add.wasm');
WebAssembly.instantiate(binary).then(({ instance }) => {
console.log(instance.exports.add(40, 2));
});
たった 4 行です! これを実行すると期待通り 42
が出力されます。この例は非常に単純ですが、これ以外にも JavaScript をあまり必要としない場合には Emscripten のデフォルトランタイム (様々な環境やオプションをサポートするもの) よりも効率が良くなる点に注目してください。現実世界の例は zeux 氏による meshoptimizer です たった 57 行で、この中にメモリ管理やメモリの成長といった機能が含まれています!
Wasm ランタイムの実行
スタンドアローンの Wasm ファイルに関してもう一つ素敵なのが、Wasm ランタイムを使って実行できる点です。Wasm ランタイムには wasmer, wasmtime, WAVM などがあります。例として、次の hello world を考えます:
// hello.cpp
#include <stdio.h>
int main() {
printf("hello, world!\n");
return 0;
}
これをビルドすれば、上述のランタイムのどれを使っても実行できます:
$ emcc hello.cpp -O3 -o hello.wasm
$ wasmer run hello.wasm
hello, world!
$ wasmtime hello.wasm
hello, world!
$ wavm run hello.wasm
hello, world!
Emscripten は可能な限り WASI API を使います。今示したようなプログラムは 100% WASI となり、WASI をサポートするランタイムで実行できます (どんなプログラムが WASI 以外のものを必要とするかは後述します)。
Wasm プラグインを書く
ウェブとサーバー以外に Wasm が注目されているのがプラグインです。例えば画像エディタなら、画像に対するフィルタといった操作を行う Wasm プラグインが可能です。この種のユースケースでは今までの例と同様にスタンドアローンの Wasm バイナリを使いますが、そこには埋め込まれるアプリケーションに対する機能を持った API も含まれます。
プラグインは動的ライブラリと一緒に語られることがあります。動的ライブラリがプラグインを実装する唯一の方法だからです。Emscripten は SIDE_MODULE オプションで動的ライブラリをサポートし、これまではこれが Wasm プラグインを作る方法でした。この記事で紹介しているスタンドアローンの Wasm オプションはいくつかの点で優れています: まず動的ライブラリは再配置可能なメモリを持ちますが、この機能が必要ない場合にはこれがオーバーヘッドとなります (ある Wasm を読み込んだ後にそれを別の Wasm にリンクしない限り必要になりません)。次に、スタンドアローンの出力は前述の通り Wasm ランタイムからも実行できます。
というわけで、分かったと思います: Emscripten は今まで通り JavaScript + WebAssembly を出力でき、さらに今では WebAssembly だけを出力することもできます。このスタンドアローンの WebAssembly は Wasm ランタイムのような JavaScript がない場所で実行でき、また自分で書いた JavaScript ローダーコードからも実行できます。それでは裏側の技術的詳細を説明しましょう!
WebAssembly の二つの標準 API
WebAssembly がアクセスできる API はインポートとして受け取ったものだけです コアの Wasm 仕様には API の詳細が一切ありません。現在の Wasm を見渡すと、インポートする API には三つの主要なカテゴリがあります:
-
Web API: Wasm プログラムがウェブで使う、JavaScript からも使える標準化された既存の API です。現在では JS グルーコードを通して間接的に呼ばれていますが、将来的には interface types を使って直接的に呼ばれるようになります。
-
WASI API: WASI はサーバー上の Wasm 用の API を標準化します。
-
その他の API: 様々な埋め込みコードがアプリケーション固有の API を定義します。例えば先述の Wasm プラグインを持つ画像エディタは画像エフェクトのための API を実装するでしょう。プラグインがネイティブの動的ライブラリと同様に「システム」 API へのアクセスを持つ場合もあれば、厳格にサンドボックス化されインポートを一切持たない場合もあります (埋め込みコードがメソッドを呼ぶだけのときなど)。
つまり WebAssembly は二つの標準化された API を持つという不思議な状況にあります。ただし一つはウェブ用でもう一つはサーバー用であり、二つの環境の要件が異なることを考えればそれほど不思議でもありません。ウェブの JavaScript と Node.js が同一の API を持っていないのと同じ理由です。
しかしウェブとサーバー以外の環境、具体的には Wasm プラグインも存在します。プラグインを実行するアプリケーションがウェブ上にある場合 (JS plugins など) もあれば、ウェブ上にない場合もあります。さらにアプリケーションがどこで実行されたとしても、プラグインの環境はウェブでもなければサーバーでもありません。つまりどちらの API を使うべきかが明らかではないのです 移植されるコードや組み込まれる Wasm ランタイムに依存します。
できるだけ統合する
ここで Emscripten が確実に役立つだろうと思って行っているのが、できる限り WASI API を利用することによる不必要な API の差異の回避です。前にも触れましたが、ウェブにおける Emscripten コードの Web API へのアクセスは JavaScript を使って間接的に行われます。そのためそこで使う JavaScript API を WASI に似せておけば、不必要な API の差異が取り除かれ、同じバイナリがサーバーでも動作するようになります。例えば Wasm が情報をログするときには JS を呼び出す必要があり、次のようにします:
wasm => function musl_writev(..) { .. console.log(..) .. }
musl_writev
はデータをファイルディスクリプタに書き込むときに musl libc が使うLinux システムコールインターフェースの実装であり、この関数が console.log
を適切なデータを使って呼び出します。Wasm モジュールがインポートして呼び出すのが musl_writev
関数であり、この関数が JS と Wasm の ABI を定義します。これを WASI と一致する ABI に置き換えると、以下のようになります:
wasm => function __wasi_fd_write(..) { .. console.log(..) .. }
大きな違いはなく、少しだけリファクタリングすれば済みます。JS 環境で実行されるときには大した作業ではありません。そしてWASI ランタイムはこの WASI API を認識できるので、この Wasm は JS を使わずに実行できます! 先述したスタンドアローンの Wasm の例はこのように実行されます。Emscripten を WASI API を使うようにリファクタリングするだけです。
Emscripten が WASI API を使うもう一つの利点が、現実世界の問題を見つけることで WASI 仕様の策定を助けられる点です。例えば私たちは WASI の "whence" 定数を変更する必要があること発見し、コードサイズや POSIX 互換性に関する議論も始めています。
Emscripten が可能な限り WASI を使えば、ユーザーは単一の SDK でウェブ、サーバー、プラグイン環境をターゲットにできます。これができる SDK は Emscripten だけではありません。例えば WASI SDK の出力は WASI Web Polyfill や Wasmer の wasmer-js を使えばウェブで実行可能です。しかし Emscripten のウェブ出力はコンパクトであり、ウェブのパフォーマンスを犠牲にしない単一の SDK として利用できます。
これに関連する機能として、スタンドアローンの Wasm ファイルとオプショナルな JS の両方を一度に生成するコマンドがあります:
emcc -O3 add.c -o add.js -s STANDALONE_WASM
このコマンドは add.js
と add.wasm
を生成します。この Wasm ファイルは前の例と同様スタンドアローンです (-o add.wasm
とすると自動的に STANDALONE_WASM
がセットされます) が、このファイルを読み込んで実行するための JS ファイルが付きます。JS を自分で書かずに Wasm ファイルをウェブで実行したいときに便利です。
スタンドアローンでない Wasm は必要なのか?
なぜ STANDALONE_WASM
フラグが存在するのでしょうか? 理論上は Emscripten が常に STANDALONE_WASM
フラグをセットしても構わず、そうすれば物事を単純にできるはずです。しかしスタンドアローンの Wasm ファイルは JS に依存できず、それには不都合があります:
-
Wasm のインポートおよびエクスポートの名前を minify できない。minify は Wasm とそれを読み込むプログラムの双方が合意していないと行えない。
-
通常 Wasm Memory はスタートアップ時に JS が操作できるよう JS が作成するので、その処理は並列に実行できる。しかしスタンドアローンの Wasm では、Wasm から Memory を作成しなければならない。
-
JS の方が使いやすい API もある。例えば
__assert_fail
は C のアサートが失敗したときに呼ばれるが、これは通常 JS で実装される。実装はたったの一行であり、呼び出される JS 関数を含めたとしても全体のコードサイズは非常に小さい。一方でスタンドアローンのビルドでは JS を使うことができず、musl のassert.c
を使うことになる。これはfprintf
を使うので、C のstdio
サポートに関する様々なコードが必要になる。その中には間接呼び出しを使っているものがあり、未使用関数を取り除くのも難しい。こういった細かい議論により一般的に言って全体のコードサイズが増加する。
ウェブとその他の場所の両方で実行するときに 100% 最高のコードサイズとスタートアップタイムが必要なら、二つの異なるビルドを作るべきです。一つは -s STANDALONE
付きで、もう一つは -s STANDALONE
なしです。フラグを反転させるだけですから、楽な仕事ですよ!
避けられない API の差異
Emscripten がなるべく WASI API を使って避けられる API の差異を取り除いていると説明しました。避けられない差異はあるのでしょうか? 残念ながら、あります WASI API の一部はトレードオフが必要です。例えば:
-
WASI は user/group/world のファイル権限など POSIX の機能のいくつかをサポートしない。そのため例えば (Linux) システムが持つ
ls
の完全な実装はできない。Emscripten が現在持つファイルシステムレイヤーはそういった機能の一部をサポートするので、全てのファイルシステム操作を WASI API に切り替えたときには完全な POSIX サポートを失うことになる。 -
WASI の
path_open
はコードサイズを増加させる。権限情報の処理を Wasm が行うためだが、このコードはウェブでは必要ない。 -
WASI はメモリの成長を通知する API を提供しない。そのため JS ランタイムはメモリが成長したかを (全てのインポートとエクスポートについて) 定期的にチェックする必要がある。オーバーヘッドを抑えるため Emscripten は通知 API
emscripten_notify_memory_growth
を提供しており、先述の zeux 氏による meshoptimizer では一行で実装されている。
時が経てば WASI も POSIX サポートやメモリの成長通知を追加するかもしれません WASI はまだ初期の実験段階なので、これから大きく変化します。そのため現在は、特定の機能を使うと Emscripten から 100% WASI でないバイナリが出力されます。具体的に言うとファイルを開く処理は WASI ではなく POSIX の方法で行われるので、fopen
を使うと Wasm ファイルが 100% WASI でなくなります。しかし最初から開いている stdout
に printf
するだけであれば、100% WASI になります。最初に示した "hello world" の例がこれであり、そのような場合には Emscripten の出力を WASI ランタイムで実行できます。
コードサイズを犠牲にして厳密な WASI 準拠を取る PURE_WASI
オプションがあれば便利だとは思います。ただこれは緊急でない (私たちが目にしてきたプラグインの多くは完全なファイル I/O を必要としない) ので、WASI が改善され Emscripten から WASI でない API が取り除けるようになるまで待つこともできるでしょう。そうなれば一番であり、これまでのリンクで示してきた通りそれに向けた作業が現在行われています。
しかし WASI が改善されたとしても、WASM に標準 API が二つあるという先述した事実は変わりません。将来的には Emscripten は interface types を使って Web API を直接呼ぶようになると私は思っています。そうした方が WASI の形をした JS API を通して Web API を呼ぶ (前に触れた musl_writev
の例) よりもコンパクトだからです。polyfill や変換レイヤーを用意することもできますが、不必要な場合には使わない方が望ましいので、Web 環境と WASI 環境向けに別々のビルドが必要になるでしょう (これは少し残念な状況です。WASI が Web API を含んでいれば理論上はこの状況を回避できますが、そうするとサーバーサイドが犠牲になります)。
現在の状況
たくさんのものが動作しています! 主な制限は以下の通りです:
-
WebAssembly の制限: C++ の例外や setjmp あるいは pthreads といった機能は、Wasm の制限により JavaScript を使っている。JS を使わない代替はない (Emscripten は Asyncify を使ってこういった機能の一部をサポートするかもしれないし、Wasm のネイティブな機能が VM に搭載されるのを待つかもしれない)。
-
WASI の制限: OpenGL や SDL といったライブラリと API には対応する WASI API はまだ存在しない。
Emscripten のスタンドアローンモードでこういった機能を使うことはできますが、その出力には JS ランタイムの補助コードへの呼び出しが含まれます。そのため出力は 100% WASI ではありません (同じ理由でこういった機能は WASI SDK で使えません)。その Wasm ファイルは WASI ランタイムで実行できませんが、ウェブでの実行は可能であり、自分で書いた JS ランタイムも使えます。それからプラグインとしても利用できます。例えばゲームエンジンに OpenGL でレンダリングを行うプラグインを持たせるのであれば、開発者はそれをスタンドアローンモードでコンパイルして、エンジンの Wasm ランタイムで OpenGL インポートを実装することになります。Emscripten は可能な限り出力をスタンドアローンにするので、ここでもスタンドアローンモードが役に立ちます。
開発はまだ進行中なので、まだ変換が行われていない API についても JS を使わずに置き換えられるものがあるでしょう。ぜひバグを報告してください。いつでも歓迎です!
Attribution
Portions of this page are reproduced from work created and shared by the V8 project and used according to terms described in the Creative Commons 3.0 Attribution License.
The original source page is Outside the web: standalone WebAssembly binaries using Emscripten V8.