[C++/WASM] Emscriptenの使い方メモ

こちらの記事でちょっと書きましたが、久しぶりにEmscriptenを利用してちょっとしたツールを作ったので、そのときにハマったポイントをメモしておきます。一歩目がなかなか難しくビルドが通りませんでしたが、通ってしまえばあとは特に問題は起こりませんでした。何年か前にも使いましたがその頃に比べてだいぶ楽になっているとは思います。

環境はMacなのでWindowsだとまた違ったやり方になりそうな気がします。

コードの書き方

当然ですが普通にC/C++が使えます。一部API(マルチスレッドとか)はサポートされていませんが、基本的には普通のC/C++です。コンパイラそのものやビルドオプションをいじることで、WebAssembly向けのバイナリを生成します。

リンカに渡すオプション

ビルド時に指定するリンカオプションを以下にまとめます。

-s AUTO_NATIVE_LIBRARIES=0

なんかごちゃごちゃと色んなものがリンクされるっぽいので、これを指定して不要なライブラリのリンクを防ぎます。

-s WASM=1

wasmファイルを出力させます。多分指定不要(今はデフォルトが1のはず)だと思いますが一応。WASM=0にするとasm.jsとして出力されるのでWebAssembly非対応ブラウザ用のコードを吐くこともできます。

-s ENVIRONMENT=web

グルーコードの対象プラットフォームをブラウザにします。他にnodeshellworkerが指定できるっぽいです。このオプションをwebにしないと、fspathなどの不要なモジュール読み込みコードが出力され実行時エラーになります。

-s "EXPORTED_FUNCTIONS=['_malloc','_free']"

_malloc_freeをJavaScript側から呼び出せるようにします。バイナリサイズが増えるので、メモリの操作を行わない場合は指定しないほうがいいでしょう。最適化オフだとこれがなくても動くらしい。謎

-s ALLOW_MEMORY_GROWTH=1

メモリを自動で伸長させます。これを指定しないと、予め確保されたメモリ(デフォルトで16MB)を使い切ってしまったときに例外が飛びます。

-s MODULARIZE=1

グルーコードをモジュール化します。通常Moduleはオブジェクトとして出力されますが、MODULARIZE=1を指定すると関数として出力されます。オブジェクトとして出力された場合、JSファイルを読み込んだ際にWASMファイルも同時に読み込まれますが、関数として出力された場合は好きなタイミングでWASMファイルを読み込むことができます。必須ではないですが後者のほうが都合がいいことが多いと思うので必要に応じて指定しましょう。

-s NO_FILESYSTEM=1

ファイルシステム関連のAPIの出力を省きます。使うことはないと思うので指定しておきましょう。

-s EXPORT_ES6=1

グルーコードをES6(ES2015)で出力します。WebAssemblyに対応しているブラウザはES6にももちろん対応しているのでこのオプションを指定しましょう。出力サイズが小さくなります。

--no-entry

main関数を使用しない場合に指定します。

-flto

emscriptenではなくllvmのオプションです。リリースビルド時に指定します。LTO(Link Time Optimization)を有効にすることで、オブジェクトファイルをまたいだ最適化が可能になります。不要な関数の削除やインライン展開などが積極的に行われるようになるのでパフォーマンスが向上します。

--closure 1

Google Closure Compilerを使用してグルーコードを圧縮します。別途Google Closure Compilerを用意する必要がありますが、出力されるJSファイルが格段に小さくなるので導入をおすすめします。

CMakeを使ったビルド

普通にCMakeを叩くのではなく、emcmakeコマンドを使って設定ファイルを生成します。

// 先頭にemcmakeを入れる。あとは同じ
emcmake cmake ../
make

これでwasmファイルと、それを呼び出すためのjsファイルが出力されます。簡単ですね。

CMakeでのハマりポイント

ビルドにはCMakeを利用していますが、非常に扱いづらいビルドシステムなのでやっぱり一歩目が辛い。「プログラムは思い通りに動かない。書いたとおりに動く」という格言(?)がありますか、CMakeの場合「CMakeは思い通りに動かないし、書いたとおりにも動かない」です。つらすぎ。

