为 Rust 程序提供插件功能:WebAssembly 组件模型

740 阅读6分钟

代码地址 ununian/rust-extension-demo

1. 背景

最近前端领域使用 rust 来编写工具(rspack、turbopack、rolldown、ocx等等) 越来越多,为了工具的扩展性,插件是这些工具必不可少的一环。

要实现插件机制,总体来说有下面三种方案:

  1. 使用 js 引擎运行 js 代码
  2. 使用 wasm
  3. 使用 本地代码生成动态链接库

三个方案各有优劣,今天介绍其中一种方案,并实现一个简单的插件系统完成下面的功能:

宿主与插件间共享一个结构体, 有 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模块在编译时就已经确定了,那就可以直接在编译时静态链接,这样就不需要组件模型了。

参考资料:

zed.dev/blog/zed-de…

zhuanlan.zhihu.com/p/615815778

component-model.bytecodealliance.org/design/why-…

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-walkerworld。可以认为一个 world 对应一个 Wasm 组件。
  • 组件内导出了一个名为walk的函数,该函数接受一个 ast-type 类型的参数,并返回一个 ast-type 类型的值。
  • 组件需要导入一个名为host的接口,其需要提供一个名为 log 的函数,并接收一个名为 param 的字符串参数。

3. 使用 Rust 创建 WebAssembly 组件的

  1. 首先运行 cargo install cargo-component 来安装 Rust 中编译组件模型工具。
  2. 创建一个 Rust Workspace
  3. 使用 cargo component new trigonometric-wasm --lib 创建一个wasm组件项目。
  4. 创建 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;
}
  1. 删除 trigonometric-wasm/wit/world.wit,然后创建 trigonometric-wasm/wit/trigonometric.wit 文件 。
package component:trigonometric;

world trigonometric {
    export share:walker/ast-walker;
}
  1. 修改 trigonometric-wasm/Cargo.toml中,把下面的内容添加到最后。
[package.metadata.component]
package = "component:trigonometric"

[package.metadata.component.target.dependencies]
"share:walker" = { path = "../share/wit/" }

  1. 修改 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);

  1. 使用 cargo component build -p trigonometric-wasm 来编译组件,会生成 target/wasm32-wasip1/release/trigonometric_wasm.wasm 文件。
  2. 使用和上面相同的步骤,创建 cubic-root-wasm 项目,并实现具体的逻辑。

4. 在 Rust 中使用 WebAssembly 组件

  1. 运行 cargo new wasm-host --bin 创建一个wasm宿主项目。
  2. 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"
  1. 编写相应代码
// 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")
    }
}
  1. 运行 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 倍左右。