2024年9月22日日曜日

様々な言語で作った Wasm をベンチマークした (1)

Wasm は様々な言語から作ることができますが、その実行速度が気になったので調査しました。 計測に使ったのは減色処理をする時に必要な、画像内にある色のカウントアップ処理です。実用的です。 WasmGC を使うとまた特性が変わるような気もするのですが、ひとまず現状チェックです。 実装は @marmooo/wasm-bench にあります。

fontconv

独断と偏見により AssemblyScript, C/C++, Rust を調査しました。 本当は Go も確認はしていて、int 処理くらいならできたのですが、 Uint8Array を引数として渡すことができなくて諦めました。 最近は TinyGo がかなり小さな Wasm を生成できるようになっていて (70KB〜)、速度が出るなら十分候補になるような気がします。 しかし Uint8Array などの型変換がまったくわからない…。

Zig も試してはみましたが、文法がわかってないのでうまくいきませんでした。 Zig のほうが文法はシンプルなので粘ればできたかもですが、ChatGPT さんも手助けにならないので粘っていません。 他に有名なのは Grain, MoonBit あたりでしょうか。 ざっとドキュメントを読んだ感じだと MoonBit は Uint8 がないのでまだ早い。 Grain はアリかも知れない。 ChatGPT さんの手助けが期待できるくらいになったら再チャレンジしてみるかも。

ベンチマーク結果

結論から言えば、C/C++ が最速で、その他の Wasm は JavaScript よりちょっと早いでした。 また C/C++ 以外の Wasm にほとんど差がないのがポイントです。
    CPU | Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
Runtime | Deno 1.46.3 (x86_64-unknown-linux-gnu)

benchmark                           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
----------------------------------- ----------------------------- --------------------- --------------------------
JavaScript, Deno 1.46.3                    166.8 ms           6.0 (164.1 ms … 171.8 ms) 169.6 ms 171.8 ms 171.8 ms
AssemblyScript 0.27.29 (Wrap)              153.8 ms           6.5 (150.1 ms … 155.0 ms) 154.4 ms 155.0 ms 155.0 ms
AssemblyScript 0.27.29 (Shift)             175.2 ms           5.7 (174.1 ms … 175.8 ms) 175.4 ms 175.8 ms 175.8 ms
AssemblyScript 0.27.29 (DataView)          156.5 ms           6.4 (156.0 ms … 157.8 ms) 156.5 ms 157.8 ms 157.8 ms
Rust 1.81.0, wasm-bindgen 0.2.93           147.4 ms           6.8 (142.9 ms … 154.1 ms) 147.4 ms 154.1 ms 154.1 ms
C, emscripten 3.1.67 (Simple)              100.7 ms           9.9 ( 99.7 ms … 101.5 ms) 100.9 ms 101.5 ms 101.5 ms
C, emscripten 3.1.67 (Struct)              100.0 ms          10.0 ( 93.0 ms … 101.5 ms) 100.9 ms 101.5 ms 101.5 ms
C++, emscripten 3.1.67 (Simple)            102.6 ms           9.7 (100.5 ms … 103.3 ms) 103.0 ms 103.3 ms 103.3 ms
C++, emscripten 3.1.67 (Class)             102.1 ms           9.8 (101.6 ms … 103.4 ms) 102.2 ms 103.4 ms 103.4 ms

AssemblyScript

AssemblyScript は、ほぼ TypeScript で記載できる Wasm 生成用言語です。 Union と型エイリアスと destructures を使えないのがつらいけど、まあなんとかなります。

コンパイルオプションが非常に多いです。 色々と最適化しながら計測してわかったことは、Wasm の実行速度の大半は GC に依存していることです。 GC を切ると 2倍以上速度が上がります。 デフォルトの Incremental GC では JavaScript より遅いですが、 --runtime minimal --exportRuntime オプションを付けて GC を手動にすると、 Rust で作った Wasm と同じ速度になり、安定します。

デフォルトの Incremental GC はかなり実行速度にブレがあって、 少し書き方を変えるだけでとんでもなく速度に差が出ます。 先ほど 2倍以上と書きましたが、書き方によっては 4倍くらい遅くなります。 これだと検証が大変になってあまりよろしくないですが、 手動の GC にするだけで非常に安定した性能になることから、 性能低下の大半が GC によるものだとわかります。 GC が性能の大半を占める特徴は、他の言語で作った Wasm にも言えることだろうと思います。

現時点では手動 GC でないと厳しい印象ですが、 そこさえ許容すれば実用に耐える状態に仕上がっている印象です。 ベンチマークにも AssemblyScript を使った様々な実装を残してあるので、 オプションを変えて実行すれば Incremental GC の挙動は確認できます。

