HTML5Experts.jp

Webブラウザで高速な演算を可能にする低水準言語asm.jsと、WebAssembly詳解ーasm.jsを高速に動作させる新しいコンパイラーターゲットWASMとは?

低水準言語asm.jsとWebAssembly詳解の第4回目です。前回までで、asm.jsの必要とされる理由と、その仕様、そしてasm.jsを生成するツールであるEmscriptenを紹介しました。CやC++で書かれたソースコードから「低水準」なJavaScriptコードが出力され、それが高速に動作する理由について述べてきました。

前回の最後で述べた通り、Emscriptenで出力されたasm.jsのファイルはサイズが大きくなりがちでした。ファイルのサイズが大きくなるとダウンロードに時間がかかるのはもちろん、ネイティブコードへのコンパイルにも大きく時間がかかってしまいます。つまり高速に動作するのは良いが、起動に時間がかかるサイトができてしまうことになります。

この問題を解決するために登場するのがWebAssembly(WASM)です。これはプログラミング言語ではなく、ファイルフォーマットの一種です。 同じプログラムをasm.jsとして出力したものと、WASMとして出力したものを比較すると、WASMの方がぐっと小さくなっていることがわかります。

プログラム コードのサイズ(asm.js) コードのサイズ(WebAssembly)
Hello World 301KiB 204KiB
AngryBots 19MiB 6MiB
StandardAssets 25.7MiB 13.4MiB
UnityChan-CRS 21.3MiB 11.4MiB

現在はその仕様策定が固まり、ブラウザへの実装も済み、リリースを待つばかりです。またコンパイラへの実装も進んでいます。特にBinaryen(後述します)への最適化処理の実装のおかげで、asm.jsより高速に実行できるようになりました。

下記のグラフ(MozillaのHacksブログより引用)はasm.jsとWASMの速度を比較したものです。値はネイティブの実行スピードに対する相対的な速度を表していて、1に近づけは近づくほどネイティブに近いスピードで動いていることを表しています。Bulletを除く全てのベンチマークでasm.jsより高速に動いています。これはコンパイラの行う最適化処理のおかげです。

この評価は2016年10月末の時点に、Intel Core i7-2600 @ 3.40GHzで動作するLinux版 64-bit Firefox 52 (Nightly)で計測されました。現在は最適化処理の更なる修正によって、Bulletでもasm.jsを上回るパフォーマンスが出るようになっています。

連載の最後は、リリースが間近のWASMについて、コンパイル方法、仕様、そしてJavaScript APIについてご紹介します。

WebAssemblyとは

WebAssemblyとはプログラムを表すファイルフォーマットの一種で、拡張子には.wasmを利用することが一般的です。 Webで扱うファイルの大半はテキスト形式ですが、WASMはバイナリ形式でプログラムを表現します。 バイナリエディタを用いてWASMファイルを作成することも、もちろん可能ですが、Emscriptenに代表されるコンパイラを使って出力することが一般的です。

仕様はW3CのコミュニティグループのメーリングリストやGitHubで議論が行われています。議論の指針は次の4つです。

このコミュニティグループには、Google、Microsoft、Apple、そしてMozillaの各ブラウザベンダが参加しています。実装もそれぞれに進んでおり、Firefoxでは 52で、Chromeでは57でそれぞれ標準サポートされました。EdgeとWebkitは開発の途中です。Edgeの開発版でAngryBotsが動作している様子は、こちらの動画で確認できますし、Webkitの状況はこちらで確認できます

WASMの想定される利用例は?

現在のところ想定されているWASMの利用方法は次の3パターンです:

  1. アプリ全てをWASMで実装する:C/C++で書かれたアプリをWebに移植するための手段
  2. WASMでほとんどを実装し、UIなどをWeb技術で実装する
  3. Webアプリケーションのうち、速度が必要な部分をWASMで実装する