具体的にどこがハマるかというと、上記のオプションをリンカに渡す部分です。target_link_optionsで指定するやつですね。実はCMakeさんには共通のオプションを勝手にまとめるというとんでもない迷惑仕様が存在します。したがって上記オプションの-sが一つにまとめられてしまい、ビルドが通らなくなります。

仕様がイマイチなら解決策もイマイチで、なんとオプションを文字列化して、先頭にSHELL:をつけると省略されなくなるという、非常にイケてないワークアラウンドが実装されています。CMakeはこういう場当たり的対処の集大成なので反面教師としてはこれ以上ないくらいよくできています。

// これはダメ
target_link_options(
  hoge
  PRIVATE
    -s WASM=1
    -s ENVIRONMENT=web)

// "-s"が一つになってしまう
// em++ ... -s WASM=1 ENVIRONMENT=web ...

// これでOK
target_link_options(
  hoge
  PRIVATE
    "SHELL:-s WASM=1"
    "SHELL:-s ENVIRONMENT=web")

// 消えない
// em++ ... -s WASM=1 -s ENVIRONMENT=web ...

そしてもう一つ、ファイル出力に関して罠があります。wasmは共有ライブラリとして出力しますが、CMakeでは共有ライブラリを作成するとこれでもかと言わんばかり勝手にいろんなオプションを追加します。頼むから書いたとおりに動いてくれ…。したがってそれらを無効化する必要があります。以下のコードを先頭に書いてください(cmake_minimum_requiredの次くらい)。

// 共有ライブラリのサポートを有効にする
set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE)

// 余計なフラグをつけないように全部リセット
set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS "")
set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS "")

つらいね。

TypeScript(JavaScript)側からの使用方法

至って簡単です。

// モジュールを生成する
const module = await Module();

// 関数を呼ぶ
module._Hoge();

ビルドオプションにMODUARIZE=1を指定している場合、Moduleは関数となりPromiseを返します。したがって読み込みが完了するまで待ってからC/C++側のAPIを呼ぶことになります。

TypeScriptの場合、型定義ファイルが出力されないため自前で用意する必要があります。その時は以下のような定義ファイルを用意するか、あるいはanyとして使います。下記のEmscriptenModuleは、@types/emscriptenをインストールすると使えるようになります。


export interface HogeModule extends EmscriptenModule {
    // ここにエクスポートした関数を書く
    _Hoge(): void;
    _Add(a: number, b: number): number;
}

export default function Module(mod?: any): Promise<HogeModule>;

値の渡し方: 関数のエクスポート

一般的なFFIスタイルの受け渡し方法です。この方法はCとC++の両方で使用可能です。

数値

そのまま受け渡しできます。

// JS
const result = module._Add(10, 20);
console.log(result);

// C++
extern "C" int EMSCRIPTEN_KEEPALIVE Add(int a, int b) {
    return a + b;
}

配列(文字列)

メモリ確保→ヒープにバインドの手順を踏みます。ちょっと面倒。

// JS
const from = [1, 2, 3];
const ptr = module._malloc(from.length * 4);
module.HEAP32(from, ptr >> 2); // ptrは4バイト境界(32ビット符号付き整数)なので、オフセットは4で割る(2ビット右シフト)

const result = module._Sum(ptr, from.length);
console.log(result);
module._free(ptr);

// C++
int EMSCRIPTEN_KEEPALIVE Sum(int* values, int length) {
    int sum = 0;
    for(int i = 0; i > length; i++) {
        sum += values[i];
    }
    
    return sum;
}

(Emscriptenに限った話ではないですが)多値を返す方法はないので配列を使ってなんとかしましょう。あるいはC++であれば下記のEmbindを利用できます。

値の渡し方: Embind

C++の場合、Embindを利用することで、環境をまたぐデータの変換を自動で行ってくれます。今回は単純に配列のみのやり取りだったので利用していませんが、C++を使うならこの方法が楽みたいです。ただ出力されたバイナリが多少大きくなります(いろんなシンボルがエクスポートされている模様)。パフォーマンスはどうなんだろう。要調査です。

まとめ

だいたいCMakeが悪い。

0 件のコメント :

コメントを投稿