Edited at

AssemblyScriptを使ってTypeScriptのコードを早くしよう

TL;DR; AssemblyScriptを使うと、TypeScriptコードをWebAssemblyに変換できます。オブジェクト指向プログラミングをしている場合は、オブジェクトが保存されるメモリ領域を自分で管理しなくてはならないので、その手間とのトレードオフを見極めてください。

なお、使用しているascのバージョンは0.3.0です。


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 --save-dev assemblyscript 

% npm install -g assemblyscript

インストールすると、ascというコマンドが利用できるようになります。これはtscコマンドのAssemblyScript版で、入力された.tsファイルからWASMを出力します。例えばadd.tsというファイルを処理したければ、次のように実行します。

% asc -o add.wasm add.ts

add.tsの中で他のtsファイルをインポートしている場合は、自動的にその依存関係を解決して、よしなに処理をしてくれます。

-oオプションで、出力するファイルの名前を指定できます。出力されるファイルの内容は、次の表のように拡張子によって変わります。なお省略するとS式を標準出力に出力します。

拡張子
出力されるファイル形式

.wasm
WebbAssembly(バイナリ)

.wast
S式(テキスト)

.wat
線形のテキスト表現

.js
asm.js


--noRuntime オプション

上述の方法で出力すると、そのままでの実行が前提となるWASMファイルが出力されます。プロジェクト全体をWASMにするなら問題ありませんが、CPUに依存する処理を高速化するといった該当する処理のみWASMになれば良い場合にはバイナリサイズが巨大になってしまいます。

そういう場合には、--noRuntimeオプションをつけると良いでしょう。ランタイム部分が省かれたWASMが出力されます。前出のWASMファイルは、このオプションをつけて出力しています。


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の整数値(符号なし)

usize
i32 or i64
4 or 8
メモリ上のアドレスを表す型。WASM32の場合はi32に、WASM64の場合はi64へ変換される

f32
f32
4
32bitの浮動小数点

f64
f64
8
64bitの浮動小数点

bool
i32
1
真偽値

void

返り値がないことを表す型


追加された演算子

WASMで定義されている演算子が追加されています。詳しくはAssemblyScriptのドキュメントを参照してください。


型が変更された値

NaNInfinityは型が変更されています。


NaN
f64

NaNf
f32

Infinity
f64

Infinityf
f32


使用できない型

次の型は使用できません。


  • 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インスタンスの持つメモリ領域に保存される

  • メモリ領域の操作は、memsetmemcopyという関数によって行われる

まだ何を言っているかわからないと思うので、順番に見てゆきましょう。


変換されたコードの使い方:コンストラクタ

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オブジェクトはmXmYの属性を持ちますが、これを次のように保存します。なお図中の数字はセルの添え字です。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引数の値が、コンストラクタの返り値となります。


メソッドの呼び出し

zerooneを足します。TypeScriptでは、zeroに対してaddメソッドを呼び出すことで、実現できます。

var result = zero.add(one);

変換されたaddメソッドは、Vec#addという関数となっています。

zerooneを足す」

というコードを、この関数を使って書くと次のようになります。

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はxyをgetterメソッドの呼び出しではなく、属性の参照として解決しようとするからです。しかし、そのようなそのような属性は定義されていないため、コンパイルエラーととなってしまいます。

これは将来的に解決されるかもしれませんが、現時点(0.3.0)ではコンパイルエラーとなります。


メモリ操作をする関数をインポートさせる必要があります

内部でnewを使用する関数やメソッドを書いている場合、それらは内部でWASMメモリを操作することになります。

AssemblyScriptは、memsetmallocといったメモリの操作を行うための関数群が定義されていることを前提に、コードの変換を行います。これらのコードは自動的に追加されるので、通常はきにする必要がありません。

しかし--noRuntimeオプションをつけている場合は、WASMをインスタンス化する際にこれらの関数をインポートしなければなりません。

自分で実装してもいいですが、標準で追加される実装はAssemblyScriptのmemory management runtimeとして別レポジトリで配布されています。ここから必要なものだけ読み込むというのでも良いでしょう。


まとめ

AssemblyScriptを使うと、TypeScriptを「ほぼそのまま」WASMへ変換できます。

そうはいっても、メモリ管理を自分でしなければならなかったり、getter/setterあたりの変更が必要だったり、i32のように数値をより細かく型指定しなくてはいけなかったり、変更点はいくつかあります。

全てのコードをいきなり突っ込むのではなく、本当にパフォーマンス改善が必要な点だけを別関数にして、その関数のみをWASMにする、といったような使い方の方が現実的なのかもしれません。

とはいえ、CやC++を書けなくてもWASMの力を利用できるようになったのは、大きな選択肢を与えてくれていると思います。

パフォーマンスが欲しい時、

「AssemblyScriptつかってみるか」

と思っていただければ、幸いです。