LoginSignup
4
1

More than 1 year has passed since last update.

WebAssemblyでRustから値を橋渡しする備忘録

Last updated at Posted at 2022-12-01
  • 限界開発鯖 Advent Calender 2022 2日目 です
  • wasm-bindgenwasm-pack 使いたくねぇなって思ったので, 使わずにどうこうする方法をこいつらから学びました
    • 昨日の敵は今日の友, ボイラープレートは書き写せ
  • RustとwasmとJavaScriptのことについて, 特に断りは入れません

前置き

wasm-bindgenwasm-pack も使いたくねえので, 自分で1から値を受け渡しします.
ただ, プリミティブ以外の値の受け渡しについて記事が見つからなかったので, 自分で書き残しておこうと思います.

環境

zsh
> uname -snrmo
Linux archlinux 5.15.78-1-lts x86_64 GNU/Linux
zsh
> rustup -V
rustup 1.25.1 (2022-11-01)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.67.0-nightly (73c9eaf21 2022-11-07)`
~/.cargo/config.toml より抜粋
[target.'cfg(all())']
rustflags = ["-C", "target-cpu=native", "-Z", "share-generics=y"]

(nightly常用者なのでnightlyになっていますが, 多分他のchannelでも大丈夫だと思います)

あとWebAssemblyの実行にはdenoを使います.

zsh
> deno -V
deno 1.28.2

作業

作業空間作成はいつものことなのでざっくりとだけ.

zsh
> cargo new workspace --lib
     Created library `workspace` package
> cd workspace
> # Cargo.tomlを編集
> cargo build --target wasm32-unknown-unknown # 今後, buildする際にはこれを実行しているとする
   Compiling workspace v0.0.0 (/home/user/workspace)
--- snip ---
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
Cargo.toml
[package]
name = "workspace"
version = "0.0.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

あと利便性のためsymlink貼っときます.

zsh
> ln -s target/wasm32-unknown-unknown/debug/workspace.wasm .

ディレクトリ構造について, 主要な部分だけ. (lsd に吐かせた)

.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
├── target
│   └── wasm32-unknown-unknown
│       └── debug
│           └── workspace.wasm
└── workspace.wasm → target/wasm32-unknown-unknown/debug/workspace.wasm

技術的詳細 / 参考

この辺と, あと rustwasm/wasm-bindgen (wasm-bindgen ってのはもちろんこれのこと) の吐くボイラープレートと, rustwasm/wasm-pack (wasm-pack はこれ) の吐くボイラープレートを観察している.

で, まずwasmのデータ型は何があるんじゃいってのが重要で, これは Types — WebAssembly 2.0 (Draft 2022-11-09) より:

  • Number (i32 i64 f32 f64)
    • これが重要
  • Vector (v128)
    • SIMDとかに使うらしい, 蚊帳の外
  • Reference (…)
    • 関数ポインタのこと?よくわかんない

ってんで, 次にRustのプリミティブとの対応は WebAssembly 開発環境構築の本 | WebAssembly 入門#暗黙の型変換 を見れば大体分かります. ちなみに何故か usize との対応が書いてないんですけど, 見た感じ i32 っぽいです.

なので, まぁまぁプリミティブから当てにならない (Rustのそれとは違う) 上, 型変換で予期しない動作をしたらたまったもんじゃない. のでこの辺を意識しておく必要がある.

また, rustc/wasm-bindgenの生成するWebAssemblyを観察する : "struct" から:

  • 構造体をまんま返すってのはできない
    • もちろんバイト列を返すってのも出来ない
  • のでどっかで動的にメモリを確保した上でJavaScript側から吸い出してやる必要がある
  • もちろん取得, 開放はプログラマの責任 (Rust的に言えばunsafe)

ってのを知った.

ちなみにここで述べてるのは手元でもちゃもちゃしてた時のことなので, これより後の文言とはあんまり関係がない. 以後の文書部分で全部読み取れる…と思う.

実装: (i32) -> i32

まず腕ならしと行こう.
ちなみにこれは一瞬で書けるし余裕で動く.

src/lib.rs
#[no_mangle]
extern fn identity(n: i32) -> i32 { n }

buildして, 以下のようにdenoから操作してみる.

stdin
const raw = await Deno.readFile("workspace.wasm");
const mod = new WebAssembly.Module(raw);
const { exports: wasm } = new WebAssembly.Instance(mod);

console.log(wasm.identity(10));
console.log(wasm.identity(0.1));
console.log(wasm.identity(-1));

出力は:

stdout
10
0
-1

まぁ, そうよね.

観察: () -> &str

本題. "値を受け渡し" と銘打っているが, 具体的にはバイト列の受け渡しがしたい.
だけど, まぁなんかいいかなって思って (?) 取り敢えず文字列で試してみることにした.

src/lib.rs
#[no_mangle]
extern fn return_str() -> &'static str { "hi, wasm." }

すると, 以下の警告が表示される.

warning: `extern` fn uses type `str`, which is not FFI-safe
 --> src/lib.rs:2:27
  |
2 | extern fn return_str() -> &'static str { "hi, wasm." }
  |                           ^^^^^^^^^^^^ not FFI-safe
  |
  = help: consider using `*const u8` and a length instead
  = note: string slices have no C equivalent
  = note: `#[warn(improper_ctypes_definitions)]` on by default

