最近刚刚入门Rust的WebAssembly工具链,写了一些很平凡的小程序,发现了一个很小的值得优化的点,即wasm-bindgen
对用来封装单个基本类型变量的结构体的处理不够优化。
你要问我做不做这个优化究竟有什么意义,我只能回答确实没什么太大意义,这几乎不太可能成为性能瓶颈,所以这篇文章主要目的其实只是记录一下我作为初学者探索wasm-bindgen
的工作原理的过程。
环境
先放一下工具链的版本,毕竟关于WASM的一切都还在快速更新,版本不一样结果可能会有不一样:
1 | $ rustc -V |
因为我本身是一个初学者,所以贴一下项目的构建过程:我是follow官方的https://rustwasm.github.io/docs/book/game-of-life/setup.html 的构建过程,看Setup
一章和Hello, World!
一章即可。以下假定项目的名字叫blog
,且目录结构与这篇文档中的一样。
问题
首先在src/lib.rs
里写如下代码(原有的代码删了,都用不到):
1 | use wasm_bindgen::prelude::*; |
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 | import {Answer} from "blog"; |
在www
目录下运行npm run start
,访问,很不幸出错了,浏览器的输出如下:
1 | 42 |
可见第二次the_answer
调用失败了,就像是我们没有加#[derive(Copy, Clone)]
的时候,Rust编译器不让我们第二次调用the_answer
一样。我大胆地推测一下,目前wasm-bindgen
这个工具并没有考虑我们写的#[derive(Copy, Clone)]
。
探究原因
我们编写的JS代码和WASM的交互是经过pkg/blog_bg.js
这一层的间接的,看看这里面the_answer
是怎么实现的:
1 | export class Answer { |
原来是调用过一次之后就置成空指针了,这就解释了为什么第二次调用the_answer
时报了一个空指针错误。但是为什么要这样做呢?为什么不能留着这个指针多次调用呢?
稍微修改一下Cargo.toml
,删掉以下的部分,让接下来的结果更易读一些:
1 | [profile.release] |
它为了节约空间会让一些函数不内联,这样我们读起来还需要跨越函数,就不方便了,所以删掉它。删掉它也不会影响其它该做的优化的效果。
那我们现在编译一下,看看WASM下这两个函数会被编译成了什么:
1 | $ wasm-pack build |
请注意wasm-pack build
就是以优化模式编译的,wasm-pack build --debug
才是以调试模式编译的。
看看the_answer
的结果:
1 | (export "answer_the_answer" (func $3)) |
这也太复杂了吧!如果你不熟悉这个WAT(WebAssembly Text Format)的语法,还可以用wasm2c
(与wasm2wat
安装方法一样)来看看它翻译成C之后长啥样:
1 | static u32 w2c_answer_the_answer(u32 w2c_p0) { |
我还可以再人工精简一下这个C代码,不过不是等价转化,只是为了理解方便:
1 | // WASM默认是32位的,所以sizeof(u32 *) == sizeof(u32)成立 |
至此可以总结:Rust中的移动语义在WASM和JS中的体现方式为:
- JS中调用过一次即把指针置0
- 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/ )里都有介绍,为了方便理解我这里再用简单的文字描述一下:
IntoWasmAbi
/FromWasmAbi
,分别表示将一个Rust类型可以转换成一个小的handle类型,和转化回来。尺寸较大的类型没法用一个handle直接表示,所以肯定只能申请动态内存,用对应的指针来表示;尺寸较小的类型,如u32
,f64
等几个基本类型,可以用自身作为handle直接表示。ReturnWasmAbi
与IntoWasmAbi
几乎是一个意思,只是为了处理一些我们这里不用考虑的特殊情形。ReturnWasmAbi::return_abi
的默认实现就是调用IntoWasmAbi::into_abi
。WasmDescribe
:文档中不公开,似乎与内部API的名字有关,看起来它的实现不能乱改,但是具体是怎么影响到其他代码的我现在也没有完全了解。
接下来分析一下生成的代码的各个组件,为了美观我对生成的代码做了一点小调整,没有改变语义。请注意之前列出的WASM代码都是Rust代码翻译来的(中间经过了LLVM IR,不过这不重要),而JS代码则不是,是根据Rust代码中的额外信息(即不是代码本身)加上一定的策略生成的,这我后面会提到。
Rust的数据传递给JS:
1 | impl IntoWasmAbi for Answer { |
JS调用Rust的函数:
1 | impl FromWasmAbi for Answer { |
从这一来一回的代码可以看出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 | impl WasmDescribe for Answer { |
这个一看就知道是在描述Answer
的名字,6是名字的长度,后面的都是ascii码。这个实现是必要的,因为wasm-bindgen
定义trait IntoWasmAbi: WasmDescribe
。
1 | impl From<Answer> for JsValue { |
这个实现让Rust这边可以将一个Answer
转化为JsValue
,这样的trait实现如果我们不用到其功能的话是可以删掉的。
还有一些例如OptionIntoWasmAbi
,RefMutFromWasmAbi
的trait实现,从名字就很容易看出其作用,也都是如果不用可以删掉的。
剩下的对我们的优化没有什么帮助,因此不再分析了(其中有一些我也没有看懂)。
优化
在我们的应用中没有必要用指向WasmRefCell<Answer>
的指针来表示一个Answer
,因为一个u32
足以表示Answer
,而且也没有一个函数需要修改Answer
的内容,完全可以让JS只保存这个u32
,遗憾的是目前wasm-bindgen
没有这样的接口。我们的目标就是手动编写这些函数,实现这个目标。需要说明的是,这些代码中用到了很多wasm-bindgen
的内部API,所以肯定不适合人手工编写,但是如果只是为了探索的目的,用在一些简单的个人项目里,并且写死wasm-bindgen
的版本,我想应该也没有什么问题。
我希望能够重写IntoWasmAbi
等trait的实现,因此不能给结构体加上#[wasm_bindgen]
的标记,而在impl块上的#[wasm_bindgen]
则可以保留。为此,上面提到的所有函数中,除了我指出没有必要的,和与后面定义的函数相关的两个函数之外,其他函数都需要自己写一遍。
1 | // WasmDescribe的实现不用改变 |
至此还有一个问题,即使重写了这些函数,并不会影响到生成的JS的代码中将ptr
置0的操作,这个操作是由fn the_answer(self)
的定义决定的。如果你感兴趣是这个self
是怎么转化到将ptr
置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 | use wasm_bindgen::prelude::*; |
更新:
坏消息,这个方法在最新的wasm-bindgen
已经不再能work了。因为我自己给wasm-bindgen
提了一个issue-2168,作者觉得这确实是个bug,并且在commmit-cc36bdc中修复了!修复的方式,修改的代码都与我设想的完全一样。
不过好消息是这个commit是发布在wasm-bindgen
的0.2.63
版本中的(我还不确定Cargo每个版本的代码具体是怎么来的,它是在发布0.2.63
之后版本的一次commit,我用0.2.63
版本测试了一下还能work,也许是0.2.64
版本才会发布这个改动),也就是我用的0.2.62
并不受影响。
一个不利用这个bug也能work的版本,虽然经过了一些间接,麻烦一些,但是经过优化后生成的WASM代码是完全一样的:
1 | // 上面的代码基本复制下来,去掉impl FromWasmAbi for Answer和impl Answer块后再加上这些代码 |
别忘了www/index.js
里的the_answer()
要改成the_answer
。编译,npm run start
,访问浏览器,成功!
看看生成的WASM:
1 | (export "answer_the_answer" (func $0)) |
完美!
后续:JS代码的生成
上面其实说到了,生成的”代码和数据”,数据在哪里呢?这是为the_answer
生成的一个数组:
1 |
|
如果你把定义改成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操作是由这个字节决定的。但是要完整地分析就太长了,而且这只是人家现在的实现而已,并没有什么原理上值得学习的地方,不值得长篇大论地去分析。这里只贴出相关的链接,感兴趣的人可以自己去阅读(不同文件间基本是按调用关系排序的):
这个字节是由函数的定义决定的:
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/codegen.rs#L92
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/codegen.rs#L123
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L358
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L423
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/backend/src/encode.rs#L497
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/shared/src/lib.rs#L8
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/shared/src/lib.rs#L93
JS中有无置0操作是由这个字节决定的:
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/lib.rs#L349
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/wit/mod.rs#L1409
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/wit/mod.rs#L1466
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/decode.rs#L157 (decode的操作与encode类似,就不贴那么多链接了)
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/lib.rs#L429
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/js/mod.rs#L2193 到2196行
- https://github.com/rustwasm/wasm-bindgen/blob/87663c6d2a442d98b3d8ea6242f20c5c21fc0174/crates/cli-support/src/js/binding.rs#L130