Running Wasm on V8 Without JS API
V8 を wasm の実行環境として C++ アプリケーションに組み込んでみよう。
今回使う V8 のバージョンは 7.8.58。samples/hello-world.cc
に wasm を実行する例があるけれど、この例は JS 上に定義された API を叩くスクリプトを実行する、という体で書かれている。V8 としては JS から使う前提があるからこういう例になっているのだろうけど、JS を使わずに wasm を実行できないだろうか。
検索すると stackoverflow に同じ思いの先人が質問していて、回答を元にコードを例示してくれていた。ありがたい。ただいくつか API の変更があったようで、v7.8.58 では動かない。V8 の変更履歴を参照しつつ改変したら動くようになった。これで JS スクリプトを経由せずに wasm を実行できるようになった。が、依然として JS API 自体には依存しており処理が減っている感じがしない。
さらに言えば前処理と後処理が冗長なのはいいとしても、値の引き回しに v8::Local<T>
を使わないといけないのも釈然としない。今回は JS を使わないのだから、C++ の数値を V8 JS value に変換し、さらにそれから JS value を wasm の数値型に変換する、といったステップを踏むのは無駄に感じる。C++ ⇔ wasm の型変換があればいい。
そう思いつつ V8 のコードを読んでいると c-api.cc というファイルが目についた。冒頭のコメントによると、仕様への提案をベースに書かれたものらしい。wasm を C/C++ に組み込むための提案、だそう。これが求めていたものっぽい。
使い方を調べてみよう。V8 は Wasm C API を third_party/wasm-api
配下に組み込んでいる。V8 を組み込む C++アプリケーションはthird_party/wasm-api/wasm.hh
をインクルードすることで wasm の関数の引数や戻り値を v8::Local<T>
を使うことなくやりとりできるようになる。提案の例を見れば使い方はつかめると思う。要点を抜粋すると以下のような感じ。
wasm::vec<byte_t> binary; // wasm バイナリ、外部からの入力
// 実行環境の初期化
auto engine = wasm::Engine::make();
auto store_ = wasm::Store::make(engine.get());
auto store = store_.get();
// コンパイルとインスタンス生成
auto module = wasm::Module::make(store, binary);
auto instance = wasm::Instance::make(store, module.get(), nullptr);
// 関数の取り出し
auto exports = instance->exports();
assert(exports.size() == 1 && exports[0]->kind == wasm::EXTERN_FUNC && exports[0]->func());
auto run_func = exports[0]->func();
// 実行
wasm::Val args[] = { wasm::Val::i32(14), wasm::Val::i32(9) };
wasm::Val results[1];
run_func->call(args, results);
printf("%d\n", results[0].i32());
(コード全体はここを参照)
V8 API を使った例に比べるとだいぶんすっきりとする。ただ内部ではv8::Context
やらv8::Isolate
なんかを使っているので初期化等にかかる時間はたぶん大差がない。
一方、関数呼び出しの引数と戻り値に関しては V8 JS value への変換がなくなっており、この部分は高速化が見込めるんじゃないだろうか。
今回は V8 を使うのを前提としたけれど、実際のところ wasm を実行したいだけであれば Wasmer を使う、あるいは WASI に則った実行環境を使うほうが良い。JS のオーバヘッドが気になるなら JS をサポートしてない環境を使うのが理にかなっている。
wasm: Copy と Anyref Export の速度比較
AssemblyScript でライブラリコードの高速化をしてみる の Appendix で anyref
を使うとコピーのオーバーヘッドを減らせるかもしれないとの記述があった。おおなるほどそうかも、と思ったが少し考えてみると別のオーバーヘッドが生じそうだ。メモリアクセスをエクスポートされた関数呼び出しに変える必要がある。エクスポートされた関数の呼び出しはインライン化できないし、JS <-> wasm の型変換も必要になる。
ということでどちらが早いのかを 2019 年 7 月時点での評価をしてみようと思う。現時点で anyref
が使えるのは Firefox nightly と Chrome。Chrome では --js-flags=--experimental-wasm-anyref
フラグを渡す必要がある。
やりたいことと比較する対象を整理する。なんらかのデータを JS 側で Uint8Array として保持している。このデータを wasm で効率的に処理したい。比較する手段は次の二つ。
- Copy: wasm モジュールのメモリ領域にデータをコピーする。
- Export: データにアクセスする関数を
anyref
を使ってエクスポートする。
1 は従来のやり方。 JS 側で持っているデータを wasm の メモリ領域にコピーして、コピー先のアドレスとデータの長さを wasm で定義した関数に渡す。2 は冒頭のスライドに書いてあるやり方。anyref
があればデータへアクセスする関数を軽量に wasm モジュールに提供できるのでは、というアイデアだ。JS にある Uint8Array buf
に対して、 buf[offset]
と同等の関数 readByteFromUint8Array(arr: anyref, offset: i32): i32
を wasm モジュールにエクスポートする。
それぞれのオーバーヘッドをみるために Uint8Array の各バイトを足し合わせる単純な関数を wasm で定義する。C で書くとこんなふうなやつ。
/* Copy を使った関数 */
int32_t accumulateCopy(uint8_t *arr, int32_t length) {
int32_t sum = 0;
for (int32_t i = 0; i < length; i++) {
sum += arr[i];
}
return sum;
}
/* Anyref を使った関数 */
extern struct Uint8Array;
extern uint8_t readByteFromUint8Array(Uint8Array *arr, int32_t length);
int32_t accumulateAnyref(Uint8Array *arr, int32_t length) {
int32_t sum = 0;
for (int32_t i = 0; i < length; i++) {
sum += readByteFromUint8Array(arr, i);
}
return sum;
}
現時点では anyref
を出力する安定したコンパイラがないので今回は上記の関数を wat で手書きした。
この 2 つの関数の実行にかかる時間をデータのサイズ毎に計測する。データサイズを 1K, 1M, 10M とした時に手元の Chrome でかかった時間(ミリ秒)のグラフを以下に示す。横軸は対数。
複数回試してみたところ、手元の環境ではデータサイズが 1K の場合は大差ないが、大きいデータサイズではコピーするほうが常に 10 倍ぐらい速い、という結果になった。Firefox でも傾向は同じ。多分どの環境でも似たような結果がでるんじゃないかと思う。使ったベンチマークはここに置いた。
結果を簡単に考察すると: anyref
を使ってエクスポートした関数を用意するとコピーにかかる時間は省略できるが、別のオーバーヘッド、すなわち wasm と JS を行き来するコストが支配的かつコピーよりも遅い。TypedArray とそのメソッドを JS を経由せずに wasm から直接呼び出せて、かつその呼び出しをインライン化できないと anyref
を使っても速度の向上は見込めなさそう。
Tweaking site design
Nord のカラーパレットが気に入ったので使わせてもらおう。pygments のスタイルも合うように変更する。
柔らかい感じの見た目にしたいので border-radius
で枠を少し丸くするように変更。あと line-height
も調整した。少し行間がある方が読みやすく感じる。
Rust: Raspberry Pi (Raspbian) 向けの実行バイナリを手軽に作る
Rust で書いたアプリケーションを手元の Raspberry Pi 3 で動かしたい。ベアメタルではなくて OS は Raspbian。
まずは raspi 上でコンパイルしてみる。遅い。自分のアプリをコンパイルするのに 60 分 (!) もかかる。アプリの開発自体は PC でやっているからそんなに頻繁に raspi 用のバイナリを作る必要はないのだけど、さすがにこれではやっていられない。
raspi 上でバイナリを作るのは諦めてクロスコンパイル環境を用意する。Rust は簡単にいろんなアーキテクチャ向けの toolchain 入れられるものの、コンパイルだけでなく Raspbian 向けの実行バイナリをリンクするには GCC のクロスコンパイラ (gcc-arm-linux-gnueabihf) が別途必要になる。開発しているマシンの OS に対する依存が少なく、かつ手軽にリンクまでできる環境を作りたい。となると Docker コンテナでクロスコンパイル環境を作れば良さそうだ。
まずは toolchain のセットアップから。DockerHub に Rust の公式イメージがあるのでこれをベースにする。加えて GCC のクロスコンパイラと Rust のarmv7-unknown-linux-gnueabihf
向け toolchain をインストールする。コンテナが起動したら cargo build
するようにしておく。Dockerfile はこんな感じ。
FROM rust:1.35
# コンテナ実行時に -v オプションでマウントされるのを前提としている。
WORKDIR /usr/src/myapp
RUN apt-get update && apt-get install -y gcc-arm-linux-gnueabihf
RUN rustup target add armv7-unknown-linux-gnueabihf
CMD ["cargo", "build", "--target", "armv7-unknown-linux-gnueabihf", "--release"]
続いて Rust で書いたアプリのリポジトリ配下の.cargo/config
にクロスコンパイル用の設定を書いておく。
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
あとは Docker イメージ作って実行すれば raspi 上で動くバイナリを生成できる。
$ docker build -t myapp-cross-raspi
$ docker run -it -v "$PWD":/usr/src/myapp myapp-cross-raspi
$ file target/armv7-unknown-linux-gnueabihf/release/myapp
target/armv7-unknown-linux-gnueabihf/release/myapp: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV),
...
ただ、上記の設定だけではcargo
のキャッシュがコンテナ内に保持されてしまう。つまりコンテナを作り直すたびに myapp が依存している crates をダウンロードするはめになる。これは資源の無駄なので自分は CARGO_HOME
環境変数を上書きしてホストの適当な場所を指すようにしている。
$ docker run -it -v "$PWD":/usr/src/myapp -e CARGO_HOME="$PWD"/.docker-cargo myapp-cross-raspi
補記
Docker Desktop (Windows と Mac) では最近になって Arm アーキテクチャのコンテナを x86 環境でも動かせるようになったらしい。
Building Multi-Arch Images for Arm and x86 with Docker Desktop - Docker Engineering Blog
つまり Windows と Mac ではコマンド一つ叩くだけで Rasbian 上で動くバイナリをクロスコンパイルできる。
$ docker run -it -v "$PWD":/usr/src/myapp -w /usr/src/myapp arm32v7/rust:1.35 cargo build
こっちのほうが楽だ、と一瞬思ったが、これが結構遅い。内部で QEMU 使っているようだから遅いのはしょうがない気がするが、actix-web を使った単純な hello-world でも手元のマシンで 10 分ぐらいコンパイルに時間がかかった。
参考
梅雨入り前の一日
風鈴がちりん、となった。我が家には風鈴があるのであった。
たしか川崎大社で買い求めたものだ。存在を忘れていたが風情があって良い。
午後にかけてよく晴れた日。ふらっと散歩をして気を良くした。
WebAssemblyのanyref
WebAssembly にreference typesがあるとどううれしいのか、を理解しようとして書いたメモ。参照型として二つ提案されているが、ここではanyref
に着目する。
(注: Reference types は提案段階なので各種ブラウザではまだデフォルトで有効にはなっていない。Chrome で試すにはコマンドライン引数に--js-flags=--experimental-wasm-anyref
をつける必要がある。)
任意の JS の値を受け取って、それをそのまま返すidentity()
という関数を wasm で作ろう。とってつけた例題だけど骨子を最小限のコードで示すために許してほしい。こういうやつ。
identity(value) === value; // => true
anyref
が存在しない現在の仕様だとこれは wasm だけでは定義できない。なぜなら wasm の関数は引数/戻り値に整数型か浮動小数点型しか指定できないから。JS には数値以外にも文字列型やオブジェクト型がある。文字列やオブジェクトを数値に変換する仕組みが JS 側に必要だ。
任意の JS の値を数値に変換する単純な方法としてぱっと思いつくのは、グローバルな配列をひとつ用意して、そこを値の格納先として使うやり方だろう。JS/wasm 間での値の受け渡しはこの配列のインデックスを使う。要はヒープみたいなもの。必要最小限の実装はこんな感じ。
let heap = [];
function toWasmValue(value) {
heap.push(value);
return heap.length - 1;
}
function fromWasmValue(index) {
return heap[index];
}
Wasm の関数を呼ぶときの流れは、toWasmValue()
でインデックスを取得、wasm で定義された関数の呼び出し、戻り値をfromWasmValue()
で JS の値に戻す、という感じになる。
(async function() {
const stream = fetch("identity.wasm");
const { instance } = await WebAssembly.instantiateStreaming(stream);
// Wrapper function of `identity`
function identity(value) {
const index = toWasmValue(value);
const ret = instance.exports.identity(index);
return fromWasmValue(ret);
}
console.log(identity(42)); // => 42
console.log(identity(window)); // => window
})();
Wasm 側のコードはただ受け取ったインデックスを返すだけ。
(module
(func (export "identity") (param i32) (result i32)
;; Just return the argument
get_local 0))
これでidentity()
を定義できたが、値を単純に JS → wasm → JS と引き回したいだけなのになんだか面倒だ。しかも今の実装は実用に耐えない。toWasmValue()
を呼ぶたびにヒープが大きくなるし、オブジェクトへの参照が消えないのでリークが起きる。きちんと使えるものを作ろうと思うと、不要になった値をヒープから削除しなければならないし、必要に応じてフラグメンテーションも解消しないといけない。
要はメモリ管理が必要になるのだけど、GC がある JS の上にメモリ管理機構を作るのは冗長だと思える。wasm 側で値を操作しないなら(単純に JS に引き渡すだけなら)、その値への参照を wasm に渡すのを許可してもいいのではないか。そうすれば値の生存期間の管理は JS 側の GC に任せられるし、値の変換も不要になる。
…こんな感じでanyref
が提案されたんだろう、と自分は理解した。
実際にanyref
を使って書き直すと以下のようにだいぶすっきりする。JS 上にヒープを作る必要はない。JS 側の GC が参照の生存管理をする。
(async function() {
const stream = fetch("identity_anyref.wasm");
const { instance } = await WebAssembly.instantiateStreaming(stream);
console.log(instance.exports.identity(42)); // => 42
console.log(instance.exports.identity(window)); // => window
})();
Wasm 側のコードは引数と戻り値の型を変えるだけ。wat2wasm
で以下をコンパイルするときは--enable-reference-types
フラグをつける。
(module
(func (export "identity") (param anyref) (result anyref)
;; Just return the argument
get_local 0))
anyref
が解きたい課題は分かった。では実際の問題に対してanyref
はどううれしいのか。多分ほとんどの開発者には特段メリットはないんじゃないか。というのも、こういう低レイヤの変換やメモリ管理はすでに Emscripten や wasm-bindgen が面倒を見てくれているから。これらのフレームワーク自体にとってはanyref
があるとラッパー関数なんかを削減できてうれしいと思う。開発者にとってもランタイムのサイズが減って間接的にうれしいかもしれない。
提案の概要にはここで説明した以外の動機も書いてある。むしろ主眼はそちらかもしれなくて、reference types はほかの提案の土台としての側面が強そう。
補記
anyref
はWebAssembly.Global
の型やWebAssembly.Table
の要素型としても指定できる。
参考
再帰的なデータ構造のイテレータを手書きする
amos.me - Recursive iterators in Rust が面白かった。以前 AST をたどるイテレータを書いたときに同じような問題に遭遇した記憶がある。そのときは既存のイテレータやアダプタを組み合わせてうまい具合にやる方法が思いつかず、結局自分でイテレータを実装した。イテレータの内部では状態を管理する列挙型と、今どのノードをたどっているのかを記憶するスタックを使用する。
上記のエントリで出てくる例題に対して実装するとこんなかんじ → Rust Playground
うまく既存のイテレータやアダプタを使って関数型言語っぽい書き方をできるようになりたいなあと思う一方、自分にはどうも手続き的なコードのほうが理解しやすい。イテレータを組み合わせて書いてあるコードはすっきりしていて賢いと思うんだけど、理解が追いつかないことが多い。Rustを使い始めてそこそこ経つので慣れの問題だけじゃないかもしれない。
ニューヨークでベーグルを食べた
4 月に出張でトロントとニューヨークを訪れた。渡航前に引いた風邪を引きずってしまったせいで体調が悪く、しんどい旅となった。
トロントではあまり観光できなかったが、後半少し回復したのでニューヨークを少し観光することができた。ニューヨークを訪れるのは二度目。最初に訪れたときに食べたベーグルが美味しかったので、今回もベーグルを食べようとお店を巡った。
最初に訪れたのは Tompkins Square Bagels。界隈でベストなベーグル屋さんはどこ?と現地の同僚に聞いたら教えてくれたお店である。
ドライトマトが入ったクリームチーズを挟んでもらった。翌日まで顎がつかれるぐらい噛みごたえのある生地で自分好みの食感であった。
別の日にはアッパーウエストサイドにある Aboslute Bagles を訪問した。ここは前回ニューヨークに来たときに一番印象に残っていたので再訪しようと思っていたお店だ。朝 9 時ぐらいについたのだけど、けっこうな行列ができていた。ただ、店内で食べていく人はあまりいないので待ち時間はそれほどでもない。どれを食べようかと悩んでいる間に行列は掃けた。
定番のサーモンが入ったスプレッドにしよう思っていたのだけど、陳列されていたブルーベリーのやつが美味しそうだったので少し悩んだ結果ブルーベリーに変更した。ベーグルはそれに合いそうなプレーンを選択。
これが正解で、とても美味しかった。写真ではわからないかもしれないが、ここのベーグルはかなり大きい。一食分としては半分でも多いくらい。それでも完食してしまった。おかげでこの日はお昼が入らなかった。再度ニューヨークを訪れる機会があればここのベーグルはまた食べたい。
最終日の空港に向かう前に訪れたのはBrooklyn Bagel & Coffee Company。ここではスプレッドを頼まずベーグル単体で購入した。種類はプレーンと全部入り(Everything)。
これは結局食べる余裕がなくて帰国後冷凍保存した。これも一つが大きいので食べごたえがありそうであった。
TypeScriptの型チェックと仲良くする
TypeScript のコンパイルエラーを一時的に抑止したい場面は多々ある。この記事では、自分が型エラーを回避するのに便利だなと思っている機能を状況に応じて 3 つ紹介したいと思う。想定している文脈は、趣味プロジェクトで、フレームワークを使わない素のフロントエンド開発。
Type Guards
状況: このエレメントは<foo>
なんだからbar
っていう属性があるのに TypsScript はそれを分かってくれない。
例えばオーディオを再生するページを静的に記述したとする。
<audio id="my-audio"></audio>
このオーディオに対して再生位置をリセットするスクリプトを書きたい。書いている側からするとmy-audio
はHTMLAudioElement
であることが分かっている。getElementById('my-audio')
の返り値はHTMLAudioElement
だからと思って以下のように書くとコンパイラに怒られる。
const audioEl = document.getElementById("my-audio");
// NG: `[ts] Property 'currentTime' does not exist on type 'HTMLElement'. [2339]`
audioEl.currentTime = 0;
この場合は Type guards に頼る。if 文で型の整合性をチェックすると、コンパイラがコントロールフローを解析して型を限定してくれる。以下では if 文以降audioEl
はHTMLMediaElement
であることが保証される。
const audioEl = document.getElementById("my-audio");
if (!(audioEl instanceof HTMLMediaElement)) {
throw new Error("#my-audio is not an HTMLMediaElement");
}
// OK: At this point TS compiler knows `audioEl` is an HTMLMediaElement.
audioEl.currentTime = 0;
冗長だけど if 文以降に型チェックの恩恵を受けられることを考えるとトレードオフとしては悪くない。as
を使う方法もあるけれど、 Type Guards を使ったほうがより安全になる。
参考: Advanced Types · TypeScript
Non Null Assertion Operator
状況: 関数foo()
はnull
やundefined
じゃない値を返すのが分かってるのに TypeScript がそれを分かってくれない。
2D の絵を描きたいとする。ブラウザ上で 2D の絵を描くにはHTMLCanvasElement
を用意してそれに対してgetContext('2d')
を呼んで描画コンテキストを取得する。だけど TypeScript はgetContext()
はnull
を返すかもしれないと文句を言ってくる。
// NG: [ts] Type 'CanvasRenderingContext2D | null' is not assignable to type 'CanvasRenderingContext2D'.
// Type 'null' is not assignable to type 'CanvasRenderingContext2D'. [2322]
const ctx: CanvasRenderingContext2D = canvas.getContext("2d");
この場合は!
を末尾につけている。これはNon Null Assertion Operatorというやつで、式の末尾に!
をつけるとコンパイラはその式がnull
やundefined
を返すことがないと仮定するようになる。null
やundefined
を返す式にしか使えないけど、Type guards を使うよりも簡潔に型を限定できる。
// OK
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
ctx.clearRect(0, 0, width, height);
ただ Type guards と違ってコンパイラが null や undefined にならないことを保証してくれるわけでは無い、という点に注意。
参考: TypeScript 2.0 · TypeScript
@ts-ignore
状況: window
に一時的にデバッグ用のプロパティを追加したいけど TypeScript がそれを許してくれない。
開発の初期段階ではブラウザのデベロッパーツールを使っていろんな検証をしたい。例えば自作のApp
オブジェクトの状態をデベロッパーツールで確認したいとする。そんなときには手っ取り早くアクセスできるオブジェクト、例えばwindow
にそのオブジェクトをぶら下げるのが簡単だろう。でも単純にそれをやろうとするとコンパイラが怒る。
const app = new App(...);
// NG: [ts] Property 'app' does not exist on type 'Window'. [2339]
window.app = app;
こういう状況では@ts-ignore
を使っている。@ts-ignore
をコメントとして書くと、以後の一文だけはコンパイラは何もエラーを出さなくなる。tsconfig.json などで一括にエラーを抑止するのは避けたいけど、この一文だけ見逃して欲しい場面で重宝する。
const app = new App(...);
// @ts-ignore
window.app = app; // OK
...
console.log(window.app); // NG
あくまで@ts-ignore
の直下の行だけエラーを出力しないようになるだけなので、ほかの場所でapp
を使おうとするとコンパイラに怒られる。自分は@ts-ignore
をDevToolsを使ったデバッグや調査をしたいときや、トリッキーなimport
をしている場所のエラーを抑止したいときなんかに使っている。
参考: TypeScript 2.6 · TypeScript
参考文献
Revised Revised 型の国のTypeScript は非常に良い入門書。一日ぐらいかけて目を通しておくと TypeScript の気持ちがわかるようになると思う。自分は技術書典3で紙の本を購入した。
TypeScriptのunsafeな操作まとめ では TypeScript の型検査が常に有効ではないことを議論している。
Rustでwasm用カスタムアロケータを書く
Wasm とホスト間での関数呼び出しでは数値しか受け渡すことができないので、文字列や配列をやり取りするときには wasm インスタンスのメモリを経由する必要がある。例えば文字列をホスト(JavaScript)から wasm に渡したいときは一度 wasm インスタンスのメモリ領域に文字列を書いてから、書いた場所のアドレスとサイズを関数に渡す。これは数値以外の情報をやり取りする場合は wasm 側にメモリアロケータが必要であることを意味している。
Rust が生成する wasm がメモリ割り当てをどうやっているのかを調べて、自分でナイーブなアロケータを作るところまでやったので、その過程で理解したことをエントリにしておこうと思う。
ここではホスト環境としてブラウザ(JavaScript)を想定している。また wasm のインスタンス化の際にはメモリを import せず wasm 内で管理することを前提とする。
WebAssembly のメモリ
WebAssembly は単純なメモリモデルを採用していて、メモリは連続したバイト列として表現される。初期化時にある一定のサイズが確保されて、必要に応じて領域を広げることができる。ただし一度確保した領域を縮小することはできない。メモリ領域の伸張は 64KB のページ単位で行う。
Wasm インスタンスのメモリは JavaScript からは ArrayBuffer として見える。例として[1,2,3]
という配列を JS から wasm に&[u32]
として渡したいとしよう。JS 側のコードはこんな感じになる。
// `wasm` is an WebAssembly.Instance.
const arg = [1, 2, 3];
const arr = new Uint32Array(wasm.memory.buffer);
const addr = wasm.allocBytes(arg.length * 4);
arr.set(arg, addr / 4);
wasm.someFunc(addr, arg.length);
wasm.freeBytes(addr);
wasm.memory.buffer
から見える ArrayBuffer が wasm インスタンスのメモリ。wasm 側のアロケータの仕事は、wasm.memory.buffer
をヒープ領域として扱い、そのヒープを適宜伸張しながら malloc()/free()相当の機能を提供すること。上記ではそれらをwasm.allocBytes()
やwasm.freeBytes()
として定義している。
wasm からメモリ領域を伸張するにはmemory.grow
命令を使う。この命令は引数を二つとる。一つは新たに確保するページの数。もう一つは対象となるメモリ領域を指定するインデックス。このインデックスには常に 0 を指定する。仕様の上では wasm インスタンスは複数のメモリ領域を参照できるようになっているが、これは将来の拡張のために用意されているもの。現時点では一つの wasm インスタンスにつきメモリ領域は一つしか存在しない。
アロケータの実装
Rust 側での実装に話を進めていく。Rust から WebAssembly のmemory.grow
命令を呼ぶにはwasm32::memory_grow()
を使う。以下の Rust のコードをコンパイルすると、
#![cfg_attr(target_arch = "wasm32", feature(stdsimd))]
use core::arch::wasm32;
fn f() -> usize {
const MEMORY_INDEX: usize = 0;
unsafe { wasm32::memory_grow(MEMORY_INDEX, 1) }
}
以下の wasm が生成される。
(func (;1;) (type 1) (result i32)
i32.const 1
memory.grow)
memory_grow()
は成功すると伸張前のメモリのサイズをページ単位で返す。言い換えれば、新規に確保されたメモリの開始アドレスは戻り値に 64KB を掛けることで求められる。また伸長に失敗したときはusize::max_value()
を返す。以下のようなラッパーを書くと malloc()に近い使い勝手になる。
unsafe fn grow_pages(num_pages: usize) -> *mut u8 {
let num_pages = wasm32::memory_grow(MEMORY_INDEX, num_pages);
if num_pages == usize::max_value() {
process::abort();
}
(num_pages * PAGE_SIZE) as *mut u8
}
下準備ができたのでアロケータを書いていく。今回はアロケータを作るうえで必要なパーツを説明するのが主眼なので、Memory Allocators 101という記事を参照にして単純な first-fit のアロケータを作成した。コードは Github にあげているのでここではインタフェースのみ載せておく。
struct Heap {
// ...
}
impl Heap {
unsafe fn alloc(&mut self, size: usize) -> *mut u8 { /* ... */ }
unsafe fn dealloc(&mut self, ptr: *mut u8) { /* ... */ }
}
ポインタを安全でない方法でキャストしたりするので割り切って全体を unsafe にしている。もっと Rust 力があれば安全に書けるかもしれないが、後述する GlobalAlloc が unsafe なのであまり頑張っても意味がないかもしれない。
GlobalAlloc
さらに、さきほど作成したアロケータを Rust のVec<T>
やString
などの標準ライブラリのメモリ割り当てにも使うようにする。ホスト環境とのデータのやり取りのみに自作のアロケータを使う場合はこの作業は不要だが、デフォルトのアロケータはバイナリサイズが大きく(10KB ぐらいになる)、使用するアロケータは一つに統一しておいたほうが良い。
標準ライブラリのアロケータを自前のものに差し替えるには以下の二つを行う。
- GlobalAllocトレイトを実装する型を作る
- その型の static なグローバル変数を
#[global_allocator]
属性をつけて定義する
GlobalAlloc
を実装するには以下の二つのメソッドを実装する。Layout
はサイズとアライメントを保持する構造体。
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
これら二つのメソッド、&mut self
ではなく&self
を取る。これは GlobalAlloc を実装する型が static なグローバル変数として使われることからくる制約だろう。
とはいえメモリの割り当てと解放にはアロケータの状態を変更しなければならない。こういう場合の Rust のイディオムは内部可変性を使うことだろう。ここではUnsafeCell<T>
を使う。内部可変性を提供する型を書く際は排他制御をして各種の競合に注意しなければならないが、Rust は WebAssembly 向けのスレッドはまだサポートしていないので今回はシングルスレッドを前提にする。
struct CustomAlloc {
heap: UnsafeCell<Heap>,
}
impl GlobalAlloc for CustomAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
(*self.heap.get()).alloc(layout.size())
}
unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
(*self.heap.get()).dealloc(ptr)
}
}
unsafe impl Sync for CustomAlloc {}
static ALLOC: CustomAlloc = CustomAlloc {
heap: UnsafeCell::new(Heap { /* ... */ }),
};
UnsafeCell<T>
はSync
を実装しないので、シングルスレッドで使う前提でSync
を明示的に実装している。これを忘れるとCustomAlloc
を static な変数として使おうとしたときにコンパイラに怒られる。
これで実装は一通り完了したのであとは実際に使ってみる。
wasm-bindgen
冒頭に説明したように wasm とホスト環境で文字列や配列をやり取りする際には面倒な手順を踏む必要がある。文字列を渡す場合を例に具体的なステップを見てみると、(1) ホスト側からメモリ領域を確保するよう wasm インスタンスに要求する、(2)ホスト側で wasm のメモリ領域に文字列の内容を書く、(3)データを書いたアドレスとサイズを wasm の関数に渡す、(4)wasm の関数では渡されたアドレスとサイズから String なり &str に変換する、という作業が必要になる。
この部分も自作しようかと考えたが、結構煩雑になるのでこの変換部分は wasm-bindgen に頼ることにした。wasm-bindgen を使うとホスト(JavaScript)と wasm 側両方のラッパーを作ってくれるのでとても楽になる。
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn to_uppercase(s: &str) -> String {
s.to_uppercase()
}
あとはビルドして wasm-bindgen の CLI で後処理をすれば最終的な wasm/JS ができる。wasm-bindgen はデフォルトだと Webpack で処理する前提の ES modules 形式で出力するが、オプションを指定することでブラウザで直接読み込める形式にすることもできる。
$ cargo build --release --target wasm32-unknown-unknown
$ wasm-bindgen --browser --no-modules target/wasm32-unknown-unknown/release/wasm_custom_allocator_example.wasm --out-dir public/dist
wee_alloc
アロケータを差し替える主な動機はバイナリサイズの削減だろう。今回は理解を深める目的で自分でアロケータを書いてみたが、実用的には独自アロケータを書くよりもwee_allocを使うのが良いと思う。アロケータは unsafe なコードがどうしても多くなるし、unsafe なコードをきちんと書くのは相当難しい。