Open42

RustでWASMを作成したメモ

kurehajimekurehajime

wasm-packを入れる

cargo install wasm-pack

するが、以下のようなエラーが出る。

error: failed to run custom build command for `openssl-sys v0.9.77`
kurehajimekurehajime

追記:

cargo install wasm-pack --no-default-features

こっちを実行すると良い。

kurehajimekurehajime

openssl-sysのビルドがこける問題は結構みんなはまってるらしい。

https://zenn.dev/akgcog/articles/50a1f0b1c9034b

OpenSSLをインストール、そして上の記事に従い環境変数を設定。

しかしうまくいかない…

と思ったが、環境変数を再読み込みしてないだけだった。

と思ってターミナルを再起動したがだめ。

kurehajimekurehajime

調子に乗ってOpenSSL v3.0.7を入れたが、
https://qiita.com/t_katsumura/items/526bd21442e3c4bf2f8b

OpenSSLをインストールする上での注意点としては以下2点です。

サポートされているのは OpenSSL 1.0.1 ~ 1.1.1 と LibreSSL 2.5 ~ 3.4.1
環境変数OPENSSL_DIR(+OPENSSL_LIB_DIR+OPENSSL_INCLUDE_DIR)を設定する

バージョンが新しすぎても駄目っぽい。

kurehajimekurehajime
cargo install wasm-pack --no-default-features

このオプション付きでやるといいらしい。

kurehajimekurehajime

雛形を作って

cargo new --lib hello-wasm

サンプルコードに書き換え

lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}
kurehajimekurehajime

