ウェブの外: 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.jsadd.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)
)

_startWASI 仕様の一部であり、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 には三つの主要なカテゴリがあります:

つまり 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.jsadd.wasm を生成します。この Wasm ファイルは前の例と同様スタンドアローンです (-o add.wasm とすると自動的に STANDALONE_WASM がセットされます) が、このファイルを読み込んで実行するための JS ファイルが付きます。JS を自分で書かずに Wasm ファイルをウェブで実行したいときに便利です。

スタンドアローンでない Wasm は必要なのか?

なぜ STANDALONE_WASM フラグが存在するのでしょうか? 理論上は Emscripten が常に STANDALONE_WASM フラグをセットしても構わず、そうすれば物事を単純にできるはずです。しかしスタンドアローンの Wasm ファイルは JS に依存できず、それには不都合があります:

ウェブとその他の場所の両方で実行するときに 100% 最高のコードサイズとスタートアップタイムが必要なら、二つの異なるビルドを作るべきです。一つは -s STANDALONE 付きで、もう一つは -s STANDALONE なしです。フラグを反転させるだけですから、楽な仕事ですよ!

避けられない API の差異

Emscripten がなるべく WASI API を使って避けられる API の差異を取り除いていると説明しました。避けられない差異はあるのでしょうか? 残念ながら、あります WASI API の一部はトレードオフが必要です。例えば:

時が経てば WASI も POSIX サポートやメモリの成長通知を追加するかもしれません WASI はまだ初期の実験段階なので、これから大きく変化します。そのため現在は、特定の機能を使うと Emscripten から 100% WASI でないバイナリが出力されます。具体的に言うとファイルを開く処理は WASI ではなく POSIX の方法で行われるので、fopen を使うと Wasm ファイルが 100% WASI でなくなります。しかし最初から開いている stdoutprintf するだけであれば、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 を含んでいれば理論上はこの状況を回避できますが、そうするとサーバーサイドが犠牲になります)。

現在の状況

たくさんのものが動作しています! 主な制限は以下の通りです:

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.

広告