フューチャー技術ブログ

C/C++を呼び出しているRustのWASM化

はじめに

こんにちは、Futureでアルバイトをしている川渕です。

本記事ではC/C++を呼び出しているRustのwasm化について説明します。結論から述べるとemscriptenを使用することでうまくいきました。

経緯

アルバイトの前はインターンシップでRust製SQLフォーマッタであるuroborosql-fmtの作成を行なっていました。(前編, 後編)
現在はアルバイトで拡張機能化やwasm化を行なっています。

基本的にRustで書いたコードのwasm化は簡単に行えるのですが、今回はC/C++で書かれたプロジェクトに依存していたため非常に苦戦しました。最終的になんとかwasm化に成功したので、本記事ではその方法について説明します。

説明すること

  • WebAssembly(wasm)とは何か
  • Rustをwasm化する主な方法とチュートリアル
    • wasm-pack
    • wasm32-unknown-emscripten
  • C/C++を呼んでいるRustのwasm化
  • 性能検証

説明しないこと

  • WebAssembly System Interface(wasi)について

環境

OS: macOS Monterey 12.6.1
CPU: Apple M1 Pro
Rust: 1.67.1

WebAssembly(wasm)とは

現在ブラウザ上でプログラムを実行する場合はJavaScriptが使用されます。JavaScriptの役割は元々HTMLの補助程度でしたが、現在はさまざまな用途に使用されており、速度が求められています。近年ではJITコンパイルによって高速化が行われていますが、JITコンパイルはよく呼び出される部分しかコンパイルされない、型推論を間違える可能性がある、などの欠点があります。

そこで、WebAssembly(wasm)という「ブラウザ上で動くバイナリコードの新しいフォーマット(仕様)」が開発されました。wasmは現在Firefox、Chrome、Safari、Edge等の主要なブラウザの全てに対応しており、Google, Microsoft, Mozilla, Appleによって仕様が策定され開発が進められています。

基本的に直接記述ではなく、C/C++やRust、Golang、TypeScriptなどからコンパイルされます。wasmはJavaScriptを補完する目的で開発されており、JavaScriptから呼び出すことで実行できます。また、wasmからJavaScriptの機能にアクセスすることもできます。

wasmはCPUの活用、起動の高速化から、ネイティブアプリ並の速度で動作すると言われており、実際に多くのアプリケーションでwasmが使用されています。

また、wasm化することでフロントエンドだけでアプリケーションが動くようになるため、RustやGoで書いたアプリケーションも簡単にGitHub Pagesなどの静的なサイトで実行することができます。

Rustをwasm化して実行する主な方法

主に以下の2つがあります。

  1. wasm-pack
    • Rustのwasm化において一番メジャーで簡単な方法
    • wasm-unknown-unknownとwasm-bindgenをラップしたツール
    • C/C++に依存していない純粋なRustの場合はこちらがオススメ
  2. wasm32-unknown-emscripten
    • emscriptenのインストールが必要
    • C/C++を呼び出している場合はこちらがオススメ

wasm-pack

Rustのwasm化において一番メジャーで簡単な方法です。wasm-packさえインストールすれば自動で全部やってくれるので非常に楽です。内部ではターゲットをwasm32-unknown-unknownとしてビルドし、wasm-bindgen-cliを用いてグルーコードを生成しています。wasm-bindgenとはJavaScriptとRustの型を繋ぐツールのことです。

基本的にwasm-packはC/C++を呼んでいる場合は使えないので注意してください。

チュートリアル

簡単にwasm-packのチュートリアルを説明します。

  1. wasm-packのインストール

    cargo install wasm-pack
  2. プロジェクトの新規作成

    wasm-pack new hello-wasm-pack
  3. ビルド
    targetをwebに指定してビルドを実行します。

    wasm-pack build --target web
  4. wasmの実行
    以下のようなindex.htmlを作成します。

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

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

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

    </html>

    適当な方法でローカルサーバを立てます。(サーバを立てずにwasmを実行するとCORSエラーが発生します。)
    今回はpythonを使う方法でやってみます。

    python3 -m http.server 8080

    ブラウザでhttp://localhost:8080/にアクセスすると画面上にアラートボックスが現れ、Hello, hello-wasm-pack!と表示されたら成功です。

wasm-packは何をしてくれているのか

wasm-packはビルド時に以下の処理をしてくれています。

  1. Rustコードをwasmにコンパイル
    • cargo build --target wasm32-unknown-unknownを実行
      (ビルドターゲットにwasm32-unknown-unknownがインストールされていない場合はrustup target add wasm32-unknown-unknownを実行してビルドターゲットに追加)
    • ここでコンパイルしたwasmはtarget/wasm32-unknown-unknown/release/に生成される
  2. グルーコードの生成
    • wasm-bindgen-cliを用いてwasmとjsがデータをやり取りするためのjsファイルを作成し、pkgディレクトリに格納
  3. Cargo.tomlを読んで等価なpakcage.jsonを作成
  4. README.mdが存在する場合はpkgにコピー

wasm32-unknown-unknownの「wasm32」はアドレス空間が32bitであること、1つ目の「unknown」はコンパイルを行うシステムのこと、2つ目の「unknown」はターゲットとしているシステムのことを示しています。つまり、wasm32-unknown-unknownはコンパイルを行うシステムとターゲットとするシステムの両方に制約がなく、どのような実行環境でも動作することを示します。

何故C/C++

完全には理解できませんでしたが、wasm-packはC/C++の標準ライブラリにリンクする機能が含まれていないようです(参考1参考2)。

C/C++を呼び出しているとwasm-packは使用できないと述べましたが、実はwasm-packでも頑張ればできるらしいです。しかし、記事では依存元のソースコードをいじって動くようにしており、できる限り依存元のソースコードは触りたくないため選択肢から除外しました。どうしてもwasm-packを使いたい方はこちらの記事の方法を試してみてはいかがでしょうか。

wasm32-unknown-emscripten

emscriptenのコンパイラ(emcc)を利用してコンパイルを行います。emscriptenとはC/C++をwasmにコンパイルするためのClang/LLVMベースのコンパイラです。
C/C++を呼んでいる場合はこちらの方法をオススメします。

チュートリアル

  1. Python3のインストール
    Python3をインストールしていない方はインストールしてください。

  2. emscriptenのインストール
    まずemsdkをインストールします

    git clone https://github.com/emscripten-core/emsdk.git

    emsdkを利用してemscriptenをインストールします。ここでバージョンを2.0.24にしている点に注意してください。(私の環境では最新のemscriptenでは成功しませんでした。)

    cd emsdk
    ./emsdk install 2.0.24

    emscriptenを有効にします。emccコマンドが実行できれば成功です。

    # 使用しているshellに合わせて実行するスクリプトを適宜変更してください
    source ./emsdk_env.sh
    emcc --version
    # emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 2.0.24 (416685fb964c14cde4be3e8a45ad26d75bac3e33)
    # Copyright (C) 2014 the Emscripten authors (see AUTHORS.txt)
    # This is free and open source software under the MIT license.
    # There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

    Windowsで公式ページのインストーラを用いてPythonのインストールを行なっており、かつGit Bashなどを使っている場合はemsdkの実行がうまくいかない場合があります。
    WindowsではデフォルトでPython3コマンドが入っていますが、そのコマンドはPython3ではなくMicrosoftのPython3インストールページが起動します。また、Python公式ページのインストーラを用いてインストールされるPython3はpythonコマンドで起動します。よって、無効なpython3コマンドと有効なpythonコマンドが存在している状態になります。
    emsdkではまずpython3コマンドを探し、存在しなければpythonコマンドを探します。そのため、先に述べた環境の場合は無効なpython3コマンドが使用されてしまいます。
    対応方法は以下の3つです。

    1. MicrosoftストアからPython3をインストールする
      • Microsoftストアが使える方はこの方法が正攻法です
      • 業務用PCなどでMicrosoftストアが使えない方は以下の方法を試してみてください
    2. emsdkのpython3コマンドを探す箇所を削除する
  3. プロジェクトの新規作成

    cargo new --lib hello-emscripten
  4. ターゲットにwasm32-unknown-emscriptenを追加

    rustup target add wasm32-unknown-emscripten
  5. Cargo.tomlを以下のように変更

    Cargo.toml
    [package]
    name = "hello-emscripten"
    version = "0.1.0"
    edition = "2021"

    [lib]
    crate-type = ["cdylib"]
  6. src/lib.rsを以下のように変更

    src/lib.rs
    use std::ffi::{c_char, CString};

    #[no_mangle]
    pub fn greet(src: *mut c_char) -> *mut c_char {
    let src = unsafe {
    match src.as_mut() {
    Some(src) => {
    // ポインタからCStringに変換
    let s = CString::from_raw(src);
    // CStringからStringに変換
    s.into_string().unwrap()
    }
    None => "guest".to_owned(),
    }
    };

    let res = format!("Hello, {src}!");
    // Rustの文字列から終端文字がnullのC形式の文字列に変換し、ポインタに変換
    CString::new(res).unwrap().into_raw()
    }

    #[no_mangle]アトリビュートを付与することで関数名をマングリングしないようにすることができます。マングリングとはコンパイラが関数名などをユニークな名前に変更することです。(例: int Add(int a, int b)_Z3Addii)
    今回の例では関数greetの名前を勝手に変更してほしくないので#[no_mangle]アトリビュートを付与しています。

  7. build.shの作成
    プロジェクトのルートディレクトリにbuild.shを作成します。

    build.sh
    # 自分の環境のemsdkの場所に合わせてパスに書き換えてください
    # 使用しているshellに合わせて実行するスクリプトを適宜変更してください
    # emccを有効にする
    source ../emsdk/emsdk_env.sh

    # emccの設定
    export EMCC_CFLAGS="-o hello-emscripten.js
    -s EXPORTED_FUNCTIONS=['_greet']
    -s EXPORTED_RUNTIME_METHODS=ccall"
    # ビルド
    cargo build --target wasm32-unknown-emscripten --release

    emccの設定の詳細は以下の通りです。ドキュメントはこちら

オプション 説明
-o hello-emscripten.js jsのグルーコードを出力する リンク
-s EXPORTED_FUNCTIONS=[‘_greet’] エクスポートする関数の指定 リンク
-s EXPORTED_RUNTIME_METHODS=ccall エクスポートするランタイムメソッドの指定 リンク
  1. ビルドの実行

    source build.sh

    実行が完了するとプロジェクトのルートディレクトリにhello-emscripten.jshello-emscripten.wasmというファイルが生成されます。

  2. index.htmlの作成
    以下のようなindex.htmlを作成します

    index.html
    <html>

    <body>
    <!-- グルーコードの読み込み -->
    <script async src=hello-emscripten.js></script>

    <div style="text-align: center">
    <textarea id="name" rows="10" cols="30"></textarea>
    </div>
    <div style="text-align: center">
    <input type="button" value="greet" id="greet" />
    </div>
    <script>
    Module = {}
    Module["onRuntimeInitialized"] = function () {
    const name = document.getElementById("name");
    const button = document.getElementById("greet");
    button.addEventListener("click", (event) => {
    const target = name.value;
    const res = ccall("greet", "string", ["string"], [target]);
    console.log(res);
    });
    };
    </script>
    </body>

    </html>
  3. 実行
    適当な方法でローカルサーバを立てます。今回はpythonを使う方法でやってみます。

    python3 -m http.server 8080

    ブラウザでhttp://localhost:8080/にアクセスすると以下のようなページが表示されます。

greet テキストボックスに適当なテキストを入力し、下部のボタンを押します。 コンソールに"Hello, (入力したテキスト)!"と表示されれば成功です。 Hello, Tom!

SQLフォーマッタのwasm化をやってみる

私たちが作成したRust製SQLフォーマッタ(uroborosql-fmt)のwasm化をやってみます。

フォーマッタはCで書かれたtree-sitterに依存しているため、今回は先ほど紹介したemscriptenを使う方法でwasm化を行います。方法は先述したチュートリアルとほぼ同じなので詳細は割愛しますが、ビルド用シェルスクリプトは少し変更を加えたため説明します。

ビルド用シェルスクリプトを変更した理由は、依存しているプロジェクトであるtree-sitter-sql(tree-sitterのSQL文法)のビルドにおいて、EMCC_CFLAGS="-o uroborosql-fmt.html"のようにhtmlを出力する設定にしていると失敗してしまったためです。調査しましたが原因不明であったため、とりあえずtree-sitter-sqlだけ先にビルドし、その後にemccの設定を変更し、最後に全体のビルドを行うアプローチを取りました。