ファイルを保存すると
extern {

extern "C" {
に勝手に書き換えられる。

なんでだろ。ここでいうCはC言語?Javascriptに書き換えなくて大丈夫?

kurehajimekurehajime

とりあえずCargo.tomlに以下を追記

Cargo.toml
...
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
kurehajimekurehajime

wasm-packでビルドする

wasm-pack build --target web

pkgフォルダが出来た。

kurehajimekurehajime

HTMLを用意する

プロジェクトルートに用意する。

index.html
<!DOCTYPE html>
<html lang="en-US">

<head>
    <meta charset="utf-8">
    <title>hello-wasm example</title>
</head>

<body>
    <script type="module">
        import init, { greet } from "./pkg/hello_wasm.js";
        init()
            .then(() => {
                greet("WebAssembly")
            });
    </script>
</body>

</html>
kurehajimekurehajime

ローカルサーバーで動作確認

npmが使える環境で以下を実行。

npx http-server

いけた。

kurehajimekurehajime

生成されたJavascriptを覗いてみる

前評判ではwasmでやり取りできるのは数字だけとか可変長配列は難しいとか何か色々見た気がするので、どうやって可変長文字列を渡しているのか気になる。

export function greet(name) {
    const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
    const len0 = WASM_VECTOR_LEN;
    wasm.greet(ptr0, len0);
}

Javascriptから見える引数はnameひとつだけだが、WASMを呼び出すときはポインタと長さを渡してる。

kurehajimekurehajime

再ビルドしようとしたらできなくなった

一回目はできたのに、同じコマンドを二度目実行すると駄目になった。

$ wasm-pack build --target web
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
    Finished release [optimized] target(s) in 0.03s
[INFO]: Installing wasm-bindgen...
Error: failed to download from https://github.com/WebAssembly/binaryen/releases/download/version_90/binaryen-version_90-x86-windows.tar.gz
To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.
kurehajimekurehajime

JS側に公開するメソッドを用意。

なんかタプル使えなかったり色々面倒だったので引数も戻り値もStructにした。
MapArrayの正体はVec<isize>で、そのまま引数にしようとするとエラーになる。構造体として公開するとOK。うーん。

use wasm_bindgen::prelude::wasm_bindgen;

use crate::rule::MapArray;

pub mod ai;
pub mod eval;
pub mod rule;

#[allow(dead_code)]
#[wasm_bindgen]
pub struct Result {
    from: usize,
    to: usize,
}

#[wasm_bindgen]
pub struct Arg {
    map: MapArray,
    turn_player: isize,
    depth: isize,
}

#[wasm_bindgen]
pub fn think_ai(arg: Arg) -> Result {
    let result = ai::think_ai(&arg.map, arg.turn_player, arg.depth, None, None, None);
    Result {
        from: result.0.unwrap().0,
        to: result.0.unwrap().1,
    }
}
kurehajimekurehajime

ビルド

getrandomでエラーになる。
getrandomというメソッドを自分は用意した覚えはないが、randクレートは利用している。

wasm-pack build --target web
error: the wasm32-unknown-unknown target is not supported by default, you may need to enable the "js" feature. For more information see: https://docs.rs/getrandom/#webassembly-support
   --> C:\Users\gabill\.cargo\registry\src\github.com-1ecc6299db9ec823\getrandom-0.2.8\src\lib.rs:263:9
    |
263 | /         compile_error!("the wasm32-unknown-unknown target is not supported by \
264 | |                         default, you may need to enable the \"js\" feature. \
265 | |                         For more information see: \
266 | |                         https://docs.rs/getrandom/#webassembly-support");
    | |________________________________________________________________________^

error[E0433]: failed to resolve: use of undeclared crate or module `imp`
   --> C:\Users\gabill\.cargo\registry\src\github.com-1ecc6299db9ec823\getrandom-0.2.8\src\lib.rs:290:5
    |
290 |     imp::getrandom_inner(dest)
    |     ^^^ use of undeclared crate or module `imp`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `getrandom` due to 2 previous errors

今回高速化したかった範囲ではないので、randを利用してる箇所を泣く泣く削除する。
調べれば回避方法はあるかもしれない。
疑似乱数を自前で実装してしまうのも良い。その方がテストやりやすくなるし。

kurehajimekurehajime
[INFO]: Installing wasm-bindgen...
Error: failed to download from https://github.com/WebAssembly/binaryen/releases/download/version_90/binaryen-version_90-x86-windows.tar.gz
To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.

またこれでビルドできない。
最適化はいったん諦めて、

[package.metadata.wasm-pack.profile.release]
wasm-opt = false

でやる。
手動インストールすれば何とかなるかもしれないが、それよりも誰でも気軽にビルドできる方を今は取りたい。

kurehajimekurehajime

Javascriptを確認してみる

/**
* @param {Arg} arg
* @returns {Result}
*/
export function think_ai(arg) {
    _assertClass(arg, Arg);
    var ptr0 = arg.ptr;
    arg.ptr = 0;
    const ret = wasm.think_ai(ptr0);
    return Result.__wrap(ret);
}

/**
*/
export class Arg {

    __destroy_into_raw() {
        const ptr = this.ptr;
        this.ptr = 0;

        return ptr;
    }

    free() {
        const ptr = this.__destroy_into_raw();
        wasm.__wbg_arg_free(ptr);
    }
}
/**
*/
export class Result {

    static __wrap(ptr) {
        const obj = Object.create(Result.prototype);
        obj.ptr = ptr;

        return obj;
    }

    __destroy_into_raw() {
        const ptr = this.ptr;
        this.ptr = 0;

        return ptr;
    }

    free() {
        const ptr = this.__destroy_into_raw();
        wasm.__wbg_result_free(ptr);
    }
}

うーん、呼び出せる気がしない。
引数用のクラスからもプロパティが消失している。
どうやって引数を渡せば良いんだ。

export class Arg {

    __destroy_into_raw() {
        const ptr = this.ptr;
        this.ptr = 0;

        return ptr;
    }

    free() {
        const ptr = this.__destroy_into_raw();
        wasm.__wbg_arg_free(ptr);
    }
}
kurehajimekurehajime

あ、プロパティにpubを付け忘れてた。
rustは標準で非公開だった。

kurehajimekurehajime

配列の渡し方が分からない

#[wasm_bindgen]
pub struct Arg {
    pub map: MapArray, // ←
    pub turn_player: isize,
    pub depth: isize,
}

矢印のところで以下のエラーがでる。MapArrayの正体はVec<isize>。

the trait bound `Vec<isize>: std::marker::Copy` is not satisfied
the trait `std::marker::Copy` is not implemented for `Vec<isize>`
kurehajimekurehajime

Rustの戻り値と引数の定義はこんな感じ。

#[wasm_bindgen]
pub struct Result {
    pub from: usize,
    pub to: usize,
}

#[derive(Serialize, Deserialize)]
pub struct Arg {
    pub map: MapArray,
    pub turn_player: isize,
    pub depth: isize,
}

pub fn think_ai(arg: JsValue) -> Result {

で、Javascriptにはこのように公開される。

export function think_ai(arg: any): Result;

export class Result {
  free(): void;
/**
*/
  from: number;
/**
*/
  to: number;
}

any型…嫌な感じだが、やむを得ない。

kurehajimekurehajime

ドキュメントをよく読んでみたら、Vecは使えないけど数字の配列のスライスは使えた。
固定長配列を試して駄目だったから諦めてたけど、スライス使えるのなら実質Vecじゃん。

という訳でRust側のコードは以下のようにする。

#[wasm_bindgen]
pub struct Result {
    pub from: usize,
    pub to: usize,
}

#[wasm_bindgen]
pub fn think_ai(map: &[isize], turn_player: isize, depth: isize) -> Result {

するとJavascriptのコードからもAnyが消える。

/* tslint:disable */
/* eslint-disable */
/**
* @param {Int32Array} map
* @param {number} turn_player
* @param {number} depth
* @returns {Result}
*/
export function think_ai(map: Int32Array, turn_player: number, depth: number): Result;
/**
*/
export class Result {
  free(): void;
/**
*/
  from: number;
/**
*/
  to: number;
}
kurehajimekurehajime

動作確認

どうやってソース管理するかはこの際置いといて、生成物を強引にViteのプロジェクトに突っ込んで動作確認だけしておきたい。

kurehajimekurehajime

やっぱりwasm-opt = falseは嫌だ

Docker使ってwasm-opt = falseを外すことにした。
これでビルドしたら生成されるwasmが31KB→21KBになった。

Dockerfile
FROM rust:slim-bullseye
WORKDIR /usr/src/myapp
RUN apt update && apt install -y build-essential
RUN cargo install wasm-pack
version: '3'
services:
  rust:
    image: rust
    volumes:
      - .:/usr/src/myapp
    build: ./
    command: wasm-pack build --target web
kurehajimekurehajime

ボードゲームに組み込んだ結果

Javascriptのほうがわずかに速い…なんで?
複数回実行しても結果はあまり変わらず。
レベルを下げたら実行時間は0msになるので、オーバーヘッドは無視できそう。
えぇー…。

条件

ブラウザ:Chrome
レベル:6
配置パラメータ:init=1,2,3,4,5,6,7,8
1手目:2を右上
2手目:2を上

結果

WASM

  • 一手目:796ms
  • 一手目:1575ms

Javascript

  • 一手目:734ms
  • 一手目:1457ms
kurehajimekurehajime

FirefoxではWASMの方が速くなっていた

WASMの結果はChromeとほぼ同じ。
Javascriptの結果はChromeより倍くらい遅い。

これまでいろんなブラウザでこのボードゲームの処理速度を測ってきたけど、ブラウザによって速度はだいぶバラツキがあった。
でも今回WASMの実行速度が異なるブラウザでほぼ同じ結果になったのは興味深い。

WASM

  • 一手目:798ms
  • 一手目:1583ms

Javascript

  • 一手目:1322ms
  • 一手目:2576ms
kurehajimekurehajime

なんでだろう

呼び出しオーバーヘッドがあったのでは?

JavascriptからWASMを呼び出す回数は一手あたり1回。WASMからJavascriptを呼び出すことはない。
レベルを1に下げた時の実行時間が0ms(ミリ秒以下四捨五入)なことから、オーバーヘッドはほぼ無視して良いレベル。

WASMの呼び出しに時間がかかってるのでは?

読み込み時間はベンチに入れてないし、仮に入れていたとしてもJavascriptをバンドルする時にWASMも全部HTMLにインライン埋込してるので読み込み時間もほぼ変わらない。

###JavascriptとRustでアルゴリズムを変えてるのでは?
どちらもこれからずっとメンテしていく予定なのでほぼ変えてない。JavascriptとRustのコードはほぼ1行:1行で対応している。
ただJavascript版はInt8Arrayを使っているが、Rust版はisizeを使っているという違いがある。

ChromeのJavascriptエンジンが優秀過ぎるのでは?

たぶんこれじゃないかと思ってる。

ちなみにこのボードゲームの思考ロジックでは、number型の配列は遅いのでInt8Arrayを使ったり、array.lengthでfor文を回すと遅いからfor文を固定回数で回したり、Math.floor(n)が遅いから~~nで端数の処理をしたり、呼び出し回数が多いメソッドではfor文で12回ループをするのすらネックになるので処理を12回ベタ書きまでしている。可読性を酷く犠牲にしてまで速く動くように書いているので、もともと最適化が効いていた可能性は高い。

コンパイルのオプションがちゃんとチューニングされていないのでは?

これもある。
いちおう最適化レベルは3になっているので、変な設定にはなっていないはず。
でも自分はRust歴がまだ1ヶ月ちょっとと浅いので、みんなが当たり前にやっているチューニング設定をやっていない可能性は高い。

kurehajimekurehajime

#[inline]をつけてみる

#[inline]を付けてinline化してみた。
まだJavascriptには叶わないが、わずかに改善。
複数回測っても傾向は変わらなかった。
バイナリサイズは21KB→20KBに減った。

WASM(inlineなし)
一手目:796ms
二手目:1613ms

WASM(inlineあり)
一手目:773ms
二手目:1501ms

Javascript
一手目:710ms
二手目:1413ms

kurehajimekurehajime

固定長配列にしてみる

Rust版ではJavascript版のアルゴリズムを忠実に再現したと上の方に書いたが、ちょっと嘘がある。
JavascriptではInt8Arrayを使っているが、RustではVec<isize>を使ってる。Vecは便利だが贅沢過ぎる。まずは固定長配列[isize;56]にしてみる。

WASM(Vec(isize)→[isize;56])
一手目:698ms
二手目:1389ms

ついにRust版がJavascript版をわずかに上回った。

kurehajimekurehajime

isizeからi8にしてみる

WASM(isize → i8)
一手目:547ms
二手目:1139ms

だいぶ速度が増した。
WASMってi32、i64、f32、f64しかない認識なんだけど、なんで速くなるんだろう。

kurehajimekurehajime

よく分からないけどWASMにはi32.store8という8bit整数をコンパクトに格納する命令もあるらしい。
i8を使うのもまったく無駄というわけではなさそう。