1はWebを新しいアーキテクチャとみなし、C/C++のソースコードをクロスコンパイルする、というイメージです。この典型例はゲームでしょう。iOSやAndroid、PS4、Xbox One、PCなど様々あるゲームプラットフォームにWebが加わり、いくつかのプラットフォームへゲームをリリースするのと同じようにWeb向けにゲームをリリースする、というイメージです。

2は既に持っているCやC++の資産を活かしつつ、リッチなUI/UXを提供するといった使い方です。例えば画像認識エンジンや、動画像処理エンジンをすでに持っていて、これらをサービス化したい、といった場合です。それらのエンジンをWASMに変換した上で、UXやUIはWebの技術や開発手法を利用して提供することで、お互いの良いところを取ることが可能です。

3は暗号や圧縮・展開、画像処理といった重たい処理の高速化にWASMを利用する、といった使い方です。asm.jsがよく使われてきた用法でもあります。サーバサイドであれば、Rubyが遅くなってきたのでRustで書き換える、Pythonでは辛いからGoで、といったことがよく行われてきました。フロントエンドではそのようなことは難しかったのですが、WASMを使うことで、アルゴリズムの改良などへ踏み込まずに高速化を行えるようになります。

EmscriptenでWebAssemblyを出力するには

テキスト形式のプログラムからWebAssemblyを変換して出力する方法は、概ね次の3つになります:

  1. C/C++で書かれたソースコードをEmscripntenで処理して出力する
  2. Rustで書かれたソースコードをコンパイルして出力する
  3. WAST(WASMのテキスト形式)で書き、それを変換して出力する

現在のところは1が最も事例の多い変換方法でしょう。Emscripten自身のインストール方法は前回の記事をご覧いただくとして、ここではWASMを出力できるように設定を行い、Hello,Worldをコンパイルするところまでを説明します。

EmscripntenでWASMを出力するためには、開発版とBinaryenというツールが必要です。どちらもemsdkを利用してインストールできます。

$ emsdk install sdk-incoming-64bit emscripten-incoming-64bit clang-incoming-64bit
$ emsdk activate sdk-incoming-64bit emscripten-incoming-64bit clang-incoming-64bit

アクティベート後は忘れずに環境変数を更新しておきます:

$ source ./emsdk_evn.sh

では次のHello,WorldをWASMに出力します。 まず次のようなC言語で書かれたソースコードを用意します。

#include <stdio.h>

int main(int argc, char **argv){ printf("Hello, World!\n"); }

このコードをEmscriptenを使ってコンパイルします。

$ emcc -o hello-world.html -s WASM=1 hello-world.c

この 2 つがポイントです。特に-s WASM=1をつけないと、asm.jsが出力される点にご注意ください。

出力されたWASMファイルが動作しているかどうかは、出力されたHTMLファイルをブラウザで表示させることで確認できます。asm.jsなどはファイルを直接開いて実行を確認できましたが、WASMでは行えません。これはWASMの実行時にはクロスオリジン要求が必要で、それがfileスキームには実装されていないためです。そのため簡易なWebサーバを起動する必要があります。任意のものをお使いいただけますが、Emscriptenに同梱されているemrunコマンドを利用すると設定やインストールをしなくても簡易なWebサーバを起動できます。

$ emrun emrun --no_browser --port 8080 .

上記のコマンドを実行すると、コマンドを実行したフォルダをドキュメントルートとしたWebサーバが、8080番ポートで実行されます。こちらへアクセスすることで、動作を確認できます。

WASMモジュールの構造

実はWASMのMVP(Minimal Viable Product/最低限の目標)はasm.jsとの互換機能の提供でした。つまりWASMはasm.jsでできることはできると思っておくと、概ね間違いはありません。例えばasm.jsファイルはソフトウェアモジュールを定義していましたが、同様にWASMファイルもソフトウェアのモジュールを定義します。

