1. 背景
最近前端领域使用 rust 来编写工具(rspack、turbopack、rolldown、ocx等等) 越来越多,为了工具的扩展性,插件是这些工具必不可少的一环。
要实现插件机制,总体来说有下面三种方案:
- 使用 js 引擎运行 js 代码
- 使用 wasm
- 使用 本地代码生成动态链接库
三个方案各有优劣,今天介绍其中一种方案,并实现一个简单的插件系统完成下面的功能:
宿主与插件间共享一个结构体, 有 start 和 end、 result 3 个字段
第一个插件对 start 和 end 之间的所有整数调用 sin().cos().tan() 并相加,取整后存储在 result 中
第二个插件计算 result 的立方根,取整后并存储在 result 中
1. WebAssembly 组件模型 简介
WebAssembly 网上很多资料,这里就不过多介绍。
WebAssembly 组件模型是一个提案,旨在通过定义模块在应用程序或库中如何组合来构建核心 WebAssembly 标准。
初看到这句话可能有点摸不着头脑,但其实核心就是模块和组合两个概念。
在平常的用例中,我们使用 wasm 模块,往往是一个 wasm 文件,里面导出一些函数,供宿主调用。
这里
宿主是指 wasm 的 Runtime,在网页中是浏览器,另外还有一些运行在非网页环境中的 Runtime,例如 wasmtime、wasmedge 等。
wasm 模块与宿主 是通过共享内存以原始字节的形式共享数据的,而且只允许传递整数和浮点数。 这时候如果需要传递复杂数据类型(例如,字符串、结构体等),就需要通过指针、偏移量等技术来实现。
如果有 多个wasm 模块,他们要共享数据,就需要这些模块对复杂结构的实现方式相同。但每个语言或者技术很难做到统一,甚至就算同为 Rust 不同版本的实现方式都有可能不相同。
因此,为了实现多个wasm 模块的互操作,就需要一套标准来表示这些复杂类型。
WebAssembly 组件模型就是这个标准,它定义了一个标准的 应用程序二进制接口(ABI),用来描述wasm模块的导入导出,这样才能让多个wasm模块组合在一起。
另外还有一种情况,如果多个wasm模块在编译时就已经确定了,那就可以直接在编译时静态链接,这样就不需要组件模型了。
参考资料:
2. WIT(WebAssembly Interface Type) 文件
WebAssembly 组件模型使用 WIT 文件来描述wasm模块的导入导出。具体的类型介绍可以看 component-model.bytecodealliance.org/design/wit.…
- 导入和导出的函数
- 导入和导出的接口
- 用于上述函数和接口的参数或返回值的类型,包括record(类似于结构体)、variant(类似于Rust的enum,一种tagged union)、union(类似于C的union,untagged union)、enum(类似于C的enum,映射到整数)等。
下面是个例子
package component:walker@1.0.0;
interface types {
record ast-type {
content: u64,
start: u64,
end: u64
}
}
world ast-walker {
use types.{ast-type};
export walk: func(ast: ast-type) -> ast-type;
import host: interface {
log: func(param: string);
}
}
- 一个名为
ast-walker的world。可以认为一个 world 对应一个 Wasm 组件。 - 组件内导出了一个名为
walk的函数,该函数接受一个ast-type类型的参数,并返回一个ast-type类型的值。 - 组件需要导入一个名为
host的接口,其需要提供一个名为log的函数,并接收一个名为param的字符串参数。
3. 使用 Rust 创建 WebAssembly 组件的
- 首先运行
cargo install cargo-component来安装 Rust 中编译组件模型工具。 - 创建一个 Rust Workspace
- 使用
cargo component new trigonometric-wasm --lib创建一个wasm组件项目。 - 创建
share/wit目录,并在其中创建ast-walker.wit文件。
package share:walker;
interface ast-walker {
record ast-type {
content: u64,
start: u64,
end: u64
}
walk: func(ast: ast-type) -> ast-type;
}
- 删除
trigonometric-wasm/wit/world.wit,然后创建trigonometric-wasm/wit/trigonometric.wit文件 。
package component:trigonometric;
world trigonometric {
export share:walker/ast-walker;
}
- 修改
trigonometric-wasm/Cargo.toml中,把下面的内容添加到最后。
[package.metadata.component]
package = "component:trigonometric"
[package.metadata.component.target.dependencies]
"share:walker" = { path = "../share/wit/" }
- 修改
trigonometric-wasm/src/lib.rs,实现具体的逻辑
#[allow(warnings)]
mod bindings;
use bindings::exports::share::walker::ast_walker::{AstType, Guest};
struct Component;
impl Guest for Component {
fn walk(mut ast: AstType) -> AstType {
let result: f64 = (ast.start..=ast.end)
.map(|i| (i as f64).sin().cos().tan())
.sum();
ast.content = result.round() as u64;
return ast;
}
}
bindings::export!(Component with_types_in bindings);
- 使用
cargo component build -p trigonometric-wasm来编译组件,会生成target/wasm32-wasip1/release/trigonometric_wasm.wasm文件。 - 使用和上面相同的步骤,创建 cubic-root-wasm 项目,并实现具体的逻辑。
4. 在 Rust 中使用 WebAssembly 组件
- 运行
cargo new wasm-host --bin创建一个wasm宿主项目。 - 在
wasm-host中添加相应依赖。
[dependencies]
async-std = { version = "1.12.0", features = ["attributes"] }
wasmtime = "18.0.1"
clap = { version = "4.3.19", features = ["derive"] }
wasmtime-wasi = { version = "18.0.1" }
anyhow = "1.0.72"
- 编写相应代码
// main.rs
use clap::Parser;
use run::{exports::share::walker::ast_walker::AstType, WasmInstance};
use std::env;
mod run;
#[derive(Parser)]
#[clap(name = "wasm-host", version = env!("CARGO_PKG_VERSION"))]
struct HostApp {
end: u64,
}
#[async_std::main]
async fn main() -> anyhow::Result<()> {
HostApp::parse().run().await
}
impl HostApp {
async fn run(self) -> anyhow::Result<()> {
let mut trigonometric_instance =
WasmInstance::create("./target/wasm32-wasip1/release/trigonometric_wasm.wasm").await?;
let mut cubic_root_instance =
WasmInstance::create("./target/wasm32-wasip1/release/cubic_root_wasm.wasm").await?;
for end in 1..=self.end {
let ast = AstType {
start: 1,
end,
content: 0,
};
let ast = trigonometric_instance.run(ast).await?;
let ast = cubic_root_instance.run(ast).await?;
println!("end = {} ;root result = {}", end, ast.content);
}
Ok(())
}
}
核心是下面的代码
// run.rs
use anyhow::Context;
use exports::share::walker::ast_walker::AstType;
use wasmtime::component::*;
use wasmtime::{Config, Engine, Store};
// 根据 WIT 文件生成相应的绑定代码,例如结构体
wasmtime::component::bindgen!({
path: "../share/wit/ast-walker.wit",
world: "ast-handle",
async: true
});
// 一个 Wasm Runtime 实例,只需要创建一次
pub struct WasmInstance {
instance: AstHandle,
store: Store<()>,
}
impl WasmInstance {
pub async fn create(path: &str) -> anyhow::Result<Self> {
let mut config = Config::default();
config.wasm_component_model(true);
config.async_support(true);
let engine = Engine::new(&config)?;
let linker = Linker::new(&engine);
let mut store = Store::new(&engine, ());
// 从文件加载 wasm 组件模型
let component = Component::from_file(&engine, path).context("Component file not found")?;
// 实例化 wasm 组件
let (instance, _) = AstHandle::instantiate_async(&mut store, &component, &linker)
.await
.context("Failed to instantiate the ast-handle world")?;
Ok(WasmInstance { instance, store })
}
pub async fn run(self: &mut Self, ast: AstType) -> wasmtime::Result<AstType> {
// 调用 wasm 组件的 share:walker 模块的 ast-walker 接口的 walk 函数
self.instance
.share_walker_ast_walker()
.call_walk(&mut self.store, ast)
.await
.context("Failed to call walk function")
}
}
- 运行
cargo run -p wasm-host 200来运行宿主程序。
5. 性能对比
参考项目中的 native 项目,分别用 N = 100,1000,10000 运行,记录时间
hyperfine './target/release/wasm-host 10000' './target/release/native 10000' -N --warmup 10
# N = 100
Benchmark 1: ./target/release/wasm-host 100
Time (mean ± σ): 14.7 ms ± 0.4 ms [User: 32.7 ms, System: 14.4 ms]
Range (min … max): 13.7 ms … 15.5 ms 205 runs
Benchmark 2: ./target/release/native 100
Time (mean ± σ): 1.1 ms ± 0.1 ms [User: 0.5 ms, System: 0.3 ms]
Range (min … max): 0.9 ms … 2.2 ms 1380 runs
Summary
./target/release/native 100 ran
13.81 ± 1.29 times faster than ./target/release/wasm-host 100
# N = 1000
Benchmark 1: ./target/release/wasm-host 1000
Time (mean ± σ): 50.1 ms ± 1.2 ms [User: 58.4 ms, System: 24.8 ms]
Range (min … max): 48.0 ms … 54.3 ms 58 runs
Benchmark 2: ./target/release/native 1000
Time (mean ± σ): 7.7 ms ± 0.2 ms [User: 6.8 ms, System: 0.6 ms]
Range (min … max): 7.5 ms … 8.9 ms 381 runs
Summary
./target/release/native 1000 ran
6.48 ± 0.20 times faster than ./target/release/wasm-host 1000
# N = 10000
Benchmark 1: ./target/release/wasm-host 10000
Time (mean ± σ): 2.556 s ± 0.034 s [User: 2.457 s, System: 0.132 s]
Range (min … max): 2.474 s … 2.588 s 10 runs
Benchmark 2: ./target/release/native 10000
Time (mean ± σ): 643.7 ms ± 4.7 ms [User: 638.6 ms, System: 4.4 ms]
Range (min … max): 636.6 ms … 649.2 ms 10 runs
Summary
./target/release/native 10000 ran
3.97 ± 0.06 times faster than ./target/release/wasm-host 10000
次数比较小的时候因为有冷启动的原因,wasm 的性能会比原生慢很多,次数多了之后,wasm 的性能会趋向慢 4 倍左右。