HTML5Experts.jp

Webブラウザで高速な演算を可能にする低水準言語asm.jsと、WebAssembly詳解ーasm.jsの仕組みとコーディング例

連載の第1回目は asm.jsの紹介と、asm.jsが導入された背景を概観しました。

Just in Timeコンパイルによって高速にJavaScriptを実行できるようになりましたが、立ち上がりが遅い、やり直しが発生する、コンパイルによって一時的に負荷が向上する、といった問題が残されています。

これを解決するためにプログラムの実行を行うより前にネイティブコードへとコンパイルするAhead of Timeコンパイルを導入したいのですが、JavaScriptは柔軟すぎて効率の良いネイティブコードを出力することが難しい、という問題がありました。

asm.jsはこの問題に一定の解をあたえるものとなります。今回はそのasm.jsがどのようなものなのか、JavaScriptの関数を asm.js化しながら解説していきます。

asm.jsがコンパイルされるまで

前述したとおりasm.jsで記述されたプログラムは、実行以前にコンパイルされます。コンパイルは下図のような過程で行われます。

ソースコードが字句解析、構文解析されてAST(Abstract Syntax Tree:抽象構文木)になるところまでは一緒ですが、その後意味解析が行われます。この意味解析でそれぞれの式や変数、関数の型がチェックされます。

意味解析が終了後、プログラムはコンパイルされネイティブコードが出力されます。このネイティブコードはメモリ上に展開されたあと、プログラム中で利用しているJavaScriptの関数やasm.jsに用意されている標準ライブラリとのリンクが行われます。

意味解析とリンク、この2つに失敗する場合もあります。プログラム中に型エラーが発見された場合は前者の失敗します。 後者はメソッドの呼び出しや、JSにエキスポートできない種類のデータを引数に指定した関数呼び出しを行った場合に失敗します。

このような場合、プログラムはasm.jsとして処理されるのではなく、通常のJSとして処理されます。asm.jsがJSのサブセットであることによって、このようなフォールバックも可能になっています。

asm.jsモジュール

asm.jsで書かれたプログラムとして事前コンパイルされる際の単位は、モジュールです。ファイル単位でコンパイルが行われるわけではないので、1つのJSファイルのうち高速化が必要な部分をasm.jsで書き、それ以外の部分は通常のJSとして書くといったことが可能です。

asm.jsのモジュールは、次のようにCやC++のソースコードと似た構造となっています。

function ModuleName(stdlib, ffi, heap){
"use asm";
 // (1) 外部からインポートするシンボルの宣言
 // (2) 関数宣言
 // (3) 関数表の宣言
 // (4) モジュールのエキスポート
}

1の部分では利用する標準ライブラリや、JSの関数、定数などのシンボルを列挙します。ちょうどCのextern宣言と同じような役割です。

2の部分で、それぞれの処理を関数として定義します。asm.jsではオブジェクトやクラスの定義が許されていません。そのため処理はクラスやオブジェクトとしてではなく、あくまで関数として定義します。

3の部分では同じ型の関数をまとめた表を定義できます。ちょうどCでの関数ポインタの機能を代替するものです。関数を直接呼び出すのではなく、この表を参照する形で呼び出すことで、呼び出す関数の振る舞いを変えられるので、多態性を持った関数を定義したい場合に有用です。

とはいえ、いまいちイメージがつかめないかと思います。そこで足し算を行うモジュールをasm.js化しながら、構成を見てゆきましょう。変更するのは以下のようなモジュールです。

