asm.js: 仕様と実装の今

  • 10
    Like
  • 0
    Comment

皆さんはasm.jsを覚えているでしょうか。4年ほど前(2013年)に登場してFirefoxで実装され、「C/C++で書かれたプログラムをWebで高速に実行できる!」みたいな話題になったやつです。その後WebAssemblyが登場したので、敢えてasm.jsに取り組む意味は薄くなりました。

しかしここでは懐古趣味として、asm.jsの現状を調査してみたいと思います。

仕様書

asm.jsの仕様書はここで見れます:http://asmjs.org/ (このサイトはどうやらGitHub Pagesでホストされているようで、GitHubリポジトリは https://github.com/dherman/asm.js です)

この仕様書は「asm.js Working Draft -- 18 August 2014」となっており、結構古いです。これが「枯れている」ことを意味していればよかったのですが、単に更新が止まっているだけであり、この3年間で発覚した問題点などが反映されていません。

既知の問題点や改善案が集まっていそうな場所をいくつか挙げておきます:

型について

asm.jsではいくつかの型が定められていますが、TypeScript風に書けば次のようになるかと思います:

type fixnum      = 0 | 1 | ... | 2^31-1;                    // 31ビット符号なし整数(signed と unsigned の共通部分)
type signed      = -2^31 | ... | -1 | 0 | 1 | ... | 2^31-1; // 32ビット符号付き整数
type unsigned    = 0 | 1 | ... | 2^32-1;                    // 32ビット符号なし整数
type int         = signed | unsigned | boolean;             // 32ビット整数、ただし比較演算は利用不可
type intish      = number | boolean | undefined;            // 整数同士の演算の結果
type double      = number;
type maybedouble = number | undefined;                      // Float64Array から読み出した結果
type float       = ...;                                     // 32ビット浮動小数点数
type maybefloat  = float | undefined;                       // Float32Array から読み出した結果
type floatish    = number | undefined;                      // float 同士の演算の結果

ただし、 double? の代わりに maybedouble, float? の代わりに maybefloat と表記しています。

TypeScriptと違い、配列から読み出した結果の型には | undefined がつきます。

実装

仕様は実装されなければ意味がないので、実装も確認します。特に断らない限り、各ブラウザの2017年9月時点での最新版 (Firefox 55, Edge 40, Chrome 61) を使っています。

Firefox (SpiderMonkey / OdinMonkey)

Firefox (SpiderMonkey / OdinMonkey) の実装はこのへんで見れるようです。

特筆すべき拡張としては、後述するように、SIMD.jsがasm.jsから使えるようになっています。

その他、SpiderMonkeyのソースコードを見た限りでは、Atomicsが実装されているように見えます。筆者がAtomicsをよく知らないのと、まだ実験的な実装であると思われるため、ここでは解説しません。

Edge (Chakra)

Microsoft EdgeのChakraでも実装されており、ChakraCoreの中にasm.js対応部分も含まれています:AsmJsModule.cppなど。

初期のアナウンスでFirefoxのチームと協力してやっていると言っていただけあって、仕様は(古い仕様書ではなく)かなりSpiderMonkeyに近いです。目立った違いは、 Math.min/max が float を受け取らないことくらいです。

Chrome (V8)

Google ChromeのV8は、筆者の記憶では確か「asm.jsを特別扱いはせず、通常のJITで(高速に)実行する」という方針でした。しかし記事執筆時の最新版のChrome 61 (V8 version 6.1) で、ついにasm.jsの検証 (validation) とWebAssemblyへのコンパイルが行われるようになったようです:V8 JavaScript Engine: V8 Release 6.1 最初に「懐古趣味」と書きましたが、調べてみると意外とホットな話題だったわけです。

V8のasm.js実装はこのへんで見れます。

V8の実装は(2017年に実装された割には)古い仕様書にかなり忠実で、標準ライブラリ呼び出し側の型強制が省略可能なこと、 Math.min/max に float のオーバーロードが追加されたこと、 Math.clz32 があること、変数定義に関する諸々を除けばほぼ仕様書通りと言っていいかもしれません。仕様バグにも忠実ですし、独自のバグも埋め込まれています(後述)。