AssemblyScript は GC 以外にも様々なオプションがありますが、 GitHub の issues などを眺めていると、 ファイルサイズは最適化しないほうが速度面で良いようです。 まあインライン化などの最適化をした時にサイズが増えることから、想像は付きますね。 現在利用している実行オプションは以下のようになっていますが、一番よく効くのは先に書いた --runtime minimal --exportRuntime で、 残りのオプションはちょっとずつ効果があるという感じです。
asc countup.ts -o countup.wasm --bindings esm \
  --runtime minimal --exportRuntime \
  -O3 --converge --noAssert --uncheckedBehavior always

C

C はポインタ以外は JavaScript と書き味が一緒なので、割と気軽に書けます。 面倒なのはたいていドキュメント不足の環境整備ですが、Wasm を作るだけなら emscripten をインストールするだけなので迷うことはないです。 C/C++ で作った Wasm はメモリリークの危険性がある手動管理です。 GC でマークを付けないことが大きそうで、Rust / AssemblyScript より明確に早いです。 そんな訳で Wasm で速度を出したいときには C/C++ が最強のような気はします。

ただ Rust / AssemblyScript で作った Wasm と異なり、JavaScript からの呼び出しが面倒です。 malloc してポインタとサイズを渡して使い終わったら free が必要です。 データのサイズを渡さないといけないので実装が複雑になります。 他の言語に慣れているとかなり面倒で、早いとわかっていてもあまり使いたくないです。 AssemblyScript で同様のメモリ管理機能ができたら、絶対そっちを使うと思う。 薄いラッパーがあれば欲しいです。ラッパーがあればかなり使いやすくなる気はします。 というわけで JavaScript でお手軽な Struct <-> TypedArray ラッパーを書いて、簡単に使えるようにして (?) 比較してみましたが、速度低下はありませんでした。

実行オプションはこんな感じ。実質 -O3 だけで、他は利用しやすくするための設定です。 今回書いた程度のベンチマークでは大差ないようですが、 -flto --closure 1 も入れておくと良い みたいです。 -s EXPORTED_FUNCTIONS=_malloc,_free は入れておかないとメモリ管理ができないので、実質的に必須です。
emcc countup.c -o countup.js -O3 -flto --closure 1 \
  -s MODULARIZE \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s EXPORTED_FUNCTIONS=_malloc,_free

C++

C++ もポインタ以外は JavaScript と書き味が近いので、まあなんとかなります。 C++ で作った Wasm は Embind がクラスと関数の定義を渡せば自動でバインディングしてくれます。 バインディングの方法は理解するのに非常に時間が掛かりましたが、わかれば非常に使いやすいです。 コンパイルするときはクラスをバインディングするために --bind を付けるだけです。 固定配列ではなく std::vector にバインディングされるので、将来的に TypedArray が可変的になった時にも対応しやすいです。 TypedArray は今は固定長だけど、ES2024 Resizable ArrayBuffers には注意が必要なのかも知れません。 でも C++ なら心配なさそう。 欠点は Wasm のサイズが少し大きくなることですが、許容範囲です。 Wasm を作るのにはかなり良い言語だなと思いました。
emcc countup.c -o countup.js -O3 -flto --closure 1 \
  --bind \
  -s MODULARIZE \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1

Rust

最近の高速化でよく使われる癖つよ言語ですが、Wasm は割と簡単に作れます。 wasm-pack をインストールして、Cargo.toml に設定するだけで作れるので、言語仕様の理解だけが問題です。 正確な速度差はさらに調査しないとわかりませんが、上記の結果では Rust で作っても AssemblyScript で作っても速度差はそれほどなさそうと思いました。 癖つよの言語仕様を覚えるよりかは、AssemblyScript でも良いかなと思ったりもしました。

ただ、特別なメモリ管理をしないでも速度が出る利点を考えると、やはりライブラリには使いやすいのかな。 今回調査した以外の言語で Wasm を作る場合も、特別なメモリ管理をしないでも速度が出るかどうかは、それなりに重要な差になりそうです。 Cargo.toml は以下の最適化を入れています。
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
あとは wasm-pack で release モードの Wasm で出力するだけです。
wasm-pack build --target web --release


ネットでは AssemblyScript が遅い!の記事が多かったのですが、最適化すると割と戦えるっぽいです。 もうしばらく AssemblyScript で遊んでみようと思います。 --runtime stub にしたとき C/C++ と同じようなメモリ管理ができると良いのですが、できないっぽいかな? いまいちよくわからないです。 ベンチマークの種類も増やすかも。

0 件のコメント: