😹

C言語へのFFIを含むRustをWASM化するのは難しすぎる

2022/07/12に公開約8,600字6件のコメント

つらみ

https://zenn.dev/newgyu/articles/8bff73505c7b35

PlantUMLをwasm化するためにGraphvizへの依存をどうしたものか考えていました。すべてRustで書き直せればそれがいちばん手堅いのですが、Graphvizのソースコードは中々に大きく、それをRustで書き直すのは現実的ではありません。そこで考えたのが、RustからFFIでGrapvizのC++コードを呼ぶようにして、それをwasm化すればいいじゃないかというアイデアです。

こんなことを言いましたがツラい・・・この道はツラいです。もう諦めようと思っています。私の力では限界を感じました。

というわけで、やり散らかしたままにしておいても時間の無駄になるので何らか学びを得るためにまとめてみようと思います。

RustでCのライブラリにFFIするのは簡単だ

RustはそもそもFFIの機構を持っている

https://doc.rust-lang.org/nomicon/ffi.html

Rustはにはexternキーワードがあり、以下のようにC言語の関数をRustから参照して呼ぶことが出来ます。

extern "C" {
    fn my_c_function(x: i32) -> bool;
}

ただその際に3つ気にすべきことがあります。

  1. リンク問題。その関数はどのライブラリファイルの中に入っているものなのか
  2. 型定義問題。Rustの型とCの型は微妙に異なる
  3. unsafe問題

リンク問題

なんとかしてお目当てのC言語の関数が含まれているライブラリファイルの場所をRustコンパイラに教える必要があります。ほとんどの場合はCargoが使われるでしょうからCargoに焦点を当てます。

Library search path

RustのFFIはStatic/Dynamicいずれのリンクもサポートします。

Dynamic Library(*.so)の場合は公式ドキュメントにも書かれている通りLD_LIBRARY_PATHを参照します。

Static Library(*.a)の場合にはデフォルトで参照しに行くパスは無いようで、-Lオプションまたは、build.rsでprintf("cargo:rustc-link-search=static={}", path)を書くことで指定可能です。(Dynamic Libraryでもこの方法で指定可能です)

search pathの中から具体的にどのライブラリファイルにリンクするかですが2通り方法があります。

  1. ソースコード中のlink属性で指定する
  2. build.rsでprintf("cargo:rustc-link-lib=[KIND=]{}", name)を書く

ここに書くnameはファイル名と完全一致しません。Unix系の命名規則が暗黙に期待されています。

  • kind=dylib, name=my_functions => libmy_functions.so
  • kind=static, name=my_functions => libmy_functions.a

型定義問題

Rustの持つ型はC言語の型に近い物が多いですがギャップはあります。
例えばC言語では文字列がヌル文字終端であったり、生のポインタを扱ったりしますがRustはそうではありません。そのために相互に型変換が必要になります。

型変換に関してはこちらの記事が非常に参考になりました。

https://zenn.dev/eduidl/articles/f2fd959f220393

unsafe問題

これは問題というよりただのルールです。Rustはコンパイラで安全性を保証する思想になっていますが、externで外部参照する関数はコンパイラの目が行き届きません。そのため、externで外部参照する関数はunsafeブロックで囲う必要があります。