確かに str はそのまま吐いてはいけなさそうな感じがする. 取り敢えず素直に生ポインタに直しておく.

src/lib.rs
#[no_mangle]
extern fn return_str() -> *const char { "hi, wasm.".as_ptr() as _ }

まあ, Cで文字列って言ったら char * ですし, こちらも *const char にしときました.
ではbuildして, 以下のようにdenoから操作してみる.

stdin
const raw = await Deno.readFile("workspace.wasm");
const mod = new WebAssembly.Module(raw);
const { exports: wasm } = new WebAssembly.Instance(mod);

console.log(wasm.return_str());

出力は:

stdout
1048576

まぁ, そうですよね. *const char を都合よく文字列と解釈してくれる訳ありませんよね…
まぁ後々分かるんですけど, これはアドレスです. 当然といえば当然ですが, なんか文字列をごにょごにょしたものかな〜 (語彙力) ってふんわりしてたら明後日の方向で検証してたので, 一応.
でも, まぁ文字列が格納されたアドレスが貰えた所で, どこから取り出すの? となる訳です.

観察: () -> String?

昨日の敵は今日の友, wasm-bindgenwasm-pack に頼ってみます.
あ, しれっと cargo-edit 使ってます. ちなみに v0.11.6 です.

zsh
> cargo add wasm-bindgen
    Updating crates.io index
      Adding wasm-bindgen v0.2.83 to dependencies.
             Features:
             + spans
             + std
             - enable-interning
             - serde
             - serde-serialize
             - serde_json
             - strict-macro
             - xxx_debug_only_print_generated_code

() -> &str に倣って wasm-bindgen にも str を返させようと (脳死で) したらですね,

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

#[wasm_bindgen]
pub fn return_string() -> &'static str { "hi, wasm." }
error: it is currently not sound to use lifetimes in function signatures
 --> src/lib.rs:4:28
  |
4 | pub fn return_string() -> &'static str { "hi, wasm." }
  |                            ^^^^^^^

と怒られるので, String に変更.

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

#[wasm_bindgen]
pub fn return_string() -> String { "hi, wasm.".to_owned() }

これはbuildできます. で, ここで wasm-pack の出番. ちなみにこれは v0.10.3 .

wasm-pack について

なんかよく分かんないけど, cargo install が通らないんですよね.

🐛 v0.10.3 "Segmentation fault (core dumped)" when installing from sources · Issue #1186 · rustwasm/wasm-pack

zsh
> cargo install wasm-pack --features curl/static-curl

私はこうやって導入してます. ちなみに公式のインストーラ使ってもいいですが, あれはリリースに置いてある (Linuxなら x86_64-unknown-linux-musl 向けの) バイナリを落としてきてるだけなので, 実質的にさっきのIssueで言及されていた解決法と被るんですよね. ちなみに手元環境で -musl 向けのbuildは試してません.


zsh
> wasm-pack build -t web

何やら色々出ますが, 無視. pkg/ に色々吐かれます.

pkg
├── package.json
├── workspace.d.ts
├── workspace.js
├── workspace_bg.wasm
└── workspace_bg.wasm.d.ts

でまあ, denoから使ってみよう.

stdin
import init, { return_string } from "./pkg/workspace.js";

await init();

console.log(return_string());

なにやらよくわからんお作法が出てきていますがこの辺は割愛. 出力は:

stdout
hi, wasm.

おお, ちゃんと出ている…
ここででっけぇボイラープレート (pkg/workspace.js) についてかいつまんで見ていく. ちなみに他のファイルはほぼ見るとこない. .wasm はただのbuild済みのバイナリだし, *.d.ts はただの型定義ファイルだし.

pkg/workspace.js
// --- snip ---
async function init(input) {
    if (typeof input === 'undefined') {
        input = new URL('workspace_bg.wasm', import.meta.url);
    }
    const imports = getImports();

    if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) {
        input = fetch(input);
    }

    initMemory(imports);

    const { instance, module } = await load(await input, imports);

    return finalizeInit(instance, module);
}
// --- snip ---