その他

SafariのJavaScriptCoreは、(WebAssemblyには対応するが)asm.jsには特別な対応はしていないはずです。asm.jsとしての検証もしません。従って、この記事での出番はありません。

ブラウザのJavaScriptエンジン以外では、asm.js公式のvalidatorと、Binaryenのasm2wasmがあります。しかし、前者は古く、後者は筆者が試したところ容易にabortしやがるので今回は試しません。

Emscriptenなどの、「asm.jsを出力する側」も調べてみるべきかもしれませんが、割愛します。

仕様書と実装の差異

仕様書と実装の違いの多くは、asm.jsを拡張するもの、つまり「従来の仕様では認められないものをasm.jsとして許可する」という方向性ですが、一部には仕様バグ、つまり「asm.jsとして実行した場合と通常のJavaScriptとして実行した場合で挙動が異なる」への対処が含まれています。

標準ライブラリ呼び出しへの型強制 (call-site coercion)

仕様書では、 fround 以外の関数呼び出しを式の中で用いる場合は、関数呼び出しに対して

  • 単項 + をつける(→ double へ)、
  • 後ろに |0 をつける(→ signed へ)、
  • fround で囲う(→ float へ)

のいずれかの措置(型強制)が必要とされています。

しかし、外部の関数はともかく、標準ライブラリの関数(Mathオブジェクトの関数の一部)は型が既知なので、型強制は必要ないはずです。また、asm.jsを拡張してSIMDに対応させる場合、SIMD演算の全てに型強制が必要になってしまいます(SIMD演算は関数呼び出しなので)。

このような事情があり、現在のSpiderMonkeyやChakraの実装では標準ライブラリ呼び出しへの型注釈は不要になっています。V8も、SIMDには対応していませんが、同様に標準ライブラリ呼び出しの型注釈は不要となっています。

これに伴い、SpiderMonkeyとChakraでは一部の関数の型を変更しています。具体的には、

  • 仕様で float を返すように定められていた関数 (abs, ceil, floor, sqrt) は float ではなく floatish を返す(float 型の値を得るには、従来通り fround を要求する)
  • 整数に関する abs は signed ではなく unsigned を返す

ようになっています。一方、V8ではこれらの関数の型が仕様書通りなので、 "use asm" の有無でプログラムの動作が変わってしまう場合があります(後述)。

https://discourse.wicg.io/t/drop-requirement-for-callsite-coercion-for-stdlib-callees/675

検証コード:

// call-site coercion can be omitted (validated: YES, rejected: NO)
(function(stdlib){"use asm";var sqrt=stdlib.Math.sqrt;function f(){var x=2.;x=sqrt(x);return +x;}return f;}(this)())

SIMD

SpiderMonkeyではSIMD.jsがasm.jsから使えるようになっています。ChakraCoreでも条件コンパイル次第で有効になるようです。

ただ、SIMDはJavaScriptではなくてWebAssemblyでやっていこうという方針になったみたいなので、ここでは詳しい調査はしません。

https://discourse.wicg.io/t/request-for-comments-simd-js-in-asm-js/676

型と関数に関するもの

ゼロの符号:-0

仕様書では、(符号付き)数値リテラルは小数点を含まなければ整数ということになっています。通常はこれでも問題ないのですが、 0 に関しては浮動小数点的に +0 と -0 の区別があるので、問題になります。仕様バグです。

Chrome 61のV8はこの仕様バグを見事に踏み抜いているので、 "use asm" の有無で挙動が変わるプログラムが書けます:

// 通常の JavaScript としては -Infinity となるべきだが、 Chrome 61 では Infinity となる
1/(function(){"use asm";function f(){var x=0.;x=+-0;return +x;}return f;}()())

なお、リテラル以外で単項マイナスを使う場合は型が int ではなく intish となるので、マイナス0が漏れる問題はありません。

~~ 演算子(整数化)

仕様書, V8: (double) -> signed /\ (float?) -> signed

SpiderMonkey, Chakra: (double?) -> signed /\ (float?) -> signed

asm.jsで浮動小数点数(double/float)を整数に変換する手段として提供されているのが、 ~~ 演算子(~ を2回重ねたものを特別扱いする)です。

