TL;DR; AssemblyScriptを使うと、TypeScriptコードをWebAssemblyに変換できます。オブジェクト指向プログラミングをしている場合は、オブジェクトが保存されるメモリ領域を自分で管理しなくてはならないので、その手間とのトレードオフを見極めてください。
なお、使用しているascのバージョンは 0.9.2 です。
C書けない私にWebAssemblyをつくれと言われましても
WebAssembly(以下、WASM)とはWebブラウザで動くプログラムのバイナリ表現です。Safari, Edge, Chrome, Firefoxと、モダンなWebブラウザへの搭載も終わり、本格的に利用できるようになってきました。その特徴はスピードです。ネイティブに近いスピードで動作します。画像処理やエンコード、暗号といったCPUの処理能力に依存するような処理を行うモジュールをWebAssemblyにすると、従来よりも高いパフォーマンスを引き出すことができます。
そんなWASMですが、高級なプログラミング言語を変換して作成することが、よくある作成方法です。CやC++の変換にはEmscriptenと呼ばれるツールがよく使われます。とはいえ、普段Webを作っている我々にとってCやC++は縁遠く
「C使えば簡単に作れるよ!!」
と言われても、
「簡単とは?」
みたいな気持ちになると思います。
Cは書きたくないが、遅い処理を早くしたい。そんな我らの味方がAssemblyScriptです。
AssemblyScript
AssemblyScriptとは、TypeScript(正しくは、そのサブセット)を変換してWebAssemblyを出力するコンパイラです。つまりCを書かなくても、TypeScriptを書くことができればWASMを作れます。
例えば次のような整数同士の足し算を行う関数があったとします。TypeScriptにu32
なんていう型はありませんが、その辺りに目をつむると、よく知るコードだと思います:
export function add(a: u32, b: u32) : u32 {
return a + b;
}
これをAssemblyScriptで処理すると、次のようなWASMコードになります:
(module
(type $iii (func (param i32 i32) (result i32)))
(memory $0 1)
(export "add" (func $add))
(export "memory" (memory $0))
(func $add (type $iii) (param $0 i32) (param $1 i32) (result i32)
(i32.add
(get_local $0)
(get_local $1)
)
)
)
これはテキスト形式ですが、もちろんバイナリ形式のファイルも出力されます。これをWASMの使い方にしたがって、ロード、コンパイル、インスタンス化することで、WASMになったadd
関数を呼び出せます:
WebAssembly.instantiateStreaming(fetch("add.wasm"), {}).then(mod => {
const add = mod.instance.exports.add;
const result = add(1, 2);
console.log(result);
});
npmでインストール可能
「そう言っても開発環境を整えるのは面倒なんでしょう?」
「いやいや、そんなことはありません。npmでさっくりインストールできますよ」
「えええー」
asm.jsになったBinaryenとTypeScriptのcompiler APIを利用しているので、AssemblyScript自身はJSだけで実装されています。そのおかげで、コンパイラーやCmakeのようなビルド環境を整備しなくても、npmを使うだけでさっくりインストールできます。
グローバル環境にインストールする場合
% npm install -g assemblyscript
インストールすると、asc
というコマンドが利用できるようになります。これはtsc
コマンドのAssemblyScript版で、入力された.ts
ファイルからWASMを出力します。例えばadd.ts
というファイルを処理したければ、次のように実行します。
AssemblyScript プロジェクトを作る場合
AssemblyScript を使うプロジェクトを今から始める場合は、asinit
を使うと便利です。コンパイラのインストール以外にも、テンプレートにしたがって様々な設定やディレクトリの作成が行われます。
まずプロジェクト用のディレクトリを作ります。その中で、npm init
をして、package.json
を作ります。
% mkdir as_project
% cd as_project
% npm init
次に@assemblyscript/loader
とassemblyscript
をインストールします。
% npm i @assemblyscript/loader
% npm i -D assemblyscript
これでasinit
コマンドが実行できるようになります。次のようにnpx
コマンドを使って、実行します。
% npx asinit .
終了後は、次のようにフォルダが作成されています。
% ls
assembly index.js package-lock.json tests
build node_modules package.json
それぞれのフォルダは次の通りの役割を持っています。
フォルダ名 | 役割 |
---|---|
assembly | AssemblyScript のソースコード用フォルダ |
build | ビルドされたWASMファイルの保存場所 |
tests | テストの保存場所 |
index.js | ビルドされたプログラムのエントリーポイント |
asbuild
スクリプトを実行すれば、assembly/index.ts
をエントリーポイントに、ビルドが開始します。
% npm run asbuild
> @ asbuild /Users/chikoski/dojo/hello-as
> npm run asbuild:untouched && npm run asbuild:optimized
> @ asbuild:untouched /Users/chikoski/dojo/hello-as
> asc assembly/index.ts -b build/untouched.wasm -t build/untouched.wat --sourceMap --debug --runtime none
> @ asbuild:optimized /Users/chikoski/dojo/hello-as
> asc assembly/index.ts -b build/optimized.wasm -t build/optimized.wat --sourceMap --optimize --runtime none
このスクリプトは最適化されたリリースビルドと、デバッグ用のビルドの2種類を作成します。optimized.wasm
が最適化されたもの、untouched.wasm
がデバッグ用となっています。
またWATファイルとソースマップも、同時に作成します。
% ls build
optimized.wasm optimized.wat untouched.wasm.map
optimized.wasm.map untouched.wasm untouched.wat
asc の使い方
% asc -o add.wasm add.ts
add.ts
の中で他のtsファイルをインポートしている場合は、自動的にその依存関係を解決して、よしなに処理をしてくれます。
-o
オプションで、出力するファイルの名前を指定できます。出力されるファイルの内容は、次の表のように拡張子によって変わります。なお省略するとS式を標準出力に出力します。
拡張子 | 出力されるファイル形式 |
---|---|
.wasm | WebbAssembly(バイナリ) |
.wast | S式(テキスト) |
.wat | 線形のテキスト表現 |
.js | asm.js |
--help
オプション
--help
オプションをつけると、利用可能なコマンドラインオプションが全て出力されます。
--runtime
オプション
上述の方法で出力すると、そのままでの実行が前提となるWASMファイルが出力されます。プロジェクト全体をWASMにするなら問題ありませんが、CPUに依存する処理を高速化するといった該当する処理のみWASMになれば良い場合にはバイナリサイズが巨大になってしまいます。
そういう場合には、--runtime
オプションを使います。--runtime none
と指定すれば、ランタイムを省いがWASMファイルが出力されます。前出のWASMファイルは、このオプションをつけて出力しています。
--runtime
オプションには、none
以外にも、以下の値を指定できます。
値 | 説明 |
---|---|
full | 標準のランタイム。メモリ管理に TLSF と参照カウントを利用 |
half | fullと同様だが、ランタイムのコードは出力されない |
stub | free と GC を省いた最小構成 |
none | stubと同様だが、ランタイムのコードは出力されない |
AssemblyScriptとTypeScriptの違い
ここまでAssemblyScriptはTypeScriptを処理できるかのように書いてきましたが、実はそうではありません。WASMへの出力を行う関係で、型関連に若干の違いがあります。
追加された型
まず次の型が追加されています。TypeScriptでの数値はJavaScriptと同じくnumber
だけですが、WASMが整数、実数、データ長や符号の有無を区別する関係で、より細かく拡張されています。また真偽を表すプリミティブ型や、void
も追加されています。
型 | WASM での型 | sizeof演算子の評価値 | 説明 |
---|---|---|---|
i8 | i8 | 1 | 8bitの整数値 |
i16 | i16 | 2 | 16bitの整数値(符号あり) |
u16 | u16 | 2 | 16bitの整数値(符号なし) |
i32 | i32 | 4 | 32bitの整数値(符号あり) |
u32 | u32 | 4 | 32bitの整数値(符号なし) |
i64 | i64 | 8 | 64bitの整数値(符号あり) |
u64 | u64 | 8 | 64bitの整数値(符号なし) |
f32 | f32 | 4 | 32bitの浮動小数点 |
f64 | f64 | 8 | 64bitの浮動小数点 |
bool | i32 | 1 | 真偽値 |
代入できるもの、できないもの
number
が整数と不動小数点に細分化され、サイズや符号の有無による派生も生まれています。この変更で、数値であっても代入できる場合とできない場合があります。ざっくりまとめると、次のルールにしたがっています。
- 整数値を整数型の変数に代入できます:例:
a:i32 = -18;
- 不動小数を不動小数点型の変数に代入できます。例:
b:f64 = 3.14;
- サイズの小さい値を、サイズの大きい型の変数へ代入できます。例:
a:i8 = 1; b:i32 = a;
- サイズの大きい値を、サイズの小さい型の変数へは代入できません。できない例:
a:i32 = 8; b:i8 = a;
- 符号付きの値を符号なしの変数に代入できます。逆も可能です。例:
a:u32 = -1; b:i32 = a;
ほかにも u64/i64
を除く整数を不動小数点型の変数に代入できるとか、符号なしの値を符号ありにした時の変換ルールとか、サイズの小さい値を大きなサイズの型へ代入した時、上位のビットはどうなるのか、とかいろいろ細かい規則があります。詳しくは こちらの表を参照してください。
特別な型
次の型も利用できます。anyref
は Reference Type の仕様で定義されている参照型を表現します。
型 | WASM での型 | 説明 |
---|---|---|
isize | i32(WASM32) / i64 (WASM64) | WASM32 では i32 のエイリアス、WASM64 では i64 のエイリアス |
usize | u32(WASM32) / u64 (WASM64) | WASM32 では u32 のエイリアス、WASM64 では u64 のエイリアス |
void | 返り値がないことを表す型 | |
anyref | anyref | 参照型 |
型のエイリアス
JS/TS でお馴染みの型も利用できますが、内部ではエイリアスとして処理されます。ただし使用は推奨されません。
型 | 実際の型 | WASM での型 |
---|---|---|
number | f64 | f64 |
boolean | bool | i32 |
型が変更された値
NaN
とInfinity
は、文脈によって型が決定されます。
値 | 型 |
---|---|
NaN | f32 もしくは f64 |
Infinity | f32 もしくは f64 |
使用できない型
次の型は使用できません。
- undefined
- any
-
クラス名 | null
を除くunion
またオプション引数には必ず初期化項が必要です。
オブジェクト指向のコードはどうなってしまうのか
例えば次のような2次元ベクトルを表すクラスがあったとします。メソッドも持っていて、ベクトル同士の足し算の計算が可能です。
export class Vec {
mX: i32;
mY: i32;
constructor(x: i32 = 0, y: i32 = 0) {
this.mX = x;
this.mY = y;
}
add(b: Vec): Vec {
return new Vec(this.mX + b.mX, this.mY + b.mY);
}
getX(): i32 {
return this.mX;
}
getY(): i32 {
return this.mY;
}
get x(): i32 {
return this.mX;
}
get y(): i32 {
return this.mY;
}
set x(value: i32) {
this.mX = value;
}
set y(value: i32) {
this.mY = value;
}
}
これを変換すると次のようになります(--noRuntime -f linear -O
オプションをつけています)。
(module
(type $iiii (func (param i32 i32 i32) (result i32)))
(type $iii (func (param i32 i32) (result i32)))
(type $ii (func (param i32) (result i32)))
(import "lib" "malloc" (func $lib:malloc (param i32) (result i32)))
(import "lib" "memset" (func $lib:memset (param i32 i32 i32) (result i32)))
(memory $0 1)
(export "Vec" (func $Vec))
(export "Vec#add" (func $Vec#add))
(export "Vec#getX" (func $Vec#getX))
(export "Vec#getY" (func $Vec#getY))
(export "memory" (memory $0))
(func $Vec (type $iiii) (param $0 i32) (param $1 i32) (param $2 i32) (result i32)
(i32.store
(get_local $0)
(get_local $1)
)
(i32.store offset=4
(get_local $0)
(get_local $2)
)
(return
(get_local $0)
)
)
(func $Vec#add (type $iii) (param $0 i32) (param $1 i32) (result i32)
(return
(call $Vec
(call $lib:memset
(call $lib:malloc
(i32.const 8)
)
(i32.const 0)
(i32.const 8)
)
(i32.add
(i32.load
(get_local $0)
)
(i32.load
(get_local $1)
)
)
(i32.add
(i32.load offset=4
(get_local $0)
)
(i32.load offset=4
(get_local $1)
)
)
)
)
)
(func $Vec#getX (type $ii) (param $0 i32) (result i32)
(return
(i32.load
(get_local $0)
)
)
)
(func $Vec#getY (type $ii) (param $0 i32) (result i32)
(return
(i32.load offset=4
(get_local $0)
)
)
)
)
これを見てわかる人は
「ははぁ。なるほど。Cでオブジェクト指向やった時みたいになるんだねえ」
などと思って見ていいただければ良いのですが、ざっくり説明すると次のように変換されています。
- コンストラクタとメソッドは、それぞれ別の関数へと変換される
- getter / setter はWASMからは消える
- フィールドはWASMインスタンスの持つメモリ領域に保存される
- メモリ領域の操作は、
memset
とmemcopy
という関数によって行われる
まだ何を言っているかわからないと思うので、順番に見てゆきましょう。
変換されたコードの使い方:コンストラクタ
TypeScriptでは、次のようにインスタンス化します。
var zero = new Vec(1, 0);
var one = new Vec(2, 3);
変換されたWASMでは、コンストラクタはVec
という関数になっていて、次のように使います。なお例のmod
は、インスタンス化されたWASMモジュールです。
const zero = mod.exports.Vec(0, 1, 0);
const one = mod.exports.Vec(8, 2, 3);
元のコンストラクタと比べて引数が1つ増えています。増えたのは第1引数です。
第2、第3引数はコンストラクタの第1、第2引数に対応しています。
この第1引数は、データを保存するメモリ領域の開始アドレスを表します。WASMインスタンスは、JSとは異なるメモリ領域を持ちます。そう聞くと難しそうですが、要は配列です。
ただ異なる点ももちろんあります。それは保存するデータの種類によって、消費されるセルの数が変わるという点です。今回扱っているi32
という型は4つのセルを消費します。
AssemblyScriptは、その配列にオブジェクトの属性を、宣言された順に保存します。Vec
オブジェクトはmX
とmY
の属性を持ちますが、これを次のように保存します。なお図中の数字はセルの添え字です。mX
, mY
ともにi32
という型なので、4つのセルを使っています。
|zero.mX|zero.mY|
0 4 8
上記のコードを実行して、Vec
を2つ作成すると、WASMのメモリ領域は次のようになります。
|zero.mX|zero.mY|one.mX|one.mY|
0 4 8 12 16
コンストラクタに追加された最初の引数は、データを保存するセルの添え字を表しています。つまり mod.exports.Vec(8, 2, 3);
は次のように翻訳できます:
「メモリ領域の8番目のセルから順に、2と3を保存して」
この時に指定した第1引数の値が、コンストラクタの返り値となります。
メソッドの呼び出し
zero
にone
を足します。TypeScriptでは、zeroに対してadd
メソッドを呼び出すことで、実現できます。
var result = zero.add(one);
変換されたadd
メソッドは、Vec#add
という関数となっています。
「zero
にone
を足す」
というコードを、この関数を使って書くと次のようになります。
const result = mod.exports["Vec#add"](zero, one);
第1引数は、そのメソッドを呼び出すオブジェクトの先頭アドレスです。これはコンストラクタと同様です。
コンスラクタと異なるのは、変換された関数の名前に#
が含まれる点です。JavaScriptでは#
を変数名や関数名に利用できないため、呼び出す時には文字列を与えることで属性を参照して、それが指す関数オブジェクトを得なければなりません。
なんどもやるのは面倒な場合は、単純に他の変数へ代入してもよいでしょう:
const add = mod.exports["Vec#add"];
const result = add(zero, one);
属性値を操作する関数をgetter/setterとは別に用意した方がよい
もしgetter/setterを定義している場合は、それらと同じ動きをする関数を別途用意しておいた方がいいでしょう。なぜならWASMのコードからgetter/setterが消去されてしまうからです。
これは意外なところに効いてきます。それはgetter/setter経由で属性値を操作している場合です。例えば上記の例で定義されているadd
メソッドは、getterメソッドを使って次のように書くこともできます:
add(b: Vec): Vec {
return new Vec(this.x + b.x, this.y + b.y);
}
しかし、このコードをWASMに変換できません。ascはx
やy
をgetterメソッドの呼び出しではなく、属性の参照として解決しようとするからです。しかし、そのようなそのような属性は定義されていないため、コンパイルエラーととなってしまいます。
これは将来的に解決されるかもしれませんが、現時点(0.3.0)ではコンパイルエラーとなります。
メモリ操作をする関数をインポートさせる必要があります
内部でnew
を使用する関数やメソッドを書いている場合、それらは内部でWASMメモリを操作することになります。
AssemblyScriptは、memset
やmalloc
といったメモリの操作を行うための関数群が定義されていることを前提に、コードの変換を行います。これらのコードは自動的に追加されるので、通常はきにする必要がありません。
しかし--runtime stub
を指定するといった形で、メモリ管理をランタイムから削除している場合は、 WASMをインスタンス化する際にこれらの関数をインポートしなければなりません。
自分で実装してもいいですが、標準で追加される実装はAssemblyScriptのmemory management runtimeとして別レポジトリで配布されています。ここから必要なものだけ読み込むというのでも良いでしょう。
まとめ
AssemblyScriptを使うと、TypeScriptを「ほぼそのまま」WASMへ変換できます。
そうはいっても、メモリ管理を自分でしなければならなかったり、getter/setterあたりの変更が必要だったり、i32
のように数値をより細かく型指定しなくてはいけなかったり、変更点はいくつかあります。
全てのコードをいきなり突っ込むのではなく、本当にパフォーマンス改善が必要な点だけを別関数にして、その関数のみをWASMにする、といったような使い方の方が現実的なのかもしれません。
とはいえ、CやC++を書けなくてもWASMの力を利用できるようになったのは、大きな選択肢を与えてくれていると思います。
パフォーマンスが欲しい時、
「AssemblyScriptつかってみるか」
と思っていただければ、幸いです。