MashPlant的笔记

“你将孤单度过一生”

0%

尝试优化wasm-bindgen生成的程序

最近刚刚入门Rust的WebAssembly工具链,写了一些很平凡的小程序,发现了一个很小的值得优化的点,即wasm-bindgen对用来封装单个基本类型变量的结构体的处理不够优化。

你要问我做不做这个优化究竟有什么意义,我只能回答确实没什么太大意义,这几乎不太可能成为性能瓶颈,所以这篇文章主要目的其实只是记录一下我作为初学者探索wasm-bindgen的工作原理的过程。

环境

先放一下工具链的版本,毕竟关于WASM的一切都还在快速更新,版本不一样结果可能会有不一样:

1
2
3
4
5
$ rustc -V
rustc 1.45.0-nightly (a74d1862d 2020-05-14)
$ wasm-pack -V
wasm-pack 0.9.1
# Cargo中依赖的wasm-bindgen版本为"0.2.62"

因为我本身是一个初学者,所以贴一下项目的构建过程:我是follow官方的https://rustwasm.github.io/docs/book/game-of-life/setup.html 的构建过程,看Setup一章和Hello, World!一章即可。以下假定项目的名字叫blog,且目录结构与这篇文档中的一样。

问题

首先在src/lib.rs里写如下代码(原有的代码删了,都用不到):

1
2
3
4
5
6
7
8
9
10
11
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
#[derive(Copy, Clone)]
pub struct Answer(u32);

#[wasm_bindgen]
impl Answer {
pub fn new() -> Answer { Answer(41) }
pub fn the_answer(self) -> u32 { self.0 + 1 }
}

the_answer参数用self而不是&self从正常的Rust角度来看是完全合理的,因为Answer很小,而且实现了Copy,如果不考虑编译器优化的话传self可以节省函数体内的一次访存,考虑了编译优化self&self至少也是一样快。

如果是正常的Rust程序,假设是x86_64-linux的ABI的话,你应该会期望这两个函数非常简单,Answer结构体可以直接用寄存器传参/返回,所以new应该直接编译成寄存器赋值,the_answer应该直接编译成一条寄存器加法(其实是lea指令)。

我们来编写一点JS代码来测试一下。提醒一下,在此之前请先follow官方的教程,修改www/package.json以及执行npm install。把src/index.js改成这样:

1
2
3
4
5
import {Answer} from "blog";

let answer = Answer.new();
console.log(answer.the_answer());
console.log(answer.the_answer());

www目录下运行npm run start,访问,很不幸出错了,浏览器的输出如下:

1
2
3
42
Error importing `index.js`: Error: null pointer passed to rust
...

可见第二次the_answer调用失败了,就像是我们没有加#[derive(Copy, Clone)]的时候,Rust编译器不让我们第二次调用the_answer一样。我大胆地推测一下,目前wasm-bindgen这个工具并没有考虑我们写的#[derive(Copy, Clone)]

探究原因

我们编写的JS代码和WASM的交互是经过pkg/blog_bg.js这一层的间接的,看看这里面the_answer是怎么实现的:

1
2
3
4
5
6
7
8
9
10
export class Answer {
...
the_answer() {
var ptr = this.ptr;
this.ptr = 0;
var ret = wasm.answer_the_answer(ptr);
return ret >>> 0;
}
...
}

原来是调用过一次之后就置成空指针了,这就解释了为什么第二次调用the_answer时报了一个空指针错误。但是为什么要这样做呢?为什么不能留着这个指针多次调用呢?

稍微修改一下Cargo.toml,删掉以下的部分,让接下来的结果更易读一些:

1
2
3
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

它为了节约空间会让一些函数不内联,这样我们读起来还需要跨越函数,就不方便了,所以删掉它。删掉它也不会影响其它该做的优化的效果。

那我们现在编译一下,看看WASM下这两个函数会被编译成了什么:

1
2
3
4
5
6
$ wasm-pack build
$ cd pkg
# wasm-dis是从https://github.com/WebAssembly/binaryen clone并编译的
# wasm2wat的效果一样,它是从https://github.com/WebAssembly/wabt clone并编译的
# 这两个项目都是编译后用make install来安装,但是它们的readme中都没有提到
$ wasm-dis blog_bg.wasm

请注意wasm-pack build就是以优化模式编译的,wasm-pack build --debug才是以调试模式编译的。

