AssemblyScript Loader を使って数値以外を WebAssembly とやり取りするまで


WebAssembly の advent calendar、このままだと登録1件だけで盛り上がりも何もありませんが(笑)、前からビミョーに場当たり的な対応して動かしていた AssemblyScript Loader 周りをこの機会にまとめてみました。

概要

JS(TS)使いでも WebAssembly 使いたい!といった時のFirst choiceが AssemblyScript です。AssemblyScript で検索したとき上位の記事が「整数の足し算できた、オッケー!」的な記事ばかりで、さまざまな型を扱おうとすると途端に情報が薄くなるのとモジュール読み込み・fetchの不具合対応等連鎖的に対応が必要な部分が出てくるので、それらの部分を中心にまとめていきたいと思います。

そもそもWebAssemblyがユースケースとして想定しているのは?

以下公式ドキュメントの記載の和訳です。よくJSを置き換えるもの(?)という記載を見ることがあるのですが、基本的には数値演算コア以外にはオススメしないよ、が大方針です

シナリオ オススメ
重いアルゴリズムの計算 WebAssembly を使おう
ほぼDOMとのやり取り JavaScript を使おう
ゲーム CPU-intensive な部分には WebAssembly を使おう
WebGL どれぐらいAPIをcallするかに寄る、おそらく両方
ウェブ, ブログ, ... JavaScript がキミの友達だよ

Loaderの使用要否の選択

AssemblyScript で開発する上で一番の分かれ目は補助モジュールのLoaderを投入するかどうかです。Loaderはwasmの読み込み以外にメモリ管理のユーティリティ関数を提供してくれます。WebAssembly を雑に説明すると仕組み上C/C++言語の仕様に近く、

  • 数値は WebAssembly 側と直接やりとりできる
  • 文字列・配列・クラスは WebAssembly 側とポインタでやり取りする必要がある
  • JSのオブジェクトは WebAssembly では扱えない

という制約があります(あ、ページ離脱のクリック音がする、、)。この文字列・配列・クラスを扱うためにデータ長を計算したりといったメモリのハンドリングを自身でゴリゴリを書くか、Loaderのユーティリティを使いある程度楽に処理するか、が悩みどころになります。以下、Loaderを使用するケースを想定し記事を進めます。

開発方法の詳細

通しでの動作はサンプルのリポジトリをご覧ください。Loaderは昔はrequire()のみの対応で、最近ESD/UMDにも対応しました。ただWasmを読み込めない環境もあったり、コンパイル通さないと動作確認ができないので、最初にnpm scriptでbundleまで通しができるようにしておくと良いです。(Webpack/npm sctiptまわりの詳細は他の記事にお任せということで)

  • AssemblyScript を記述する
  • asc で AssemblyScript をコンパイル
  • JS側からloaderを使いfetchかbufferでwasmを読み込むところを記述(注意点後述)
  • AssemblyScript の返り値をJS側で処理するコードを書く(注意点後述)
  • Webpackでbundle
  • ブラウザで実行

自分の使用しているnpm scripts は以下です。npm run testでAssemblyScriptのコンパイルとバンドルを連続で実行しています。

package.json
"scripts": {
  "test": "run-s asbuild:optimized wpbuild-dev",
  "asbuild:untouched": "asc assembly/index.ts -b build/untouched.wasm -t build/untouched.wat --sourceMap --debug",
  "asbuild:optimized": "asc assembly/index.ts -b build/optimized.wasm -t build/optimized.wat --sourceMap --optimize",
  "asbuild": "npm run asbuild:untouched && npm run asbuild:optimized",
  "wpbuild-dev": "npx webpack --mode='development'"
}

VSCode extension のインストール

文法チェックを走らせたい場合の手順が以下になります。nixというサーバーをインストールする必要があり、筆者は面倒なのでtsのまま型定義のエラーだけ無視していつも実装しています、、。エラー無視してもascでコンパイルする時点でエラーはわかります。

wasm の読み込み方法

wasmファイルをloaderで読み込む方法として以下の方法がオフィシャルでは紹介されています。

  • fetch(web)
  • buffer(web/nodejs)
  • fs.readFileSync(nodejs)
  • fs.promises.readFile(nodejs)

※ちなみにloaderを使わない通常の WebAssembly.instantiate() でも同様の制約です。
※※instantiateはサイズによっては数秒かかることもあるので、大きめのwasmを読み込む場合はweb workerを使って読み込むのも良いかもしれません。

以後ブラウザで動かす場合の注意点を以下に記します。

fetchを使い Web server 設定を追加する

ブラウザ上でfetchを使いwasmを読み込む場合はhttp-serverでwasmのmimetypeに対応させておく設定が必要です。結構これの設定がされていない標準ツールがまだまだ多いので、wasmってmimetype知らないよというエラーが出てくる場合は独自にhttp-serverを起動します。