function AddFunctions(){
  function add1(value){
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

このモジュールは次のように利用できます。

const module = AddFunctions();
const one = module.add1(0);    // 1
const two  = module.add1(one); // 2

asm.jsディレクティブ

asm.js化の第一歩は、ディレクティブの追加です。”use strict” ディレクティブをつけると、その関数はstrict modeで解釈されるのと同様に、”use asm”ディレクティブをつけることで、処理系はその関数をasm.jsのモジュール定義として処理します。

function AddFunctions(){
  "use asm";
  function add1(value){
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1,
  }
}

 型アノテーション

次にAOTを行うための型アノテーションを行います。型アノテーションは、TypeScriptなどのように型を直接記述する方法が一般的かと思いますが、JavaScriptとしても解釈できなくてはいけないasm.jsでは異なります。同値となるような式を追加することで、型情報を明示します。

明示的に型アノテーションを行う対象は次の3つです。

これらの情報を元に、関数の型や式の型が決定されます。

引数に対する型アノテーション

引数に対する型アノテーションは、関数本体の先頭で行います。次の例では、add1の引数valueの型はintであることを示す型アノテーションが加わっています。value = value | 0; が型アノテーションを行っている部分です。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

引数で利用できる型はint, doubleの2つです。それぞれの型はは次の表のようにアノテーションします。

型アノテーション
int value = value | 0
doube value = +value
float value = f(value)

尚、外部で定義された関数呼び出しを行った場合は、floatとして解釈されます。

変数宣言

asm.jsでは、関数内で利用する変数に対しても型アノテーションを行います。これは宣言時に初期値として代入するを適切に選ぶ形で行います。整数値を代入すればintに、実数値の場合はdoubleとなります。

尚、1.0のような小数点以下の数字が0のものは実数値として扱われます。変数宣言に型アノテーションをつけると、先ほどまでのモジュールは以下のようになります。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result = 0; // intとして宣言
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

返り値に対する型アノテーション

返り値に対して型アノテーションを行います。この情報と引数の型情報とを組み合わせて、関数の型が決定されます。

返り値で利用できるのはdouble, signed, float, そしてvoidの4つの型です。それぞれのアノテーション方法は以下の表の通りです。また即値を書く場合は、アノテーションは必要ありません。

アノテーション例 メモ
double return +result;
signed return result 0|
float return f(result) fは関数
void return;

asm.jsの型システム

これまでintやdoubleといった型を利用してきましたが、asm.jsで利用できる型を列挙し、それぞれの継承関係を示すと次の図となります。 矢印の元にある型は先の型を継承しています。

http://asmjs.org/spec/latest より引用

継承するということは、何かの制約が厳しくなっていくということです。asm.jsの型システムではnullを許容するか、実行時に割り当てられるレジスタがdoubleかintか、といった点での制約が厳しくなっていきます。

また、JavaScriptで定義されている関数に渡せる型も決まっています。背景色が薄い灰色になっているfixnum / signed / extern / double型のデータのみが許可されています。

型のキャスト

先ほどから変更しているプログラムは、意味解析に失敗します。それは次の部分に原因があります。

result = value + 1;

これはint+fixnumの計算を行い、その結果をint型の変数に代入しようとしています。型エラーの入り込む余地はなさそうに思えます。しかし、この加算の評価値の型はinterishとなっています。そのため、int型へinterish型の値を代入することになり、型エラーが起きるというわけです。

そこでキャストを行い、演算結果の型を明示します。この変更をおこないasm.jsの意味解析に成功するコードは次のようになります。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result = 0; // int として宣言
    result = (value + 1) | 0; // int へキャスト
    return result;
  }
  return {
    add1: add1
  }
}

JavaScriptとの組み込み

以上で、AddFunctionsモジュールをasm.js化することができました。これをJavaScriptのプログラムに組み込みんでいきます。

ここでは 上記で作成したAddFunctionsモジュールをJavaScript側から利用する方法と、AddFunctionsモジュール内で JavaScriptの関数を利用する方法について説明します。

JavaScriptからの利用

asm.jsのモジュールとJavaScriptのモジュールは、JavaScriptからみると区別できません。下記のようにJSのモジュールを呼ぶように利用できます。

const module = AddFunctions();
var one = module.add1(0);
var two = module.add1(one);

function add2(n){ return module.add1(module.add1(n)); }

JavaScriptの関数をasm.jsから呼ぶには

まず、asm.jsの内部からJavaScriptで定義された関数を呼ぶことはできます。また、Mathオブジェクトの持っているいくつかのメソッドは、標準ライブラリ中の関数として提供されています。

これら関数への参照はモジュールを定義する関数の引数として与えます。例えば、AsmModuleにasm.jsモジュールが定義されている場合、次のように呼び出すことでJavaScriptの関数をasm.js内から呼び出せます。

const ffi = {
  put: n => console.log(n)
};

const module = AsmModule(window, ffi);

asm.jsで定義される関数はオブジェクトの解決ができません。そのため利用する関数はあらかじめ外部からインポートするシンボルとして宣言しておきます。