まず init についてですが, 実は initSync の方が分かりやすく:

pkg/workspace.js
// --- snip ---
function initSync(module) {
    const imports = getImports();

    initMemory(imports);

    if (!(module instanceof WebAssembly.Module)) {
        module = new WebAssembly.Module(module);
    }

    const instance = new WebAssembly.Instance(module, imports);

    return finalizeInit(instance, module);
}
// --- snip ---

私が自分で書いてたWebAssemblyのインスタンス生成 (denoを使った操作の冒頭部分) とほぼ同じ.
と, その他諸々のあんましようわからん (wasm-bindgen のお作法なので詳しいことは知らない) ことをしてます.

で次, return_string について.

pkg/workspace.js
// --- snip ---
export function return_string() {
    try {
        const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
        wasm.return_string(retptr);
        var r0 = getInt32Memory0()[retptr / 4 + 0];
        var r1 = getInt32Memory0()[retptr / 4 + 1];
        return getStringFromWasm0(r0, r1);
    } finally {
        wasm.__wbindgen_add_to_stack_pointer(16);
        wasm.__wbindgen_free(r0, r1);
    }
}
// --- snip ---

まずなんとなく分かるのは, "wasm.return_string, なんか自分の書いた関数と違くね?" ってこと.
というわけで cargo-expand (1.0.35 + prettyplease 0.1.21) を使ってRust側のマクロを展開してみる.

zsh
> cargo expand
--- snip ---
    Finished dev [unoptimized + debuginfo] target(s) in 7.53s
stdout
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use wasm_bindgen::prelude::*;
pub fn return_string() -> String {
    "hi, wasm.".to_owned()
}
#[automatically_derived]
const __wasm_bindgen_generated_return_string__const: () = {
    pub unsafe extern "C" fn __wasm_bindgen_generated_return_string() -> <String as wasm_bindgen::convert::ReturnWasmAbi>::Abi {
        let _ret = {
            let _ret = return_string();
            _ret
        };
        <String as wasm_bindgen::convert::ReturnWasmAbi>::return_abi(_ret)
    }
};

これを見てもなんか関数シグネチャが違う気がするけど, あんま気にしないでおきます.
ここで注目すべきなのは, ReturnWasmAbi::return_abi ってやつ. これ, めちゃくちゃコールスタックが深いので, ざっくり見ると:

  • String::return_abi (ReturnWasmAbi)
  • String::into_abi (IntoWasmAbi)
  • String::into_bytes
  • Vec::<u8>::return_abi (IntoWasmAbi)
  • Vec::<u8>::into_boxed_slice
  • Box::<[u8]>::into_abi (IntoWasmAbi)

そして行き着いたのが これ:

wasm-bindgen/src/convert/slices.rs#L128~130
vectors! {
    u8 i8 u16 i16 u32 i32 u64 i64 usize isize f32 f64
}

もちろん冗談です. このマクロの 行き着く先 は (インデント調節してます):

wasm-bindgen/src/convert/slices.rs#L33-L46 fixed
impl IntoWasmAbi for Box<[$t]> {
    type Abi = WasmSlice;

    #[inline]
    fn into_abi(self) -> WasmSlice {
        let ptr = self.as_ptr();
        let len = self.len();
        mem::forget(self);
        WasmSlice {
            ptr: ptr.into_abi(),
            len: len as u32,
        }
    }
}

ただのfat pointerやんけ. ちなみに WasmSlice ってのは:

#[repr(C)]
pub struct WasmSlice {
    pub ptr: u32,
    pub len: u32,
}

こんな感じ.
これをどうやって帰り値にしてるのかまでは分からないけど, 取り敢えずfat pointerもどきのデータ構造を返してるのは分かった. で, これを踏まえた上でさっきの return_string についてのボイラープレートを見てみよう.

        wasm.return_string(retptr);
        var r0 = getInt32Memory0()[retptr / 4 + 0];
        var r1 = getInt32Memory0()[retptr / 4 + 1];
        return getStringFromWasm0(r0, r1);

なんとなく, r0 ってのが ptr で, r1len なんだな〜って気がしてきますね?しますよね?

pkg/workspace.js
// --- snip ---
function getStringFromWasm0(ptr, len) {
    return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}
// --- snip ---

(笑)

全部の実装までは出しませんが, この get*Memory0() ってやつは TypedArray でメモリのビューを取ってます. 代表として Uint8Array について見ると:

pkg/workspace.js
// --- snip ---
let cachedUint8Memory0 = new Uint8Array();

function getUint8Memory0() {
    if (cachedUint8Memory0.byteLength === 0) {
        cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer);
    }
    return cachedUint8Memory0;
}
// --- snip ---