pub fn hello_by_c() -> String {
    unsafe { CStr::from_ptr(ffi_example_sys::hello()) }
        .to_str()
        .unwrap()
        .to_string()

CargoはC言語のコードを一緒にビルドすることができる

前述の通りRustはFFIの機構で既存の外部ライブラリに含まれる関数を参照して利用する事ができます。それだけではなく、cargo build時に言語のソースコードをコンパイルしライブラリファイルとしてビルドすることも出来ます。

これは前回の記事にも書いた通り、build.rsファイルCMakeクレートCCクレートを使うことで可能です。

https://github.com/NewGyu/rust-ffi-with-cmake-example/blob/master/ffi-example-sys/build.rs

RustでWASMにコンパイルするのは簡単だ

Rustのエコシステムにはwasm-packというモダンなWASM向けのツールがあります。これまでC/C++の界隈でWASMツールとしてメジャーだったEmscriptenとは別のツール群になります。

MDNのチュートリアルが非常にわかりやすいです。

https://developer.mozilla.org/ja/docs/WebAssembly/Rust_to_wasm

C言語へのFFIを含むRustをWASM化するのは難しすぎる

さて、2つのことはそれぞれ簡単なのに組み合わさるととても難しくなってしまうのは何故なのでしょうか。我々はその謎を解き明かすべくアマゾンの奥地へと足を踏み入れることになります。

何が難しかったのかというとWASMを作るためにwasm-packとEmscriptenという2つの出自の違うものが混ざることでした。

WASM Coreは余計なものを纏わない全裸主義

https://webassembly.github.io/spec/core/intro/introduction.html#security-considerations

WebAssembly provides no ambient access to the computing environment in which code is executed. Any interaction with the environment, such as I/O, access to resources, or operating system calls, can only be performed by invoking functions provided by the embedder and imported into a WebAssembly module.

WASMは設計思想としてIOアクセスやシステムコールなどの機能を単独ではサポートしません。それらの機能はWASMを実行するRuntime環境からインポートしてもらうことになっています。

WASM JavaScript Interface

WASMはその生まれの由来から主なRuntime環境としてブラウザを想定しています。そしてブラウザと言えばJavaScriptが動作する環境であるためJavaScript InterfaceでWASMにJavaScriptの関数をインポートして利用するための仕様が決められています。つまりは足りないものはJavaScript頼みなのです。

WASIの話はここでは端折ります。

一般的なプログラムはどうか?

多くの人はプログラムに入門するときにHello Worldを書くと思います。

main.c
 #include <stdio.h>
 int main() {
     printf("Hello World");
     return 0;
 }

C言語ではstdioライブラリに含まれるprintf関数を利用して標準出力に出力します。

main.rs
fn main() {
    println!("Hello, world!");
}

Rustではstdクレートに含まれるprintln!マクロを利用して標準出力に出力します。

このような単純なプログラムですら実は言語処理系が標準的に具備するライブラリに頼っています。

ところが前述の通りWASMはこのような標準ライブラリが当たり前に持っている機能を具備していません。そのためWASM一人ではHello Worldを動かすことができません。printf/println!相当の標準出力する関数を外部からインポートしてもらう必要があるのです。

この標準ライブラリ相当の部分をどうするかというアプローチがEmscriptenとwasm-packでは全く異なっているのです。

Emscriptenは既存のCのコードをほぼそのまま動かせるよう頑張っている

https://emscripten.org/docs/porting/emscripten-runtime-environment.html

The Emscripten runtime environment is different to that expected by most C/C++ applications. Emscripten works hard to abstract and mitigate these differences, so that in general code can be compiled with little or no change.

Emscriptenはここに書かれているようにネイティブ用にかかれたC言語コードをできるだけそのままコンパイルできるように努力しています。EmscriptenにはemccというCコンパイラに一枚皮を被せたようなコマンドツールがあります。皮を被せたと言うのは事実で内部はclangコンパイラになっています。

皮を被せて何をやっているかというと以下のことをやっています。

  • clang/llvmを使ってコードをWASM化
  • 生成されたWASMを実行するためのJavaScriptコードを生成

※ emccはpythonで記述されており読むことは出来ますがきちんと読んでいないので想像も入っています。

Emscriptenは元々asm.jsへのトランスパイラだったということもあり、2点目のJavaScriptコード生成にとても力が入っており、ここで標準ライブラリ関数の多くをサポートできるようにJavaScriptのコードを大量に吐いています。

wasm-packはあっさりしている

wasm-packは単にwasm-bindgenのラッパーだったりします。

https://rustwasm.github.io/wasm-bindgen/introduction.html

wasm-bindgenは#[wasm_bindgen]属性をRustコードに付加することでJSとのインターフェースを生成してくれる一種のジェネレーターです。

MDNのチュートリアルのコードを見てみましょう。これはJS<-->Rust双方向の例です。

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));
}

上部のexternしている部分はJavaScriptのalert関数をexternで外部参照することを示しています。下部はRustで書かれたgreet関数をJavaScriptから呼べるようにしています。wasm_bindgen属性が付加されることでwasm-bindgenがいい感じのJavaScriptコードを吐いてくれます。

以下はwasm-bindgenが生成したJavaScriptの一部抜粋です。

export function greet(name) {
    const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
    const len0 = WASM_VECTOR_LEN;
    wasm.greet(ptr0, len0);    //wasmに定義されたgreet関数を読んでいる
}

async function load(module, imports) {
        const bytes = await module.arrayBuffer();
        return await WebAssembly.instantiate(bytes, imports); //wasmをloadするときにimportsを練り込んでいる
}