cargo build-vvを付与(“very verbose”モード、処理の詳細が出力される)して確認したところ、各ビルドでは以下のような処理を行なっていることがわかりました。

  1. 1回目のビルド
    • tree-sitter-sqlとそれに依存するライブラリをビルド、このときオブジェクトファイル等(*.a 、*.o )が生成される
  2. 2回目のビルド
    • uroborosql-fmtに依存するライブラリをビルド、このときtree-sitter-sqlはビルド済みとしてスキップ
    • uroborosql-fmtをコンパイルするときに依存するライブラリのオブジェクトファイル等があるパスがrustcに渡され、そこからオブジェクトファイル等を検索してまとめてwasm化する
build.sh
# 自分の環境のemsdk/emsdk_env.shのパスに書き換えてください
# emccを有効にする
source ../emsdk/emsdk_env.sh

# emccの設定変更
export EMCC_CFLAGS="-O3"
# tree-sitter-sqlのビルドを実行
cargo build --package tree-sitter-sql --target wasm32-unknown-emscripten --release

# emccの設定変更
export EMCC_CFLAGS="-O3 -o uroborosql-fmt.js -s EXPORTED_FUNCTIONS=['_format_sql'] -s ALLOW_MEMORY_GROWTH=1 -s EXPORTED_RUNTIME_METHODS=ccall"
# 全体のビルドを実行
cargo build --target wasm32-unknown-emscripten --release
オプション 説明
-O3 最高レベルの最適化 リンク
-o uroborosql-fmt.js jsのグルーコードを出力する リンク
-s EXPORTED_FUNCTIONS=[‘_format_sql’] エクスポートする関数の指定 リンク
-s ALLOW_MEMORY_GROWTH=1 動的にメモリを増やす リンク
-s EXPORTED_RUNTIME_METHODS=ccall エクスポートするランタイムメソッドの指定 リンク

今回はSQLフォーマッタなので、動的にメモリを確保する方法を選択しました。ちなみにメモリサイズのデフォルトの初期値は16MB、最大値は2GBで、こちらもオプション(INITIAL_MEMORY, MAXIMUM_MEMORY)で変更できます。

私の環境では大差は見られませんでしたが、動的にメモリを確保する方法は重くなる可能性があるらしい(参考)ので、動的にメモリを確保する必要がないサービスの場合は避けた方が良いかもしれません。

ローカルサーバを立てて実行してみるとちゃんと動きました 🎉
format.gif

速度検証

napi-rsを用いてNodeアドオン化して拡張機能に載せたフォーマッタ(詳細はこちら)と今回作成したwasmで実行時間の計測を行なってみました。

最適化なしのwasmはビルドの際に--releaseを付与せずにビルドしたものです。

検証方法

  • フォーマット部分のみの時間を計測
  • 10200行のSQLを使用
  • 20回実行して90パーセンタイルを取得

検証結果

結果は以下のようになりました。

種類 時間(ms)
napi-rs 73.89
wasm 171.10

他の方の調査(1, 2, 3, 4)ではwasmはネイティブレベルかそれ以上の性能を叩き出していたので、wasmの方が2倍ほど遅いと言う結果は意外でした。しかし、遅いと言っても10200行のSQLで171msなので十分実用的な速度だと思います。

最適化検証

Rustの最適化レベルを変更してサイズ、速度の調査を行います。検証方法は速度検証と同様です。

検証結果

種類 説明 サイズ(KB) 時間(ms)
0 最適化なし 1392 428.70
1 基本的な速度最適化 1178 207.90
2 いくつかの速度最適化 1122 177.70
3 全ての速度最適化(リリースモードのデフォルト) 1124 171.10
“s” バイナリサイズの最適化 1113 218.80
“z” バイナリサイズの最適化+ループのベクトル化もオフ 1111 300.40

今回のケースではサイズの最適化を行なってもwasmのサイズに大きな変化は見られませんでした。

まとめ

C/C++を呼び出しているRustのwasm化について説明しました。

本記事には書きませんでしたが、tree-sitter-sqlのパーササイズが大きすぎてコンパイルできない問題などにも遭遇して非常に苦戦していました。最終的にはなんとかwasm化することができたのでよかったです。同様の問題を抱えている方の助けになれば幸いです。

参考文献