Rust 加 WASM 的 FFI 性能测试:不同数据传递方式的吞吐量对比数据

📅 2026/7/25 2:27:02
Rust 加 WASM 的 FFI 性能测试:不同数据传递方式的吞吐量对比数据
Rust 加 WASM 的 FFI 性能测试不同数据传递方式的吞吐量对比数据一、FFI 是 WASM 的阿喀琉斯之踵在上一篇评估报告中我说 WASM 运行时推理延迟比原生慢 30%-120%。当时没展开说这 30% 差在哪里。经过两周的精细 benchmark答案很清楚超过 70% 的开销来自 FFI 边界的数据序列化和内存拷贝。WASM 的线性内存模型决定了宿主和 WASM 模块之间没法直接共享对象每次跨越边界都要做数据转换。这篇文章是我对三种主流数据传递方式的吞吐量对比全部基于 Rust → WASMwasmtime 运行时实测。二、三种数据传递方式三、Benchmark 设计与实现3.1 测试数据结构use serde::{Serialize, Deserialize}; /// 模拟 AI 推理场景中的 token 批次数据 /// 包含 token ID 数组和对应的注意力掩码 #[derive(Clone, Serialize, Deserialize)] pub struct TokenBatch { /// token ID 列表可变长度1~2048 pub tokens: Vecu32, /// 注意力掩码矩阵二维数组展平为一维 pub attention_mask: Vecf32, /// 批次元数据 pub batch_size: u32, pub max_seq_len: u32, }3.2 方式一JSON 序列化/// 方式一JSON 序列化传递 /// 将 TokenBatch 序列化为 JSON 字符串写入 WASM 线性内存 fn benchmark_json_serialization(batch: TokenBatch, store: mut wasmtime::Store(), memory: wasmtime::Memory) { // 序列化为 JSON 字符串 let json serde_json::to_string(batch).unwrap(); let bytes json.as_bytes(); // 在 WASM 线性内存中分配空间 let alloc instance.get_typed_func::i32, i32(store, alloc).unwrap(); let ptr alloc.call(store, bytes.len() as i32).unwrap(); // 将数据写入 WASM 内存 let mem_data memory.data_mut(store); mem_data[ptr as usize..ptr as usize bytes.len()].copy_from_slice(bytes); // 调用 WASM 处理函数 let process instance.get_typed_func::(i32, i32), i32(store, process_json).unwrap(); process.call(store, (ptr, bytes.len() as i32)).unwrap(); }3.3 方式二指针 布局描述/// 方式二通过结构体布局描述直接传递原始数据 /// 避免序列化开销但依然需要拷贝内存 #[repr(C)] // 确保 Rust 和 WASM 端看到相同的字段排列 struct TokenBatchLayout { tokens_ptr: u32, // 指向 token 数组的指针在 WASM 内存中 tokens_len: u32, // token 数组长度 mask_ptr: u32, // 指向掩码数组的指针 mask_rows: u32, // 掩码行数 mask_cols: u32, // 掩码列数 } fn benchmark_raw_pointer(batch: TokenBatch, store: mut wasmtime::Store(), memory: wasmtime::Memory) { // 步骤1分配 WASM 内存并拷贝 token 数据 let token_bytes bytemuck::cast_slice(batch.tokens); let token_ptr wasm_alloc(store, token_bytes.len()); memory.data_mut(store)[token_ptr..token_ptr token_bytes.len()] .copy_from_slice(token_bytes); // 步骤2分配并拷贝掩码数据 let mask_bytes bytemuck::cast_slice(batch.attention_mask); let mask_ptr wasm_alloc(store, mask_bytes.len()); memory.data_mut(store)[mask_ptr..mask_ptr mask_bytes.len()] .copy_from_slice(mask_bytes); // 步骤3组装布局描述结构体 let layout TokenBatchLayout { tokens_ptr: token_ptr as u32, tokens_len: batch.tokens.len() as u32, mask_ptr: mask_ptr as u32, mask_rows: (batch.attention_mask.len() / batch.max_seq_len as usize) as u32, mask_cols: batch.max_seq_len, }; // 步骤4传递布局描述给 WASM let layout_ptr wasm_alloc(store, std::mem::size_of::TokenBatchLayout()); let layout_bytes bytemuck::bytes_of(layout); memory.data_mut(store)[layout_ptr..layout_ptr layout_bytes.len()] .copy_from_slice(layout_bytes); let process instance.get_typed_func::i32, i32(store, process_raw).unwrap(); process.call(store, layout_ptr as i32).unwrap(); } /// WASM 端的辅助函数分配线性内存 fn wasm_alloc(store: mut wasmtime::Store(), size: usize) - usize { let alloc instance.get_typed_func::i32, i32(store, alloc).unwrap(); alloc.call(store, size as i32).unwrap() as usize }3.4 方式三直接内存共享wasm-bindgen 模式/// 方式三宿主和 WASM 协商好内存布局零拷贝 /// 要求双方使用相同的 #[repr(C)] 结构体定义 fn benchmark_zero_copy(batch: TokenBatch, store: mut wasmtime::Store(), memory: wasmtime::Memory) { // 直接获取 WASM 线性内存的可变引用 let mem memory.data_mut(store); // 在 WASM 内存栈上预分配区域直接写入数据 // 注意生产环境需要用更安全的分配策略 const OFFSET: usize 1024; // 预留给 WASM 内部使用的区域之后 // 写入 token 计数 数据 let token_count batch.tokens.len() as u32; mem[OFFSET..OFFSET 4].copy_from_slice(token_count.to_le_bytes()); let token_slice mem[OFFSET 4..OFFSET 4 batch.tokens.len() * 4]; // 注意这里不能直接 cast需要用安全的方式写入 // 实际项目中建议用 bytemuck 或 zerocopy crate // WASM 函数只接收一个偏移量参数 let process instance.get_typed_func::i32, i32(store, process_zerocopy).unwrap(); process.call(store, OFFSET as i32).unwrap(); }生产翻车现场上面这个零拷贝方案在 x86 机器上跑得飞快但部署到 Apple Silicon (M2) 上时推理结果全是乱码。排查了一整天发现#[repr(C)]结构体在 x86 上是紧凑排列的但在 ARM64 上结构体末尾会插入 padding 字节。WASM 模块用的是 x86 内存布局宿主是 ARM64 机器copy_from_slice时多读了 4 字节的 padding 数据。修复方案在TokenBatchLayout上显式标注#[repr(C, packed)]并用bytemuck::AnyBitPattern做对齐检查。生产实战经验MessagePack 的内存泄露陷阱除了 ARM64 对齐问题序列化格式的选择也有坑。起初用 JSON 传 2048 tokens 数据FFI 耗时 3.8ms。换成 MessagePack 降到 2.1ms省 45%但连续推理 100 次后Chrome DevTools Memory 面板显示 WASM 线性内存从 12MB 涨到 47MB——存在渐进式内存泄露。排查发现 MessagePack 反序列化时每个字段都触发 WASM 侧alloc分配小块内存释放不及时导致碎片累积。最终用bytemuck::Pod定义固定内存布局双方直接按 struct 读取/// 用 bytemuck 定义跨 FFI 的原始字节布局 /// 无需序列化/反序列化双方按字节拷贝即可 #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[repr(C)] struct TokenBatchFfi { tokens_ptr: u32, tokens_len: u32, mask_ptr: u32, mask_rows: u32, mask_cols: u32, }bytemuck::Pod保证 struct 是纯数据——无指针、无 padding、无复杂字段可安全跨边界按字节传输。改造后 FFI 耗时从 2.1ms 降到 0.8ms连续推理 500 次内存稳定在 13MB。四、Benchmark 结果数据量JSON 序列化原始指针零拷贝64 tokens180 MB/s650 MB/s1200 MB/s256 tokens320 MB/s1100 MB/s1900 MB/s1024 tokens510 MB/s1800 MB/s2300 MB/s2048 tokens620 MB/s2100 MB/s2450 MB/s方案延迟增加(vs 原生)实现复杂度安全性JSON80% ~ 120%低高原始指针30% ~ 50%中中零拷贝5% ~ 15%高需手动保证结论小数据量1KB三种方式差异不大用 JSON 最简单中等数据1KB~100KB原始指针是性价比最高的方案大数据量100KB零拷贝方案优势明显尤其在 AI 推理场景中每次传递 2048 tokens 的 embedding 矩阵约 8MB。线上实测数据在我们的 AI CLI 插件中一次典型推理调用需要传递 token_ids~2KB attention_mask~8KB 模型元数据~500B。用 JSON 方案时 FFI 耗时 3.8ms原始指针方案 1.1ms。但因为 FFI 只占整个推理延迟45ms的一小部分我们最终选了 JSON 方案——开发效率提升远大于 2.7ms 的性能损失。不是所有场景都需要零拷贝先 benchmark 再决定。端到端延迟拆解WASM vs 原生对 2048 tokens 场景按环节拆解各阶段延迟环节原生耗时WASM(JSON)WASM(bytemuck)差异说明数据准备0.1ms3.8ms0.8ms序列化 vs 内存拷贝推理计算18.2ms19.5ms19.1msWASM JIT vs LLVM AOT结果回传0.1ms1.2ms0.3ms反序列化开销总延迟18.4ms24.5ms20.2ms—额外开销—33%9.8%—WASM 推理计算本身只比原生慢约 5%19.1ms vs 18.2ms差距不大。主要额外开销来自 FFI 序列化——JSON 方案的 FFI 相关延迟达 5ms占总延迟 20%bytemuck 方案降至 1.1ms占比 5%。数据量 1KB 时 JSON 的简便性远大于性能损失超过 10KB 后直接内存布局的收益非常明显。五、总结Rust WASM 的 FFI 性能瓶颈不在 WASM 指令执行本身而在数据跨越边界的成本。三个实用建议优先减少 FFI 调用次数——与其传 100 次小数据不如合并成一次大数据调用对性能敏感路径用原始指针——serde 反序列化在 WASM 侧的alloc是主要瓶颈零拷贝适用于固定长度数据——如果你的数据结构大小在编译期已知#[repr(C)] 内存布局协商是最优解。这次 benchmark 也让我重新审视了 AI CLI 工具的架构如果未来要支持 WASM 插件做自定义推理后处理FFI 路径的设计会直接决定插件的性能天花板。好消息是Rust 的类型系统让我们在追求零拷贝性能的同时不至于完全放弃内存安全。下一篇预告AI Agent 的用户体验设计——loading 状态、错误提示和置信度展示的最佳实践。