次の例では、標準ライブラリ中のMath.expとMath.log、そして自作関数であるputを外部からインポートするシンボルとして宣言しています。

function AsmModule(stdlib, ffi, heap){
  "use asm";
  var exp = std.lib.Math.exp;
  var log = std.lib.Math.log;

var put = ffi.put;

これらの関数は、関数定義内でasm.js内部で定義された関数と同様に呼び出せます。ただ1点注意しなくてはならないのは、引数に渡すデータの型です。標準ライブラリ以外の外部関数に渡せるのはfixnum、signed、extern、doubleのいずれかです。 それ以外の値を渡すとリンクエラーとなり、通常のJSとして実行されます。演算の結果を適切にアノテーションすることで、リンクエラーを避けられます。

var value = 1;
put(value + 1); // リンクエラー
put((value + 1) | 0); // OK

ヒープの利用

asm.jsで定義される関数は、数値演算しかできません。また、オブジェクトの解決もできません。つまり、次のような関数は定義できないことになります。

function caesar(string, key){
  var result = "";
  for(let i = 0; i < string.length; i++){
    result += String.fromCharCode(a.charCodeAt(0)+key);
  }
  return result;
}

ところでC言語では文字列を数値の配列として扱います。この考えを応用すれば、asm.jsでも文字列を数値演算の範囲で扱えるようになります。

上記の関数をasm.jsに書き直すと以下のようになります。

function Caesar(stdlib, ffi, heap){
  "use asm";
  var HEAP = new stdlib.Int8Array(heap);

function encrypt(key){ key = key | 0; var i = 0; for(;(HEAP[i << 0 >> 0] | 0) != 0; i = i + 1 | 0){ buffer[i << 0 >> 0] = ((buffer[i << 0 >> 0] | 0) + key) | 0; } return; }

文字列はHEAPというArrayBufferに格納されています。このArrayBufferはモジュールの定義時に引数として与えられます。 ArrayBufferのビューは標準ライブラリとして提供されているため、上記のようにモジュール内のHEAPを大域変数として宣言する際にビューもあわせて定義します。

HEAPの添字は、ビューの各要素の大きさに合わせてシフトする必要があります。シフトするビット数は、2を底として要素のバイトサイズのlogをとると求まります。

上記の例で利用しているInt8Arrayの場合、各要素の大きさは1バイトのため、0ビットシフトしています。ビューとシフトするビット数の対応は次の表を参照してください。

ビュー 要素のサイズ(バイト) シフトするビット数 ロード時の型 保存時の型
Uint8Array 1 0 intish intish
Int8Array 1 0 intish intish
Uint16Array 2 1 intish intish
Int16Array 2 1 intish intish
Uint32Array 4 2 intish intish
Int32Array 4 2 intish intish
Float32Array 4 2 float? floatish, double?
Float64Array 8 3 double? float?, double?

ArrayBufferを与えてモジュールの作成と関数の呼び出しを行うと、次のようなコードとなります。

気をつけなければならいのは、TypedArrayの大きさです。212 以上、224バイト未満の大きさになるようにするか、224バイトの整数倍の大きさになるようにしてください。そうしなければ、リンクに失敗してします。

これが原因でリンクに失敗した場合は、コンソールに適切なサイズが表示されます。それを参考に大きさを際設定すればようでしょう。

const heap = new Int8Array(0x10000)
const caesar = Caesar(window, {}, heap);
heap[0] = 72; heap[1] = 65; heap[2] = 76; // HALと設定
caesar.encrypt(1); // HALが1文字ずつシフトされる

まとめ

以上のように、JavaScriptと比べてasm.jsは随分と書きづらく、できることも限られています。ArrayBufferを駆使すればベクトルの計算も可能ですが、オブジェクトと平坦なArrayBufferとの相互変換を自分で実装しなくてはならず、なかなか骨が折れる作業であることは否めません。

その代わり得られる効果は絶大です。JITによって処理が重たくなることもなく、高速な実行が可能となります。またコンパイルされた結果はキャッシュされるため、2回目以降は高速に起動できるようになります。

とはいえ、手で書くのは骨が折れます。

「人間のやることではない」

「高級言語で実装したい」

そう思う方も多いでしょう。そのために用意されているツールがEmscriptenです。次回はEmscriptenを利用したC言語やC++で実装されたコードのasm.jsへの変換について解説します。