私は開発環境で独自の簡易サーバーをNodeで立ち上げています。詳細はサンプルをご覧ください。

node web-server-simple.js 3000

wasmを配列テキストにしておいて通常のJS読み込み→bufferを生成

上記の方法で問題解決する場合は大丈夫なのですが、どうしてもWebServer設定をいじれないところにDeployすることもあるかと思います。そういう場合はfetchを使用するのをあきらめてwasmを配列テキストにしておいてBufferを生成する方法を取ります。以下のNodeスクリプトでは引数のファイルを読み込んで、wasm_buffer.jsというファイルに変数定義を書き込みます。

convertWasm2Buffer.js
const fs = require("fs");
const fileName = process.argv[2];

//バイナリなのでエンコーディング指定しない
fs.readFile(fileName, (err, data) => {
  if (err) throw err;
  //bufferをUint8Arrayに変換
  let buf = new Uint8Array(data.buffer);
  //ファイルを書き込む
  const headStr = "(function(exports){exports.wasmArray = new Uint8Array(";
  const footStr = ");}(typeof exports === 'undefined' ? this.wasm_buffer = {} : exports));";

  fs.writeFileSync( "wasm_buffer.js" ,headStr + JSON.stringify(Array.from(buf)) + footStr);
});
generatejs.sh
node convertWasm2Buffer.js xxx.wasm

このjsを読み込むと wasmArray というUInt8Arrayの変数が作られるのでそれをfetch()の部分に置き換え、読み込みます。

AssemblyScript と文字列やクラスの受け渡し

先にも記載した通り、AssemblyScriptでは文字列・配列・クラスは WebAssembly 側とポインタでやり取りをするので、loaderモジュールの関数を使ってやり取りを実装します。公式ドキュメントをコピペしようかと思ったのですが、まだ仕様変更が入る可能性もあるので以下リンクを列挙します。

用意されている関数の一覧

API

使い方サンプル

注意点1

クラスのメンバに配列・文字列がある場合、それらもポインタで返ってくるので、値を引っ張ってくるコードが必要です。

x.ts
const fooPtr2 = calcPacket(arrayPtr);//配列を受け取って演算結果のクラスを返す関数、ポインタをJS側で保持
const result = Foo.wrap(fooPtr2);//ポインタをFooインスタンスでラップ

//クラス→オブジェクトに変換する
const rtrnObj = {
  a: result.a,
  b: result.b,
  c: result.c,
  d: result.d,
  str: __getString(result.str) //ポインタが格納されているのでユーティリティで戻す
};

注意点2

ポインタは使用終了の度にリリースが求められています(公式ドキュメントより抜粋)。

WARNING: Remember that pointers to managed objects returned by the module must be __released when done with them. This is also true for property getters, including those automatically generated for fields. Even though the loader abstracts these away to property accesses like foo.str, getters are still represented by exported functions under the hood.

なので関数入れ子にして記載せず、ポインタはポインタで定義しておいてリリースできるようにしてください。

x.ts
//✕ 動作はするけどポインタがリリースできない
const foo = Foo.wrap(getFoo())
console.log(__getString(foo.getString()))

//◯
const fooPtr = getFoo()
const foo = Foo.wrap(fooPtr)
const strPtr = foo.getString()
console.log(__getString(strPtr))
__release(strPtr)
__release(fooPtr)

バージョン間の差異

以下、記事検索した時にハマる可能性もあるのでご注意ください。

モジュール読み込み

かなり前だとloaderモジュールやWebAssembly内の階層がバラバラだったのですが、今はModule.exportsに統一されています。

x.ts
// JavaScript
const { Foo, getFoo, calcRTpacket, UINT8ARRAY_ID } = myModule.exports
const { __getString, __release, __retain, __newArray } = myModule.exports

関数名の変更

気づいた限り、loaderの以下の関数は途中で名前が変わりました。

  • __allocArray → __newArray

AssemblyScript この1年の雑感

  • 過去は大きな構造変更・命名規則の変更が入ることがありましたが、この数ヶ月は仕様が固まってきた感じです。
  • 公式ドキュメントが追従していないこともたまにあるので、そういうときはGitHubのIssuesを見ると大抵同じことをみんな困っていて、解決方法が示されていることが多いです。
  • コンパイラ本体・loader モジュールとも利用ユーザーが確実に増えてきているので不具合の修正速度も上がっており、かなり使いやすくなってきている印象です。

結び

少しだけ環境構築・学習コストはかかりますが、パフォーマンスアップで行き詰まった場合にAssemblyScriptを試してみると新しい次元のWebアプリが作れるかもしれません。皆さんぜひチャレンジしてみてください。