看看the_answer的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(export "answer_the_answer" (func $3))
...
(func $3 (param $0 i32) (result i32)
(local $1 i32)
(local $2 i32)
(block $label$1
(if
(local.get $0)
(block
(br_if $label$1
(i32.load
(local.get $0)
)
)
(i32.store
(local.get $0)
(i32.const 0)
)
(return
(i32.add
(block (result i32)
(local.set $2
(i32.load offset=4
(local.get $0)
)
)
(call $1
(local.get $0)
)
(local.get $2)
)
(i32.const 1)
)
)
)
)
(call $6)
(unreachable)
)
(call $7)
(unreachable)
)

这也太复杂了吧!如果你不熟悉这个WAT(WebAssembly Text Format)的语法,还可以用wasm2c(与wasm2wat安装方法一样)来看看它翻译成C之后长啥样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static u32 w2c_answer_the_answer(u32 w2c_p0) {
u32 w2c_l1 = 0;
FUNC_PROLOGUE;
u32 w2c_i0, w2c_i1;
w2c_i0 = w2c_p0;
if (w2c_i0) {
w2c_i0 = w2c_p0;
w2c_i0 = i32_load((&w2c_memory), (u64)(w2c_i0));
if (w2c_i0) {goto w2c_B0;}
w2c_i0 = w2c_p0;
w2c_i1 = 0u;
i32_store((&w2c_memory), (u64)(w2c_i0), w2c_i1);
w2c_i0 = w2c_p0;
w2c_i0 = i32_load((&w2c_memory), (u64)(w2c_i0) + 4u);
w2c_i1 = w2c_p0;
w2c_f2(w2c_i1); // 这个函数非常复杂,经分析,应该是用来释放内存
w2c_i1 = 1u;
w2c_i0 += w2c_i1;
goto w2c_Bfunc;
}
w2c_f7(); // 这个函数是抛异常
UNREACHABLE;
w2c_B0:;
w2c_f8(); // 这个函数也是抛异常
UNREACHABLE;
w2c_Bfunc:;
FUNC_EPILOGUE;
return w2c_i0;
}

我还可以再人工精简一下这个C代码,不过不是等价转化,只是为了理解方便:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// WASM默认是32位的,所以sizeof(u32 *) == sizeof(u32)成立
static u32 w2c_answer_the_answer(u32 *arg) {
if (arg != nullptr) { // 显然这是在做空指针检查,尽管我们写的是self,但最后还是按指针传递的
if (arg[0] != 0) { // 这是在检查什么呢?
throw exception_f8();
}
arg[0] = 0;
u32 ret = arg[1] + 1; // 函数的逻辑就这一行...
free(arg); // 所以说JS传来的指针在这里被free了,那JS里把它置0是合理的
return ret;
} else {
throw exception_f7();
}
}

至此可以总结:Rust中的移动语义在WASM和JS中的体现方式为:

  1. JS中调用过一次即把指针置0
  2. WASM中free掉传入的参数

而不是直接传值,即使这个结构体只有一个字段,完全可以由一个JS的number来表示。

所以,在wasm-bindgen的代码中用self这样的移动语义并不能带来任何好处。事实上,如果将the_answer的参数换成&self,重复一遍上面的工作,就会发现结果仅有的改变只是WASM中没有了free,JS中没有了指针置0。

分析wasm-bindgen的工作原理

我们将用cargo-expand工具来查看#[wasm_bindgen]生成的代码,没有安装的人可以执行cargo install cargo-expand来安装。具体参数是:

1
cargo expand --lib --target wasm32-unknown-unknown

注意这个target参数不可缺少,这是Rust编译为WASM时的target参数,如果使用默认target,结果会不一样。