この ~~ 演算子は、仕様書では double または float? を受け取って signed を返すことになっています。しかし、 double ではなく double? を受け取るようにしても良いはずです。SpiderMonkeyとChakraでは実際そうなっています。

検証コード:

// ~~ accepts double? (validated: YES, rejected: NO)
(function(stdlib,foreign,heap){"use asm";var F64=new stdlib.Float64Array(heap);function f(){var x=0;F64[0]=2.;x=~~F64[0];return x|0;}return f;}(this,{},new ArrayBuffer(64*1024))());

+ 演算子(2項)

仕様書, V8: (double, double) -> double /\ (float?, float?) -> floatish (このほか、 int に関する規定あり)

SpiderMonkey, Chakra: (double?, double?) -> double /\ (float?, float?) -> floatish (このほか、 int に関する規定あり)

double の足し算について、仕様では double? を許可していません。しかし、入力を double? に緩めても問題ないです。2項 + 演算は文字列が絡むと挙動が変わるのでそれを警戒したのかもしれませんが、数値と undefined だけならその心配はありません。

https://github.com/dherman/asm.js/issues/90

検証コード:

// binary + accepts double? (validated: YES, rejected: NO)
(function(stdlib,foreign,heap){"use asm";var F64=new stdlib.Float64Array(heap);function f(){F64[0]=2.;F64[1]=40.;return +(F64[0]+F64[1]);}return f;}(this,{},new ArrayBuffer(64*1024))());

Math.max と Math.min

仕様書: (int,int...) -> signed /\ (double,double...) -> double

V8: (int,int...) -> signed /\ (double,double...) -> double /\ (float,float...) -> float

SpiderMonkey: (signed,signed...) -> signed /\ (double?,double?...) -> double /\ (float?,float?...) -> float

Chakra: (signed,signed...) -> signed /\ (double?,double?...) -> double

問題点1:数学関数はどうせ内部で引数を double に変換するので、 double じゃなくて double? を受け取るようにしても問題ないはずです。

実際、SpiderMonkeyとChakraではそのようになっています。(V8はそうなっていません)

// Math.max accepts double? (validated: YES, rejected: NO)
(function(stdlib,foreign,heap){"use asm";var max=stdlib.Math.max;var F64=new stdlib.Float64Array(heap);function f(){F64[0]=2.;F64[1]=40.;return +max(F64[0],F64[1]);}return f;}(this,{},new ArrayBuffer(64*1024))());

問題点2:仕様書には float を受け取って float を返すオーバーロードはありませんが、あえて禁止する理由はないはずです。

SpiderMonkeyでは float? を受け取りますが、V8は float しか受け取りません。

Chakraの数少ないSpiderMonkeyからの乖離がここで、Chakraは float を受け取る min/max を実装していません。意図的なものか、うっかり忘れたのかは不明です。

// Math.max accepts float (validated: YES, rejected: NO)
(function(stdlib,foreign,heap){"use asm";var fround=stdlib.Math.fround;var max=stdlib.Math.max;var F32=new stdlib.Float32Array(heap);function f(){F32[0]=2.;F32[1]=40.;return fround(max(fround(0.),fround(42.)));}return f;}(this,{},new ArrayBuffer(64*1024))());

// Math.max accepts float? (validated: YES, rejected: NO)
(function(stdlib,foreign,heap){"use asm";var fround=stdlib.Math.fround;var max=stdlib.Math.max;var F32=new stdlib.Float32Array(heap);function f(){F32[0]=2.;F32[1]=40.;return fround(max(F32[0],F32[1]));}return f;}(this,{},new ArrayBuffer(64*1024))());

問題点3:仕様書では int を受け取るとなっていますが、 int は符号が不定な32ビット値なので、大小関係にsensitiveなこれらの関数に直接渡してはいけません。仕様バグです。

https://discourse.wicg.io/t/math-min-max-int-signed/784

SpiderMonkeyとChakraでは、 signed を受け取るように修正されています。

一方、V8ではこの仕様バグをモロに踏んでいます。つまり "use asm" の有無で挙動が変わるコードが書けます(Chrome 61で確認)。

