Rust を WebAssembly にコンパイルして JavaScript から呼び出す方法のまとめ
以前の記事にも書いたように、Rust コードを JavaScript でコンパイルするのは wasm-pack を使うと簡単にできます。しかし、wasm-pack が使えない場合はいくつか面倒な点があったり、wasm-pack を使う場合でも若干引っかかる点があったりします。
この記事では、JavaScript 側のビルドツールとしては Vite を使う前提で説明します。
また、ツールやライブラリのバージョンは以下を前提とします。
- rustc 1.83.0
- emcc 3.1.74
- vite 5.4.11
- vite-plugin-top-level-await 1.4.4
wasm-pack
が使える場合
wasm-pack
が使える場合にはこれを使うのが一番簡単です。wasm-pack
には様々な利点があります。
- 比較的複雑なデータ型も直接 Rust - JavaScript 間でやり取りできる
- JavaScript として自然な形で呼び出すためのグルーコードも TypeScript の型宣言も含めて自動で生成される。このグルーコードにより、普通の JavaScript モジュールを読み込むような感覚で Rust コードを利用できる。
ただし一つだけ注意点があります。wasm-pack
の build target を bundler
(デフォルト値) にした場合、Vite の production build (npm run build
) では正常に動作しないという問題があります。これは vite-plugin-wasm などを使っても解消できません。
この問題は、build target を web
にし、wasm のインスタンス化部分を明示的に呼び出すことで解決できます。ここで、インスタンス化は非同期的に行われることに注意が必要です。グルーコード中の default export 関数 (__wbg_init
) の返す Promise をもとにインスタンス化の完了を明示的に待つ方法もありますが、vite-plugin-top-level-await などを使ってトップレベルでインスタンス化を呼び出すのが簡単であると思われます。この場合 vite-plugin-wasm
はなくても動きます。
なお、記事執筆時点では、vite-plugin-top-level-await
がvite 6 系列と互換性がなかったようで、vite のバージョンを 5 系列にしないと動きませんでした。どうしても vite 6 系列が必要な場合は、トップレベルのインスタンス化を避けて、ロード時など適当なタイミングでインスタンス化をする必要があると考えられます。
wasm-pack
を使わない場合
必ずしも wasm-pack
が使えるとは限りません。例えば、cc を使ってプロジェクトの一部を C++ で記述する場合、wasm-pack
では C++ 部分を (wasm32-unknown-unknown
ターゲットに) コンパイルするところで失敗します。
この場合は、Rust / JavaScript 間のデータのやり取りを自前で書いた上で、wasm32-unknown-emscripten
ターゲットにビルドするということが必要になります。
ビルドの方法
ライブラリ (cdylib) crate を wasm32-unknown-emscripten
ターゲットにビルドする場合、出力先ファイルの拡張子が .wasm
になり、グルーコードとなる .js
ファイルが生成されません。拡張子は Rust のソースコード内 にハードコードされており、調べた限りではこれを変更できるオプションは見つかりませんでした。
bin target であれば出力ファイルの拡張子は .js
となるため(この場合 .wasm
ファイルは暗黙に生成されます)、問題なくグルーコードが生成されるように見えます。しかし実際に使おうとしてみると、#[wasm_bindgen]
を付けた関数が出力ファイルに存在しない場合があります。(TODO: 実際に確かめる) これは、bin target では main()
から呼ばれない関数は本来必要がないため、最適化の過程で消されてしまうためであると考えられます。
これらの問題は、かなり強引ではありますが emcc
のラッパーを挟んでライブラリとしてビルドするという方法で解決することができます。このために必要なものは次の 2 つです。
.cargo/config.toml
リンク時にラッパーを使うようにする指定と、リンク時に emcc
に渡す追加の引数の指定をここで行います。
記載例を下に示します:
[target.wasm32-unknown-emscripten]
linker = "./util/emcc_wrapper"
rustflags = [
"-C",
"panic=abort",
"-C",
"link-args=-s MODULARIZE=1 -s ALLOW_MEMORY_GROWTH=1 -s FILESYSTEM=0 -s ENVIRONMENT=web,worker -s EXPORT_ES6=1",
]
(https://github.com/semiexp/enigma_csp/blob/main/.cargo/config.toml)
emcc
のラッパースクリプト
必要な機能は次の 2 つです。
- 出力先のファイル名の拡張子が
.wasm
であれば、.js
に変更する - 出力先のファイル名に応じて、
EXPORTED_FUNCTIONS
を追加する
後者については必ずしも必要ではありませんが、_malloc
や _free
などを使いたい場合には指定する必要が出てきます。デフォルトだと Rust 側で実装して #[no_mangle]
がついている関数は自動で出力に含まれるようなのですが、_malloc
や _free
といった関数は export されません(参考)。これらの関数は、JavaScript 側から Rust のメモリ領域を明示的に確保、解放するために必要です[1]。
これについては config.toml
に書けばいいじゃないかと思われるかもしれません。しかし、ビルドの過程で複数の cdylib crate がビルドされる場合にはそれらすべてに同じ指定が反映されてしまい、存在しない関数名を指定するとエラーになるため、出力先に応じて指定を変える必要が出てきます。
実装例を下に示します:
#!/usr/bin/env python3
# A simple wrapper for emcc that changes the output file extension to .js
import argparse
import os
import subprocess
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-o", type=str, required=True)
overwritten_args, extra_args = parser.parse_known_args()
orig_output = overwritten_args.o
root, ext = os.path.splitext(orig_output)
assert ext in [".wasm", ".js"]
output = root + ".js"
if os.path.basename(root) == "<file_name>":
extra_args += ["-s", "EXPORTED_FUNCTIONS=<function_name>,_malloc,_free"]
args = ["emcc", "-o", output] + extra_args
subprocess.check_call(args)
if __name__ == "__main__":
main()
(https://github.com/semiexp/cspuz_core/blob/main/util/emcc_wrapper)
なおこのスクリプトは実行可能にしておく必要があります (chmod +x emcc_wrapper
)。
データのやり取り
emscripten を使う場合、JavaScript と C++ の間で直接やり取りできるデータは、数値(整数、浮動小数点数)およびポインタのみです(参考)。ポインタは JavaScript 側では単なる数値として扱われるため、実質的に数値しか扱えないと言えます。Rust についても同様に、JavaScript 側に公開する関数の引数、戻り値としては数値型や生ポインタ型しか使えないと考えられます。
渡せるものが定数個の数値だけでは複雑な処理を行うことはできないので、可変長のデータを渡せる必要があります。もし文字列を渡すことができれば、serde などを使った JSON 表現を挟むことで、より複雑なデータもやり取りできるようになります。
以下、JavaScript と Rust の間で文字列をやり取りする方法について説明します。
Rust 側
文字列を受け取る / 渡す関数のシグネチャは次のようになります。
fn foo(input: *const u8) -> *const u8 {
...
}
まず受け取り側、つまり input
をどう解釈するかですが、std::slice::from_raw_parts
を使うと生ポインタとスライスの長さからスライス &[u8]
を得ることができます。これを使うなら、関数の引数として文字列の長さも受け取るようにすると良いでしょう。&[u8]
が得られれば、str::from_utf8
で &str
が得られますし、serde_json::from_slice
で直接 JSON deserialize を行うこともできます。
文字列を返すのはもう少し複雑になります。文字列は可変長かつ関数呼び出しの後もポインタが利用可能である必要があるため、必然的にヒープ領域上に値を置くことになりますが、
- Rust 側から見ると、関数呼び出しの後も値を生存させる必要がある
- 一方、JavaScript 側では受け取った値を使い終わったら解放する必要がある(そうでないとメモリリークが発生する)
というように、Rust の領域でのメモリ管理の一部を JavaScript 側で明示的に行う必要が出てきます。
簡易的には、戻り値を static mut SHARED_ARRAY: Vec<u8>
などに保管して、そのポインタを返すことで対応できます。この場合、次の関数の呼び出しが発生する前に戻り値を JavaScript 側で処理しきることが前提となります。また細かい問題として、戻り値の文字列の長さをどう渡すかということがありますが、SHARED_ARRAY
の先頭数バイトを文字列の長さを入れておく場所としておくなどで対処できます。
(筆者はやったことがありませんが)もう少し丁寧に実装するなら、
- 返す値は一度
Box
に入れ、into_raw
して生ポインタを返す - こうして返された生ポインタからデータにアクセスするための関数、および
Box
を解放するための関数も別途用意し、JavaScript 側ではこれを経由してデータにアクセスする
のようになるかと思われます。
JavaScript 側
JavaScript から Rust の関数を呼び出すときの手順は次のようになります。
- 文字列については、
_malloc
などを使って渡すデータのための領域を確保し、データをコピーする(呼び出し後に_free
などで解放する) - 関数を呼び出す(このとき、関数名には先頭に
_
がつく) - 戻り値の値を JavaScript 側にコピーする。Rust 側の実装によっては、使い終わった後にメモリ解放用の関数を明示的に呼び出す
この方法による実装例が https://github.com/semiexp/cspuz_core/blob/main/tests/test_cspuz_solver_backend.js にあります。
-
_malloc
や_free
に対応する関数を Rust のコードとして自前で用意するという方法もあります。余計な関数(特に、Rust と C++ の境界面の関数で、JavaScript 側から使われることを意図しないもの)が export されても問題がない場合は、このようにした上で明示的なEXPORTED_FUNCTIONS
の指定を省略するということも考えられます。 ↩︎
Discussion