とはいえディテールは大きく異なります。asm.jsは(低水準とはいえ)JavaScriptとして処理できる形式でモジュールとその関数本体を定義していたのに対し、WASMは仮想的なハードウェアで動く低水準な命令の集まりとしてモジュールを定義します。WASMはその仕様で型付けされたスタックマシンを定義しており、WASMファイルにはそのスタックマシンで動作するモジュールの抽象構文木(AST:Abstruct Syntax Tree)が定義されています。 抽象構文木がバイナリ形式で与えられているおかげで、ネイティブコードを出力するために必要な処理の中で重たい字句解析の全てと、構文解析の大半を省くことができます。

さてWASMファイルはプリアンブルと、モジュール定義の2つの部分から成っています。プリアンブルはWASMファイルであることを表すマジックナンバー(32bit)と、バージョン番号(32bit)の2つのフィールドで構成されます。モジュール定義は次の7種類のセクションを持ち得ます。依存関係はありますが、これらの全てを省略することもできます。

セクション名 説明
Type 関数のシグネチャ
Import インポートするシンボルの定義
Function 関数とそのシグネチャの対応付け
Table 関数表など
Memory メモリ領域に関する設定
Global グローバル変数
Export エキスポートするシンボル
Start 処理を始める関数(エントリーポイント)の定義
Element 表の要素
Code コードセグメント。関数本体
Data データセグメント

asm.js同様、WASMでも関数は全て型付けされます。その型が列挙されているのがtypeセクションです。関数の型は引数の型と返り値の型を組み合わせて定義します。例えば次のような足し算を行う関数は(i32, i32) -&gt; i32のように型付けされます。

int add(int a, int b){
  return a + b;
}

このような関数の型のことを「シグネチャ」と呼びます。シグネチャには、それぞれidが割り振られます。このidを用いて、個々の関数を型付けしていきます。個々の関数と、その関数のシグネチャの対応づけが行われるのがfunctionセクションです。上記の(i32, i32) -&gt; i32というシグネチャのidが0の時、次の2つの関数は0番目の関数(addのこと)のシグネチャは0、1番目の関数(subのこと)のシグネチャは1、というようにfunctionセクションへ記述されます。

int add(int a, int b){
  return a + b;
}
int sub(int a, int b){
  return a - b;
}

それぞれの関数の本体、つまり関数定義の{}に挟まれた部分は、codeセクションに記述されます。このセクションに記述される内容については節を分けて説明します。

上述したように、WASMはモジュールを定義します。つまりWASM内で定義された関数のうちいくつかは外部へと露出します。逆に外部で定義された関数を読み込んで関数を定義することもあります。露出する関数や読み込まれる関数は、それぞれexportとimportのセクションに記述されます。

Memoryセクションには、このモジュールで利用するヒープ領域の初期サイズが設定されます。WASMのメモリは後述しますが線形で、最大4GiBまで利用できます。このセクションで定義されるメモリサイズはあくまで初期化時に割り当てられるもので、grow_memory命令でより多くのメモリを確保できます。

これら以外のセクションもモジュールの定義に必要とされることがありますが、今回は説明を割愛します。詳しくはこちらのデザイン文書をご覧ください。

WASMで定義される抽象構文木

ここまで述べてきたWASMの特徴をまとめると、次の3点になるでしょう。

ここからはより詳しくWASMの構造と、それが表す抽象構文木について見ていきます。

そのために WASMのテキスト表現であるWASTを利用します。このWASTは現在仕様が議論中のため、将来的に記法が変わる可能性があることにご注意ください。

さてa = 1 + 2 * 3;という式は、WASTでは次のように表現されます。

(set_local $a
  (i32.add
     (i32.const 1)
     (i32.mul
        (i32.const 1)
        (i32.const 2)
     )
  )
)

このようにとても括弧が多いのがWASTの特徴です。この記法はS式と呼ばれるもので、LISPというプログラミング言語でよく用いられるもので、木やリストの形式的な記法です。()で囲まれたものがノードとなり、その間に挟まれた別のノードは子ノードとなります。先ほどのWASTは次の図のような木構造を表しています。

