2024年10月8日火曜日

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

以前作ったお手軽ベンチマークを高度化してベンチマークの種類を増やしました。 減色処理では色のカウントアップの後に色のリストアップを行うのですが、 そのリストアップ処理までのベンチマークです。 前回 (countColors) は色のカウントアップ、 そのリストアップをして返す getColors、 リストアップした結果は返却せず内部に保持する initColors の 3種類にしました。 getColors は動的配列は使わなくても実装できるのですが、面倒なのでアルゴリズム上の変更はしません。 クラスや構造体や動的配列に対応していない言語、明らかに遅いとわかっている言語は実装しませんでした。 実装は @marmooo/wasm-bench にあります。

fontconv

getColors ベンチマークの追加

getColors ベンチマークでは、すべて JavaScript の性能を下回りました。 結果は以下。
    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                            192.0 ms           5.2 (189.7 ms … 201.7 ms) 192.0 ms 201.7 ms 201.7 ms
AssemblyScript 0.27.30 (Number)                    286.1 ms           3.5 (233.6 ms … 322.7 ms) 307.5 ms 322.7 ms 322.7 ms
AssemblyScript 0.27.30 (Class)                     319.3 ms           3.1 (279.6 ms … 370.7 ms) 334.2 ms 370.7 ms 370.7 ms
Rust 1.81.0, wasm-bindgen 0.2.93 (Simple)             2.2 s           0.5 (   2.2 s …    2.2 s)    2.2 s    2.2 s    2.2 s
Rust 1.81.0, wasm-bindgen 0.2.93 (Serde)           337.3 ms           3.0 (316.3 ms … 355.4 ms) 351.8 ms 355.4 ms 355.4 ms
C++, emscripten 3.1.68                             390.2 ms           2.6 (368.9 ms … 401.8 ms) 398.0 ms 401.8 ms 401.8 ms
わかってはいましたが、オブジェクトの転送コストが大きいとものすごく遅くなります。 オブジェクトを転送しなければ早いので、とにかくオブジェクトを転送してはいけないことがわかります。 メモリで転送するのはプリミティブと TypedArray に留めたほうが良さそうです。

C++ は計算後に JavaScript オブジェクトへ変換するのが一番早かったです。 ちまちま JavaScript オブジェクトを触りながら作るとものすごく遅くなります。 この結果を見ると、Wasm では動的配列を JavaScript オブジェクトに変換すること自体が間違いでしょう。 Rust と AssemblyScript はメモリリークで結構ハマりました。 おそらく JavaScript オブジェクトを内部で使うと 解放のタイミングがわからなくなるのでメモリリークします。 AssemblyScript の対策はわかりやすくて、 内部オブジェクトで export しないように気を付ければいいだけです。 Rust もたぶん一緒なのですが、wasm-bindgen が自動変換してくるので見落としがちで、 きちんと手動で JavaScript オブジェクトに変換しないと怖い印象を受けました。

Rust はさらに serializer を使わないと異常に遅くなる問題に直面しました。 Rust はなんでこれが遅いの? が結構ある気がします。 C++ や AssemblyScript は serializer を使うともっと早くなるのかも。 最終的には AssemblyScript が C++ より早くて少し驚きました。 他にも 2^20 くらいのループまでは Wasm のほうが JavaScript より早かったりするのもよくわからないところです。 メモリの再割当てが原因かな。

initColors ベンチマークの追加