// 通常のJavaScript的にはどちらも 1 となるべきだが、 Chrome 61 ではどちらも 0 となる。
// SpiderMonkeyでは、asm.jsとしての検証に失敗し、通常のJavaScriptとして実行される。
(function(stdlib){"use asm";var max=stdlib.Math.max;function f(){var x=0;x=max(42,0xffffffff)!=42;return x|0;}return f;}(this)())
(function(stdlib){"use asm";var min=stdlib.Math.min;function f(){var x=0;x=min(0,0xffffffff)==0;return x|0;}return f;}(this)())

Math.abs

仕様書, V8: (signed) -> signed /\ (double?) -> double /\ (float?) -> float

SpiderMonkey, Chakra: (signed) -> unsigned /\ (double?) -> double /\ (float?) -> floatish

仕様書では Math.abs に signed を与えた時の返り値は signed となっていますが、SpiderMonkeyとChakraでは型強制が省略可能な関係で、 unsigned を返すようになっています。

一方、V8では型強制が省略可能なくせに結果の型が signed のままです。なぜ signed のままではいけないかについては、次のコードを見てください:

// 通常のJavaScript的には 1 となるべきだが、Chrome 61では 0 となる
(function(stdlib){"use asm";var abs=stdlib.Math.abs;function f(){var x=0;x=0<abs(-0x80000000);return x|0;}return f;}(this)())

仕様書では呼び出し側の型強制が必須なので、仕様バグというよりは、ただのV8のバグです。

Math.ceil / Math.floor / Math.sqrt

仕様書, V8: (double?) -> double /\ (float?) -> float

SpiderMonkey, Chakra: (double?) -> double /\ (float?) -> floatish

これらの関数は、仕様書では float に対して float を返すようになっていますが、呼び出し側の型強制が必須じゃなくなった関係で、 floatish を返すようになりました。

しかしChrome 61のV8はそうなっていないので、以下のコードがasm.jsとしてコンパイルされて、1.4142135381698608が帰ってきます:

// asm.js としてはエラーとなり、1.4142135623730951が返ってくるべき
(function(stdlib){"use asm";var fround=stdlib.Math.fround;var sqrt=stdlib.Math.sqrt;function f(){var a=fround(2.);a=sqrt(a);return a;}return f;}(this)())

仕様書では呼び出し側の型強制が必須なので、仕様バグというよりは、ただのV8のバグです。

ceil, floor, それから abs に関してはこの種の問題はなさそうな気がしますが、SpiderMonkeyとChakraではいずれも floatish が返るようになっています。

Math.imul

仕様書, V8: (int, int) -> signed

SpiderMonkey, Chakra: (intish, intish) -> signed

仕様書では int を受け取るようになっていますが、どうせ中でToInt32をかますので、 intish を受け取るようにしても問題無いはずです。

実際、SpiderMonkeyとChakraでは intish を受け取るようになっています。V8では int しか受け取りません。

// Math.imul accepts intish (validated: YES, rejected: NO)
(function(stdlib){"use asm";var imul=stdlib.Math.imul;function f(){var x=0;x=imul(1+1,1+2);return x|0;}return f;}(this)());

Math.clz32

SpiderMonkey, Chakra: (intish) -> fixnum

V8: (intish) -> signed

Math.clz32 は ECMAScript 6 (2015) に追加されたやつです。仕様書には載っていませんが、実装されています。

明らかに(imul, fround と同じく)低レベル向けの関数なのにasm.jsの仕様書にないということは、2014年8月以降に提案&実装されたのでしょうか。

引数はビット演算と同じく32ビット整数に変換されるので、 int ではなく intish です。

処理系によって結果の型が fixnum と signed で異なりますが、関数呼び出し時に |0 をつければ差は見えません。

// Math.clz32 accepts intish (validated: YES, rejected: NO)
(function(stdlib){"use asm";var clz32=stdlib.Math.clz32;function f(){var x=0;x=clz32(0xfffe+1);return x|0;}return f;}(this)());

// Math.clz32 returns fixnum (validated: YES, rejected: NO)
(function(stdlib){"use asm";var clz32=stdlib.Math.clz32;function f(){var x=0;x=(0>>>0)==clz32(0xfffe);x=(0|0)==clz32(0xffff);return x|0;}return f;}(this)());

