Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
86
Help us understand the problem. What is going on with this article?
@chikoski

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

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/loaderassemblyscriptをインストールします。

% 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

型が変更された値

NaNInfinityは、文脈によって型が決定されます。

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インスタンスの持つメモリ領域に保存される
  • メモリ領域の操作は、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といったメモリの操作を行うための関数群が定義されていることを前提に、コードの変換を行います。これらのコードは自動的に追加されるので、通常はきにする必要がありません。

しかし--runtime stubを指定するといった形で、メモリ管理をランタイムから削除している場合は、 WASMをインスタンス化する際にこれらの関数をインポートしなければなりません。

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

まとめ

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

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

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

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

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

「AssemblyScriptつかってみるか」

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

86
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
chikoski
A transport matrix.

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
86
Help us understand the problem. What is going on with this article?