了解 wasm 使用场景,复杂对象传递和经验法则。
简介
WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行。它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C ++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。
WebAssembly 提供了一条途径,使得以各种语言编写的代码都可以以接近原生的速度在 Web 中运行。
WebAssembly 设计初衷
- 它设计的目的不是为了手写代码而是为了诸如 C、C++ 和 Rust 等低级源语言提供一个高效的编译目标。
- WebAssembly 的模块可以被导入的到一个网络 app(或 Node.js)中,并且暴露出供 JavaScript 使用的 WebAssembly 函数。JavaScript 框架不但可以使用 WebAssembly 获得巨大性能优势和新特性,而且还能使得各种功能保持对网络开发者的易用性。
如何得到 WebAssembly 二进制文件
- 现代语言几乎都支持将 wasm 作为它的编译输出,如 Go、Python、C/C++、Rust、TypeScript 等都可以,只是由于 wasm 因为需要通过网络传播,因此大小很重要,因此更推荐如 C/C++、Rust 没有像垃圾收集器那样额外的运行时语言,可以使 wasm 体积更小。
- 直接编写 wasm 代码(了解即可)
- wasm 的二进制格式也有文本表示,两者之间 1:1 对应。你可以手工书写或者生成这种格式然后使用工具把它转换为二进制格式。这是一种用来在文本编辑器、浏览器开发者工具等工具中显示的中间形式
- 二进制格式通常为
.wasm
格式,文本格式通常为.wat
格式 - 理解 WebAssembly 文本格式
WebAssembly 优势
- 紧凑的二进制格式,使其能够以接近原生性能的速度运行,并支持在各种上下文中使用
- 为诸如 C++ 和 Rust 等拥有低级的内存模型语言提供了一个编译目标以便它们能够在网络上运行
WebAssembly 劣势
- 对于编写网络应用程序而言,不如 JavaScript 灵活且富有表达力
- 只有很小的一个值类型集合,基本上限制在简单数值的范围,复杂数据类型需要进行编解码,如字符串、对象、数组需要先编码成二进制再存放到 wasm 内存段里
- 与 JavaScript 胶水代码的交互带来的性能损耗一定程度上抵消了 wasm 本身带来的性能提升
使用场景
使用 WebAssembly 的原因
- 关注性能敏感代码:使用 Rust 你不需要成为 JS 优化专家,不需要熟悉 JIT 内部实现,不需要魔法也能加速。
- 集成方便:直接编译为
.wasm
,使得现有的 JS 代码库可以增量式部分采用 WebAssembly。而且还可以保持你现有代码库,不需要重写。 - 复用已有的其他语言编写的代码模块
开发软件时使用 wasm 的常见方式
- 纯 wasm 实现,包括 ui 和逻辑
- UI 使用 HTML/CSS/JS,逻辑计算使用 wasm
- 复用其他语言的库,使用 wasm 移植到已有的 web 软件中
现有的使用 wasm 编写的应用有
- Google Earth
- AutoCAD Web
- PhotoShop Web:Web 端和 PC 端由一份编码编译生成
- Figma:wasm+rust 的 web 应用框架 zaplib
- bilibili:wasm 版的 FFmpeg/tensorflow
扩展:Rust
Rust 简介
Rust 是 Mozilla 开发的一门静态的支持多种范式的系统编程语言。
- 惊人的运行速度
- 防止内存错误
- 保证线程安全
Rust 关键特性
- 借用和所有权
- 模式匹配
- 生命周期
- 并发编程
WASM 支持
与现有的 JavaScript 工具集成良好,支持 esm,你可以继续使用你喜欢的工具,如 npm 和 webpack。 使用 Rust 开发在开发效率和便捷性、包体积大小、对 WebAssembly 的支持度相对完善、社区活跃度高等方面有很大的优势。
Rust 已有的相关生态使我们编写 WebAssembly 应用更加容易。
- wasm-bingdgen:导入 js 以及导出 rust、允许 js/wasm 与字符串、js 对象、类等进行通信,而不是纯整数和浮点数
- wasm-pack:集成编译、npm 打包
- js-sys:用于绑定 js 环境中的全局对象和函数的绑定
- web-sys:wasm 操作 web api
小试牛刀
安装 Rust 工具链
- rustup:负责安装 Rust、切换 Rust 版本、下载标准库文件等
- rustc:Rust 的编译器(一般通过 cargo 命令调用)
- cargo:Rust 的项目管理工具(类似 Node 的 NPM)
初始化与编译
- cargo new projectName
- cargo build
- cargo build --release 性能更好,文件更小,去除了 debug 信息
- cargo run 调试代码
Rust 安装
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装 WebAssembly 工具链
- wasm-pack:用于将 Rust 项目打包成单个 .wasm 文件,运行 curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 安装。
- cargo-generate 用于快速生成 WebAssembly 项目的脚手架,运行 cargo install cargo-generate 即可安装。
完成 cargo-generate 的安装后,通过如下方式创建 WebAssembly 项目
cargo generate --git https://github.com/rustwasm/wasm-pack-template
运行 wasm-pack build
命令,即可编译出 WebAssembly 模块,wasm-pack 会在项目的 pkg 目录下生成 .wasm 等文件。
├── xxx.wasm # rust 编译成 wasm 的源代码
├── xxx.js # JavaScript 粘合剂代码
├── xxx.d.ts # 用于支持 TypeScript 的声明文件
├── package.json # 用于协助我们发包
尝试一个求斐波拉契数列的例子
extern crate cfg_if;
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
// 通过如下标记,即可实现自动生成 WASM 函数接口
#[wasm_bindgen]
pub fn fib(i: u32) -> u32 {
match i {
0 => 0,
1 => 1,
_ => fib(i-1) + fib(i-2)
}
}
执行速度如下
- 当参数为 20 时,wasm 0.039ms,js 0.214ms
- 当参数为 40 时,wasm 409.901ms,js 814.952ms
核心概念
线性内存(Linear Memory)
- 一种内存寻址技术,其中内存被组织在一块线性地址空间中,也被成为扁平内存模型,线性内存模型使理解、编程、和表示内存变得更容易,js 和 wasm 可以同步的读写内存
- 缺点就是重新排列内存中的元素需要大量的执行时间,并且会浪费大量的内存区域
两个关键接口
- 内存 Memory:可变长的 ArrayBuffer,能够被实例存取的原始字节内存。
- 表格 Table:位于 wasm 内存之外的可变长类型数组,用于存储多个函数引用,能同时被 js 或 wasm 访问和更改。
wasm 目前无法直接访问垃圾收集堆。js 可以读写 wasm 线性内存空间,但仅作为标量值(u8, i32, f64等...)的 ArrayBuffer。
字符串和数组传递
数组传递演示
#[wasm_bindgen]
pub fn send_array() -> Box<[JsValue]> {
vec![
JsValue::NULL,
JsValue::UNDEFINED,
JsValue::from_str("123我"),
JsValue::TRUE,
JsValue::FALSE,
]
.into_boxed_slice()
}
对于字符串的传递相对而言比较简单,我们可以对代码简单调试。
上述测试的字符串是"123我",通过 ptr+len 进行内存访问,因为 utf-8 编码中,123各一个字节,中文三个字节,结果为 49, 50, 51, 230, 136, 145,然后交给 TextDecoder 解码即可。
对象数据传递
当你需要传给复杂数据结构时,通常需要进行序列化和反序列化,如在 Rust 中可以引用 serde
和 serde-wasm-bindgen
,如当你需要返回一个复杂对象给 js 时,简单演示如下
#[derive(Serialize, Deserialize)]
pub struct Obj {
pub field1: HashMap<u32, String>,
pub field2: Vec<Vec<i32>>,
pub field3: [f32; 4],
pub field4: bool,
pub field5: String,
}
#[wasm_bindgen]
pub fn send_obj() -> JsValue {
let mut map = HashMap::new();
map.insert(0, String::from("ex"));
let obj = Obj {
field1: map,
field2: vec![vec![1,2], vec![3,4]],
field3: [1.,2.,3.,4.],
field4: true,
field5: "test".to_string(),
};
serde_wasm_bindgen::to_value(&obj).unwrap()
}
当 js 需要传递一个复杂对象给 rust 时,你需要在 rust 中定义好数据结构以及反序列化规则。
#[derive(Serialize, Deserialize)]
pub struct Address {
province: String,
city: String,
}
#[derive(Serialize, Deserialize)]
pub struct User {
pub name: String,
pub age: u8,
pub add: Address,
}
#[derive(Serialize, Deserialize)]
pub struct UserView {
pub name: String,
pub age: u8,
pub add: Address,
pub isYoung: bool,
}
#[wasm_bindgen]
pub fn updateUser(u: JsValue) -> JsValue {
let mut user: User = serde_wasm_bindgen::from_value(u).unwrap();
user.age += 1;
serde_wasm_bindgen::to_value(&user).unwrap()
}
#[wasm_bindgen]
pub fn toUserView(u: JsValue) -> JsValue {
let user: User = serde_wasm_bindgen::from_value(u).unwrap();
let v = UserView {
name: user.name,
age: user.age,
add: user.add,
isYoung: user.age <= 18,
};
serde_wasm_bindgen::to_value(&v).unwrap()
}
类传递
常见于 wasm 将内部模块作为句柄暴露给 js 使用。简单实例如下
// 定义复杂对象 A
#[wasm_bindgen]
pub struct A {
pub b: i32,
pub c: i32,
}
// A 的工厂方法
#[wasm_bindgen]
pub fn AFactory(input_array: Vec<i32>) -> A {
let b = calculate_b(&input_array);
let c = calculate_c(&input_array);
A { b, c }
}
// 计算 b 的函数
fn calculate_b(input: &[i32]) -> i32 {
// 在这里进行复杂的计算逻辑,这里简化为求和
input.iter().sum()
}
// 计算 c 的函数
fn calculate_c(input: &[i32]) -> i32 {
// 在这里进行复杂的计算逻辑,这里简化为数组长度
input.len() as i32
}
// js 端
const inputArray = [1, 2, 3, 4, 5];
// 调用 AFactory 工厂函数创建复杂对象 A
const aObject = AFactory(inputArray);
// 输出结果
console.log(aObject);
执行情况和 A 的胶水代码如下
TypedArray
如果情况允许(如 three.js 中 geometry 的顶点坐标),我们更推荐直接传递 TypedArray,因为本身就是线性空间,具备如下优势
- 无需数据拷贝:将TypedArray直接传递给wasm模块时,实际上是将TypedArray的内存引用传递给了模块,而不是将其内容进行拷贝。
- 更少的内存分配:由于TypedArray在JavaScript中已经分配了内存空间,直接将其传递给wasm模块可以避免在模块内部进行额外的内存分配操作。
简单代码演示如下
#[wasm_bindgen]
pub fn send_typed_array(attribute: &Float32Array) {
log!("it becomes {:?}", attribute.to_string());
}
// in js
// const float32 = new Float32Array(2);
// float32[0] = 42;
// send_typed_array(float32);
经验法则
作为一般的经验法则,一个好的 js+wasm 接口设计通常是这样的:大型的、长期存在的数据结构被实现为 rust 类型,这些类型存在于 wasm 线性内存中,并作为不透明句柄暴露给 js。js 调用导出的 wasm 函数,这些函数接受这些不透明句柄,转换它们的数据,执行大量计算,查询数据,并最终返回一个小的、可复制的结果。通过仅返回计算的最小结果,我们避免在 js 垃圾收集的堆和 wasm 线性内存之间来回复制和/或序列化所有内容。
use wasm_bindgen::prelude::*;
// 定义一个简单的数据结构,例如 Vec<i32>
#[wasm_bindgen]
pub struct LargeData {
data: Vec<i32>,
}
#[wasm_bindgen]
impl LargeData {
// 创建 LargeData 的构造函数
#[wasm_bindgen(constructor)]
pub fn new(data: Vec<i32>) -> Self {
LargeData { data }
}
// 获取数据的长度
#[wasm_bindgen(getter)]
pub fn length(&self) -> usize {
self.data.len()
}
// 获取数据的指针
pub fn data_ptr(&self) -> *const i32 {
self.data.as_ptr()
}
}
WASM 的确相对于 JS 上有执行上有性能提升,但最终整体而言,可能效果并不明显。为什么呢?原因就在于 JS <-> WASM 的数据通信成本比想象中高很多,这其实是大多数需要跨语言通信的场景的瓶颈所在。
使用 WASM 的一些建议
- 尽可能将纯计算逻辑限定在 WASM 里
- 尽量减少 JS <-> WASM 的来回调用,否则你的代码会更慢
- Protobuf 替换 JSON?未验证
- 永远优先考虑优化代码的时间复杂度,o(n^2) 的代码就算写成汇编它也还是 o(n^2) 的
- 使用共享内存:TypedArray。但这意味着任何复杂对象的传递都需要经过序列化和反序列化,只要涉及序列化就避免不了拷贝。
资料
- Rust 🦀 and WebAssembly
- Tutorial: Conway's Game of Life
- wasm-pack
- wasm-bindgen
- zaplib:使用 rust+wasm 开发 web 应用的库