変数に関する文法

const によるグローバル定数

"use asm" が書かれた関数(モジュール)のスコープでは var でグローバル変数を定義できますが、 const で定数を定義できるようにしよう、というやつです。

SpiderMonkeyでは型注釈の規則が変数の場合と少し異なり、 signed 型、 unsigned 型や fixnum 型の定数を定義できます。

https://discourse.wicg.io/t/allow-const-global-variables/684

https://github.com/dherman/asm.js/issues/77

// global constant (validated: YES, rejected: NO)
(function(stdlib){"use asm";const magic=42;function f(){var x=magic;return x|0;}return f;}(this)());

// global constant with exact type (validated: YES, rejected: NO)
(function(stdlib){"use asm";const magic=42;function f(){var x=magic;x=(0>>>0)==magic;x=(0|0)==magic;return x|0;}return f;}(this)());

comma区切りのグローバル変数

仕様書では var 1つで変数一個のように読めますが、1つのvar文にcomma区切りで複数のグローバル変数を許しても問題ないはずです。SpiderMonkey, Chakra, V8いずれもOKです。

https://github.com/dherman/asm.js/issues/63

// comma-separated global variable definitions (validated: YES, rejected: NO)
(function(stdlib){"use asm";var x=42,fround=stdlib.Math.fround;function f(){}return f;}(this)());

グローバル変数を float で初期化

単純に抜けたのだと思います。SpiderMonkey, Chakra, V8ではいずれも、グローバル変数を float (fround 呼び出し) で初期化できるようになっています。

// global float variable (validated: YES, rejected: NO)
(function(stdlib){"use asm";var fround=stdlib.Math.fround;var x=fround(42.);function f(){return fround(x);}return f;}(this)());

float 変数の初期化

変数の型注釈に使う fround の引数の数値リテラルが . を含む、となっていますが、この制限が撤廃されています。SpiderMonkey, Chakra, V8ではいずれもOKです。

https://discourse.wicg.io/t/numericliteral-and-fround/699

// float variable initializer without decimal sign (validated: YES, rejected: NO)
(function(stdlib){"use asm";var fround=stdlib.Math.fround;function f(){var y=fround(42);return fround(y);}return f;}(this)());

// global float variable initializer without decimal sign (validated: YES, rejected: NO)
(function(stdlib){"use asm";var fround=stdlib.Math.fround;var x=fround(42);function f(){return fround(x);}return f;}(this)());

ヒープに関するもの

リンク時のヒープに関する制約です。満たされなかった場合はリンクエラーとなります。

最小ヒープサイズ

仕様では4KiBですが、SpiderMonkeyとChakraは64KiBを要求するようになっています。V8は下限はなさそうです。

https://discourse.wicg.io/t/increase-minimum-heap-length-to-64kb/564

// minimum heap size (validated: <= 4KiB, link error: > 4KiB)
(function(stdlib,foreign,heap){"use asm";var F64=new stdlib.Float64Array(heap);function f(){F64[0]=2.;return +F64[0];}return f;}(this,{},new ArrayBuffer(4*1024))());

要求ヒープサイズ

SpiderMonkeyとChakraでは、ヒープに定数でアクセスすることにより、要求する最小のヒープサイズを明示することができます。V8は対応していません。

// heap size assertion with constant heap access (NaN: NO, link error: YES)
(function(stdlib,foreign,heap){"use asm";var F64=new stdlib.Float64Array(heap);function f(){F64[0x10000]=2.;return +F64[0x10000];}return f;}(this,{},new ArrayBuffer(64*1024))());

その他

仕様では return 時に型強制が必須とされていますが、簡単に調べた限りでは、SpiderMonkey, V8, Chakra いずれも、実際の型が double, signed, float のいずれかであれば問題ないようです。

他にも漁れば色々出てきそうですが、やっていたらキリがないのでこの辺でやめておきます。

まとめ

asm.jsはすでに枯れた技術のように思えますが、規格としては案外闇が多いです。それもこれも仕様書の更新が2014年で止まっていることに起因します。Mozillaいい加減にしろ