てなわけで, TypedArray のコンストラクタで言うところの new Uint8Array(buffer [, byteOffset [, length]]); を使って ArrayBuffer のビューを取ってます. 対象となる ArrayBuffer は生成済みの WebAssembly.Instance から .exports.memory にある WebAssembly.Memory オブジェクトの .buffer プロパティから参照できます.

実装: () -> *const FatPointer (to str)

そろそろ読むだけ読んだので, 文字列を自分で吐き出してみる.
取り敢えず wasm-bindgen はお役御免.

zsh
> cargo rm wasm-bindgen
    Removing wasm-bindgen from dependencies

で, 実装.

src/lib.rs
#[repr(C)]
struct FatPointer {
    ptr: usize,
    ext: usize,
}

#[no_mangle]
extern fn return_str() -> *const FatPointer {
    let str = "hi, wasm.";

    let fatptr = FatPointer {
        ptr: str.as_ptr() as _,
        ext: str.len() as _,
    };

    let boxed = Box::new(fatptr);
    let ptr = Box::into_raw(boxed) as *const _;

    ptr
}

残念なことにfat pointerはそのままではJavaScript側に返せないので一度ヒープに置いて読み出させる. 実行ごとに core::mem::sizeof::<FatPointer>() 分のメモリリークが起きていることに留意.
実際に返す値は *const FatPointer なので, ヒープ上の格納位置へのポインタとなる.

buildして, いざ読み出してみよう. denoで以下のように操作する:

stdin
const raw = await Deno.readFile("workspace.wasm");
const mod = new WebAssembly.Module(raw);
const { exports: wasm } = new WebAssembly.Instance(mod);

const ptr = wasm.return_str();
const i32view = new Int32Array(wasm.memory.buffer);
const fatptr = {
  ptr: i32view[ptr / 4 + 0],
  ext: i32view[ptr / 4 + 1],
};

const u8view = new Uint8Array(wasm.memory.buffer);
const rawstr = u8view.subarray(fatptr.ptr, fatptr.ptr + fatptr.ext);

const decoder = new TextDecoder("utf-8", { ignoreBOM: true, fatal: true });
const str = decoder.decode(rawstr);

console.log(str);

出力は:

stdout
hi, wasm.

きたこれ.

実装: () -> *const FatPointer (to [u8])

目標であったバイト列について. さっきの文字列とほぼやってることは同じ.

src/lib.rs
use core::mem::ManuallyDrop;

#[repr(C)]
struct FatPointer {
    ptr: usize,
    ext: usize,
}

#[no_mangle]
extern fn return_str() -> *const FatPointer {
    let bytes = ManuallyDrop::new(vec![0x00, 0x01, 0x02, 0x03 as u8]);

    let fatptr = FatPointer {
        ptr: bytes.as_ptr() as _,
        ext: bytes.len() as _,
    };

    let boxed = Box::new(fatptr);
    let ptr = Box::into_raw(boxed) as *const _;

    ptr
}

気をつける点としては core::mem::ManuallyDrop を使っている点. Vec は動的メモリ確保なので, そのまま野放しにしたらメモリ領域が開放されてしまう. なので, 要は Vec::drop を呼び出させないよう, メモリを開放しないよう にしている訳だ. ちなみにもれなくこれもメモリリークである.
そしたら, 読み出して配列に格納する. denoで以下のように操作する:

stdin
const raw = await Deno.readFile("workspace.wasm");
const mod = new WebAssembly.Module(raw);
const { exports: wasm } = new WebAssembly.Instance(mod);

const ptr = wasm.return_str();
const i32view = new Int32Array(wasm.memory.buffer);
const fatptr = {
  ptr: i32view[ptr / 4 + 0],
  ext: i32view[ptr / 4 + 1],
};

const u8view = new Uint8Array(wasm.memory.buffer);
const rawbytes = u8view.subarray(fatptr.ptr, fatptr.ptr + fatptr.ext);
const bytes = [];
rawbytes.forEach((e) => bytes.push(e));

console.log(bytes);

そして, 出力:

stdout
[ 0, 1, 2, 3 ]

GG. 対戦ありがとうございました.

最後に

めちゃくちゃ取り急ぎで書いた記事なので, 掘っ建て感満載ですが直近でやったこととその備忘録として執筆しました. 実はこれ以外にも値を受け取ったり, 変更したり, あとメモリリーク部分の後始末も…って色々したかったんですけど, 時間の都合上盛り込めませんでした. っていうかまだやったことないし. 書けない…

宣伝と背水の陣ですが, 私のやる気があれば Nanai10a/quanote に今回のwasmの知識が盛り込まれたアプリケーションがそのうち上がるはずです. まだなにもありません.

もしこの記事が何かの参考になれば幸いです. では.

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1