JavaScriptのガチャシミュをWebAssemblyに移植するまでのはまりメモ

2020/11/03

JavaScriptで書いたFEHのガチャシミュレーター(https://puarts.com/?pid=1557)の計算部分をWebAssemblyに移植してみました。最終的に移植が無事完了して、JavaScript版から5倍くらい高速化できたのですが、それまでに色々とはまったので、あれこれはまりメモを残しておきます。

移植方法

wasmの生成にはEmscripten(https://emscripten.org/)を使用しました。作業環境は Windows です。IDE は VS Code (JavaScript や HTML の編集、Emscriptenのコンパイル処理実行など)と Visual Studio 2019(C++のソースコード編集や機能テストなど)を使用しました。

おおよそ以下のような流れで移植を行いました。

  1. JavaScriptのソースコードをC++に手動で移植
  2. Visual Studio上でガチャシミュレーターが正しく動作するまでGoogle Testで確認しながら実装作業
  3. JavaScriptとwasmのインターフェースとなる(JavaScriptからデータを受け取ったり、データを受け渡したりする)関数を作成
  4. 作成したC++ソースからEmscriptenでwasmに変換
  5. JavaScript側でwasmにデータを受け渡したり、受け取ったりする部分を実装
  6. もともと使っていたHTMLやJavaScriptで実装したフロントエンドはそのまま使いまわして、シミュレーション結果が正しく表示されることを確認

移植直後、JavaScript版よりwasm版の方が遅くなってしまった

機械的にJavaScriptをC++コードに置き換えていき、コンパイルが通り、いよいよwasmで爆速になったガチャシミュをお目にかかれるとウキウキしながら処理を実行したのですが、結果としては JavaScript の数十倍遅くなってしまい、絶望しました。JavaScriptだと一瞬で終わっていた処理がwasmだと14秒くらいかかる症状です。

試しに、C++をmsvcでコンパイルしてデバッグ実行してみても9秒ほどかかったので、何かおかしいと思い、プロファイリングしてみたところ、JavaScriptのMath.random()を置き換えた乱数生成コードが異常に処理時間がかかっていました。具体的には以下のようなコードです。

double GetRandomByMt() {
    std::random_device randomDevice;
    std::mt19937 engine(randomDevice());
    std::uniform_real_distribution<> randReal(0.0, 1.0);
    return randReal(engine);
}

お恥ずかしながらC++11のrandomを使用したことがなかったので、ググってヒットしたサイトのコードを丸コピしてしまっていたのがまずかったです。そう、乱数シードによる初期化を乱数生成の度に行ってしまっていて、その初期化に使用していた std::random_device が異常に重かったというしょうもないミス。この初期化処理を1回にしたところ、14秒が3秒くらいに改善しました。いや、それでも遅すぎると思って更にプロファイリングをしてみると、今度はstd::vectorのpush_backがボトルネックになっていました。こちらもJavaScriptの配列操作を何も考えずに機械的にstd::vectorに置き換えたのがまずかったです。固定長配列に置き換えられる部分を置き換えたところ、450ms程度になりました。その勢いでプロファイリングと最適化を繰り返しました。std::vectorのreserveで事前にメモリ確保したり、乱数生成をより高速なアルゴリズム Xorshift に変更したり(JavaScriptのMath.randomはXorshiftから派生したXoroshiroというアルゴリズムで実装されているらしい)、無駄な演算をなくしたり...最終的に200ms程度まで改善しました。しかし、JavaScriptよりまだ遅い。どういうこと?と思って、コンパイルオプションを確認したところ、最適化オプションがないことに気づきました。は..!最適化が無効になってる..ということで、-O3オプションを指定してみたところ、ようやくJavaScriptより3倍くらい速い結果になりました。思ったより速くはなりませんでしたが、一安心しました。

Chrome DevToolsを開いたまま計測すると本来より大分遅い

DevToolsのConsoleのログに処理時間を出力して計測していたのですが、JavaScriptで45秒かかる処理がwasmで13秒くらいかかって、思ったより遅いなぁという感想だったのですが、実はDevToolsを開いたまま計測するのと、閉じて計測するのとでは結構計測時間に差がつくことがわかりました。DevToolsを閉じて処理が終わるのを待つと5秒程度で終わりました。wasmに限った話ではないとは思いますが、こんなに差がつくとは計測時の思わぬ落とし穴でした。(たまたまJavaScriptの方はDevToolsを閉じて計測していました)

メモリ不足でエラーになった

ガチャシミュの平均取得回数を10万回にすると、メモリが足りなくなりました。emscriptenのwasmではデフォルトで16MBのメモリが確保されるようですが、それを上回ってしまったようです。以下でメモリが足りない時に増加するオプションで解決しました。

-s ALLOW_MEMORY_GROWTH=1

printf が Console に出力されない

C++側で書いた printf は Chrome の Console 等に console.log() などと同様に表示されるということだったので、JavaScript から受け渡された引数が正しくC++側で受け取れているのか確認するログを以下のように出してみました。

printf("args: %f, %f, %f", arg0, arg1, arg2);

しかし、何も表示されない。調べてみると、どうやら改行コードがコンソールへログ出力される条件になっているとのことでした。ということで、以下のように改行コードを入れたら無事 Chrome の Console にログが出力されました。

printf("args: %f, %f, %f\n", arg0, arg1, arg2);

cwrap で array を引数に指定してもうまくいかない

JavaScript側からC++側に配列を受け渡したくて、以下のように引数に array 指定してみたのですが、エラーが発生してうまくいきませんでした。

let testFunc= Module.cwrap('testFunc', null, ['array']);
testFunc([0, 1, 2, 3]);

エラー内容は数値しか出力されないエラーで原因もよくわからず。ネットで調べると、そもそも array を使うんじゃなくて、ヒープから配列用のバッファを確保して、そのポインタを受け渡すようなやり方ばかり出てきました。ということで、そのやり方に変更することで無事配列を受け渡すことができました。

function intArrayToPointer(int32Array) {
  var pointer = module._malloc(int32Array.length * 4);
  var offset = pointer / 4;
  module.HEAP32.set(int32Array, offset);
  return pointer;
}
function free(buffer) {
  var freePointer = module.cwrap('freePointer', null, 'number');
  freePointer(buffer);
}

let testFunc= Module.cwrap('testFunc', null, ['number']);
let inputArrayPointer = intArrayToPointer([0, 0, 0, 1]);
testFunc(inputArrayPointer);
free(inputArrayPointer);

ポインタはcwrapだとnumberによる指定になります。Module._free()は次の項目で書いていますが、コンパイルオプションで最適化有効にすると、消えてしまったので、開放用の関数を EMSCRIPTEN_KEEPALIVE でC++側に定義しています。

Module._free() が JavaScript側から呼べない

wasm側の関数に配列データを受け渡すために Module._malloc() でヒープから確保したバッファを、使用後に Module._free() で破棄しようとしたのですが、Module._free() は関数ではないと怒られました。どうやら、最適化オプションを有効にすると消されてしまうようです。最終的に破棄する関数も EMSCRIPTEN_KEEPALIVE で用意することで解決しました。

JavaScript側で受け取った配列の値が正しくない

配列データをメンバに持つ構造体をJavaScript側で受け取った時に正しい値が入らなくて、少しはまりました。ありがちなミスですが、単純にヒープのオフセット計算ミスで、間違ったアドレスの値を取得してしまっていました。例えば、以下のコードでは int の id、float の count をメンバに持つ構造体の配列の値を取得する例ですが、sizeOfIdAndCount は HEAP32 や HEAPF32 の添え字用に構造体のサイズ 8 byte を 4 byte で除算しないといけませんが、除算を忘れて 8 を代入してしまっていました。

let arrayPtr = wasmModule.HEAP32[pointer / 4];
let sizeOfIdAndCount = 8 / 4;
let id0 = wasmModule.HEAP32[arrayPtr / 4 + 0 * sizeOfIdAndCount];
let count0 = wasmModule.HEAPF32[arrayPtr / 4 + 0 * sizeOfIdAndCount + 1];
let id1 = wasmModule.HEAP32[arrayPtr / 4 + 1 * sizeOfIdAndCount];
let count1 = wasmModule.HEAPF32[arrayPtr / 4 + 1 * sizeOfIdAndCount + 1];

マルチスレッドはまだ発展途上

WebAssembly で並列化して爆速になるのを想像しながらウキウキしていたのですが、調べてみると、2020年11月現在ではWebAssemblyの並列化周りはまだ標準化に向けて頑張っている最中で、試験的に使えるよ程度の温度感のようです。(WebAssembly のロードマップ: https://webassembly.org/roadmap/)

Chrome の最新版は標準でマルチスレッドで使用する SharedArrayBuffer が有効になっているそうですが、Firefox ではテスト機能として手動で有効にしないといけなかったり、そもそも他のブラウザは未対応だったりという状況で、何も意識せずに使えるとしても PC の Chrome のみで非常に限定的になりそうです。

Tensorflow ではブラウザで WebAssembly でマルチスレッド可能かどうかを見てロードする wasm を切り替える実装になっているとのことです。(SIMD およびマルチスレッド処理で TensorFlow.js WebAssembly バックエンドを高速化する: https://developers-jp.googleblog.com/2020/09/simd-tensorflowjs-webassembly.html)

バイナリを高速化のために分岐する感じがシェーダーみたいですね。そこまでして限定的に使いたいとも思わなかったので、私の用途では一旦マルチスレッド対応は見送ることにしました。

移植中に参考にしたサイト

1年間本番環境で WebAssembly ( by Emscripten )を使ってきた中で生じた問題とその解決策
https://qiita.com/goccy/items/1b2ff919b4b5e5a06110

Emscripten FAQページ
https://emscripten.org/docs/getting_started/FAQ.html


  このエントリーをはてなブックマークに追加    

<<「Web 開発」の記事一覧に戻る

関連記事