async function init(input) {
    const imports = {};
    imports.wbg = {};
    imports.wbg.__wbg_alert_a5a2f68cc09adc6e = function(arg0, arg1) {  //importsにalert関数を詰め込んでいる
        alert(getStringFromWasm0(arg0, arg1));
    };

このようにwasm-pack/wasm-bindgenでは使いたいJavaScript関数を明示的にimportするスタイルになっています。

ここがEmscriptenとは大きく考え方が異なる部分です。

そうなると何が困るのか

私のケースの場合、次のようなビルド構成になっていました。

  • GraphvizはCで書かれてEmscriptenでWASM化される
  • Graphvizを使う部分をRustで書いてwasm-packでWASM化する

GraphvizをCMake+emccでビルドすることで中間的に生成される *.a, *.o はWASMバイナリとして生成され、rustcのリンカーはそれらを見事につなぎ合わせて一つのWASMファイルを生成してくれました。素晴らしい。

しかしそのWASMファイルの中を見て絶望しました。以下は生成されたWASMをWATにしたもののごく一部です。

  (import "env" "strncpy" (func $env.strncpy (type $t1)))
  (import "env" "fflush" (func $env.fflush (type $t4)))
  (import "env" "ftell" (func $env.ftell (type $t4)))
  (import "env" "malloc" (func $env.malloc (type $t4)))
  (import "env" "fseek" (func $env.fseek (type $t1)))
  (import "env" "fread" (func $env.fread (type $t6)))
  (import "env" "fiprintf" (func $env.fiprintf (type $t1)))
  (import "env" "vfprintf" (func $env.vfprintf (type $t1)))
  (import "env" "tmpfile" (func $env.tmpfile (type $t8)))

そう、EmscriptenでコンパイルされたGraphviz部分は大量のenvをインポートしてもらう前提になっていました。Emscriptenのアプローチを考えれば当然です。これの何が問題なのか?

パッケージ全体としてはwasm-packを使ってビルドしているので、wasm-packはこれらのenvのことについて感知していません。wasm-packが生成するJavaScriptファイルを見てみると、冒頭にただ一行こう書かれていました。

import * as __wbg_star0 from 'env';

そして、envに該当するファイルはどこにもありません。つまりはenvは自分で用意しなさいよということです。

envをどの様に用意して良いのか。これはEmscriptenが自動的に吐き出すのですが、EmscriptenはEmscriptenで自身でWASMモジュールをロードしてインスタンス化して実行する前提のJavaScriptコードを吐いてしまうので上手いことenvを組み立てているところだけ分離する必要があるのです。。。しかも(当然ですが)Emscriptenの吐き出すJavaScriptはminifyされており…ここで心が折れました。

まとめ

  • Emscriptenとwasm-packではJSグルーコードに対する考え方がぜんぜん違う
  • Emscripten側の吐くグルーコードから良いところだけ持ってくる方法が分からない
  • 詰んだ

Discussion

こんにちは。はじめまして。
こちらの記事大変参考になりました。WASM難しいですね、。

私も過去にRust+Graphvizを試してみたことがあります。その時のリポジトリです。

https://github.com/yskszk63/wasinodot

GraphvizのコンパイルにはWASIを使いました。
Cのツールチェーンには wasi-sdkを利用しています。
Rustもwasm32-wasiターゲットがあるので、importsが揃いEmscriptenより相性が良い印象です。

もし、ご参考になりましたら幸いです。

おおっ、ありがとうございます。
WASIで動かすの次のステップとして視野に入っていたのですが、まずはWeb Browserで動くところを目指していました。

一つ質問なのですが、

https://github.com/yskszk63/wasinodot/blob/main/web/public/libwasinodot.wasm
このwasmがWASI SDKでビルドされたものなのでしょうか? READMEのキャプチャを見るにブラウザ上で動作しているようですが、WASI SDKもブラウザ向けのwasmの生成をサポートしているのでしょうか?

あ、少し読んでわかったかもしれないです。
こちらSSRでServerSide側でwasm on wasi runtime で動かしている感じなのですね。

ブラウザ上でWASMを動かしてますよ。

http://wasinodot.vercel.app/

WebAssembly.instantiateに渡すWASIのI/Fに従ったimportObjectを用意できればブラウザ上でも動かせるようです。

https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-wasi_snapshot_preview1

I/O発生しない部分しか使わないので、空のimportObject実装を用意してブラウザ上で動作させています。

https://www.npmjs.com/package/empty-wasi

ありがとうございます。
確かにGraphvizにI/Oとか求めてなくて、既存C実装に入ってしまっているのをなんとかしたいだけなので、空のオブジェクトをインポートして回避できるならそれで十分ですね。試してみます。

ハテブ(普段見ない)みたらコメントついてたのに気づいたので。

  • JSでブリッジ…がよくわかりませんが、ここからRustで書いたWASMとしてPlantUMLライクなものを作るのがゴール感なのでJSをゴリゴリ描くのは視野に入っていません
  • ということでCをゴリゴリ書くのも視野に入っていません(Rustを実用してみたいという動機が強いので。)
  • https://github.com/hpcc-systems/hpcc-js-wasm のGraphvizのいなし方(=Emscriptenでビルドしている)を参考にした結果ここにたどり着いたという経緯です
ログインするとコメントできます