getColors のようにオブジェクトの変換コストが大きいと Wasm 化する利点がないので、 変換せず内部に保持するようにした initColors ベンチマークでは、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                   188.9 ms           5.3 (173.5 ms … 213.0 ms) 194.7 ms 213.0 ms 213.0 ms
AssemblyScript 0.27.30                    148.8 ms           6.7 (123.7 ms … 187.2 ms) 160.3 ms 187.2 ms 187.2 ms
Rust 1.81.0, wasm-bindgen 0.2.93           70.3 ms          14.2 ( 63.0 ms … 104.4 ms)  74.9 ms 104.4 ms 104.4 ms
C++, emscripten 3.1.68                     43.7 ms          22.9 ( 42.4 ms …  48.8 ms)  44.0 ms  48.8 ms  48.8 ms
AssemblyScript はクラスをグルーコードとして生成できない課題があるので、 constructor と使いたいメソッドだけをクラスの外部に関数として定義するのが良いです。 面倒ではありますが、メモリリークの可能性が減るので悪くはない気がしました。 Rust は記法に慣れていないことによって時間掛かりましたが、そんなに悩みはしなかった気がします。 C++ は Array だと大きなメモリ確保ができなくて落ちることだけハマりました。 この 3つの言語は書くのも楽で、あまり迷うところがないです。 どれも uint8 を使わないほうが型変換が減るぶんだけわずかに早い気もしますが、 省メモリ動作も Wasm の利点なので真面目にしました。

結論としては C++ が鬼のように早いことがわかりました。Rust も結構早い。 opencv.js を見ていると Wasm は JavaScript より 4倍くらい早そうと思っていたのですが、 データ転送もオブジェクト変換もなければ、もっと早いかもなあ。

countColors ベンチマークの改善

前回作ったベンチマークも多言語サポートを進めました。 といっても試してみた結果、まだ実用段階にないことを確認することがほとんどでしたが。

Rust

Rust は速度が気になったので色々な方式で実装してみました。 まずはポインタを返り値として処理するときは C/C++ と同じ速度が出ることがわかりました。 しかし Vec, Box<[u32]> などを返り値として処理すると、 おそらく内部で wasm-bindgen が js_sys を使って自動型変換をしているのですが、遅くなります。 C++ の embind だと自動型変換をしても爆速なので課題を感じます。 他にもJavaScript 側で import の仕方を間違えた場合なども遅くなったりします。 unsafe { Uint32Array::view(&color_count) } の書き方を見つけてからは、これが一番楽そうかなと思っています。

Go

Go はポインタなら C/C++ に近い速度で動作します。 しかしオブジェクトを透過的に扱おうとすると、export ディレクティブが使えず、 JavaScript 化した関数をグローバルにエクスポートする処理を書く必要が出てきます。 サンプルなどを見ると globalThis にエクスポートしているのですが、 これは非常に邪魔なので ESM のように自由な名前でエクスポートしたいところです。 設計面での変更が必要そうです。

またベンチマークを取ると GC がうまく動いていないことがわかります。 Wasm は GC をセルフ実装することになるのでベンチマークを取ってみることは大切そうです。 他にも little endian がデフォルトの JavaScript と、 big endian がデフォルトの Go では整合性を取るのが大変ということがわかりました。 Go に慣れていればこのへん楽かも知れませんが、慣れていないのでね。 さらに syscall/js の Uint32Array から Index(i) して取得したデータをInt() でしか取得できないので、 uint32 で欲しい画像処理の場合、取得した時点で範囲外の数値が壊れます。 byte で取得できないと処理がかなり大変で、関数が不足しているように感じます。 範囲を指定してデータ取得もできないので、色々と限界はあります。 メモリコピーするしか解決策がわかってないのですが、これだとまあ遅くなります。

他にも GC=leaking ならテストが通るのに、その他だとテストが通らないような問題も発生しました。 さらに関数を JavaScript 化すると現状は非常に遅い問題などもあります。 issuses #32591, #46473 にも上がっていましたが、 報告からすでに 5年間も経っているので解決は時間が掛かりそうです。 使えない実装も残してはいるのでこのへんの問題が確認はできるようにしてあります。 実用面はまだまだこれからかなあ。

Zig

Zig は色々試してみましたが、文法から難しくてまだ理解できていないです。 理解の範疇でば Wasm を作りにくい気もしていて、その理由は関数の返り値です。 配列を JavaScript と共有するにはヒープに明示する必要があるようなのですが、 alloc の成否をチェックする必要があって error union ポインタが返り値になります。 しかし C 互換で export する時はこの機能を使えません。といってキャストもよくわからなかったです。 オプショナルポインタで表現するのも駄目なので、どうすれば良いのかわからなかったです。 C/C++ のポインタは実質 NULL を取り得るオプショナルポインタだと思うのですが、わからん。 グルーコードも生成してくれないので実行までのハードルも高いです。 zig-js とかあるし、できないことはないはずなんだけどなあ。