set_locali32.addi32.muli32.constというのが、WASMの仕様でで定義されているスタックマシンの命令です。それぞれローカル変数への代入、加算、乗算、即値の定義を行います。四則演算や速値の定義を行う演算子は、扱うデータ型によって異なるものが用意されています。関数本体はこのように記述され、codeセクションに格納されます。

先ほどのWASTを使って、引数に1+2*3の結果を足す関数は次のように記述できます。詳しくは説明しませんが、なんとなく雰囲気は伝わってくるかと思います。

(module
  (func $addSome (param i32) (result i32)
    (local $a i32)
    (set_local $a
       (i32.add
          (i32.const 1)
          (i32.mul
            (i32.const 1)
            (i32.const 2))))
    (get_local 0)
    (get_local $a)
    (i32.add))
  (export "addSome" (func $addSome))
)

なお、このWASTはスタックマシンの性質をうまく使えていません。その特徴をうまく使うと、次のようになります。

(module
  (type (;0;) (func (param i32) (result i32)))
  (func (;0;) (type 0) (param i32) (result i32)
    (local i32)
    i32.const 1
    i32.const 1
    i32.const 2
    i32.mul
    i32.add
    set_local 1
    get_local 0
    get_local 1
    i32.add)
  (export "addSome" (func 0)))

またコンパイラで最適化処理を行うと、次のように単純化されます。1+2*3の結果をコンパイル時に計算を済ませてしまって定数とすることで、不必要な計算を省いています。

(module
  (type (;0;) (func (param i32) (result i32)))
  (func (;0;) (type 0) (param i32) (result i32)
    get_local 0
    i32.const 7
    i32.add)
  (export "addSome" (func 0)))

WASMの構造を把握するには、The WASM Explorerというサイトを利用されるといいでしょう。 このサイトでは、C/C++のプログラムとWASM、Firefoxによって変換されるx86のアセンブラとを比較できます。 Emscriptenで出力されたものと比べると、標準ライブラリで定義されるシンボルや関数が含まれないためコンパクトで、理解しやすいものが出力されます。

WASMで扱えるデータ型

WASMで扱えるデータ型は次の4つです。

型名 意味 サイズ 符号化方式
i32 整数型 32bit 2の補数
i64 整数型 64bit 2の補数
f32 浮動小数点型 32bit IEEE 754-2008
f64 浮動小数点型 64bit IEEE 754-2008

整数型と浮動小数点型とを区別して扱える点が、通常のJavaScriptとは異なります。また64bitの整数が扱える点もJavaScriptやasm.jsとも異なっています。

それぞれのデータ型に対して、四則演算、比較演算、即値、データのロードとストアなどの演算子が用意されています。また整数型に対してはビット演算子が、浮動小数型に対しては切り捨て、切り上げなどの演算子が用意されています。

また型間のデータ変換のための演算子も用意されています。32bitから64bitへの変換だけでなく、64bitから32bitへの変換も行えます。もちろん整数から小数へ、小数から整数への変換も行えます。

線形メモリ

WASMの命令セットはスタックマシン型として設計されていますが、メモリに対するアクセスも行えます。WASMのメモリモデルには次の特徴があります:

メモリスタックへのデータの読み込みは各データ型の持つload演算を使って行います。WASMはC/C++コードから生成されることを念頭に置いているため、load演算には、i32.load_8_si32.load_16_uのようなWASMにはない8bitや16bitのintをロードするものがあります。これらの命令はメモリ上では8bitや16bitの大きさを持つ値を、自動的に32bitへと拡張します。

同様にメモリへのデータ書き込みはi32.storeのようなストア演算で行います。ストア演算も各データ型ごとに用意されており、整数型に対してはi32.store8i32.store16のように一部分だけを保存する演算も用意されています。

上述したようにWASMのメモリは線形で、0から順にアドレスが付いています。ロード演算やストア演算はアドレスを使って、読み書きするメモリ上の位置を指定します。現在のところアドレスは32bitの符号なし整数で表現されます。そのため利用できるメモリの大きさは4GiBに制限されます(この制限はのちに解消される予定です)。

