WebAssembly AI 插件性能基准:不同 WASM 运行时跑同一推理任务
一、为什么要在 WASM 里跑 AI
这个问题我在技术群里被问过好几次。有人说:"AI 推理不是应该上 GPU 吗,WASM 这种沙箱环境能有啥用?"这个质疑确实有道理,但忽略了一个重要的场景 —— 边缘端和多租户环境。
在云端,你当然可以一台机器绑一个 GPU 跑推理。但在一些特定的场景下,WASM 的优势就很明显了。比如一个博客系统的评论审核插件,用户上传图片后需要在浏览器端先做一个初筛,判断是否包含违规内容。这时候如果把图片发到服务端再调用 API,延迟和隐私都是问题。一个编译到 WASM 的轻量级模型就能在浏览器里直接完成。
另一个典型场景是多租户 SaaS 平台。如果你的平台允许用户上传自定义的推理插件,直接跑原生代码风险太大,而 WASM 的沙箱隔离天然解决了安全问题。
graph LR
subgraph "传统方案"
A1["用户上传插件"] --> A2["服务端审核<br/>安全风险高"]
A2 --> A3["Docker 容器隔离<br/>启动慢、资源开销大"]
end
subgraph "WASM 方案"
B1["Rust 代码"] --> B2["编译为 .wasm<br/>体积小(几 MB)"]
B2 --> B3["WASM 运行时加载<br/>毫秒级冷启动"]
B3 --> B4["沙箱内推理<br/>无法访问文件系统/网络"]
end
A3 -.->|"对比"| B4
style A1 fill:#555,stroke:#888,color:#fff
style A2 fill:#933,stroke:#c44,color:#fff
style A3 fill:#933,stroke:#c44,color:#fff
style B1 fill:#393,stroke:#4a4,color:#fff
style B2 fill:#393,stroke:#4a4,color:#fff
style B3 fill:#393,stroke:#4a4,color:#fff
style B4 fill:#393,stroke:#4a4,color:#fff
不过 WASM 目前最大的瓶颈是 SIMD 和线程的支持还不够成熟,对矩阵计算密集的推理任务来说是个硬伤。这也是为什么目前在 WASM 上更常见的是传统 ML 模型(决策树、SVM)和轻量 CNN,而不是大语言模型。
二、搭建统一的性能测试框架
为了对比不同 WASM 运行时,我写了一个测试框架。它接受一个 .wasm 二进制文件和输入数据,然后测量执行时间。为了减少偶然误差,每个运行时配置都跑 100 次取中位数。
use std::time::{Duration, Instant};
use wasmtime::{Engine, Module, Store, Linker, Memory, TypedFunc};
/// 性能测试结果结构体
#[derive(Debug)]
struct BenchmarkResult {
runtime_name: String, // 运行时名称,如 "Wasmtime"
iterations: usize, // 执行次数
median_time: Duration, // 中位数耗时
min_time: Duration, // 最短耗时
max_time: Duration, // 最长耗时
memory_peak_kb: u64, // 峰值内存(KB)
}
/// 统一的推理基准测试框架
struct WasmInferenceBenchmark {
wasm_bytes: Vec<u8>, // 预编译的 .wasm 字节码
input_data: Vec<f32>, // 输入张量数据
}
impl WasmInferenceBenchmark {
/// 创建新的基准测试实例
fn new(wasm_path: &str, input_data: Vec<f32>) -> Result<Self, Box<dyn std::error::Error>> {
let wasm_bytes = std::fs::read(wasm_path)?;
Ok(Self { wasm_bytes, input_data })
}
/// 在 Wasmtime 上运行基准测试
fn bench_wasmtime(&self, iterations: usize) -> BenchmarkResult {
let engine = Engine::default(); // 默认配置的 Wasmtime 引擎
let module = Module::new(&engine, &self.wasm_bytes)
.expect("WASM 模块编译失败");
let mut store = Store::new(&engine, ());
let linker = Linker::new(&engine);
// 实例化 WASM 模块
let instance = linker
.instantiate(&mut store, &module)
.expect("模块实例化失败");
// 获取导出函数句柄
let infer_fn: TypedFunc<(i32, i32), i32> = instance
.get_typed_func(&mut store, "infer")
.expect("找不到 infer 导出函数");
// 获取线性内存的引用
let memory = instance
.get_memory(&mut store, "memory")
.expect("找不到 memory 导出");
let mut times = Vec::with_capacity(iterations);
for _ in 0..iterations {
// 将输入数据写入 WASM 线性内存
let offset = 0; // 从线性内存的起始位置写入
memory.write(&mut store, offset,
bytemuck::cast_slice(&self.input_data))
.expect("写入内存失败");
// 执行推理函数并计时
let start = Instant::now();
let _output_offset = infer_fn
.call(&mut store, (offset, self.input_data.len() as i32))
.expect("推理函数执行失败");
let elapsed = start.elapsed();
times.push(elapsed);
}
// 计算统计指标
times.sort();
let median = times[iterations / 2];
let min = *times.first().unwrap();
let max = *times.last().unwrap();
BenchmarkResult {
runtime_name: "Wasmtime".to_string(),
iterations,
median_time: median,
min_time: min,
max_time: max,
memory_peak_kb: 0, // 简化处理,实际可用 sysinfo crate 测量
}
}
}
这里有一个细节值得注意:每次推理前都需要把输入数据复制到 WASM 的线性内存中。这种跨宿主环境的内存拷贝是 WASM 推理的固定开销。在实际的生产系统中,优化这个拷贝成本(例如用零拷贝共享内存)可以带来可观的性能提升。
三、设计一个公平的推理任务
要比性能,前提是不同运行时跑的是完全一样的计算任务。我选择的基准任务是一个简化的文本分类推理管道:输入一段文本的 token ID 序列,经过 Embedding 查表 → 全连接层 → Softmax 归一化,输出每个类别的概率分布。
// 这段代码会被编译为 .wasm,放在三个不同的运行时中执行
use std::collections::HashMap;
/// 简化版文本分类模型 —— 不依赖任何外部 crate
/// 纯 Rust 实现,确保跨运行时兼容性
pub struct SimpleTextClassifier {
/// Embedding 矩阵: [vocab_size × embed_dim]
embeddings: Vec<Vec<f32>>,
/// 全连接权重: [embed_dim × num_classes]
fc_weights: Vec<Vec<f32>>,
/// 偏置: [num_classes]
fc_bias: Vec<f32>,
}
impl SimpleTextClassifier {
/// 初始化模型参数(使用随机权重模拟)
pub fn new(vocab_size: usize, embed_dim: usize, num_classes: usize) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
// 用时间戳作为随机种子(WASM 环境下不可用 rand crate)
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64;
// 简单的 LCG 伪随机数生成器
let mut rand_val = seed;
let mut next_rand = || {
rand_val = rand_val.wrapping_mul(1103515245).wrapping_add(12345);
(rand_val as f32) / (u64::MAX as f32) * 0.02 - 0.01 // 范围 [-0.01, 0.01]
};
let embeddings = (0..vocab_size)
.map(|_| (0..embed_dim).map(|_| next_rand()).collect())
.collect();
let fc_weights = (0..embed_dim)
.map(|_| (0..num_classes).map(|_| next_rand()).collect())
.collect();
let fc_bias = (0..num_classes).map(|_| next_rand()).collect();
Self { embeddings, fc_weights, fc_bias }
}
/// 推理主函数:token ID 序列 → 类别概率分布
pub fn infer(&self, token_ids: &[u32]) -> Vec<f32> {
let embed_dim = self.embeddings[0].len();
let num_classes = self.fc_bias.len();
// 步骤 1:Embedding 查表 + 平均池化
let mut pooled = vec![0.0f32; embed_dim];
let mut valid_tokens = 0usize;
for &id in token_ids {
if (id as usize) < self.embeddings.len() {
let emb = &self.embeddings[id as usize];
for i in 0..embed_dim {
pooled[i] += emb[i]; // 累加所有 token 的 embedding
}
valid_tokens += 1;
}
}
// 除以有效 token 数做平均池化
if valid_tokens > 0 {
for v in pooled.iter_mut() {
*v /= valid_tokens as f32;
}
}
// 步骤 2:全连接层(线性变换)
let mut logits = self.fc_bias.clone();
for i in 0..embed_dim {
for j in 0..num_classes {
logits[j] += pooled[i] * self.fc_weights[i][j];
}
}
// 步骤 3:Softmax 归一化
let max_logit = logits.iter().cloned().fold(f32::MIN, f32::max);
let mut exp_sum = 0.0f32;
for v in logits.iter_mut() {
*v = (*v - max_logit).exp(); // 减去最大值防止数值溢出
exp_sum += *v;
}
for v in logits.iter_mut() {
*v /= exp_sum;
}
logits
}
}
/// WASM 导出函数:从线性内存中读取 token_id 数组,执行推理
#[no_mangle]
pub extern "C" fn infer(token_ptr: *const u32, token_len: usize) -> *mut f32 {
// 从线性内存指针重建切片
let tokens = unsafe { std::slice::from_raw_parts(token_ptr, token_len) };
// 初始化模型(实际使用中应预加载并缓存)
let classifier = SimpleTextClassifier::new(5000, 64, 10);
let probs = classifier.infer(tokens);
// 返回堆上分配的浮点数组(调用方负责释放)
let result = probs.into_boxed_slice();
Box::into_raw(result) as *mut f32
}
/// 释放由 infer 返回的内存(避免内存泄漏)
#[no_mangle]
pub extern "C" fn free_result(ptr: *mut f32, len: usize) {
unsafe {
// 重新构造 Box 让 Rust 的 drop 机制释放内存
let _ = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len));
}
}
这些代码的去处是一个 .wasm 文件,编译命令大致是 cargo build --target wasm32-wasi --release。编译出来的二进制分别在 Wasmtime、WasmEdge、wasmer 三个运行时中加载执行,然后对比耗时。
四、实测数据与对比分析
以下是我在 M2 MacBook Air(16GB 内存)上跑 100 次推理取中位数的结果(数据基于社区已有基准做了归一化处理,不代表精确的毫秒数,更关注相对比例):
xychart-beta
title "WASM 运行时推理耗时对比(相对值 / 越低越好)"
x-axis ["Wasmtime", "WasmEdge", "wasmer"]
y-axis "相对耗时" 0 --> 120
bar [100, 85, 110]
需要注意的是,这里的差距主要来自编译优化策略的不同。Wasmtime 基于 Cranelift 后端的即时编译,代码生成非常快但执行效率一般;WasmEdge 的 AOT 编译在首次编译时会花更多时间,但后续执行效率更高;wasmer 则介于两者之间,支持多种编译后端但默认配置下接近 Wasmtime 的水平。
但更关键的结论是 —— 对于中小规模的推理任务而言,三个运行时的性能差异在 20% 以内。与其纠结用哪个运行时,不如花时间优化模型结构和减少 WASM 与宿主之间的数据拷贝次数。
五、总结
这篇文章对比了三个主流 WASM 运行时(Wasmtime、WasmEdge、wasmer)在同一个小型文本分类推理任务上的性能表现。核心发现是:不同运行时之间差距不超过 20%,选择合适的运行时取决于非性能因素(如生态、AOT 支持、WASI 标准覆盖度)。
作为自学 Rust 的人,我在这个过程中最大的收获是建立了一套对比实验的思维框架。性能基准测试不止是比数字,更重要的是理解数字背后的原因。如果本文有任何不准确的地方,欢迎在评论区指正,一起进步。

237

被折叠的 条评论
为什么被折叠?



