- 限界開発鯖 Advent Calender 2022 2日目 です
-
wasm-bindgen
とwasm-pack
使いたくねぇなって思ったので, 使わずにどうこうする方法をこいつらから学びました- 昨日の敵は今日の友, ボイラープレートは書き写せ
- RustとwasmとJavaScriptのことについて, 特に断りは入れません
前置き
wasm-bindgen
も wasm-pack
も使いたくねえので, 自分で1から値を受け渡しします.
ただ, プリミティブ以外の値の受け渡しについて記事が見つからなかったので, 自分で書き残しておこうと思います.
環境
> uname -snrmo
Linux archlinux 5.15.78-1-lts x86_64 GNU/Linux
> 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)`
[target.'cfg(all())']
rustflags = ["-C", "target-cpu=native", "-Z", "share-generics=y"]
(nightly常用者なのでnightlyになっていますが, 多分他のchannelでも大丈夫だと思います)
あとWebAssemblyの実行にはdenoを使います.
> deno -V
deno 1.28.2
作業
作業空間作成はいつものことなのでざっくりとだけ.
> 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
[package]
name = "workspace"
version = "0.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
あと利便性のためsymlink貼っときます.
> 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
技術的詳細 / 参考
- 今更だけどRustでwasmを試す | ryonkmr.com
- rustc/wasm-bindgenの生成するWebAssemblyを観察する
- WebAssembly 開発環境構築の本 | WebAssembly 入門
この辺と, あと 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
まず腕ならしと行こう.
ちなみにこれは一瞬で書けるし余裕で動く.
#[no_mangle]
extern fn identity(n: i32) -> i32 { n }
buildして, 以下のようにdenoから操作してみる.
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));
出力は:
10
0
-1
まぁ, そうよね.
観察: () -> &str
本題. "値を受け渡し" と銘打っているが, 具体的にはバイト列の受け渡しがしたい.
だけど, まぁなんかいいかなって思って (?) 取り敢えず文字列で試してみることにした.
#[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
はそのまま吐いてはいけなさそうな感じがする. 取り敢えず素直に生ポインタに直しておく.
#[no_mangle]
extern fn return_str() -> *const char { "hi, wasm.".as_ptr() as _ }
まあ, Cで文字列って言ったら char *
ですし, こちらも *const char
にしときました.
ではbuildして, 以下のようにdenoから操作してみる.
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());
出力は:
1048576
まぁ, そうですよね. *const char
を都合よく文字列と解釈してくれる訳ありませんよね…
まぁ後々分かるんですけど, これはアドレスです. 当然といえば当然ですが, なんか文字列をごにょごにょしたものかな〜 (語彙力) ってふんわりしてたら明後日の方向で検証してたので, 一応.
でも, まぁ文字列が格納されたアドレスが貰えた所で, どこから取り出すの? となる訳です.
観察: () -> String?
昨日の敵は今日の友, wasm-bindgen
と wasm-pack
に頼ってみます.
あ, しれっと cargo-edit
使ってます. ちなみに v0.11.6
です.
> 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
を返させようと (脳死で) したらですね,
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
に変更.
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
が通らないんですよね.
> cargo install wasm-pack --features curl/static-curl
私はこうやって導入してます. ちなみに公式のインストーラ使ってもいいですが, あれはリリースに置いてある (Linuxなら x86_64-unknown-linux-musl
向けの) バイナリを落としてきてるだけなので, 実質的にさっきのIssueで言及されていた解決法と被るんですよね. ちなみに手元環境で -musl
向けのbuildは試してません.
> wasm-pack build -t web
何やら色々出ますが, 無視. pkg/
に色々吐かれます.
pkg
├── package.json
├── workspace.d.ts
├── workspace.js
├── workspace_bg.wasm
└── workspace_bg.wasm.d.ts
でまあ, denoから使ってみよう.
import init, { return_string } from "./pkg/workspace.js";
await init();
console.log(return_string());
なにやらよくわからんお作法が出てきていますがこの辺は割愛. 出力は:
hi, wasm.
おお, ちゃんと出ている…
ここででっけぇボイラープレート (pkg/workspace.js
) についてかいつまんで見ていく. ちなみに他のファイルはほぼ見るとこない. .wasm
はただのbuild済みのバイナリだし, *.d.ts
はただの型定義ファイルだし.
// --- 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
の方が分かりやすく:
// --- 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
について.
// --- 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側のマクロを展開してみる.
> cargo expand
--- snip ---
Finished dev [unoptimized + debuginfo] target(s) in 7.53s
#![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
)
そして行き着いたのが これ:
vectors! {
u8 i8 u16 i16 u32 i32 u64 i64 usize isize f32 f64
}
もちろん冗談です. このマクロの 行き着く先 は (インデント調節してます):
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
で, r1
が len
なんだな〜って気がしてきますね?しますよね?
// --- snip ---
function getStringFromWasm0(ptr, len) {
return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}
// --- snip ---
(笑)
全部の実装までは出しませんが, この get*Memory0()
ってやつは TypedArray
でメモリのビューを取ってます. 代表として Uint8Array
について見ると:
// --- 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
はお役御免.
> cargo rm wasm-bindgen
Removing wasm-bindgen from dependencies
で, 実装.
#[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で以下のように操作する:
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);
出力は:
hi, wasm.
きたこれ.
実装: () -> *const FatPointer
(to [u8]
)
目標であったバイト列について. さっきの文字列とほぼやってることは同じ.
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で以下のように操作する:
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);
そして, 出力:
[ 0, 1, 2, 3 ]
GG. 対戦ありがとうございました.
最後に
めちゃくちゃ取り急ぎで書いた記事なので, 掘っ建て感満載ですが直近でやったこととその備忘録として執筆しました. 実はこれ以外にも値を受け取ったり, 変更したり, あとメモリリーク部分の後始末も…って色々したかったんですけど, 時間の都合上盛り込めませんでした. っていうかまだやったことないし. 書けない…
宣伝と背水の陣ですが, 私のやる気があれば Nanai10a/quanote に今回のwasmの知識が盛り込まれたアプリケーションがそのうち上がるはずです. まだなにもありません.
もしこの記事が何かの参考になれば幸いです. では.