HTML5Experts.jp

Webブラウザで高速な演算を可能にする低水準言語asm.jsと、WebAssembly詳解ーC / C++をasm.jsに変換するツールEmscripten

間が随分と空いてしまいましたが、低水準言語とasm.jsとWebAssembly詳解の第3回目です。

前回は、型システムを中心にasm.jsを解説しました。asm.jsはプログラムの中に型が明示されるため、事前コンパイルをして高速に動作させられる点がasm.jsの大きな特徴でした。

しかし数値演算しか行えなえず、また式にすべて型アノテーションを行わなくてはならないため、通常のJavaScriptのプログラムのように手で書くのはなかなかに辛いものがあることもわかりました。

これはasm.jsで書かれたプログラムは、JavaScriptとしても実行可能であることが求められたためでもありました。そこでasm.jsよりも書きやすい言語で書かれたプログラムを、asm.jsに変換するツールが提案されています。

そのもっともよく使われている例であるEmscriptenについて、インストール方法や簡単な使い方、ファイルアクセス、そしてJavaScriptとの相互連携までを解説します。

トランスコンパイラ Emscripten

Emscriptenはオープンソースのトランスコンパイラで、コンパイラフレームワークであるLLVMを利用して実装されています。これを利用することで、C言語やC++で書かれたプログラムをasm.jsに変換できます。

たとえば次のCで書かれたHello,worldもEmscriptenを使うと、asm.jsに変換され、Node.jsやWebブラウザで実行できます。

#include <stdio.h>
int main(int argc, char** argv){
  printf("Hello, world!\n");
  return 0;
}

変換に利用するのがEmscriptenのフロントエンドであるemccコマンドです。これをgccのように使って、.cファイルをコンパイルします。次の例では、上記のHello,worldをasm.jsに変換し、Node.jsで実行しています。

3行目に出力されている”Hello,world!”は、Node.jsが出力しています。Cで書かれたプログラムが、Node.jsの上で動作する、というのはなかなか味わい深いものがありますね。

$ emcc -o hello.js hello.c
$ node hello.js
Hello, world!

Emscriptenは、自分の書いたプログラムだけでなく、libcやlibc++といった標準ライブラリも一緒に変換します。

上記の例ではCの標準関数も一緒に変換が行われているため、printfが正しく実行されています。

またOpenGLの関数呼び出しはWebGLへ変換されます。SDLの関数も利用できるため、次のようなSDLを用いたプログラムも変換できます。

これらはCanvas要素をディスプレイ代わりに利用するため、変換時にJavaScriptだけでなくHTMLも合わせて出力させることで実行できます。

$ emcc -o hello-world-sdl.html hello-world-sdl.cpp 

Emscriptenのインストール

Emscriptenはemsdkというツールを利用してインストールします。このツールは単なるインストーラではなく、nvmrustuprvmのようなツールで、使用するEmscriptenのバージョン指定や更新を行えます。

emsdkとEmscriptenのインストールは配布元のサイトで確認いただけますが、概要を述べると次のようになります。

  1. 依存するツールのインストール:GCC/Git/Cmake/Python 2.7.x 2.emsdkのクローンと更新
  2. 最新版のEmscriptenのインストール
  3. インストールしたEmscriptenの有効化

emsdkはGitHubで配布されています。また内部の処理にGitを利用します。またEmscriptenのインストールや更新時にclangのコンパイルを行います。cmakeやgccはそのコンパイルに利用されます。

環境によってインストール方法は異なります。それぞれの環境にあった方法でインストールしてください。Homebrewを利用できるようになっているmacOSの場合、GCC/Git/Pythonはすでにインストールされています。CMakeは次のようにbrewコマンドを使ってインストールできます。

$ brew install cmake # cmake のインストール

依存するツールをインストールしたらemsdkをクローンします。

$ git clone https://github.com/juj/emsdk.git
$ cd emsdk

emsdkフォルダ内にある、emsdkというファイルを使って、Emscriptenの管理を行います。

まずupdateコマンドを実行して、emsdkそのものと、インストール可能なEmscriptenのバージョンリストを最新のものへと更新します。次にinstallコマンドを実行して、最新の安定板をインストールします。

$ ./emsdk update
$ ./emsdk install latest

インストールしただけでは、Emscriptenを利用できるようにはなりません。activateコマンドで利用するEmscriptenのバージョンを指定し、環境変数を設定することで、インストールした最新バージョンのEmscriptenが利用できるようになります。

$ ./emsdk activate latest
$ source ./emsdk_env.sh

Hello, world!

asm.jsへのコンパイルはemccコマンドを利用して行います。まずは正しくインストールできたかどうかを確認するために、バージョン番号を表示させてみましょう。

$ emcc -v

バージョン番号が表示されれば正しくインストールできています。次はHello,worldでしょう。次のようなプログラムを作成し、hello.cという名前で保存します。

#include <stdio.h>

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

プログラムを作成したら、つぎのコマンドを実行します。

