Rust + WebAssembly 全链路解析

第10章 WASM 运行时性能:从理论到实测

作者 杨艺韬 · 19,969 字 · 发布于 · 更新于

"In God we trust, all others must bring data." — 统计与管理界流传的格言(常被归于 W. Edwards Deming,Wikiquote 列为误归属)

10.1 性能天花板:WASM vs JS vs 原生

WASM 的核心承诺是"接近原生的性能"。WebAssembly 规范的设计目标之一就是可预测的性能——不像 JS 依赖 JIT 的投机优化,WASM 的类型和内存模型让编译器不需要猜测就能生成高效代码。但"接近原生"不等于"等于原生"——理解差距的来源才能做出正确的技术决策。

graph TD
  A["原生代码性能<br/>基准"] --> B["WASM 理论峰值<br/>额外检查开销"]
  B --> C["JIT 编译后<br/>寄存器压力"]
  C --> D["含内存隔离<br/>安全开销"]
  D --> E["WASM 实际峰值<br/>低于原生一档"]
  A --> F["JS 基线解释器<br/>远低于原生"]
  F --> G["JS JIT 优化后<br/>受类型反馈质量影响"]
  G --> H["JS 峰值<br/>波动大、不可预测"]
  style A fill:#10b981,color:#333
  style E fill:#f59e0b,color:#333
  style H fill:#6366f1,color:#fff

额外检查开销:栈式指令只是编码形式——优化编译器(第 4 章)会把操作数栈和局部变量重建成 SSA 值再做寄存器分配,local.get/local.set 本身并不对应内存读写。真正多出来的是原生代码没有的检查:函数入口的栈溢出检查、call_indirect 的表越界与签名检查等。第 1 章引用的 Jangda 等人(USENIX ATC 2019)在 SPEC CPU 上测到的差距,就把"额外检查带来的更多分支"列为主要原因之一。

寄存器压力:WASM 的局部变量数量不受限(WASM 规范允许无限 local),但真实 CPU 的寄存器数量有限(x86-64 只有 16 个通用寄存器,ARM64 有 31 个)。当同时活跃的值超过寄存器数时,JIT 必须溢出(spill)到内存——额外的 load/store 指令。引擎还要占用几个寄存器存放实例、线性内存基址等运行时状态,留给用户代码的更少,加上 JIT 的寄存器分配不如 LLVM 精细,这是 Jangda 等人找到的另一个主要原因。

安全开销:WASM 的线性内存访问必须保证越界即陷入(trap),但这不等于"每条 load/store 都插一次比较指令"。在 64 位宿主上,主流实现的做法是为 32 位线性内存预留整整 4GiB 地址空间、并在其前后放置 guard region,越界访问落进未映射页、由信号处理器翻译成 trap。Wasmtime 的架构文档写得很直白:线性内存之后那段 guard region 的存在意义就是消掉边界检查(docs/contributing-architecture.md:392)。Config::memory_reservation 的文档注释(crates/wasmtime/src/config.rs:1768-:1779)把默认值讲得更细:64 位宿主上默认预留 4GiB,配上 Config::memory_guard_size 的默认值,就意味着 64KiB 页大小的 32 位线性内存在默认配置下几乎能跳过所有边界检查;32 位宿主上只默认预留 10MiB,文档明说这种平台上边界检查是必需的。真正需要显式边界检查的是三类场景:32 位宿主、memory64,以及关闭了信号陷入(Config::signals_based_traps(false),crates/wasmtime/src/config.rs:2897)的嵌入式配置。即便如此,仍有无法抹掉的固定成本:每次访问都要加上线性内存基址做一次间接寻址,memory.grow 之后基址可能变化,编译器对基址的缓存也就受限。

与 JS 的对比:JS 的 JIT 需要类型推断(type inference)——引擎先在基线解释器中收集类型 profile,然后用类型反馈做投机优化。如果类型 profile 变化(同一段代码有时传 int 有时传 string),JIT 必须去优化(deoptimize),回退到解释器重新收集 profile。WASM 没有这个问题——类型是静态的,JIT 不需要猜测。

10.2 整数运算:WASM 的优势领域

整数运算通常是 WASM 相对原生代码差距较小的领域——因为 WASM 的整数指令直接映射到 CPU 的整数指令,不需要类型装箱(boxing)或类型检查。

10.2.1 基准测试数据

测试方法:同一个 Rust crate(Rust 1.96.0,--release,opt-level=3)分别编译到 wasm32-unknown-unknown(默认 target feature,不开 simd128;wasm-bindgen 0.2.118 --target nodejs 产物,未跑 wasm-opt,在 node v24.20.0 里执行)和 x86_64-unknown-linux-gnu(默认 target-cpu,即 SSE2 基线)。机器是 Intel Xeon Gold 6240(KVM 虚拟机)。输入数据在计时前准备好,每项预热 1 次后跑 7 次取中位数,连跑三轮,多数项相差不到 5%(i32 矩阵乘约 10%)。

关于本章的数字

本章标明"实测"的表格都来自上述单一环境,不是公开基准;其余只给定性关系。CPU 型号、引擎版本、后台负载、甚至编译器小版本都会让结果整体平移。请把它们当作"量级参考"而不是"性能承诺"——真正该信的是你自己在目标设备上跑出来的数据(§10.8 给出了测量模板)。

任务(实测) WASM (ms) 原生 (ms) WASM / 原生 说明
SHA-256 哈希 100MB(sha2 0.10) 469 511 0.92x 纯整数位运算
sort_unstable 1M 个 i32 25 20 1.27x 分支密集
Fibonacci(40) 递归 252 282 0.89x 整数算术 + 函数调用
1M 元素数组上二分查找 1000 万次 1,366 1,050 1.30x 分支密集、访存随机
CRC32(查表法)100MB 353 258 1.37x 查表 + 位运算
512×512 i32 矩阵乘(朴素三重循环) 359 270 1.33x 乘加 + 跨行访存

结果并不是"WASM 一律慢一个固定百分比":在这台机器上,两项 WASM 甚至略快于默认配置的原生构建,其余慢 25%–40%。这类小内核本来就是 WASM 最接近原生的场景;换成更大、更真实的程序,差距通常会拉大——第 1 章引用的 Jangda 等人在 SPEC CPU 上测得平均慢 45%(Firefox)到 55%(Chrome)。

10.2.2 WASM 整数与 JS 整数的差异

JS 的整数运算存在 V8 的 "Smi"(Small Integer)优化——在开启指针压缩的 64 位 V8 上,Smi 用 31 位表示小整数(最低位是标记位,为 0 表示这是 Smi 而不是堆指针),超出这个范围的整数装箱为 HeapNumber。这意味着:

  • JS 的 let x = 42 实际上是一个 31 位值 + 最低 1 位标记
  • JS 的 x + y 需要先检查 x 和 y 是否都是 Smi,如果不是则走慢路径
  • JS 的整数溢出不会回绕(wrap around),而是变成浮点数——需要额外的溢出检查

WASM 没有这些开销:i32.add 直接映射到 CPU 的 add 指令,溢出按 2^32 回绕(和 C/Rust 的 wrapping_add 一致),不需要类型检查和溢出检查。

graph LR
  subgraph "JS: x + y 的执行路径"
    A["检查 x 是否 Smi"] --> B["检查 y 是否 Smi"]
    B --> C{"都是 Smi?"}
    C -->|是| D["Smi 加法<br/>+ 溢出检查"]
    C -->|否| E["慢路径:解箱 + 转换 + 加法"]
  end
  subgraph "WASM: i32.add 的执行路径"
    F["i32.add"] --> G["add rax, rbx"]
  end
  style A fill:#dc2626,color:#fff
  style B fill:#dc2626,color:#fff
  style C fill:#f59e0b,color:#333
  style F fill:#10b981,color:#333

但这些检查是否真的拖慢 JS,要看 JIT 能不能把它们消掉。同一环境下(§10.2.1)用手写 JS 实现同样的算法对比:

任务(实测) JS (ms) WASM (ms) WASM 加速比
Fibonacci(40) 递归 1,717 252 6.8x
512×512 i32 矩阵乘,JS 用二维数组(Array of Arrays) 530 359 1.5x
512×512 i32 矩阵乘,JS 用扁平 Int32Array + Math.imul 515 359 1.4x
CRC32 100MB,JS 用 Uint8Array + Uint32Array 查表 259 353 0.73x(JS 更快)

类型稳定、只用 typed array 的 JS 循环,TurboFan 优化后已经和 WASM 同一量级——Smi 检查、溢出检查大多被投机优化消掉了,二维数组的额外间接寻址在这里也只多出几个百分点。差距最大的是递归调用密集的 Fibonacci:JS 每次调用都有更重的调用约定和类型守卫,WASM 的函数调用则接近原生。所以"WASM 整数运算比 JS 快"要加限定:对 JIT 友好的热循环,优势有限甚至没有;调用密集、类型多变、难以投机优化的代码,WASM 的可预测性才兑现为数倍优势。

10.3 浮点运算:混合的结果

浮点运算是 WASM 性能画像中最复杂的部分——不同类型的浮点操作表现差异很大。

10.3.1 标量浮点

同一环境(§10.2.1)实测:

任务(实测) WASM (ms) 原生 (ms) JS (ms) 说明
512×512 f64 矩阵乘(朴素三重循环) 428 405 590 标量浮点,内层是归约,编译器不做向量化
Mandelbrot 2048×2048,最多 256 次迭代 999 1,000 1,007 分支 + 浮点依赖链

纯标量的浮点热循环,三者几乎打平:瓶颈在浮点运算本身的延迟依赖链上,谁都绕不过去。WASM 浮点真正吃亏的地方在别处:

  1. 浮点寄存器压力:x86-64 有 16 个 XMM 寄存器(128 位),同时活跃的浮点值一多,JIT 就要溢出到栈上;JIT 的寄存器分配又不如 LLVM 精细。

  2. 部分指令的语义与 CPU 不一致:WASM 的 f32.min/f32.max 要求传播 NaN、并区分 -0 与 +0,x86 的 minss/maxss 不满足这些语义,引擎要多发几条指令修正;浮点转整数同理——i32.trunc_f32_s 越界要 trap,饱和版 i32.trunc_sat_f32_s 要把 NaN 转成 0、越界值钳到边界,都比一条 cvttss2si 多出检查。

  3. 向量宽度:原生构建可以用 AVX2/AVX-512 做更宽的自动向量化,WASM 最多是 128 位的 simd128(见下一节)——可向量化的浮点循环,差距主要来自这里。

10.3.2 SIMD 浮点:成熟度的差异

WASM SIMD 提案(v128 类型)让 WASM 可以一次操作 128 位数据——4 个 f32 或 2 个 f64。这在理论上应该大幅缩小浮点性能差距,但实际效果取决于浏览器和 CPU 的支持程度。

#[cfg(target_feature = "simd128")]
use core::arch::wasm32::*;

#[cfg(target_feature = "simd128")]
unsafe fn dot_product_simd(a: &[f32], b: &[f32]) -> f32 {
    // 简化版:要求长度相等且是 4 的倍数,否则 v128_load 会读出切片末尾(尾部处理见 §10.12.4)
    assert!(a.len() == b.len() && a.len() % 4 == 0);
    let mut sum = f32x4_splat(0.0);
    for i in (0..a.len()).step_by(4) {
        let va = unsafe { v128_load(a[i..].as_ptr() as *const v128) };
        let vb = unsafe { v128_load(b[i..].as_ptr() as *const v128) };
        sum = f32x4_add(sum, f32x4_mul(va, vb));
    }
    // 水平求和
    let arr = unsafe { std::mem::transmute::<v128, [f32; 4]>(sum) };
    arr[0] + arr[1] + arr[2] + arr[3]
}

(edition 2024 下,unsafe fn 体内调用 unsafe 函数也要写 unsafe {} 块,否则 1.96 报 E0133 警告。)

用一个能被自动向量化的循环实测宽度的影响——512×512 f32 矩阵乘,写成 i-k-j 顺序,内层是逐元素的 c[i][j] += a[i][k] * b[k][j](同一环境,§10.2.1):

构建(实测) 耗时 (ms) 相对 WASM simd128
WASM,默认 target feature(无 simd128) 102 3.6x
WASM,-C target-feature=+simd128(LLVM 自动向量化为 f32x4) 28 1.0x
原生,默认 target-cpu(SSE2,同为 128 位) 27 0.96x
原生,-C target-cpu=native(AVX2/AVX-512 可用) 18 0.64x

同样是 128 位向量,WASM 与原生 SSE2 几乎打平;原生换上更宽的向量后快了约 1.5 倍。原因:

  1. 宽度差异:WASM SIMD 固定 128 位,而原生代码在支持 AVX2 的 CPU 上可以用 256 位(一次处理 8 个 f32)。WASM SIMD 的理论吞吐上限因此只有 AVX2 的一半;实际差距还受访存带宽限制,上表是 1.5 倍左右。

  2. 指令集覆盖:固定宽度 SIMD 提案定义了 236 条指令(可以在 Wasmtime 的 crates/cranelift/src/translate/code_translator.rs 里数出来——Operator::{I8x16,I16x8,I32x4,I64x2,F32x4,F64x2,V128}* 去掉 relaxed-simd 后正好 236 个分支),条数不算少,但它是各家 SIMD 指令集的交集:x86 SSE/AVX 系列上百条特化指令(例如 pmaddubsw 这样的乘加)在 WASM 里没有对应物,只能用多条通用指令拼出来。

  3. shuffle 限制:WASM 只有一条通用的 i8x16.shuffle,用 16 个字节立即数描述任意置换。JIT 要把它模式匹配成目标 CPU 上的专用 shuffle 指令,匹配不上就只能退化成通用的字节查表(x86 的 pshufb、ARM 的 tbl)加掩码常量。

SIMD 提案在 Chrome 91+、Firefox 89+、Safari 16.4+ 默认启用(webassembly.org 特性表数据源 features.json,2026-09-24 核对)。Rust 1.96 的 wasm32 目标默认不开 simd128(第 5 章列出的默认 feature 里没有它),要真正生成 SIMD 指令需要显式打开——RUSTFLAGS="-C target-feature=+simd128" wasm-pack build,代码里再用 #[cfg(target_feature = "simd128")] 保留标量分支。注意 cfg 是编译期选择:一个 .wasm 要么带 SIMD、要么不带,含 SIMD 指令的模块在不支持的引擎上会直接验证失败;要兼容老浏览器,得构建两份产物,在 JS 侧做特性检测后择一加载。(--target 决定的是 JS 胶水的产物形态,与 SIMD 无关。)

10.4 内存操作:线性内存的访问模式

WASM 的线性内存是一个连续的 ArrayBuffer,所有数据——堆、栈、全局变量——都在这块内存中。这种统一的内存模型简化了编译器的实现,但带来了独特的性能特征。

10.4.1 内存访问延迟

WASM 的线性内存访问速度取决于数据是否在 CPU 缓存中。和原生代码一样,WASM 受缓存层级支配,但相对差距随层级递减:

访问模式 WASM 相对原生的额外开销 原因
L1 缓存命中 最明显 固定的基址加法 / 地址计算摊在极短的访问上
L2 缓存命中 已显著减小 缓存延迟本身开始主导
L3 缓存命中 更小 同上
主存访问 基本可忽略 DRAM 延迟压倒一切

规律很简单:WASM 的内存访问额外开销是一个近似固定的常数,越是命中低层缓存(本身只有几个周期),这个常数占比越大;一旦落到主存,几十上百纳秒的 DRAM 延迟就把它淹没了。所以对 WASM 而言,"提升缓存命中率"的收益比在原生代码里还要大——它同时消掉了缓存缺失和相对开销的放大效应。

10.4.2 内存布局与缓存局部性

从 CPU 缓存的角度看,WASM 线性内存和原生进程的堆没有本质区别:线性内存在宿主进程里就是一段虚拟地址区间(引擎用 mmap 一类机制预留,并不在 JS 的 GC 堆里),背后的物理页同样由操作系统按页映射、并不连续。WASM 地址只是"基址 + 偏移",缓存行、TLB 的行为和原生代码一样。

真正的差异在内存管理上:

  • 线性内存只增不减:WASM 没有把内存还给宿主的指令,memory.grow 之后峰值就留在那里。原生分配器可以把空闲的大块 munmap 还给操作系统,WASM 里碎片化造成的浪费会一直占着。
  • 分配器更朴素:Rust 的 wasm32-unknown-unknown 标准库默认用 dlmalloc(第 11 章 §11.2),它有 smallbin/treebin 这类按大小分箱的空闲链表,但没有 glibc/jemalloc 那样的线程缓存(tcache / per-thread arena),高频小分配的代价相对更高。

所以在 WASM 里,分配模式值得更早关注。优化策略:预分配 + 重用,避免频繁调用 __wbindgen_malloc/__wbindgen_free。

// 每次调用分配新内存,返回后胶水复制到 JS 再释放
#[wasm_bindgen]
pub fn process(input: &[u8]) -> Vec<u8> {
    let output = Vec::with_capacity(input.len());
    // ... 处理 ...
    output
}

// 重用预分配的缓冲区:热路径上不再分配
#[wasm_bindgen]
pub struct Processor {
    buffer: Vec<u8>,
}

#[wasm_bindgen]
impl Processor {
    #[wasm_bindgen(constructor)]
    pub fn new(capacity: usize) -> Processor {
        Processor { buffer: Vec::with_capacity(capacity) }
    }

    /// 返回输出长度;输出留在 self.buffer 里
    pub fn process(&mut self, input: &[u8]) -> usize {
        self.buffer.clear();
        // ... 处理到 self.buffer ...
        self.buffer.len()
    }

    /// 输出在线性内存里的起始地址,JS 侧据此建视图读取
    pub fn output_ptr(&self) -> *const u8 {
        self.buffer.as_ptr()
    }
}

#[wasm_bindgen] 导出的方法不能返回借用(写成 -> &[u8] 会报 cannot return a borrowed ref with #[wasm_bindgen]),所以这里返回长度和指针,JS 侧用 new Uint8Array(memory.buffer, p.output_ptr(), len) 直接读,不经过复制(视图在下一次 process 或 memory.grow 之后失效,见 §10.4.3)。

10.4.3 memory.grow 的性能代价

memory.grow 是 WASM 申请更多线性内存的唯一方式——每次申请整数页(1 页 = 64KB)。它的代价常被高估,也常被低估,值得说清楚两种情形:

  • 在预留区内增长(常见路径):如 §10.1 所述,64 位宿主为 32 位线性内存预留了一大段地址空间(Wasmtime 默认 4GiB)。这时 memory.grow 只需把预留区里的一段虚拟地址提交为可写页,不发生数据复制,代价大致与新增页数成正比(页表操作 + 首次触碰时的缺页),而与已有内存总量无关。
  • 超出预留、需要重新分配:这时才会出现"申请新缓冲区 + 复制旧内容"的昂贵路径,耗时与线性内存总量成正比。Wasmtime 用 Config::memory_reservation / memory_reservation_for_growth 控制预留大小,就是为了把这条路径推远。

浏览器侧还有一个 JS 可见的副作用:memory.grow 之后旧的 ArrayBuffer 会被 detach,所有指向它的 TypedArray 视图全部失效(§10.10.3 的案例就栽在这里)。

对策:在 WASM 模块初始化时预分配足够的内存,既避免热路径上的增长抖动,也避免视图反复重建。

#[wasm_bindgen]
pub fn init() {
    // 预分配 16MB 线性内存
    // 这在模块初始化时执行,不影响后续热路径
    let mut vec: Vec<u8> = Vec::with_capacity(16 * 1024 * 1024);
    vec.resize(16 * 1024 * 1024, 0);
    // 存入全局缓冲区...
}

10.5 函数调用开销:WASM 与 JS 的边界

WASM 导出函数的调用开销来自三个部分:

  1. JS→WASM 的调用约定转换:JS 调用栈 → WASM 值栈的参数传递。JS 的参数压到 WASM 的执行栈上,返回值从 WASM 栈弹回 JS 栈。
  2. 参数和返回值的类型转换:把 JS 的 number 按 WASM 签名转成 i32/f64(ToInt32 等抽象操作),返回值再转回 JS 值。
  3. 执行上下文切换:JS 帧 → WASM 帧的切换。V8 为此生成专门的 wrapper(JS-to-Wasm wrapper),负责搭建 WASM 需要的栈帧与实例上下文;近年 V8 对常见签名做了 wrapper 内联,把这部分开销压得很低。

实测(同一环境,§10.2.1;wasm-bindgen 0.2.118 --target nodejs 导出函数,在 JS 热循环里连续调用 1000 万次取平均,字符串与 JsValue 各项 100 万次或 10 万次,每项 5 轮取中位数,两次运行结果一致):

签名(实测) 每次调用 说明
纯 JS 函数(对照) ≈ 0.5 ns 优化后大概率已被内联
() -> () ≈ 0.8 ns 空函数,优化后几乎被消掉
() -> i32 ≈ 5 ns
(i32) -> i32 ≈ 5 ns 基准
(i32, i32, i32) -> i32 ≈ 5 ns 多两个参数看不出差别
(f64, f64) -> f64 ≈ 4.6 ns 与 i32 同一水平
JsValue(按值传对象) ≈ 37 ns externref 入表/出表
&JsValue ≈ 49 ns
&str,16 字节 ≈ 66 ns 约为 i32 调用的 13 倍:malloc + 编码 + 复制
&str,1KB ≈ 3.5 µs 随长度线性增长

也就是说,数值签名的一次跨边界调用在个位数纳秒,参数个数、i32 还是 f64 都不是关键;一旦字符串或对象跨过边界,就贵出一到三个数量级。第 6 章 §6.11 测的是两个字符串进、一个字符串出的 concat_str,多了一次分配、解码和释放,短字符串约为 i32 调用的 50 倍——两组数据测的是不同形态,量级关系一致。

真正值得优化的从来不是 i32 参数的个数,而是有没有字符串和对象跨过这条边界。

10.5.1 跨边界调用的隐藏成本

wasm-bindgen 生成的 JS 胶水代码在每次跨边界调用时做了额外的工作:

  1. 字符串转换:JS string → UTF-8 字节写入 WASM 内存(TextEncoder),WASM string → JS string(TextDecoder)。这涉及编码转换 + 一次 __wbindgen_malloc,开销与字符串长度成正比,比一次纯 i32 调用高一两个数量级。
  2. JsValue 的句柄管理:现代工具链上 JsValue 存在 WASM 的 externref 表里——JS 胶水把对象直接作为 externref 传进去,由 CLI 生成的 WASM 侧代码调 __externref_table_alloc 放进表、drop 时 __externref_table_dealloc 归还(关掉 reference-types 时退回 JS 侧的 heap 数组,见 §11.4.1)。单次是 O(1),上表里约 40 ns,但高频创建/销毁仍然可观,而且表里持有的引用会延长对象的存活期。
  3. Closure 的绑定:每次创建 Closure<dyn FnMut()> 会分配一个 JS Function 对象 + WASM 侧的闭包数据。如果闭包是短生命周期的(如事件处理器只触发一次),频繁创建/销毁闭包会产生大量 GC 压力。

10.5.2 减少跨边界调用的策略

  1. 批量操作:把 N 次小调用合并为 1 次大调用(第 11 章详述)
  2. 缓存 WASM 结果:如果同一个计算结果会被 JS 多次使用,在 JS 侧缓存而不是每次都调用 WASM
  3. 避免在热循环中跨边界:循环体完全在 WASM 内执行,循环外的设置/清理跨边界
// 糟糕:每次迭代跨边界
for (let i = 0; i < data.length; i++) {
    result[i] = wasm.process(data[i]);  // N 次跨边界
}

// 改进:批量传数据,WASM 内循环
result = wasm.process_batch(data);  // 1 次跨边界

10.6 JIT 编译:V8 的 Liftoff + TurboFan

理解 V8 的 JIT 管线对 WASM 性能优化至关重要——同样的代码在不同编译阶段的性能可能差出明显一截。

10.6.1 Liftoff:基线编译器

Liftoff 是 V8 的 WebAssembly 基线编译器(baseline compiler),设计目标是快速编译而非最优代码。它采用单遍(single-pass)策略——每条 WASM 指令直接翻译为机器码,不做任何优化分析。

Liftoff 的特点:

  • 编译速度:比 TurboFan 快一个数量级——这正是它存在的理由:让模块尽快"能跑",而不是"跑得最快"
  • 代码质量:不做独立的寄存器分配,不做内联,不做常量传播——生成的代码明显慢于 TurboFan
  • 执行时机:V8 文档对当前生产配置的描述是,模块加载时不预先编译任何函数,函数第一次被调用时才用 Liftoff 编译(阻塞这次调用),随后在后台用 TurboFan 重新编译(📄 v8.dev · WebAssembly compilation pipeline)

Liftoff 适用于:首次执行、只执行一次的初始化代码、极小的函数(指令少到没有优化空间时,两个编译器的产物差别很小)。

10.6.2 TurboFan:优化编译器

TurboFan 是 V8 的优化编译器,对热点函数(hot function)做深度优化。它在后台线程执行,不阻塞主线程。

TurboFan 的优化管线:

flowchart TD
  A["WASM 字节码"] --> B["构建优化 IR"]
  B --> C["类型传播<br/>Type Propagation"]
  C --> D["内联<br/>Inlining"]
  D --> E["边界检查消除<br/>Bounds Check Elimination"]
  E --> F["寄存器分配<br/>Register Allocation"]
  F --> G["指令选择<br/>Instruction Selection"]
  G --> H["生成机器码"]
  style A fill:#6366f1,color:#fff
  style B fill:#f59e0b,color:#333
  style H fill:#10b981,color:#333

(V8 的优化编译管线本身也在演进——WASM 的优化路径已经从经典的 Sea of Nodes 迁移到基于基本块的 Turboshaft IR。对使用者来说这不改变"分层编译 + 后台优化"的模型,但意味着任何关于 IR 细节的经验都有保质期。)

关键优化 pass:

  1. 内联(Inlining):TurboFan 对小函数做内联——消除函数调用开销,暴露更多优化机会。WASM 的函数调用开销比原生小(没有 ABI 适配),但内联后可以消除参数传递和值栈操作。

  2. 边界检查消除(Bounds Check Elimination):如 §10.1 所述,64 位宿主上 wasm32 的越界检查主要靠 guard region + 信号处理承担,编译器层面要做的是把同一基址上可证明不越界的多次访问合并、把地址计算提到循环外。在需要显式检查的配置(32 位宿主、memory64)下,这个 pass 才是真正的收益来源。

  3. 寄存器分配:TurboFan 使用带活跃区间拆分(live range splitting)的线性扫描分配器,能在整个函数范围内规划寄存器;Liftoff 则根本没有独立的分配阶段——它在单遍翻译中按值栈状态就地指派寄存器,寄存器不够就立刻溢出。两者的代码质量差距主要来自这里。代价是 TurboFan 慢得多,所以只对热点函数执行。

10.6.3 热身效应与去优化

V8 的分层编译策略意味着:函数第一次执行的速度 < 后续执行的速度。

调用次数        编译状态          相对速度
最初若干次      Liftoff 基线      基准
升级阈值之前     Liftoff 基线      基准
升级完成之后     TurboFan 优化     明显更快
稳定期          TurboFan 优化     稳定在优化后水平

热身期(warmup)的长度取决于函数的复杂度和调用频率。V8 文档的说法是:监控每个 WASM 函数被调用的频繁程度,达到某个阈值就视为热点,提交给 TurboFan 后台编译。阈值是实现细节,随版本调整,不要写进代码假设里。还要记住 V8 对 WASM 不做栈上替换(OSR)(第 4 章):一次调用里跑的长循环,即使中途 TurboFan 版本编译好了,这次调用也仍用 Liftoff 代码跑完——只调用一次的大循环函数吃不到优化红利,基准测试里尤其要注意。

这里要澄清一个常见误解:WASM 的分层编译基本是单向的。函数从 Liftoff 升级到 TurboFan 之后不会因为"参数类型变了"而掉回去——WASM 的类型在验证期就已经定死,根本不存在 JS 那种"同一段代码有时传 int 有时传 string"的类型反馈失效。memory.grow 也不会触发去优化:编译出的代码从实例数据里重新加载线性内存基址即可,不需要重编译。

WASM 里确实存在的去优化只有一处窄场景:当引擎对 call_ref/call_indirect 做投机内联(根据运行时收集到的调用目标反馈,把最可能的那个 callee 内联进来)时,如果实际调用目标与反馈不符,就需要退回到未内联的版本。这条路径主要服务于 WasmGC 类工作负载,普通的数值计算模块几乎不会遇到。

基准测试的启示:测量 WASM 性能时要跳过热身期(Liftoff → TurboFan 的升级窗口),否则前几次调用的结果不具代表性。

10.6.4 其他引擎的 JIT 策略

不同浏览器的 WASM JIT 策略有差异:

引擎 基线编译器 优化编译器 热身策略
V8 (Chrome) Liftoff TurboFan 回边/调用计数
SpiderMonkey (Firefox) Baseline(Rabaldr) Ion 回边/调用计数
JavaScriptCore (Safari) BBQ(Build Bytecode Quickly) OMG(Optimized Machine-code Generator) 回边/调用计数

三家的具体升级阈值都是内部启发式、随版本变动,不要当成可依赖的常量。

值得单独一提的是 Cranelift——Bytecode Alliance 维护的独立代码生成后端,Wasmtime 的默认编译器就是它(本机 checkout 的 cranelift/ 目录)。SpiderMonkey 曾把它作为可选的 WASM 后端集成过,后来又移除了。Cranelift 的设计取舍与浏览器 JIT 相近:编译速度远快于 LLVM,代价是生成代码略逊一筹。这种取舍反映的是同一个约束——WASM 的编译发生在用户等待的关键路径上,没人愿意为几个百分点的峰值性能多等几百毫秒。

10.7 分支预测

WASM 的分支预测行为和原生代码基本一致——CPU 的分支预测器不区分 WASM 生成的分支和原生分支。但有例外:

间接调用(call_indirect)更难预测——因为目标函数取决于表索引的运行时值,CPU 的间接分支预测器需要更多样本才能学到模式。

// 间接调用——分支预测差
trait Processor {
    fn process(&self, data: &[u8]) -> Vec<u8>;
}

fn run(p: &dyn Processor, data: &[u8]) -> Vec<u8> {
    p.process(data)  // call_indirect — 目标不确定
}

// 直接调用——分支预测好
fn run_inline(data: &[u8], mode: Mode) -> Vec<u8> {
    match mode {  // 直接调用;两个分支通常编成 if/br_if,分支多且连续时才可能是 br_table
        Mode::A => process_a(data),
        Mode::B => process_b(data),
    }
}

call_indirect 在 WASM 中的实现:检查表索引是否越界、目标函数签名是否匹配,再从函数表(table)中取出函数指针调用。CPU 的间接分支预测器(indirect branch predictor)尝试学习"上次索引 X 跳到了地址 Y"的模式——但如果同一个 call_indirect 在不同时间调用不同的函数(多态调用),预测准确率下降,一次分支预测失败(misprediction)的惩罚是十几到几十个时钟周期的量级,具体值随微架构(流水线深度)变化。

⚠️ 注意别把这一节读成"trait object 的主要开销是分支预测"。本系列在《Rust 编译器与运行时揭秘》第 8 章统一过口径:一次 dyn 调用本身只是两次解引用(取 vtable 指针、再取函数指针),首要成本是编译器无法内联——内联不了就连带失去常量传播、循环不变量外提等一系列后续优化。分支预测失败是叠加在这之上的次级成本,只在同一调用点高频轮转多个实现时才显著。另外,如果这个间接调用背后是一次 I/O(读文件、发网络包),那系统调用本身就是微秒量级,而间接跳转是纳秒量级——差三个数量级,此时纠结间接调用开销没有意义。

在 Rust 的 trait object 场景中,如果某个调用点实际只会命中一个实现(站点单态),间接分支预测几乎总能命中;实现变多、调用点在多个目标之间轮转之后,命中率随目标数量下降——一旦超出预测器为该站点保留的历史容量,就基本退化成随机猜测。

10.8 性能分析方法论

可靠的 WASM 性能测量需要系统化的方法论——不是随便写个 performance.now() 就能得到可信的数据。

10.8.1 Chrome DevTools Performance 面板

Chrome DevTools 的 Performance 面板是分析 WASM 性能的主要工具:

  1. 录制性能轨迹:打开 Performance 面板 → 点击录制 → 执行操作 → 停止录制
  2. 查看 WASM 函数耗时:在 Bottom-Up 视图中筛选 WASM 帧——有名称段时显示 Rust 符号,否则显示 wasm-function[N]
  3. 查看 JIT 编译事件:时间线上能看到 WASM 编译相关的事件(Liftoff 的惰性编译发生在调用它的线程上,TurboFan 重编译在后台线程);事件名称随 Chrome 版本变化,以你手里的 DevTools 为准

DevTools 的局限:没有名称段(name 自定义段)时只能看到函数索引(wasm-function[42])。要注意 wasm-opt 不带 -g 时会丢掉名称段(wasm-opt 126 实测),而 wasm-pack 0.14 的 --release 与 --profiling 默认都跑 wasm-opt -O;--profiling 给 cargo 传的也就是 --release(src/build/mod.rs:94-100),不做配置时两者产物没有区别(第 8 章 §8.2)。想要"优化代码 + 函数名",在 Cargo.toml 的 [package.metadata.wasm-pack.profile.profiling] 里写 wasm-opt = ['-O', '-g'],再用 wasm-pack build --profiling 构建。

10.8.2 wasm2wat 分析

wasm2wat(WABT 工具包)把 .wasm 反汇编为 WAT 文本格式——用于人眼检查生成的代码质量:

wasm2wat my_lib.wasm | less

WAT 是 WASM 的文本表示,每条指令对应一行。通过阅读 WAT 可以确认:

  • 编译器是否生成了预期的指令序列
  • 是否有意外的边界检查(可以合并的)
  • 循环的指令布局是否合理
  • call_indirect 的数量(影响分支预测)

一个实用的分析流程:先用 twiggy 找到体积最大的函数,再用 wasm2wat 检查这些函数的指令是否有优化空间。

10.8.3 推荐的测量模板

async function benchmark(name, fn, warmup = 100, iterations = 1000) {
    // 热身——让热点函数尽量完成 TurboFan 升级(没有 API 能确认,只能多跑几轮)
    for (let i = 0; i < warmup; i++) await fn();

    // 预分配内存——避免 memory.grow 干扰
    // ... 确保所有 WASM 内存已分配 ...

    // 测量
    const times = [];
    for (let i = 0; i < iterations; i++) {
        const start = performance.now();
        await fn();
        times.push(performance.now() - start);
    }

    // 统计
    times.sort((a, b) => a - b);
    const median = times[Math.floor(times.length / 2)];
    const p95 = times[Math.floor(times.length * 0.95)];
    const p99 = times[Math.floor(times.length * 0.99)];
    console.log(`${name}: median=${median.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms`);
}

10.8.4 避免测量陷阱

  1. GC 压力:频繁的 JsValue 创建/销毁会触发 JS GC——GC 暂停会干扰测量。在测量前后手动调用 gc()(需要 --expose-gc flag)或用 FinalizationRegistry 观察 GC 行为。

  2. 内存增长:如果测试过程中触发了 memory.grow,测量结果会包含内存增长的延迟,还会让 JS 侧的旧视图失效。确保预分配足够的内存。

  3. JIT 编译延迟:首次调用可能触发 JIT 编译。用热身期消除这个影响。

  4. 浏览器节能模式:macOS 的低电量模式会降低 CPU 频率,影响测量。确保系统在高性能模式下运行。

  5. 计时器精度被刻意降低:Spectre 之后,浏览器对 performance.now() 做了粗化(clamping)。MDN 的 Performance: now() method 把两档分辨率写在页首:非跨源隔离上下文 100 微秒("Resolution in non-isolated contexts: 100 microseconds"),启用 cross-origin isolation(Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp)后才是 5 微秒("Resolution in isolated contexts: 5 microseconds");粗化的理由页面也写明了——"To offer protection against timing attacks and fingerprinting"。各引擎在这两档之外还可能有自己的策略,具体值以你目标浏览器当时的实现为准。这意味着单次测量微秒级的操作是不可能的——必须把操作重复 N 次再除以 N,或者干脆改用 §10.8.3 的多轮取分位数模板。

10.9 多线程与 Atomics 提案

WASM 的 Threads 提案(已在 Chrome 74+、Firefox 79+、Safari 14.1+ 落地,iOS Safari 为 14.5+;features.json,2026-09-24 核对)让 WASM 可以利用多核 CPU——这是性能突破单线程瓶颈的关键。但 WASM 的多线程模型与原生不同,理解差异才能正确使用。

10.9.1 SharedArrayBuffer 与共享线性内存

WASM 的多线程基础是 SharedArrayBuffer——一个跨 Worker 共享的二进制缓冲区。所有 Worker 看到同一份线性内存,对内存的修改对所有 Worker 立即可见(在 atomic 语义下)。

graph TB
  subgraph "主线程"
    M1["JS 主线程"] --> WM["WASM 模块实例"]
  end
  subgraph "Worker 1"
    W1["JS Worker"] --> WW1["WASM 模块实例"]
  end
  subgraph "Worker 2"
    W2["JS Worker"] --> WW2["WASM 模块实例"]
  end
  SAB["SharedArrayBuffer<br/>共享线性内存"]
  WM -.读写.-> SAB
  WW1 -.读写.-> SAB
  WW2 -.读写.-> SAB
  style SAB fill:#10b981,color:#333

每个 Worker 有自己的 WASM 模块实例(同一份字节码独立实例化),但所有实例的 memory 导入指向同一个 SharedArrayBuffer。这意味着:

  • 共享数据零拷贝:主线程写入的数据 Worker 立即可读,无需 postMessage
  • 同步必须用 atomics:普通 i32.load/i32.store 不保证可见性,必须用 i32.atomic.load/i32.atomic.store
  • Worker 启动有开销:每个 Worker 要重新实例化 WASM 模块——编译产物可以跨 Worker 复用(postMessage 传 WebAssembly.Module),但实例化、线性内存导入、初始化代码每个 Worker 都要各跑一遍。线程池必须在启动阶段建好,不能按需创建

10.9.2 Atomic 操作的性能特征

WASM Threads 提案引入了原子内存操作(atomic memory operations)——保证多线程读写的可见性和原子性。它的原子访问一律是顺序一致(sequentially consistent)语义,没有 acquire/release 这类更弱的档位,Rust 里写 Ordering::Acquire 也会按顺序一致的指令生成:

操作 无争用时的相对代价 争用时 说明
i32.load / i32.store 基准 不变 普通读写,无同步语义
i32.atomic.load 与基准相当 不变 顺序一致读,x86 上就是普通 mov
i32.atomic.store 明显高于基准 缓存行争抢时恶化 顺序一致写,x86 上要用 xchg 这类隐含锁的指令
i32.atomic.rmw.* 明显高于基准 急剧恶化 独占缓存行,多核争抢时排队
memory.atomic.wait32 高(含系统调用路径) 视等待时间 阻塞当前 Worker
memory.atomic.notify 高 与等待者数量相关 唤醒等待的 Worker

原子 RMW(read-modify-write)操作在无争用时只比普通读写贵一个小常数,但争用时(多个线程同时操作同一个地址)代价会成倍放大——CPU 的缓存一致性协议(MESI)需要让缓存行在核心间反复迁移。热点变量绝对不能放在同一个缓存行——这就是著名的"伪共享"(false sharing)问题。

10.9.3 WASM 多线程的实战:并行计算

Rust 侧用 wasm-bindgen-rayon 可以让 Rayon 的并行迭代器在 WASM 中工作:

use wasm_bindgen::prelude::*;
use rayon::prelude::*;

// 必须重新导出,JS 侧才有 initThreadPool
pub use wasm_bindgen_rayon::init_thread_pool;

#[wasm_bindgen]
pub fn parallel_sum(data: &[f64]) -> f64 {
    data.par_iter().sum()
}

JS 侧需要先初始化线程池:

import init, { initThreadPool, parallel_sum } from './my_wasm.js';

await init();
await initThreadPool(navigator.hardwareConcurrency); // 每个线程一个 Worker
const result = parallel_sum(big_array); // big_array: Float64Array

光有这两段代码还跑不起来,构建侧有三道门槛:

  • atomics 在 Rust 1.96 仍是 unstable 的 target feature(compiler/rustc_target/src/target_features.rs:746,Unstable(sym::wasm_target_feature)),而 rustup 分发的 wasm32 std 没有按 atomics 预编译,必须用 nightly(或 RUSTC_BOOTSTRAP=1)配合 -Zbuild-std=panic_abort,std 重建标准库(第 5 章 §5.10);
  • RUSTFLAGS 要加 -C target-feature=+atomics,+bulk-memory,以及共享内存、导入内存、导出 TLS 符号等一串链接参数——wasm-bindgen-rayon 的 README 和 wasm-bindgen 仓库的 examples/raytrace-parallel 给了完整清单(第 16 章也有逐项解释);
  • wasm-bindgen-rayon 目前只支持 --target web 产物,页面还要满足下面 §10.9.4 的跨源隔离。

至于能快多少,没有通用倍数:计算密集、任务粒度足够大的负载能接近核数线性加速;像大数组求和这种访存受限的负载,很快就撞上内存带宽。Worker 启动、线程池初始化、共享内存上的原子操作都有额外成本,实际收益必须在目标设备上测。

10.9.4 部署的隔离要求:COOP/COEP

SharedArrayBuffer 在 Spectre 漏洞曝光后(2018 年初)一度被各浏览器默认禁用。如今浏览器要求页面满足 Cross-Origin Isolation 才能使用 SharedArrayBuffer:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

require-corp 下,跨域资源要么以 CORS 方式请求(例如 <img crossorigin>,且对方允许 CORS),要么响应里带允许嵌入的 Cross-Origin-Resource-Policy 头(如 cross-origin),否则会被浏览器拦截(MDN 对 Cross-Origin-Embedder-Policy 的说明)。COEP 还有一个 credentialless 取值:跨域的 no-cors 请求不带凭据发出、不再要求 CORP 头,能缓解第三方资源的问题,但浏览器支持程度要以目标浏览器为准。这意味着:

  • CDN 图片:CDN 要么返回 CORP 头,要么支持 CORS 并在页面上加 crossorigin
  • 第三方 SDK:埋点脚本、广告 SDK、客服浮窗等如果既不支持 CORS 也不带 CORP 头,就会失效
  • iframe 嵌入:YouTube、Twitter 等嵌入式内容可能无法加载
  • 弹窗:COOP same-origin 会切断与跨源弹窗之间的 window.opener 联系,依赖弹窗回传结果的 OAuth 登录、支付流程要重新验证

这两个头是按文档(页面响应)下发的,技术上可以只给用 WASM 多线程的页面加;难处在于这个页面本身及其嵌入的一切都要满足上面的约束。如果业务依赖大量第三方资源,要么放弃多线程(接受单线程性能),要么投入工程把第三方资源代理到自己域下、补 CORP 头或改走 CORS。

10.10 实战:性能优化案例剖析

理论数据是抽象的——下面三个案例展示真实优化中"诊断 → 假设 → 验证 → 落地"的完整循环。它们是按典型场景整理的示意性复盘,里面的耗时数字用来说明量级关系和排查思路,不是可复现的基准。

10.10.1 案例一:图像缩放从 800ms 降到 80ms

症状:Web 端用 WASM 做 4K 图像缩放(双线性插值),单次耗时 800ms,用户感觉卡顿。

诊断流程:

  1. DevTools Performance 录制:发现 95% 的时间在 WASM 函数内,跨边界开销可忽略
  2. WAT 反汇编检查:核心循环里全是逐像素的 i32.load + i32.const 255 / i32.and / i32.shr_u——纯标量的通道拆分与合并,一条 SIMD 指令都没有
  3. wasm-opt -O4 重新优化:用 wasm-opt 速度导向的最高档再优化一遍,运行时几乎无改进——说明问题不在编译器
  4. 手动改写为 SIMD:用 f32x4 一次处理 4 个像素

关键代码改动:

// 优化前:标量循环(注意 x 在外层:按列遍历,每次写 dst 都跨一整行,缓存也不友好)
for x in 0..w {
    for y in 0..h {
        let r = bilinear(src, x as f32 * sx, y as f32 * sy);
        dst[y * w + x] = r;
    }
}

// 优化后:SIMD 一次 4 像素
#[cfg(target_feature = "simd128")]
unsafe fn scale_simd(src: &[u8], dst: &mut [u8] ) {
    use core::arch::wasm32::*;
    for x in (0..w).step_by(4) {
        let xs = f32x4(x as f32, (x+1) as f32, (x+2) as f32, (x+3) as f32);
        let xs_scaled = f32x4_mul(xs, f32x4_splat(sx));
        // ... bilinear 4 像素并行 ...
    }
}

结果:800ms → 80ms(10x 提升)。SIMD 贡献约 4x,其余来自把循环改成按行遍历(顺序访问、缓存友好)和循环展开——WASM 没有预取指令,缓存友好只能靠访问顺序。

教训:当 wasm-opt 不能再优化时,问题往往在算法层面——SIMD、并行化、算法选择比编译器优化的潜力大得多。

10.10.2 案例二:JSON 解析从 240ms 降到 30ms

症状:WASM 模块解析 5MB JSON 后输出对象,端到端 240ms。同样的 JSON 用 JSON.parse() 只要 18ms。

诊断流程:

  1. 拆分耗时:发现 WASM 内的 serde_json::from_slice 只用 80ms,但 wasm-bindgen 的对象构造耗时 160ms
  2. 检查 JsValue 转换:每个 JSON 对象的字段都生成一次 JsValue::from_str——5MB JSON 产生约 100 万次跨边界调用
  3. 回顾设计:用户实际只需要其中 3 个字段——把整个 JSON 转 JsValue 是浪费

关键改动:把"WASM 完整解析后转 JsValue"改为"WASM 只导出需要的字段":

#[wasm_bindgen]
pub struct Parsed {
    title: String,
    author: String,
    content: String,
}

#[wasm_bindgen]
impl Parsed {
    pub fn parse(json: &[u8]) -> Result<Parsed, JsValue> {
        let v: serde_json::Value = serde_json::from_slice(json)
            .map_err(|e| JsValue::from_str(&e.to_string()))?;
        Ok(Parsed {
            title: v["title"].as_str().unwrap_or("").to_string(),
            author: v["author"].as_str().unwrap_or("").to_string(),
            content: v["content"].as_str().unwrap_or("").to_string(),
        })
    }

    #[wasm_bindgen(getter)] pub fn title(&self) -> String { self.title.clone() }
    #[wasm_bindgen(getter)] pub fn author(&self) -> String { self.author.clone() }
    #[wasm_bindgen(getter)] pub fn content(&self) -> String { self.content.clone() }
}

结果:240ms → 30ms(8x 提升)。但仍比 JSON.parse 慢 1.7x——5MB 输入要先复制进线性内存,serde_json 仍要把整份文档解析成 Value,字段字符串还要再解码回 JS。

教训:WASM 不是 JS 的银弹——浏览器引擎对纯 JS 操作(如 JSON.parse)有深度优化,WASM 在这些场景反而更慢。WASM 的优势在 JS 没有内置实现的算法上。

10.10.3 案例三:去除 60ms 的 GC 暂停

症状:实时音频处理 WASM,正常情况下每帧 5ms,但偶尔出现 60ms 的卡顿。卡顿不规律但每分钟必有几次。

诊断:

  1. Performance 录制:卡顿时段的 Main 线程显示 GC 事件——明显的 JS GC 暂停
  2. GC 触发源排查:用 --expose-gc + FinalizationRegistry 观察对象创建。发现每帧都新建 Float32Array 视图(用于读取 WASM 输出),下游代码还对它 slice() 了一份——视图本身不复制数据,但每帧都在堆上留下新对象,slice() 更是每帧复制 4KB(1024 个 f32),分配速率一高,GC 就更频繁
  3. 复用视图:在初始化时一次性创建视图并缓存
// 优化前:每帧创建新视图
function processAudio(wasmInstance) {
    const ptr = wasmInstance.exports.process();
    const view = new Float32Array(wasmInstance.exports.memory.buffer, ptr, 1024);
    return view;
}

// 优化后:复用视图(输出缓冲区地址固定时成立)
let cachedView = null;
function processAudio(wasmInstance) {
    const ptr = wasmInstance.exports.process();
    const buffer = wasmInstance.exports.memory.buffer;
    if (!cachedView || cachedView.buffer !== buffer || cachedView.byteOffset !== ptr) {
        cachedView = new Float32Array(buffer, ptr, 1024);
    }
    return cachedView; // 同一块内存的视图,调用方就地读取,不要再 slice()
}

注意:如果 WASM 调用了 memory.grow,旧的 ArrayBuffer 会被 detach,缓存的 cachedView 随之失效。WASM 没有"内存增长事件"可以监听,只能像上面那样每次比对 memory.buffer 是否换了对象再重建视图——或者预分配足够的内存避免 memory.grow。

结果:60ms 卡顿消失,每帧稳定在 5ms。

教训:WASM 性能问题常常不在 WASM 内——而在 JS-WASM 边界的对象生命周期。Float32Array/Uint8Array 视图的创建、Closure 的频繁分配、JsValue 的释放都会产生 GC 压力。性能敏感场景必须监控对象分配速率。

10.10.4 优化决策的优先级清单

实战中遇到 WASM 性能问题,按以下顺序排查:

flowchart TD
  A["性能问题"] --> B{"DevTools 录制:<br/>WASM 内 vs JS-WASM 边界"}
  B -->|边界耗时主导| C["减少跨边界调用<br/>批量化、视图复用"]
  B -->|WASM 内耗时主导| D{"是否 SIMD 可向量化?"}
  D -->|是| E["手动 SIMD 改写"]
  D -->|否| F{"是否可并行?"}
  F -->|是| G["WASM Threads + Rayon"]
  F -->|否| H{"WAT 反汇编检查<br/>是否生成最优指令?"}
  H -->|否| I["wasm-opt -O4 + 算法重构"]
  H -->|是| J["接受当前性能<br/>或重新评估 WASM 适用性"]
  style A fill:#dc2626,color:#fff
  style J fill:#6366f1,color:#fff

多数 WASM 性能问题在前三层(边界、SIMD、并行)就能找到出路——只有少数极致优化场景需要深入到指令级和编译器调优。

10.11 启动性能:编译、缓存与首屏

WASM 的"启动性能"——从下载到首次执行的总耗时——往往被忽视,但它直接影响用户感知的首屏时间。模块一大、设备一慢,下载加编译就可能拖到用户明显可感知的程度。

10.11.1 启动阶段的耗时分解

WASM 模块从源到执行的完整链路:

flowchart LR
  A["1. 网络下载"] --> B["2. 字节码解码<br/>与验证"]
  B --> C["3. 编译<br/>Liftoff 基线(可惰性)"]
  C --> D["4. 实例化<br/>分配内存/表"]
  D --> E["5. start 函数执行"]
  E --> F["6. 首次导出调用"]
  G["TurboFan 优化"] -.后台.-> C
  style A fill:#6366f1,color:#fff
  style C fill:#f59e0b,color:#333
  style F fill:#10b981,color:#333

各阶段的特征(具体耗时随模块大小、网络和设备差异极大,这里只给定性关系):

阶段 主要决定因素 是否阻塞主线程
网络下载 模块体积、网络 否
字节码解码与验证 模块体积 用 compileStreaming/instantiateStreaming 时边下载边做;同步的 new WebAssembly.Module() 会阻塞
Liftoff 编译 被调用到的函数的代码量 惰性编译时,某个函数第一次被调用那一刻在调用线程上编译
实例化 内存/表的初始化、数据段复制 是
start 函数 / 初始化代码 你自己的代码 是
TurboFan 优化 热点函数的代码量 否(后台线程)

低端设备(如老款 Android 手机)上,解码和编译都会成倍变慢——启动预算要按目标用户里最慢的那批设备来定,并在真机上测。

10.11.2 streaming compilation:边下载边编译

WebAssembly.compileStreaming 和 instantiateStreaming 让浏览器在下载过程中就开始编译——不等下载完成:

// 反模式:等下载完再编译
const buffer = await fetch('my.wasm').then(r => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(buffer);

// 推荐:流式编译
const { instance } = await WebAssembly.instantiateStreaming(fetch('my.wasm'));

streaming 的收益取决于网络速度和 WASM 大小:

  • 快速网络:下载本来就快,能重叠的时间有限
  • 慢速网络:下载是瓶颈,解码和编译几乎都能藏进下载时间里
  • 大模块:解码、编译本身耗时长,重叠的收益更明显

服务器端必须返回 Content-Type: application/wasm,否则 instantiateStreaming 会直接 reject(TypeError),原生 API 不会自己回退。wasm-bindgen 0.2.118 生成的 --target web 胶水会捕获这个错误:确认是 MIME 类型不对时打一条警告,回退到 arrayBuffer() + WebAssembly.instantiate(crates/cli-support/src/js/mod.rs:1118-1132),其他错误照常抛出。

10.11.3 Code Caching:跨会话复用编译结果

V8 的 WebAssembly 实现了 code caching——把 TurboFan 生成的机器码随 HTTP 缓存一起存下来,下次加载同一个 WASM 模块时直接取用,跳过编译阶段。

触发缓存的条件(V8 文档 WebAssembly compilation pipeline 的 Code caching 一节):

  1. 模块用 WebAssembly.compileStreaming/instantiateStreaming 编译——这也意味着它来自一个 fetch 的 HTTP 响应,而不是 data: URI 或手里的字节数组
  2. 服务器返回正确的 Content-Type: application/wasm(否则 streaming 这条路本身就走不通)
  3. TurboFan 生成的代码量累积到一定阈值才会触发写缓存——小模块可能永远不会被缓存
  4. 响应本身能进 HTTP 缓存(用户禁用缓存时自然无从谈起)

命中 code cache 的二次加载可以跳过绝大部分编译时间,通常只剩下载(往往也命中 HTTP 缓存)和实例化——这是启动优化里性价比最高的一项,且不需要改一行代码。

手动缓存到 IndexedDB:对于不能依赖浏览器自动缓存的场景(例如从 data: URI 或 Blob 加载),可以自己把 .wasm 字节存进 IndexedDB。注意这里存的是字节码而不是编译产物——WebAssembly.Module 曾经可以结构化克隆进 IndexedDB,但该能力已被主流浏览器移除,不要再依赖:

async function getOrCompile(url) {
    const cached = await idb.get('wasm-cache', url);
    if (cached) {
        try {
            return await WebAssembly.compile(cached);
        } catch {
            // 缓存失效(V8 版本变更等),删掉重新编译
            await idb.delete('wasm-cache', url);
        }
    }
    const response = await fetch(url);
    const buffer = await response.arrayBuffer();
    await idb.set('wasm-cache', url, buffer);
    return WebAssembly.compile(buffer);
}

也就是说,手动缓存只能省掉网络往返,省不掉编译。真正的组合拳是:Service Worker 负责"离线可用 + 稳定命中同一个 URL",浏览器自带的 code cache 负责省掉编译。

10.11.4 首屏优化的工程模式

实际项目中,WASM 启动性能优化通常是这几个手段的组合:

模式 适用场景 改善的是什么
streaming compilation 所有 Web 端 WASM 解码、编译与下载重叠
代码分割(按需加载) 模块较大、功能可拆 首次只下载、编译用得到的部分
Service Worker 预缓存 二次访问 省掉网络往返
WebAssembly.compile 在 idle 时间 非首屏关键路径 编译挪出首屏关键路径
兜底 JS 实现 首屏必须立即响应 首屏完全不等 WASM

各项能省多少,取决于模块体积、网络和设备,没有通用百分比,要在自己的 RUM 数据里看。

最后一种模式在生产中尤其重要——许多业务的"首屏"实际上不需要 WASM。例如一个图片编辑器的首屏只需要展示画布和工具栏,真正的图像处理操作可以等用户点击"应用滤镜"时才用到 WASM。这种场景下,把 WASM 加载推迟到用户交互后,对首屏指标(FCP、LCP)几乎零影响。

10.11.5 启动性能监控

生产环境应该监控这些指标:

const metrics = {};
metrics.fetchStart = performance.now();
const response = await fetch('my.wasm');
metrics.fetchEnd = performance.now(); // fetch 在收到响应头时就 resolve,此时正文还在下载

const { instance } = await WebAssembly.instantiateStreaming(response);
metrics.instantiated = performance.now();

instance.exports.start();
metrics.firstCall = performance.now();

reportMetrics({
    time_to_headers: metrics.fetchEnd - metrics.fetchStart,
    download_compile_instantiate: metrics.instantiated - metrics.fetchEnd, // streaming 下三者重叠,拆不开
    cold_start_total: metrics.firstCall - metrics.fetchStart,
});

P95 启动耗时是关键 SLO——平均值会掩盖慢设备、慢网络上那部分用户的体验。RUM(Real User Monitoring)数据比合成监控更能反映真实用户体验,因为合成监控的网络条件和设备性能都是理想化的。

10.12 SIMD 实战编码模式

WASM 的 SIMD(v128 类型)能让可向量化的计算密集场景获得数倍加速(§10.3.2 的 f32 矩阵乘实测约 3.6 倍)——但写好 SIMD 代码不是"加几行就行"。理解编码模式、自动向量化、手动 intrinsics 三层手段是性能调优的关键技能。

10.12.1 SIMD 三层使用方式

graph TD
  A["WASM SIMD 使用方式"] --> B["自动向量化"]
  A --> C["显式 SIMD 库"]
  A --> D["手写 intrinsics"]
  B --> B1["编译器自动识别<br/>simple loops<br/>0 代码改动"]
  C --> C1["wide 等 crate<br/>抽象掉平台差异<br/>少量改动"]
  D --> D1["core::arch::wasm32::*<br/>极致控制<br/>大量代码"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

效果与代价:

方式 典型收益 代码改动 可移植性
自动向量化 有,但不稳定(编译器识别不出就是 0) 无 完美
wide crate 稳定 中 好,按目标选实现,没有 SIMD 时降级为标量
手写 intrinsics 不一定比 wide 更快(见 §10.12.4 实测) 大 仅 wasm32

先试自动向量化——零代码改动,识别得出就是白赚。识别不出再上 wide,只有 profile 明确指向某个函数、且实测证明手写版更快时才值得手写 intrinsics。(老资料里常见的 packed_simd 已停止维护;标准库的 std::simd 在 1.96 仍是 nightly 特性。)

10.12.2 模式一:让编译器自动向量化

某些循环模式编译器能识别并自动向量化:

// 编译器友好的模式:逐元素、迭代之间互不依赖
fn axpy(a: f32, x: &[f32], y: &mut [f32]) {
    for (yi, xi) in y.iter_mut().zip(x) {
        *yi += a * xi;
    }
}

启用 simd128 + opt-level=3 后,LLVM 自动把这个循环向量化为 f32x4 操作——单遍处理 4 个 f32(1.96.0 --emit asm 实测可见 f32x4.mul/f32x4.add)。

一个常见的误解是浮点求和也会被自动向量化:

fn sum_array(data: &[f32]) -> f32 {
    let mut sum = 0.0;
    for x in data {
        sum += x;
    }
    sum
}

实测它不会被向量化——生成的仍是逐个 f32.add。浮点加法不满足结合律,向量化要把求和顺序改成 4 路并行再合并,结果会变,LLVM 不会在没有 fast-math 许可的情况下这么做。同样的写法换成 i32(整数 wrapping 加法满足结合律),就会生成 i32x4.add。浮点归约要向量化,得像下面的 wide、intrinsics 版本那样自己拆成多路累加。

不友好的模式(编译器无法向量化):

// 反模式:含数据依赖
fn cumulative(data: &[f32]) -> Vec<f32> {
    let mut result = Vec::with_capacity(data.len());
    let mut acc = 0.0;
    for x in data {
        acc += x;        // 每次依赖上一次
        result.push(acc);  // 顺序依赖,无法并行
    }
    result
}

关键判断:循环每次迭代是否独立?独立则可向量化,依赖则不能。

10.12.3 模式二:用 wide crate 简化跨平台

wide crate 提供平台无关的 SIMD 抽象:

use wide::f32x4;

fn dot_product(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len());
    let chunks = a.len() / 4;

    let mut sum = f32x4::splat(0.0);
    for i in 0..chunks {
        let va = f32x4::from(&a[i*4..i*4+4]);
        let vb = f32x4::from(&b[i*4..i*4+4]);
        sum += va * vb;
    }

    // 水平求和
    let arr = sum.to_array();
    let mut total = arr.iter().sum::<f32>();

    // 处理剩余元素
    for i in chunks*4..a.len() {
        total += a[i] * b[i];
    }
    total
}

wide 按编译时开启的 target feature 选实现:wasm32 上开了 simd128 就用 v128 指令,没开就退回标量数组运算——同一份代码跑遍所有目标,但在 wasm32 上别忘了打开 simd128,否则拿到的只是标量版(下表实测)。

10.12.4 模式三:手写 intrinsics

极致性能用 core::arch::wasm32::*:

#[cfg(target_feature = "simd128")]
unsafe fn dot_product_intrinsics(a: &[f32], b: &[f32]) -> f32 {
    use core::arch::wasm32::*;

    let chunks = a.len() / 4;
    let mut sum = f32x4_splat(0.0);

    for i in 0..chunks {
        let va = unsafe { v128_load(a[i*4..].as_ptr() as *const v128) };
        let vb = unsafe { v128_load(b[i*4..].as_ptr() as *const v128) };
        sum = f32x4_add(sum, f32x4_mul(va, vb));
    }

    // 水平求和
    let arr: [f32; 4] = unsafe { std::mem::transmute(sum) };
    let mut total = arr[0] + arr[1] + arr[2] + arr[3];

    for i in chunks*4..a.len() {
        total += a[i] * b[i];
    }
    total
}

性能对比(1024 元素 dot product,每种实现在 WASM 内连续调用 100 万次取平均;同一环境,§10.2.1;wide 1.7.1):

实现(实测) 不开 simd128 开 simd128
标量循环 sum += a[i] * b[i] 1,231 ns 1,231 ns(没有被自动向量化)
wide crate 542 ns 299 ns
手写 intrinsics(上面的代码) —(需要 simd128) 414 ns
参照:原生 x86-64,wide 297 ns —

几点值得记住:标量浮点点积开不开 simd128 都一样,原因同 §10.12.2 的浮点求和;wide 即使退回标量实现也比朴素循环快一倍多(4 路独立累加打断了依赖链);而这份手写 intrinsics 反而比 wide 慢(没有逐一剖析原因,每轮循环里切片下标的边界检查是嫌疑之一)。手写 intrinsics 不自动等于更快,必须和 wide 版本对照实测。

10.12.5 SIMD 反模式

graph TD
  A["SIMD 反模式"] --> B["1. 数据非对齐<br/>v128_load 慢"]
  A --> C["2. 循环过短<br/>SIMD 启动开销 > 加速"]
  A --> D["3. 跨函数调用频繁<br/>SIMD 寄存器不传递"]
  A --> E["4. 分支密集<br/>SIMD 不擅长"]
  A --> F["5. 不做基准对比<br/>盲目认为 SIMD 一定快"]
  style A fill:#dc2626,color:#fff

每条都是真实的性能陷阱:

  • 数据对齐:WASM 的 v128.load 语义上允许非对齐访问,Cranelift 在 x86 上就直接发 movdqu(非对齐载入,见 cranelift/codegen/src/isa/x64/inst/mod.rs)。现代 CPU 上非对齐载入本身几乎免费,真正的代价是跨缓存行——把热数据按 16 字节对齐是为了避免一次访问踩两条缓存行,不是为了换一条更快的指令
  • 循环过短:只有十几个元素的短循环,SIMD 的收益往往被尾部处理、水平求和吃掉,不如标量代码——分界线以实测为准
  • 跨函数调用:每次函数调用,SIMD 寄存器内容可能丢失——把热路径打包进单个函数
  • 分支密集:SIMD 的优势在数据并行——大量分支让向量被迫串行
  • 不做基准:必须实测——SIMD 不一定加速,特别是简单算法

10.12.6 SIMD 决策清单

flowchart TD
  A["考虑 SIMD 优化"] --> B{"循环足够长?"}
  B -->|否| C["不值得 SIMD"]
  B -->|是| D{"循环迭代独立?"}
  D -->|否| E["重构为独立<br/>或标量"]
  D -->|是| F{"性能确实是瓶颈?"}
  F -->|否| G["先优化算法"]
  F -->|是| H{"目标平台支持 simd128?"}
  H -->|否| I["放弃 SIMD"]
  H -->|是| J["实施 + 基准对比"]
  style J fill:#10b981,color:#333
  style C fill:#dc2626,color:#fff

不要为了"用 SIMD"而 SIMD——SIMD 是性能优化的最后手段,不是第一选择。算法层面的优化(更好的数据结构、缓存友好布局)通常比 SIMD 收益更大。

10.12.7 工程实战的混合策略

成熟的 WASM 项目通常采用混合策略:

层级 代码量 实现
业务代码 绝大部分 标量 Rust,依赖编译器优化
性能敏感路径 少部分 wide crate 跨平台 SIMD
极致热点 极少数 手写 intrinsics + cfg gating

#[cfg(target_feature = "simd128")] gating 让开了 simd128 的构建走 SIMD、没开的构建走标量——同一份源码兼容所有目标(选择发生在编译期,运行时按浏览器能力切换需要两份产物,见 §10.3.2)。

这套策略让 SIMD 成为渐进的性能优化层——而不是一次性大改造。

10.13 WASM 与 WebGPU:CPU+GPU 协作模式

WASM SIMD 能让计算密集路径获得可观加速——但某些计算(图像处理、ML、物理仿真)即使有 SIMD 也无法满足。WebGPU 提供了浏览器内的 GPU 计算能力,与 WASM 配合能突破 CPU 的天花板。

10.13.1 三种计算路径的对比

graph TD
  A["浏览器内计算"] --> B["纯 JS"]
  A --> C["WASM + SIMD"]
  A --> D["WASM + WebGPU"]
  B --> B1["性能:基准<br/>开发:最简单"]
  C --> C1["性能:CPU 数据并行上限<br/>需要 SIMD 编码"]
  D --> D1["性能:可再高一两个数量级<br/>需要 GPU 编程 + 传输开销"]
  style B fill:#dc2626,color:#fff
  style C fill:#f59e0b,color:#333
  style D fill:#10b981,color:#333

以 4K 图像的高斯模糊这类逐像素、彼此独立的计算为例,三条路径的典型关系是:WASM 标量明显快于没有做 typed array 优化的 JS,WASM SIMD 在此基础上再快数倍,而 GPU 的并行度又比 CPU 高出一大截——前提是数据能在 GPU 上停留足够久、摊薄传输开销。具体倍数取决于 GPU、驱动和图像尺寸,需在目标设备上实测。

GPU 加速最适合"高度并行 + 数据量大"——WASM SIMD 虽快,但 CPU 的并行度有限。

10.13.2 WASM + WebGPU 的数据流

sequenceDiagram
    participant W as WASM
    participant J as JS 胶水
    participant G as WebGPU 设备
    participant GM as GPU 内存
    W->>J: 调用 process_image(buf)
    J->>GM: 创建 buffer + uploadBuffer
    J->>G: 提交 compute pipeline
    G->>GM: 执行 shader
    G-->>J: 完成
    J->>J: readBuffer 到 ArrayBuffer
    J-->>W: 返回处理后数据

关键开销:

步骤 开销来源
WASM → JS(数据准备) 在线性内存上建视图,通常不必复制
JS → GPU 上传 一张 4K RGBA8 图像约 33MB(3840×2160×4 字节),受总线带宽限制
GPU 计算 取决于 shader 与 GPU
GPU → CPU 下载 mapAsync 读回,同样受带宽限制,还要等 GPU 队列排空

上传/下载往往和计算本身同一量级甚至更贵——数据在 CPU 与 GPU 之间来回搬一次,就可能吃掉 GPU 省下的时间。

10.13.3 实战:WASM 调用 WebGPU

WASM 不直接操作 WebGPU——通过 JS 胶水调用:

// JS 侧准备 WebGPU 上下文
class WasmGpuBridge {
    constructor(device, wasmInstance) {
        this.device = device;
        this.wasm = wasmInstance;
        this.compiledShaders = new Map();
    }

    async runCompute(shaderName, inputBuffer, params) {
        // 1. 从 WASM 内存读输入
        const wasmView = new Uint8Array(
            this.wasm.memory.buffer,
            inputBuffer.ptr,
            inputBuffer.size
        );

        // 2. 上传到 GPU
        const gpuBuf = this.device.createBuffer({
            size: inputBuffer.size,
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
        });
        this.device.queue.writeBuffer(gpuBuf, 0, wasmView);

        // 3. 执行 compute pipeline
        const shader = this.compiledShaders.get(shaderName);
        // ... pipeline + bind group + dispatch ...

        // 4. 读回 GPU 结果
        const resultBuf = this.device.createBuffer({
            size: inputBuffer.size,
            usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
        });
        // ... copy + map ...
        const arrayBuffer = resultBuf.getMappedRange();

        // 5. 写回 WASM 内存
        const outputPtr = this.wasm.alloc(inputBuffer.size);
        new Uint8Array(this.wasm.memory.buffer, outputPtr, inputBuffer.size)
            .set(new Uint8Array(arrayBuffer));

        return outputPtr;
    }
}

Rust 侧通过 extern "C" 块导入 JS 函数。GPU 读回(mapAsync)天生是异步的,所以导入和导出都得是 async,不能写成同步返回指针的签名(否则 JS 返回的是一个 Promise):

#[wasm_bindgen]
extern "C" {
    // JS 侧的 async 函数:把输入交给 GPU,resolve 为结果字节(Uint8Array)。
    // input 在 JS 侧是指向 WASM 内存的视图,要在第一个 await 之前用完(如 writeBuffer)
    #[wasm_bindgen(catch)]
    async fn run_compute_shader(shader_name: &str, input: &[u8]) -> Result<JsValue, JsValue>;
}

#[wasm_bindgen]
pub async fn process_image(image: Vec<u8>) -> Result<Vec<u8>, JsValue> {
    let result = run_compute_shader("gaussian_blur", &image).await?;
    Ok(js_sys::Uint8Array::new(&result).to_vec())
}

(需要 wasm-bindgen-futures 与 js-sys 依赖。实测 0.2.118 + node v24 下,用一个模拟的 JS run_compute_shader 跑通。上面的 WasmGpuBridge 是按裸导出指针写的另一种接法,两者选一即可。)

10.13.4 何时该用 GPU 加速

flowchart TD
  A["计算性能需求"] --> B{"数据量?"}
  B --> C["< 100KB"]
  B --> D["100KB - 10MB"]
  B --> E["> 10MB"]
  C --> F["WASM 标量足够"]
  D --> G{"算法可并行?"}
  G -->|是| H["WASM SIMD"]
  G -->|否| F
  E --> I["WebGPU compute"]
  style F fill:#10b981,color:#333
  style H fill:#6366f1,color:#fff
  style I fill:#f59e0b,color:#333

图中的数据量分界只是经验划分,不是硬阈值。GPU 加速的甜点:大数据量 + 高度并行算法。小数据上 GPU 上传开销超过节省,得不偿失。

10.13.5 GPU 不适合的场景

graph TD
  A["GPU 不适合"] --> B["1. 分支密集<br/>warp divergence"]
  A --> C["2. 数据依赖<br/>无法并行"]
  A --> D["3. 不规则内存<br/>非 coalesced 访问"]
  A --> E["4. 小数据<br/>上传开销 > 计算"]
  A --> F["5. 频繁 CPU-GPU 同步"]
  style A fill:#dc2626,color:#fff

每条都是真实坑:

  • 分支密集:GPU 用 SIMT 模型,warp 内分支不一致会让 GPU 串行执行
  • 数据依赖:FFT、累加等顺序算法 GPU 没优势
  • 不规则内存:GPU 缓存对连续访问优化,跳跃访问慢
  • 小数据:一次提交、上传、读回的固定开销(驱动调用、队列同步)可能比计算本身还长,得不偿失
  • 频繁同步:每次 GPU 调用都需要 fence,多次同步开销累积

10.13.6 浏览器支持现状(2026-09-24 核对 gpuweb 仓库 wiki 的 Implementation Status 页)

浏览器 WebGPU 状态
Chrome 113+ Windows / macOS / ChromeOS 默认启用;Android 自 Chrome 121 起(按 GPU 厂商分批)
Safari 26+ macOS / iOS / iPadOS / visionOS 26 默认启用
Firefox 141+ Windows 默认启用;macOS(Apple Silicon)自 145 起;Linux、Android 仍在 Nightly
Linux 桌面 Chrome 按 GPU 分批开启(如 Intel Gen12+ 自 144 起),其余仍在 flag 后,覆盖最不稳定

WebGPU 的可用性仍然明显低于 WASM 本身——生产代码必须做特性检测并支持降级到 WebGL 或纯 WASM 路径。别把"能力可用"和"版本号够新"划等号:同一个浏览器版本在缺驱动的机器上照样拿不到 navigator.gpu。

10.13.7 工程实践

flowchart TD
  A["WASM + WebGPU 工程"] --> B["1. WebGPU 特性检测"]
  A --> C["2. fallback 到 WASM SIMD"]
  A --> D["3. shader 缓存复用"]
  A --> E["4. 数据流水线优化"]
  A --> F["5. CPU 仍处理控制流"]
  style A fill:#6366f1,color:#fff

每条都是基础但关键:

  • 特性检测:if (navigator.gpu) { ... } else { fallback } 必须做
  • fallback:WebGPU 不可用时降级到 WASM SIMD,再降级到标量
  • shader 缓存:创建 compute pipeline(含 shader 编译)开销不小,编译一次、缓存复用
  • 流水线:多张图连续处理时,让上传/计算/下载流水线交错
  • 控制流 CPU:if/loop 等放 CPU,GPU 只跑数据并行的核心算法

10.13.8 未来:wasi-nn 与 wasi-webgpu

服务器端的 GPU 访问尚未标准化。WASI 侧目前有两条相关线索:wasi-nn(把推理后端抽象成 WIT 接口,Wasmtime 44 里有 crates/wasi-nn 实现)和 wasi-webgpu(把 WebGPU 的接口搬到 WASI 世界)——两者在 WASI 提案列表里都处于 Phase 2(2026-09-24 核对 WebAssembly/WASI 仓库 docs/Proposals.md)。如果后者落地,同一份计算 kernel 就有机会在浏览器和服务器上复用。这是 WASM 生态值得跟踪的演进方向,但今天不要把架构押在上面。

10.14 WASM 性能在不同硬件架构上的差异

WASM 的"跨平台"承诺常被忽视一个细节——同一份 .wasm 在 x86、ARM、Apple Silicon 上的性能差异显著。理解这些差异有助于做硬件选型和优化决策。

10.14.1 主流架构的性能特征

graph TD
  A["WASM 运行硬件"] --> B["x86_64"]
  A --> C["ARM64"]
  A --> D["Apple Silicon"]
  A --> E["RISC-V"]
  B --> B1["桌面/服务器主流<br/>JIT 优化最成熟"]
  C --> C1["移动 + 服务器(AWS Graviton)<br/>JIT 较成熟"]
  D --> D1["M1/M2/M3<br/>极强单核 + 统一内存"]
  E --> E1["新兴<br/>JIT 支持早期"]
  style B fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff

同一份 .wasm 跑同一个负载,在不同硬件上的耗时主要由单核性能决定:频率只是一方面,IPC(每周期指令数)、缓存容量同样关键——Apple Silicon 这类高 IPC 的核心,频率低一截也能跑得更快;服务器 ARM(如 AWS Graviton)已经和同代 x86 服务器处在同一档;树莓派这类嵌入式板卡则要慢数倍。具体数字随负载差异很大,选型前在候选硬件上跑自己的负载。

10.14.2 SIMD 在不同架构的差异

WASM SIMD 在不同架构上由 JIT 翻译为对应的 native SIMD 指令:

WASM SIMD x86_64 ARM64(含 Apple Silicon)
v128.load movdqu(非对齐载入) ldr q
f32x4.add addps fadd v.4s
i8x16.shuffle pshufb 系列(需要先构造掩码) tbl

注意两点:WASM 的 SIMD 没有"对齐版本"的载入指令,所以 Cranelift 在 x86 上发的是 movdqu 而不是 movdqa;而 shuffle 只有 i8x16.shuffle 这一条通用形态(16 个字节级立即数索引),更宽的 lane shuffle 由它退化而来。

性能差异主要来自:

  • x86 SSE/AVX 历史长,后端的模式匹配更成熟,很多组合能塌缩成一条指令
  • ARM NEON 的 tbl 表达力强(任意字节置换),但在部分微架构上延迟高于 x86 的定长 shuffle
  • 常量 shuffle 掩码能不能提到循环外,往往比选哪条指令影响更大

10.14.3 内存访问性能差异

graph LR
  A["内存访问性能"] --> B["x86: 缓存大"]
  A --> C["ARM: 缓存中等"]
  A --> D["Apple Silicon: 统一内存"]
  B --> B1["高端桌面/服务器<br/>末级缓存较大"]
  C --> C1["视具体芯片<br/>移动端通常更小"]
  D --> D1["CPU/GPU 共享内存<br/>带宽高"]
  style D fill:#10b981,color:#333

Apple Silicon 的统一内存让 CPU 与 GPU 共享同一物理内存,不需要经过 PCIe 搬运,WASM + WebGPU 协作时的上传/下载比独立显卡便宜得多。但 WebGPU 的 API 语义不变:writeBuffer、mapAsync 该有的复制和同步一样要做,开销变小而不是归零。

10.14.4 JIT 优化的成熟度差异

graph TD
  A["JIT 在不同架构的成熟度"] --> B["最成熟"]
  A --> C["成熟"]
  A --> D["发展中"]
  B --> B1["x86_64<br/>V8/Wasmtime 主优化目标"]
  C --> C1["ARM64<br/>近年快速改善"]
  D --> D1["RISC-V<br/>实验阶段"]
  style B fill:#10b981,color:#333
  style D fill:#dc2626,color:#fff

JIT 编译器对架构的优化深度影响显著:

  • x86_64:最早期 + 最多投入,优化最激进
  • ARM64:近年快速改善,主流引擎已把它当作一等目标——Wasmtime 把 aarch64-unknown-linux-gnu 和 aarch64-apple-darwin 列在 Tier 2,功能覆盖与 Tier 1 齐平,唯一欠缺的就是持续 fuzzing(docs/stability-tiers.md)
  • RISC-V:基础支持有,深度优化还在进行

10.14.5 服务器场景的架构选型

flowchart TD
  A["服务器选 ARM 还是 x86?"] --> B{"工作负载?"}
  B --> C["纯 WASM 计算"]
  B --> D["混合 IO + 计算"]
  B --> E["高内存压力"]
  C --> F["ARM (Graviton) 性价比高"]
  D --> G["视具体测试"]
  E --> H["x86 大缓存优势"]
  style F fill:#10b981,color:#333
  style H fill:#6366f1,color:#fff

AWS 宣传 Graviton 系列相对同代 x86 实例有更好的性价比,但那是通用负载的口径,不是专门针对 WASM 的数据——成本敏感场景值得评估,前提是用自己的负载实测,不是所有 workload 都适合。

10.14.6 移动端的架构现实

graph TD
  A["移动端 WASM 性能"] --> B["iPhone (A 系列)"]
  A --> C["高端 Android (Snapdragon 8)"]
  A --> D["中端 Android"]
  A --> E["低端 Android"]
  B --> B1["高端机型接近桌面"]
  C --> C1["明显低于桌面"]
  D --> D1["差距进一步拉大"]
  E --> E1["可能慢一个数量级"]
  style B fill:#10b981,color:#333
  style E fill:#dc2626,color:#fff

移动端的性能差距巨大——高端 iPhone 的单核性能接近桌面,低端 Android 可能慢一个数量级(图中是定性分档,没有统一出处,以真机实测为准)。WASM 应用的 P99 性能要按低端 Android 设计,否则部分用户体验崩溃。

10.14.7 跨架构性能优化的工程模式

flowchart TD
  A["跨架构 WASM 优化"] --> B["1. 性能测试覆盖多架构"]
  A --> C["2. 算法选择架构无关"]
  A --> D["3. SIMD 用 wide crate(自动适配)"]
  A --> E["4. 监控按架构分类"]
  A --> F["5. 极致场景架构特化"]
  style A fill:#6366f1,color:#fff

最后一项"架构特化"绝大多数项目不需要——通用代码已经够好。只有性能极致敏感(游戏、ML)才需要为特定架构手工优化。

10.14.8 RISC-V 的未来

RISC-V 是开放指令集,在嵌入式领域增长很快,数据中心也有尝试。WASM 在 RISC-V 上的支持:

维度 状态(以 Wasmtime 44.0.0 为准)
Wasmtime 编译目标 riscv64gc-unknown-linux-gnu 列为 Tier 3;riscv32imac-unknown-none-elf 也在 Tier 3(docs/stability-tiers.md)
Cranelift riscv64 后端功能覆盖 SIMD / relaxed-simd / GC / component-model 等提案均已打勾
Tier 3 的含义 riscv64gc 在 CI 里是有测试的,欠的是全职维护者;riscv32imac 则连 CI 测试也没有
Winch(基线编译器) riscv64 上尚未支持

也就是说"功能上基本齐了、工程保障还差一档"。如果业务有 RISC-V 设备需求,值得现在就在自己的 CI 里加一条 riscv64 的验证流水线,而不是等生态自然成熟。

10.14.9 架构无关的性能优化原则

flowchart TD
  A["架构无关的优化"] --> B["1. 数据局部性<br/>所有架构都受益"]
  A --> C["2. 减少分支<br/>所有 CPU 都怕分支预测错"]
  A --> D["3. 算法选择<br/>O(n log n) 优于 O(n²)"]
  A --> E["4. 减少内存分配<br/>所有 GC/分配器都慢"]
  style A fill:#10b981,color:#333

大部分优化应该在这一层——架构无关、收益普遍。只有少数优化需要架构感知(SIMD 指令选择等)。

10.14.10 工程实践清单

flowchart TD
  A["跨架构 WASM 工程"] --> B["1. CI 测试 x86 + ARM"]
  A --> C["2. RUM 按架构分聚"]
  A --> D["3. SIMD 用通用抽象"]
  A --> E["4. 算法层优化优先"]
  A --> F["5. 部署支持 ARM 服务器"]
  A --> G["6. 移动端按低端机设计"]
  style A fill:#6366f1,color:#fff

每条在前面各节都已展开,这里只是汇总。

10.15 WASM 性能调优的工程流程

前面 14 节涵盖了各种性能优化技术——但生产环境如何系统化做性能优化是元层次的工程问题。这一节整理一套可复用的性能调优流程。

10.15.1 性能调优的迭代循环

flowchart LR
  A["1. 测量基线"] --> B["2. 定位瓶颈"]
  B --> C["3. 制定假设"]
  C --> D["4. 实施优化"]
  D --> E["5. 验证效果"]
  E --> F{"达标?"}
  F -->|是| G["完成"]
  F -->|否| A
  style A fill:#10b981,color:#333
  style G fill:#6366f1,color:#fff

每个循环有明确目标——避免"瞎优化"。

10.15.2 步骤 1:测量基线

// 性能基线测试的标准模式(原生或 WASI 目标上运行)
use std::time::Instant;

fn benchmark_baseline() {
    let inputs = generate_test_inputs(10000);
    let start = Instant::now();
    for input in &inputs {
        process(input);
    }
    let elapsed = start.elapsed();
    println!("Baseline: {} req/s", 10000.0 / elapsed.as_secs_f64());
}

注意 std::time::Instant 在 wasm32-unknown-unknown 上不可用:1.96.0 实测 Instant::now() 直接 panic(在 node 里表现为 RuntimeError: unreachable)。浏览器里的基线要在 JS 侧用 performance.now() 计时(§10.8.3),或经 web-sys 调 Performance::now();wasm32-wasip1/wasip2 上 Instant 则由 WASI 时钟接口支撑,可以照用。

关键:保存基线数据——后续优化都对比基线,确保改动有效。

10.15.3 步骤 2:定位瓶颈

graph TD
  A["瓶颈定位"] --> B["CPU profile<br/>找慢函数"]
  A --> C["Memory profile<br/>找大分配"]
  A --> D["I/O profile<br/>找等待"]
  B --> E["浏览器:DevTools Performance<br/>Wasmtime:--profile=guest / perfmap"]
  C --> F["原生构建上用 dhat 等<br/>或统计线性内存峰值"]
  D --> G["WASMTIME_LOG=wasmtime_wasi=trace<br/>看 WASI 调用"]
  style A fill:#6366f1,color:#fff

Wasmtime 44 的 wasmtime run --profile=guest 按固定间隔(默认 10ms)采样,输出可在 Firefox Profiler 打开的 wasmtime-guest-profile.json;--profile=perfmap/jitdump/vtune 则把 JIT 代码的符号交给 Linux perf 或 VTune(src/common.rs:466 的 Profile::parse)。WASMTIME_LOG 的用法见仓库 docs/cli-logging.md。dhat 这类堆分析工具不认识 WASM,通常在同一份代码的原生构建上跑。每类工具针对特定瓶颈——必须先用工具定位,再针对性优化。盲目优化通常错。

10.15.4 步骤 3:制定假设

性能优化必须基于具体假设:

错误做法 正确做法
"感觉这里慢" "perf 显示 X 函数占 40% CPU"
"改算法应该更快" "改用 SIMD 应该减少 60% 时间,因为输入是 4KB 数组"
"加缓存试试" "命中率假设 80%,节省 70% 时间"

每个假设应该可验证——否则不是假设是猜测。

10.15.5 步骤 4:实施优化

flowchart TD
  A["实施优化"] --> B["小步迭代"]
  A --> C["每次一个变化"]
  A --> D["保留可回滚"]
  B --> B1["每次改 < 100 行"]
  C --> C1["不要同时改算法和数据结构"]
  D --> D1["分支提交,主线干净"]
  style A fill:#10b981,color:#333

工程纪律:每次只改一个变量——这样能精确归因效果。

10.15.6 步骤 5:验证效果

fn validate_optimization() {
    // run_* 返回(吞吐量, 输出结果)
    let (baseline_throughput, baseline_output) = run_baseline();
    let (optimized_throughput, optimized_output) = run_optimized();
    let improvement = (optimized_throughput - baseline_throughput) / baseline_throughput;

    assert!(improvement > 0.1, "Optimization didn't yield > 10% improvement");

    // 也要验证正确性
    assert_eq!(baseline_output, optimized_output, "Output differs!");
}

关键:性能 + 正确性都要验证——只快不对的优化是 bug 不是优化。

10.15.7 性能瓶颈的优先级矩阵

graph TD
  A["性能瓶颈优先级"] --> B["影响 × 易修复"]
  B --> C["象限 1:高影响易修<br/>立即做"]
  B --> D["象限 2:高影响难修<br/>规划做"]
  B --> E["象限 3:低影响易修<br/>顺手做"]
  B --> F["象限 4:低影响难修<br/>不做"]
  style C fill:#10b981,color:#333
  style D fill:#f59e0b,color:#333
  style E fill:#6366f1,color:#fff
  style F fill:#dc2626,color:#fff

把待优化项放进矩阵——优先做象限 1 和 3,避免在象限 4 浪费时间。

10.15.8 性能回归的预防

flowchart TD
  A["预防性能回归"] --> B["1. CI 性能基准测试"]
  A --> C["2. 关键指标监控"]
  A --> D["3. PR 中显示性能影响"]
  A --> E["4. 性能预算"]
  style A fill:#6366f1,color:#fff

CI 配置:

# 以 criterion 为例:先在 main 上存基线,再在 PR 分支上对比
- name: Benchmark main as baseline
  run: |
    git checkout origin/main
    cargo bench -- --save-baseline main
    git checkout -

- name: Compare PR with main
  run: cargo bench -- --baseline main --noise-threshold 0.05 | tee benchmark-output.txt

- name: Fail if regressed
  run: |
    if grep -q "Performance has regressed" benchmark-output.txt; then
      echo "Performance regression detected"
      exit 1
    fi

criterion 只在变化统计显著、且超出噪声阈值时才报 "Performance has regressed.",默认阈值是 1%(criterion 0.8 noise_threshold: 0.01),这里用 --noise-threshold 0.05 放宽到 5%。共享的 CI 机器噪声很大,门槛要按实测的波动来定。

让性能成为代码 review 的一部分——避免"做完才发现性能掉了"。

10.15.9 性能优化的反模式

graph TD
  A["性能优化反模式"] --> B["1. 过早优化"]
  A --> C["2. 微基准误导"]
  A --> D["3. 优化错的地方"]
  A --> E["4. 牺牲可读性"]
  A --> F["5. 不验证正确性"]
  style A fill:#dc2626,color:#fff

每条都对应失败案例:

  • 过早优化:在没有数据支持时优化,通常浪费时间
  • 微基准误导:单一函数 benchmark 漂亮但整体没改善
  • 错的地方:优化非热点函数,影响 < 1%
  • 牺牲可读性:极致优化让代码无法维护
  • 不验证正确性:性能提升但产生错误结果

10.16 跨专栏关联:与 Rust 编译器优化的对照

WASM 的 JIT 编译和《Rust 编译器与运行时揭秘》专栏第 16 章描述的 LLVM 优化 pass 是两层关系,而不是二选一:Rust 编译到 wasm32 时,rustc 照样把代码交给 LLVM 做完整的 -O3 优化(内联、LTO、循环优化、自动向量化都在这一步),产出的 .wasm 已经是优化过的代码;引擎的 JIT 再把它翻译成机器码,只在 WASM 这一层做一遍较轻的优化。差异在于 JIT 那一层能补多少:

优化类型 rustc + LLVM(编译到 wasm32 时同样执行) 引擎 JIT(V8 TurboFan) 影响
内联 激进,开 LTO 时可跨 crate 只在模块内,按热点有限内联 大部分内联在 LLVM 阶段已完成
循环优化 完整(向量化、展开、rotate) 较轻,基本不做自动向量化 循环质量主要取决于 LLVM 阶段
LTO 对 wasm32 同样可用(lto = true) 不适用(模块即编译单元) 跨 crate 优化要在 Cargo 里打开
逃逸分析 / 标量替换 LLVM 阶段已做(不需要堆的值留在寄存器或影子栈上) 对线性内存里的数据无从做起 堆分配的多少由 Rust 代码和 LLVM 决定,与引擎无关
SIMD 可用 AVX2/AVX-512/NEON 等宽向量 只能翻译 .wasm 里已有的 128 位 SIMD 指令 原生 SIMD 的向量宽度更大

核心差异:LLVM 做的是 AOT(Ahead-Of-Time)编译,可以花较多时间做优化分析;JIT 必须在用户可接受的延迟内完成编译,不会做代价高昂的全局变换。对 WASM 来说,想要的优化尽量在 LLVM 阶段拿到:SIMD 要在编译期打开 simd128(§10.3.2),自动向量化不了的归约再手写(§10.12)。

下一章聚焦 WASM 中最容易被忽视的性能杀手——Guest-Host 通信开销。