メモリは常に4GiB確保されるわけではありません。初期化時に確保される量はモジュールで定義します。プログラムの中でgrow_memory演算を行うことで、このサイズを増やせます。また現在のメモリサイズはcurrent_memory演算で取得できます。これら2つの演算はメモリをバイト単位ではなく、ページ単位で扱います。ページサイズは64KiBのため、実際のサイズはページ数に65536(16 × 1024)をかけた値になります。

JavaScript API

ここまでWASM自体について説明してきましたが、最後にJavaScriptとの連携について説明します。今の所、WASMはJavaScript APIを利用して、明示的にコンパイルし、インスタンス化する必要があります。手順としては次の通りになります:

  1. WASMファイルをダウンロードし、TypedArrayに変換
  2. WASMモジュールがインポートする関数を用意する
  3. 1と2を利用して、WASMモジュールをインスタンス化する

この手順をコード化すると次のようになります。下記の例では、上述したsample.wasmをインスタンス化し、そのモジュールに定義されているaddSomeを呼び出しています。

const importObject = {
  hello: () => console.log("Hello"),
  world: () => console.log("world")
};

fetch("sample.wasm").then(response => response.arrayBuffer()) .then(buffer => WebAssembly.instantiate(buffer, importObject)) .then(({module, instance}) => console.log(instance.exports.addSome(1)));

WebAssembly.instantiateが、モジュールのインスタンス化を行う関数になります。第1引数にWASMファイルがロードされたArrayBuffer演算を、第2引数にはモジュールがインポートする関数や値をまとめたオブジェクトを指定します。上の例ではhelloworldという2つの関数がsample.wasm内にインポートされます(実際にインポートされるかどうかは、importセクションの記述によります)。

インスタンス化されたされたWASMモジュールは、JavaScriptのモジュールのように扱えます。変数へ代入することもできますが、属性を追加することはできません。モジュールインスタンスはexportsという名前の属性を持っており、その値のオブジェクトのメンバーとしてエキスポートされた関数を参照できます。

なおWebAssembly.instantiateの呼び出し時には、ダウンロードしたWASMモジュールの妥当性や、ファイルフォーマットの検証が行われます。これらに失敗した場合は、WebAssembly.CompileErrorが送出されます。これをcatchすることでコンパイルエラーを検出できます。

まとめ

これまで4回にわたってポータビリティや後方互換性との両立を図りつつ、ネイティブに近いスピードでの動作を実現するために行わている様々な工夫の一端を見てきました。

asm.jsとWebAssemblyによってネイティブに近い動作スピードは実現されつつあり、それを作成するツールもEmscriptenを筆頭に充実しつつあります。いずれフロント部分のほとんどがCやC++で実装されたWebアプリも登場するでしょう。

ではJavaScriptの役割は終わってしまったのでしょうか。

そんなことはありません。まずWebアプリの大半はasm.js/WebAssemblyで提供される計算性能を必要としていません。必要したとしても、大半のコードの価値はスピードよりもUXによって提供されるでしょう。JavaScriptの大きな特徴は、その生産性の高さです。高速に動作することよりも、高速に開発のイテレーションをまわ回して行く方が重要とされる局面は多くあるでしょう。そんな時にJavaScriptの生産性の高さが生きるでしょう。

ただasm.js/WASMがWebに大きな可能性を加えるものになるのは確かです。既存のWeb開発では難しかったインタラクションの多い3Dグラフィクスを扱うアプリも、UnityやUnrealEngine、Stingrayといったasm.js/WebAssemblyに対応したゲームエンジンを利用することで、効率的に開発できるようになります。工数の関係で諦めていたような表現やアプリの提案も、これらのツールを採用することで可能になる場合もあるでしょう。この連載で扱ってきた技術が、みなさまのWeb開発の選択肢に加わり、その結果Webが豊かになれば幸いです。