$ emcc hello.c

a.out.jsというファイルが作成されます。これが上記のプログラムがEmscriptenによって変換された結果です。nodeで実行させてみましょう。

ターミナルに”Hello,world!”と出力されるはずです。

$ node a.out.js

emcc コマンド

emccコマンドには、いくつものオプションがあります。それらのうち、よく使うものをまとめると次の表になります。

オプション 効果
-o &lt;file&gt; 出力するファイル名を指定する
-O0 最適化を全く行わない
-O1 LLVMの-O0最適化や、アサーションの削除といった最適化が行われる
-O2 -O1に加えて、出力するJavaScriptの最適化も行う
-O3 -O2に加えて、さらなる最適化をJavaScriptに対して行う。遅い
-g デバッグ情報を保持する
--preload-file &lt;path&gt; プログラム中で読み書きするファイルの場所を指定する
--embed-file &lt;path&gt; プログラムに埋め込むファイルの場所を指定する

-oオプションを使うと出力するファイル名を指定だけでなく、ターゲットの変更もできます。 例えば.htmlで終わるファイル名を指定することで、HTMLを出力できます。

$ emcc -o hello.html hello.c
$ ls
hello.c     hello.html  hello.js

出力されたHTMLファイルをブラウザで表示させると、次のように”Hello,world!”と出力されていることが確認できます。

ファイルも扱えます

Node.js同様、C/C++のプログラムはファイルに対する操作が行えます。C/C++の標準的なライブラリで提供されている機能を利用してreadme.txtというファイルを読み込むプログラム実装すると、次のようになります。

#include

int main(int argc, char** argv){ FILE *file = fopen("readme.txt", "r"); while(!feof(file)){ char c = fgetc(file); if(c == EOF){ break; } putchar(c); } fclose(file); return 0; }

このコードの中のfopen,feof,fgetc,fcloseがファイルアクセスに関する関数になります。これらの関数の呼び出しは同期的に行われます。このコードもEmscriptenで変換できますし、正しく動作します。

そもそもブラウザ内で動作するJSはファイルにアクセスできません。

また、このコードはファイルアクセスが同期的に処理されているため、そのまま変換を行うとアクセスが終わるまで、ブラウザの処理が止まってしまいます。下図のような仮想的なファイルシステムを提供することで、Emscriptenはこれらの問題を解決しています。

まずSynchronous File System APIによって、同期的な関数呼び出しを非同期的なものに変換しています。

このAPIはファイルシステムの実装も隠蔽しています。通常はメモリ上に構築されるファイルシステムを利用しますが、コンパイル時のオプションによってNode.jsのファイルシステムや、IndexedDBを利用したものを利用できます。この切り替えを行ったとしても、コードを変更する必要はありません。

Emscriptenの仮想ファイルシステムはPOSIXのファイルシステムと同様に、デバイスファイルの作成や、ファイルシステムのマウントなどもサポートしています。

これらの機能はFileSystem API 経由で利用できます。標準入出力も自作のオブジェクトに置き換えられますので、なかなかに工夫しがいのあるかと思います。

アセットを追加するには

上記の例で利用したreadme.txtのようなアセットを利用するためには、Emscriptenによって提供される仮想ファイルシステムにアセットのファイルを追加する必要があります。

追加方法には、次の2つがあります。

ファイルサイズの大きいものや更新が頻繁なものは後者で、そうでないものは前者の方法を利用するとよいでしょう。

前者は--embed-fileオプションを、後者は--preload-fileオプションをつけてemccを実行することで、利用できます。

例えば先ほど紹介したプログラムはreadme.txtというファイルを読み込み、その内容を標準出力へと出力します。これを後者の方法で変換するには、次のようにemccコマンドを実行します。

$ emcc -o read-file.html --preload-file readme.txt read-file.c

実行すると、HTMLファイルやJSファイル以外に.dataファイルが作成されます。

これはアセットを一つにまとめたものです。このファイルを非同期に読み込み、メモリ上に配置することで、Emscriptenはアセットのロードを実現させています。

文章はWikiPedia のEmscriptenの項目 より引用

インライン JavaScript

C/C++には、インラインアセンブラという機能があります。これはアセンブラで書かれたコードをソースコードの中に埋め込んでしまえる、というもので、高速化やシステムコールの実現などのために利用されます。

同様にEmscriptenでは、ソースコードの中にJavaScriptを埋め込めます。JavaScriptを埋め込むことで、C/C++のコードからブラウザの機能を利用できるようになります。

埋め込むためにはEM_ASM()と呼ばれるマクロを使います。

alert関数を呼び出すコードは、次のように書けます。

#include

int main(){ EM_ASM( alert("Hello, world!"); ); return 0; }

これをemccでコンパイルして、実行した結果は次のようになります。

EM_ASM_を利用すれば、パラメーターを与えることもできます。EM_ASMとの違いは、埋め込むJSのコードがブロックになっている点と、マクロの名前です。パラメータを与えるマクロは、末尾に_がついています。間違えがちなので注意が必要です。

