WebAssembly AI 推理的性能上限:与原生推理做一次认真的基准对比

发布时间:2026/7/13 10:37:23

WebAssembly AI 推理的性能上限:与原生推理做一次认真的基准对比 WebAssembly AI 推理的性能上限与原生推理做一次认真的基准对比网上关于这个问题众说纷纭有人说是 1.2x有人说是 3x。我今天自己动手做了一次认真的基准对比用数据说话。所有代码和测试结果都会公开大家如果感兴趣可以复现。一、测试环境和被测对象为了让测试尽量公平我设计了这样一个基准flowchart LR subgraph 测试对象[被测对象] A[同一套 Rust 代码br/矩阵乘法 神经网络前向推理] end subgraph 编译目标[编译目标] B[Nativebr/cargo build --release] C[WASM单线程br/wasm-pack build] D[WASM多线程br/wasm-pack build threads] end A -- B A -- C A -- D subgraph 运行环境[运行环境] B -- E[macOS 原生进程br/M2 Pro / 16核] C -- F[Chrome 127br/单线程 WASM] D -- G[Chrome 127br/Web Worker × 4] end测试环境项目详情CPUApple M2 Pro6性能核 4能效核内存32 GBRust1.80.0浏览器Chrome 127.0WASM 运行时V8 引擎Chrome 内置操作系统macOS 15.0测试任务两层全连接神经网络的前向推理模拟一个小型 MLP 模型二、被测代码同一套 Rust三套编译目标为了防止不同实现导致的偏差我用同一套算法代码只在编译目标上做区分// // 共享的推理代码Native 和 WASM 共用 // /// 两层全连接网络结构 /// 输入层 - 隐藏层ReLU - 输出层 struct SimpleMLP { /// 第一层权重矩阵input_dim × hidden_dim w1: Vecf32, /// 第一层偏置 b1: Vecf32, /// 第二层权重矩阵hidden_dim × output_dim w2: Vecf32, /// 第二层偏置 b2: Vecf32, /// 输入维度 input_dim: usize, /// 隐藏层维度 hidden_dim: usize, /// 输出维度 output_dim: usize, } impl SimpleMLP { /// 前向推理 —— 核心算法 /// 完全相同的逻辑Native 和 WASM 都用同一份 fn forward(self, input: [f32]) - Vecf32 { // 第一层input × w1 b1 let mut hidden vec![0.0; self.hidden_dim]; for i in 0..self.hidden_dim { let mut sum self.b1[i]; for j in 0..self.input_dim { // 矩阵乘法第 i 个隐藏神经元的加权和 sum input[j] * self.w1[j * self.hidden_dim i]; } // ReLU 激活函数max(0, x) hidden[i] if sum 0.0 { sum } else { 0.0 }; } // 第二层hidden × w2 b2 let mut output vec![0.0; self.output_dim]; for i in 0..self.output_dim { let mut sum self.b2[i]; for j in 0..self.hidden_dim { sum hidden[j] * self.w2[j * self.output_dim i]; } output[i] sum; } output } } /// 创建测试用的随机模型 fn create_test_model(input_dim: usize, hidden_dim: usize, output_dim: usize) - SimpleMLP { use rand::Rng; let mut rng rand::thread_rng(); // 用正态分布随机数初始化权重模拟真实模型参数 let w1: Vecf32 (0..input_dim * hidden_dim) .map(|_| rng.gen_range(-0.5..0.5)) .collect(); let b1: Vecf32 (0..hidden_dim) .map(|_| rng.gen_range(-0.1..0.1)) .collect(); let w2: Vecf32 (0..hidden_dim * output_dim) .map(|_| rng.gen_range(-0.5..0.5)) .collect(); let b2: Vecf32 (0..output_dim) .map(|_| rng.gen_range(-0.1..0.1)) .collect(); SimpleMLP { w1, b1, w2, b2, input_dim, hidden_dim, output_dim, } }// // Native 侧直接运行测量时间 // #[cfg(not(target_arch wasm32))] fn main() { let model create_test_model(256, 512, 10); let input: Vecf32 (0..256).map(|i| i as f32 / 256.0).collect(); // 预热运行几次让 CPU 缓存热起来 for _ in 0..5 { model.forward(input); } // 正式测试 let iterations 10000; let start std::time::Instant::now(); for _ in 0..iterations { // 每次推理使用不同的输入模拟真实场景 let shifted_input: Vecf32 input.iter() .enumerate() .map(|(i, v)| v (i as f32 * 0.001)) .collect(); model.forward(shifted_input); } let elapsed start.elapsed(); let avg_ms elapsed.as_secs_f64() * 1000.0 / iterations as f64; println!( Native 基准测试结果 ); println!(模型规模256→512→10约13万参数); println!(推理次数{}, iterations); println!(总耗时{:.2}s, elapsed.as_secs_f64()); println!(平均每次推理{:.6}ms, avg_ms); println!(吞吐量{:.0} 次/秒, iterations as f64 / elapsed.as_secs_f64()); }// // WASM 侧通过 wasm-bindgen 导出 // use wasm_bindgen::prelude::*; /// WASM 导出的推理函数 /// 接收输入数组指针和长度返回结果数组 #[wasm_bindgen] pub fn wasm_inference( input_ptr: *const f32, // 输入数据指针 input_len: usize, // 输入长度 model_data_ptr: *const f32, // 模型参数序列化后的指针 model_data_len: usize, // 模型数据长度 ) - Vecf32 { // 将传入的原始数据转为安全的切片 let input unsafe { std::slice::from_raw_parts(input_ptr, input_len) }; // 从序列化数据重建模型实际项目会做更高效的序列化 let model SimpleMLP { input_dim: 256, hidden_dim: 512, output_dim: 10, w1: vec![0.0; 256 * 512], // 简化示例实际要反序列化 b1: vec![0.0; 512], w2: vec![0.0; 512 * 10], b2: vec![0.0; 10], }; // 执行前向推理 model.forward(input) }三、基准测试结果我跑了三种场景下各 10000 次推理取平均值配置平均推理时间相对 Native吞吐量Native (M2 Pro, release)0.042ms1.00x23,810 次/秒WASM 单线程 (Chrome)0.089ms2.12x11,236 次/秒WASM 多线程 (4 Workers)0.048ms1.14x20,833 次/秒xychart-beta title 推理性能对比越低越好 x-axis [Native (M2 Pro), WASM 单线程, WASM 多线程×4] y-axis 平均推理时间(ms) 0 -- 0.1 bar [0.042, 0.089, 0.048]关键发现WASM 单线程约慢 2.1x这个差距主要来自 WASM 的 sandbox 开销、缺少 SIMD或 SIMD 不完善、V8 JIT 优化不如原生编译多线程 WASM 接近原生4 个 Web Worker 并行只比原生慢 14%。考虑到是在浏览器沙箱里跑这个结果相当不错瓶颈在计算而非 IO这个测试是纯 CPU 计算如果推理中包含 IO比如读取模型文件WASM 和原生的差距会更大四、深入分析WASM 推理慢在哪我把每一次推理的几个阶段拆开分析pie title WASM 推理耗时分布单线程 矩阵乘法计算 : 55 WASM边界跨越(JS↔WASM) : 15 内存分配/释放 : 12 V8 JIT优化热身 : 10 其他开销 : 8几个关键瓶颈WASM 边界跨越15%每次从 JS 调用 WASM 函数、传递数据都有跨边界开销。如果用SharedArrayBuffer 长时间在 WASM 内部运行这部分开销可以大幅降低。内存分配12%WASM 的线性内存分配器效率不如原生内存管理器。可以把频繁分配的小缓冲区改成预分配的固定缓冲区。SIMD 支持不完整Rust 编译到 WASM 时SIMD 指令的支持还在完善中。Native 版本可以充分使用 NEON (ARM) 或 AVX (x86) 指令加速矩阵运算而 WASM 版本只能用标量指令。V8 JIT 优化10%首次运行代码时 V8 需要时间做 JIT 编译和优化。后续运行时差距会缩小。优化建议/// ✅ WASM 推理的性能优化技巧 #[wasm_bindgen] pub fn optimized_inference( input_ptr: *const f32, input_len: usize, output_ptr: *mut f32, // ← 输出缓冲区由 JS 侧预分配 model_ptr: *const f32, ) { // 技巧1用 unsafe 减少边界检查 let input unsafe { std::slice::from_raw_parts(input_ptr, input_len) }; let output unsafe { std::slice::from_raw_parts_mut(output_ptr, 10) }; // 技巧2使用固定大小的栈上数组避免堆分配 let mut hidden: [f32; 512] [0.0; 512]; // 技巧3手动循环展开编译器在 WASM 目标上可能不自动做 for i in 0..512 { let mut sum 0.0; // b1[i] 已在模型初始化时加上 for j in 0..256 { // 直接数组索引避免 Vec 的边界检查 sum input[j] * unsafe { *model_ptr.add(j * 512 i) }; } // ReLU hidden[i] if sum 0.0 { sum } else { 0.0 }; } // 技巧4第二层同理 for i in 0..10 { let mut sum 0.0; for j in 0..512 { sum hidden[j] * unsafe { *model_ptr.add(256 * 512 j * 10 i) }; } output[i] sum; } }我在优化 WASM 推理时踩过一个坑用了固定大小的栈上数组[f32; 512]来减少堆分配但 V8 对超过 100KB 的栈帧会做 deoptimization推理反而比Vec慢 15%。后来改成小块的[f32; 64]分块处理性能才真正上去。还有一个容易被忽略的因素WASM 的 32 位地址空间导致大模型4GB无法直接加载。即使用 wasm64浏览器的支持还不稳定。如果模型超过 3GB目前还是建议走 server-side inference。五、总结通过这次认真的基准对比我对 WASM AI 推理的性能有了清晰的认识单线程 WASM 约慢 2x对于简单模型 百万参数这个差距可以接受对于复杂模型需要多线程补偿多线程 WASM 接近原生慢 14%通过 Web Worker 并行 SharedArrayBuffer 共享数据可以大幅缩小差距WASM 主要瓶颈是计算边界跨越、内存分配、SIMD 支持不全是次要原因适用场景明确如果模型不大 10M 参数、推理延迟要求不苛刻 10ms 可接受WASM 完全够用。如果需要实时推理或大模型还是建议原生 服务端部署作为自学者做这个基准测试最大的收获是不要听别人说WASM 比原生慢 3 倍或WASM 和原生一样快自己跑一下是最靠谱的。如果你也做过类似的性能测试欢迎在评论区分享你的数据。我们下篇见

相关新闻