有必要先介绍一下相关的trait的含义是什么,这些在文档(https://docs.rs/wasm-bindgen/0.2.62/wasm_bindgen/ )里都有介绍,为了方便理解我这里再用简单的文字描述一下:

  1. IntoWasmAbi/FromWasmAbi,分别表示将一个Rust类型可以转换成一个小的handle类型,和转化回来。尺寸较大的类型没法用一个handle直接表示,所以肯定只能申请动态内存,用对应的指针来表示;尺寸较小的类型,如u32f64等几个基本类型,可以用自身作为handle直接表示。
  2. ReturnWasmAbiIntoWasmAbi几乎是一个意思,只是为了处理一些我们这里不用考虑的特殊情形。ReturnWasmAbi::return_abi的默认实现就是调用IntoWasmAbi::into_abi
  3. WasmDescribe:文档中不公开,似乎与内部API的名字有关,看起来它的实现不能乱改,但是具体是怎么影响到其他代码的我现在也没有完全了解。

接下来分析一下生成的代码的各个组件,为了美观我对生成的代码做了一点小调整,没有改变语义。请注意之前列出的WASM代码都是Rust代码翻译来的(中间经过了LLVM IR,不过这不重要),而JS代码则不是,是根据Rust代码中的额外信息(即不是代码本身)加上一定的策略生成的,这我后面会提到。

Rust的数据传递给JS:

1
2
3
4
5
6
7
8
9
10
11
12
13
impl IntoWasmAbi for Answer {
type Abi = u32;
fn into_abi(self) -> u32 {
use wasm_bindgen::__rt::WasmRefCell;
Box::into_raw(Box::new(WasmRefCell::new(self))) as u32
}
}
...
// 这是生成的WASM中export的函数,可以认为JS直接调用它,返回值会成赋值给前面的JS里的那个ptr
#[export_name = "answer_new"]
pub extern "C" fn __wasm_bindgen_generated_Answer_new() -> <Answer as ReturnWasmAbi>::Abi {
<Answer as ReturnWasmAbi>::return_abi(Answer::new())
}

JS调用Rust的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
impl FromWasmAbi for Answer {
type Abi = u32;
unsafe fn from_abi(js: u32) -> Self {
// 这两个use的东西都和std中的对应物语义很接近
use wasm_bindgen::__rt::{assert_not_null, WasmRefCell};
let ptr = js as *mut WasmRefCell<Answer>;
assert_not_null(ptr); // 这就是空指针判断的来源
let js = Box::from_raw(ptr);
// 与std中的RefCell的borrow_mut语义类似,只是这里没有用到返回值,所以相当于只是进行borrow check
// 不过与std中的RefCell的实现不一样,感兴趣的可以自己去阅读源码
// https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/src/lib.rs#L895
// (不要去文档里找,WasmRefCell在文档中没有公开)
(*js).borrow_mut(); // 这就是if (arg[0] != 0)和arg[0] = 0的来源
js.into_inner() // 这个操作会move掉js,Box就是在这里析构,释放内存,这就是free的来源
}
}
...
// 这是生成的WASM中export的函数,可以认为JS直接调用它,参数来自前面的JS里的那个ptr
#[export_name = "answer_the_answer"]
pub extern "C" fn __wasm_bindgen_generated_Answer_the_answer(me: u32) -> <u32 as ReturnWasmAbi>::Abi {
let ret = unsafe { <Answer as FromWasmAbi>::from_abi(me) }.the_answer();
<u32 as ReturnWasmAbi>::return_abi(ret)
}
...
// 这是在JS的free函数中调用的,释放Rust这边的内存
#[cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))]
#[no_mangle]
pub unsafe extern "C" fn __wbg_answer_free(ptr: u32) {
<Answer as FromWasmAbi>::from_abi(ptr);
}

从这一来一回的代码可以看出JS中保存一个指向Rust中的WasmRefCell<Answer>的指针(实际上是用number保存的),这个指针是Rust这边动态申请得到的。为什么要用WasmRefCell<Answer>而不是直接用Answer呢?可以想象一个场景,Rust向JS export一个接受&mut self的函数,也从JS import一个函数,其中也调用了接受&mut self的函数,在Rust这边的函数中可以调用JS的函数,这样就不用unsafe而违背了借用规则。如果有WasmRefCell的话,当Rust调用JS,JS再调用Rust时,就会因为运行时的借用检查而失败。

除了这些看起来比较正常的函数之外,还有一些奇形怪状的函数和数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
impl WasmDescribe for Answer {
fn describe() {
use wasm_bindgen::describe::*;
inform(RUST_STRUCT);
inform(6u32);
inform(65u32);
inform(110u32);
inform(115u32);
inform(119u32);
inform(101u32);
inform(114u32);
}
}

这个一看就知道是在描述Answer的名字,6是名字的长度,后面的都是ascii码。这个实现是必要的,因为wasm-bindgen定义trait IntoWasmAbi: WasmDescribe

1
2
3
4
5
6
7
8
9
10
11
impl From<Answer> for JsValue {
fn from(value: Answer) -> Self {
let ptr = IntoWasmAbi::into_abi(value);
#[link(wasm_import_module = "__wbindgen_placeholder__")]
#[cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))]
extern "C" {
fn __wbg_answer_new(ptr: u32) -> u32;
}
unsafe { <JsValue as FromWasmAbi>::from_abi(__wbg_answer_new(ptr)) }
}
}

这个实现让Rust这边可以将一个Answer转化为JsValue,这样的trait实现如果我们不用到其功能的话是可以删掉的。

还有一些例如OptionIntoWasmAbiRefMutFromWasmAbi的trait实现,从名字就很容易看出其作用,也都是如果不用可以删掉的。

剩下的对我们的优化没有什么帮助,因此不再分析了(其中有一些我也没有看懂)。

优化

在我们的应用中没有必要用指向WasmRefCell<Answer>的指针来表示一个Answer,因为一个u32足以表示Answer,而且也没有一个函数需要修改Answer的内容,完全可以让JS只保存这个u32,遗憾的是目前wasm-bindgen没有这样的接口。我们的目标就是手动编写这些函数,实现这个目标。需要说明的是,这些代码中用到了很多wasm-bindgen的内部API,所以肯定不适合人手工编写,但是如果只是为了探索的目的,用在一些简单的个人项目里,并且写死wasm-bindgen的版本,我想应该也没有什么问题。

我希望能够重写IntoWasmAbi等trait的实现,因此不能给结构体加上#[wasm_bindgen]的标记,而在impl块上的#[wasm_bindgen]则可以保留。为此,上面提到的所有函数中,除了我指出没有必要的,和与后面定义的函数相关的两个函数之外,其他函数都需要自己写一遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// WasmDescribe的实现不用改变

impl IntoWasmAbi for Answer {
type Abi = u32;
fn into_abi(self) -> Self::Abi { self.0 }
}

impl FromWasmAbi for Answer {
type Abi = u32;
unsafe fn from_abi(js: Self::Abi) -> Self { Self(js) }
}

#[cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))]
#[no_mangle]
pub unsafe extern "C" fn __wbg_answer_free(_ptr: u32) {
// 什么都不用做,因为Rust这边没有管理动态内存
}

至此还有一个问题,即使重写了这些函数,并不会影响到生成的JS的代码中将ptr置0的操作,这个操作是由fn the_answer(self)的定义决定的。如果你感兴趣是这个self是怎么转化到将ptr置0的操作,可以看下一节,这里我们用另一种方法绕过去:

1
2
3
4
5
#[wasm_bindgen]
impl Answer {
#[wasm_bindgen(getter)]
pub fn the_answer(self) -> u32 { self.0 + 1 }
}

这是feature吗?还是bug?反正根据实验,加上#[wasm_bindgen(getter)]标注后生成的JS的代码中就没有置0操作了。你可能会想到,如果在原来的代码中加上
#[wasm_bindgen(getter)],第二次访问the_answer的时候不就访问到被free的内存了吗?你想对了,是这样的,我这里会得到一个:

1
Error importing `index.js`: RuntimeError: memory access out of bounds

从一般的静态语言的常识来看,这样的错误不总能被检查出来,也许下次就会悄无声息地出错,谁知道呢。

重申一遍,现在的Rust WASM工具链还在快速更新,这些东西如果只是为了探索的目的,用在一些简单的个人项目里,并且写死wasm-bindgen的版本,我觉得是没有什么问题的。

最终代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use wasm_bindgen::prelude::*;
use wasm_bindgen::convert::*;

#[derive(Copy, Clone)]
pub struct Answer(u32);

impl wasm_bindgen::describe::WasmDescribe for Answer {
fn describe() {
// 抄一下上面的实现...
}
}

impl IntoWasmAbi for Answer {
type Abi = u32;
fn into_abi(self) -> Self::Abi { self.0 }
}

impl FromWasmAbi for Answer {
type Abi = u32;
unsafe fn from_abi(js: Self::Abi) -> Self { Self(js) }
}

#[cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))]
#[no_mangle]
pub unsafe extern "C" fn __wbg_answer_free(_ptr: u32) {}

#[wasm_bindgen]
impl Answer {
pub fn new() -> Answer { Answer(41) }
#[wasm_bindgen(getter)]
pub fn the_answer(self) -> u32 { self.0 + 1 }
}

更新:

坏消息,这个方法在最新的wasm-bindgen已经不再能work了。因为我自己给wasm-bindgen提了一个issue-2168,作者觉得这确实是个bug,并且在commmit-cc36bdc中修复了!修复的方式,修改的代码都与我设想的完全一样。

不过好消息是这个commit是发布在wasm-bindgen0.2.63版本中的(我还不确定Cargo每个版本的代码具体是怎么来的,它是在发布0.2.63之后版本的一次commit,我用0.2.63版本测试了一下还能work,也许是0.2.64版本才会发布这个改动),也就是我用的0.2.62并不受影响。

一个不利用这个bug也能work的版本,虽然经过了一些间接,麻烦一些,但是经过优化后生成的WASM代码是完全一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 上面的代码基本复制下来,去掉impl FromWasmAbi for Answer和impl Answer块后再加上这些代码
use std::ops::Deref;

pub struct AnswerWrapper(Answer);

impl Deref for AnswerWrapper {
type Target = Answer;
fn deref(&self) -> &Self::Target { &self.0 }
}

impl RefFromWasmAbi for Answer {
type Abi = u32;
type Anchor = AnswerWrapper;
unsafe fn ref_from_abi(js: Self::Abi) -> Self::Anchor { AnswerWrapper(Answer(js)) }
}

#[wasm_bindgen]
impl Answer {
pub fn new() -> Answer { Answer(41) }
// 接受&self的函数用的是RefFromWasmAbi这个trait
pub fn the_answer(&self) -> u32 { self.0 + 1 }
}

别忘了www/index.js里的the_answer()要改成the_answer。编译,npm run start,访问浏览器,成功!

看看生成的WASM:

1
2
3
4
5
6
7
8
(export "answer_the_answer" (func $0))
...
(func $0 (param $0 i32) (result i32)
(i32.add
(local.get $0)
(i32.const 1)
)
)

完美!

后续:JS代码的生成

上面其实说到了,生成的”代码和数据”,数据在哪里呢?这是为the_answer生成的一个数组:

1
2
3
4
5
6
#[cfg(target_arch = "wasm32")]
#[link_section = "__wasm_bindgen_unstable"]
pub static __WASM_BINDGEN_GENERATED_7b43bf8185fa1d9d: [u8; 111usize] = {
static _INCLUDED_FILES: &[&str] = &[];
* b".\x00\x00\x00{\"schema_version\":\"0.2.62\",\"version\":\"0.2.62\"}9\x00\x00\x00\x01\x01\x06Answer\x00\x01\x00\nthe_answer\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x15blog-680414cd2bd728e3\x00"
};

如果你把定义改成the_answer(&self),就会发现只有一个字节改变了,即Answer\x00\x01\x00变成了Answer\x00\x00\x00。删掉the_answer,只保留它生成的东西,生成的JS中有置0操作;再手动把Answer\x00\x01\x00改成Answer\x00\x00\x00,生成的JS中无置0操作。这个对比实验即可证明生成的JS代码的依据确实是这个数组。

我通过阅读wasm-bindgen的源码已经理解了其中的原理,分别确定了这个字节是由函数的定义决定的,和JS中有无置0操作是由这个字节决定的。但是要完整地分析就太长了,而且这只是人家现在的实现而已,并没有什么原理上值得学习的地方,不值得长篇大论地去分析。这里只贴出相关的链接,感兴趣的人可以自己去阅读(不同文件间基本是按调用关系排序的):

这个字节是由函数的定义决定的:

  1. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/codegen.rs#L92
  2. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/codegen.rs#L123
  3. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L358
  4. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L423
  5. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L497
  6. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/shared/src/lib.rs#L8
  7. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/shared/src/lib.rs#L93

JS中有无置0操作是由这个字节决定的:

  1. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/lib.rs#L349
  2. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/wit/mod.rs#L1409
  3. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/wit/mod.rs#L1466
  4. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/decode.rs#L157 (decode的操作与encode类似,就不贴那么多链接了)
  5. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/lib.rs#L429
  6. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/js/mod.rs#L2193 到2196行
  7. https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/js/binding.rs#L130