#include

int main(){ EM_ASM_({ alert(Hello, world: ${$0}); }, 100); return 0; }

与えられたパラメータは、$0,$1のように参照できます。上記の例では、与えられたパラメータをテンプレート文字列内で利用しています。

これらのマクロはemscripten.hに定義されています。詳しくはドキュメントを参照してください。

外部関数としてJavaScript関数を呼び出す

マクロとしてJavaScriptを埋め込む以外に、C/C++からJavaScriptの関数を呼び出せます。それは外部関数として関数を宣言しておき、Emscriptenの--js-libraryオプションで実装を与える方法です。

次のコードでは、zeroという関数を呼び出しています。この関数は外部の関数として宣言されています。、コンパイラは名前、引数の数と型、返り値の型以外わかっていません。

extern int zero();

int one(){ return zero() + 1; }

通常のC/C++では、C/C++のコード(時にはコンパイル済みのバイナリ)として、この関数の実装は与えられますが、Emscriptenでは、これらに加えてJavaScriptでその実装を与えることもできます。

次のようにJavaScriptでzeroを実装し、LibraryManagerオブジェクトのlibrary属性にマージしてやることで、C/C++のコードから呼び出せるようになります。

function zero(){
  return 0;
}

mergeInto(LibraryManager.library, { zero: zero });

JavaScriptから変換されたコード利用するには

基本的な機能はC/C++で実装済みで、それを操作するUIをWebで提供したい。そんな場合は、Emscriptenで変換されたC/C++のコードを、JSから呼び出すかたちで実装するとよいでしょう。

まず基本であるCで定義された関数をJSから呼び出してみましょう。次のような足し算を行う関数が、add.cというファイルに定義されていたとしましょう。

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

これをemccコマンドでJSに変換します。

% emcc -o add.js -s "EXPORTED_FUNCTIONS=['_add']" add.c

-sオプションを使うと、コマンドラインオプションでは指定できない内部的なオプションを指定できます。

これを利用してJSへエキスポートする関数のリストであるEXPORTED_FUNCTIONSを設定しています。このリストに載せる関数は、その名前の先頭に_をつけなければならないことに注意してください。

またC++の場合は、関数がマングリングされるの防ぐために、次のようにCのプログラムとして記述する必要があります。

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

生成されたadd.jsをロードした後、次のようにJS側でasm.jsの関数をラップしたJavaScriptの関数を作成します。

const add = Module.cwrap("add", "number", ["number"]);

Moduleオブジェクトは、Emscriptenによって自動的に定義されるオブジェクトで、生成されたコードが実行されるときに、さまざまな形で利用されます。そのcwrapメソッドを使って、asm.jsの関数をJavaScriptから利用できるようにします。そのメソッドの引数はそれぞれ、ラップする関数の名前、返り値の型、引数の型を表します。

一度ラップされてしまえば、JavaScriptの関数と区別することなく利用できます。

const sum = add(1, 2); // 3
console.log(sum);

C++で書かれたコードに対しては、上記の方法に加えて、C++で定義されたクラスをJavaScriptから利用できるようになります。これの詳細については述べませんが、手順を概観すると次のようになります:

上記の手順で処理することによって、C++で定義されたクラスを作成するコンストラクタが、Moduleオブジェクトの属性として参照できるようになります。

詳しくはこちらのドキュメントを参照してください。

まとめ

手で書くのが大変なasm.jsも、CやC++で書いたコードをEmscriptenで変換することで簡単に生成できます。このとき標準ライブラリの変換も合わせて行われます。

またファイルシステムもEmscriptenによってエミュレートされるため、asm.jsへの変換を行う際にソースコードを変更する必要はほとんどありません。C/C++のコードをほとんど変えなくても、asm.jsへと変換できます。

またJavaScriptと変換されたコードの相互呼び出しも可能な上に、標準入出力もJSでエミューレートできます。そのおかげで、Emscriptenで作られたasm.jsコードと、JavaScriptで作られたコードとを柔軟に組み合わせてサイトやアプリを作ることができます。

これでめでたしめでたし、とならないのが技術の世界のつらいところです。Emscriptenによって作られたコードにも弱点があります。それはロードにかかる時間です。単なるHello Worldであっても、変換されたコードは300Kバイト程度になります。これは合わせて変換された標準ライブラリが、コードに含まれているためです。

コードのサイズは、Hello Worldなので300K程度で済んでいるともいえます。これがゲームともなれば、そのサイズはさらに増大します。コードサイズの大きさは、ダウンロードにかかる時間が大きいことも意味しますし、ダウンロード後、起動までの時間が長くなることも意味します。

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

この問題を解決するために、新しいファイルフォーマットの議論が始まりました。それがWebAssemblyです。

上の表をご覧いただければ、WebAssemblyを利用することでファイルサイズがぐっと小さくなっていることがわかるかと思います。最終回となる次回はWebAssemblyの役割と、その特徴について解説します。