Java

Java はサンプルを見たら、割と簡単に変換できることがわかりました。 やはり設定ファイルがわかりやすいと、初心者にはわかりやすいです。 ただ Java には参照渡しがない問題があるので、どうするのかと思っていました。 案の定、issue #907 を見ていても、 メモリに領域を確保しようとしたときアドレスを渡す手段がない話をしていました。 アドレスを使わないと、バイナリデータの処理は、 値渡しで画像を渡して、処理して、値渡しで返す必要があるので遅いです。 あと uint32 がないので画像はバイト単位でシフト演算するしかないでしょうが、 わずかに遅くなることも予想できます。 つまり countColors と似たなにかは実装できても、同じものは実装できない認識です。 Java で配列などを Wasm を通じて透過的に処理するためには、 事実上変化がなくても変更可能性のある一般的なデータ型の内部位置をコンパイラ側で保証し、 マッピングする必要があるのでしょうかね。 人気言語の中では Wasm 利用に課題が多いかも知れません。

Kotlin

Kotlin は文法が簡単な一方で、設定ファイルが難しいのが欠点です。 サンプルで main 文を動かす方法はわかりましたが、関数のエクスポートがわかりません。 現状は Wasm のドキュメントが皆無なのでカスタマイズができないです。 まだ main 文以外はサポートしていないかな? Java 派生ですが、Java と異なり UintArray があるのでマッピングは楽かも知れません。

Scala

Scala + Scala.js は サンプルプロジェクト を眺めてみたら、 独自の sbt 設定ファイルの内容が難しすぎて、書ける気がしなかったです。 それとも見たサンプルがいけなかったか。Scala も uint32 がない問題があります。

Dart

Dart は文法も簡単だし、簡単に Wasm を生成できるので良い感じです。 ただすぐに使える dart compile wasm コマンドでは main 文の export しか現状できないので、今後に期待です。 dart2wasm という非公開の公式ビルドツールを使うと wasm:export も使えるようなのですが、 ビルドツールを用意するのは大変そうなので諦めました。 wasm:export できるようになれば一気に使いやすくなると思いますし、 公式サポートされたらベンチマークもすぐ追加できそう。 Dart にもポインタがないですが、Uint32List があるのでマッピングは簡単そうです。

MoonBit

MoonBit は簡単なので良い感じですが、グルーコードは生成できなそうに見えます。 クラスや構造体を定義するのはあまりにも面倒なので、進化に期待したいところです。

まとめ

Wasm ビルドのコードをたくさん書いてみた結果、言語自体の話は置いておくとして、 ビルド環境自体の使いやすさは以下で決まるとわかりました。
  1. 簡単にビルドできる (Scala は課題あり)
  2. グルーコードを自動生成できる (MoonBit と Zig は課題あり)
  3. 関数が export できる (Dart と Kotlin は課題あり)
  4. 高速安定動作する (AssemblyScript と Go は課題あり)
  5. JS オブジェクトと自動型変換できる (現状 C++/Rust のみ)
4 までは実利用を考えると必ず欲しい機能です。 まとめると C++/Rust は現状 Wasm を非常に書きやすい言語ということです。 AssemblyScript もかなり良いです。 5 は現状最も実装の進んでいる emscripten や wasm-bindgen を使っても性能は出ないので、 実質的にはちょっとしたクラスや構造体のエクスポートと、 TypedArray をサポートしていることが重要になりそうです。

現状は C++ が一番楽だし高速そうですが、Rust も実用レベルと思います。 今回のコードでは適用できなかったですが、どちらも autovectorizer が組み込まれているので自動 SIMD 化も期待できます。 サクッと Wasm にしたいだけなら AssemblyScript も良い言語だと思います。 他の言語はまだ厳しいかなあ。でもどの言語もまだ Wasm 対応は始まったばかりなので、 今後に期待が良いと思います。次は Kotlin/Dart あたりが面白そうかな?

0 件のコメント: