Chapter 06

浏览器中的 Wasm 性能分析

使用 Chrome DevTools 调试 Wasm,分析性能瓶颈并优化 JS↔Wasm 交互开销

Chrome DevTools 中的 Wasm 调试

启用 Wasm 调试

Chrome 90+ 内置了对 WebAssembly 的调试支持。只要 .wasm 文件附带了 DWARF 调试符号(由 Rust 的 debug build 或 Emscripten 的 -g 选项生成),Chrome DevTools 就能显示原始源码,而不是 WAT 字节码。

# Rust:debug build 自动包含调试信息
wasm-pack build --dev --target web

# Emscripten:添加 -g 标志
emcc hello.c -g -o hello.js -s WASM=1

性能对比实验

// 对比 JS vs Wasm 的数值计算性能

// 纯 JS 实现:计算 n 以内所有素数
function sieveJS(n) {
  const isPrime = new Uint8Array(n + 1).fill(1);
  isPrime[0] = isPrime[1] = 0;
  for (let i = 2; i * i <= n; i++) {
    if (isPrime[i]) {
      for (let j = i * i; j <= n; j += i) isPrime[j] = 0;
    }
  }
  return isPrime.reduce((acc, v) => acc + v, 0);
}

// 性能测试
const N = 10_000_000;

console.time('JS sieve');
sieveJS(N);
console.timeEnd('JS sieve');   // 约 80ms

// 加载 Wasm 版本(Rust 实现)
const wasm = await import('./pkg/primes.js');
await wasm.default();

console.time('Wasm sieve');
wasm.sieve(N);
console.timeEnd('Wasm sieve');  // 约 15ms(快约 5x)

避免 JS↔Wasm 频繁跨越

跨越开销
每次 JS 调用 Wasm 函数(或 Wasm 调用 JS 函数)都有一定的上下文切换开销。对于高频调用(如每帧调用),这个开销会累积成明显的性能损失。
批量处理
将多次小调用合并为一次大调用。例如:不要逐个像素调用 Wasm 处理函数,而是将整个图像数据写入内存,一次调用处理完整图像。
零拷贝
利用 WebAssembly.Memory 的 TypedArray 视图直接读写 Wasm 内存,避免数据复制。JS 对 Wasm 内存的 TypedArray 操作是直接内存访问,没有跨越开销。
// 不好的做法:逐元素调用 Wasm
for (const pixel of pixels) {
  processedPixels.push(wasmModule.processPixel(pixel));  // 百万次跨越!
}

// 好的做法:批量处理
const inputView = new Uint8Array(wasmMemory.buffer, inputOffset, pixels.length);
inputView.set(pixels);  // 零拷贝写入
wasmModule.processAllPixels(inputOffset, pixels.length);  // 一次跨越
const outputView = new Uint8Array(wasmMemory.buffer, outputOffset, pixels.length);

Chrome DevTools Wasm 调试深度指南

DWARF 调试符号

要在 Chrome DevTools 中看到原始 Rust/C++ 源码(而不是 WAT 或十六进制),需要 DWARF 调试符号。这些符号嵌入在 .wasm 文件的自定义 Section 中(或外部 .wasm.map 文件中)。

DWARF(Debugging With Attributed Record Formats)
工业标准调试信息格式,描述源码与二进制的映射关系。包含函数名、变量名、行号、类型信息。V8 从 Chrome 90 开始通过 C++ DevTools Extension 支持 Wasm DWARF 调试。
Source Maps(.wasm.map)
JSON 格式的源码映射文件,描述 .wasm 字节偏移到源码行列号的映射。比 DWARF 更轻量,但信息量较少(只有行号,没有变量类型)。AssemblyScript 和 wasm-bindgen 主要使用 Source Maps。

在 Chrome DevTools 中调试 Wasm

# 1. 安装 Chrome DevTools C++ 扩展(支持 DWARF)
# 在 Chrome 扩展商店搜索 "C/C++ DevTools Support (DWARF)"

# 2. Rust:使用 debug build(自动包含 DWARF 符号)
wasm-pack build --dev --target web

# 3. 分离调试符号(发布时减小体积)
# 生产版:去掉调试符号
wasm-pack build --release --target web
# 单独保存调试版供开发使用
wasm-pack build --dev --target web -d pkg-dev

# 4. Emscripten:生成调试信息
emcc -g -gseparate-dwarf=hello.wasm.debug hello.c -o hello.js

Memory 面板检查 Wasm 内存

// 在 Chrome DevTools Console 中检查 Wasm 内存
// 先获取实例的内存引用:
const { instance } = await WebAssembly.instantiateStreaming(fetch('./module.wasm'));
// 在 DevTools → Memory 面板中,可以直接检查 Wasm 内存的原始字节
// 或者通过 TypedArray 在控制台查看:
const view = new Uint8Array(instance.exports.memory.buffer);
console.log(view.slice(0, 64));  // 查看前 64 字节

Wasm 性能分析实战:找到真正的瓶颈

常见的 Wasm 性能误区

误区一:Wasm 总比 JS 快
这是最大的误区。Wasm 的优势在于:确定性的执行时间(无 GC 暂停)、更紧凑的数值运算、可预测的内存布局。但在字符串操作、DOM 操作、JSON 解析等场景,Wasm 通常不比高度优化的 JS 快——因为这些操作都有 JS↔Wasm 边界开销。真正快的场景:大规模浮点计算、图像处理、密码学、物理模拟。
误区二:Wasm 初始化是即时的
Wasm 模块加载包含三个阶段:下载(网络)、编译(CPU,可能需要几十到几百毫秒)、实例化(内存分配)。首次加载一个 1MB 的 Wasm 文件可能需要 100ms+ 的编译时间。使用 WebAssembly.compileStreaming 可以边下载边编译,使用浏览器缓存可以避免重复编译。
误区三:替换所有 JS 为 Wasm
Wasm 和 JS 是互补关系,不是替代关系。理想的架构是:业务逻辑、DOM 操作、网络请求用 JS;计算密集型内核(如图像滤镜算法、加密函数)用 Rust/C++ 编译的 Wasm。频繁切换 JS↔Wasm 边界才是主要性能开销,应最小化这个边界,而不是最大化 Wasm 使用量。
性能基准测试的环境陷阱

在 Chrome DevTools 中开启 "CPU throttling 6x" 测试 Wasm 时,结果不代表真实移动设备的性能差距。CPU throttling 通过让线程休眠来模拟慢速 CPU,但它对 Wasm 和 JS 的影响比例可能不同(因为两者的 JIT 行为不同)。应当在真实目标设备(如低端 Android 手机)上测试,或使用 WebPageTest 等工具测量真实设备的数据。

Wasm SIMD:向量化计算

什么是 SIMD?

SIMD(Single Instruction Multiple Data,单指令多数据)允许一条指令同时处理多个数据。Wasm SIMD 引入了 v128 类型(128 位向量),可以表示:4 个 f32、2 个 f64、4 个 i32、8 个 i16 等。

// Rust 中使用 SIMD 加速图像处理
use wasm_bindgen::prelude::*;

#[cfg(target_arch = "wasm32")]
use std::arch::wasm32::*;  // Wasm SIMD 内置函数

#[wasm_bindgen]
pub fn grayscale_simd(data: &mut [u8]) {
    // 每次处理 4 个像素(4 × RGBA = 16 字节)
    for chunk in data.chunks_exact_mut(16) {
        // 加载 16 字节到 v128 向量寄存器
        let pixels = unsafe { v128_load(chunk.as_ptr() as *const v128) };

        // 同时处理 4 个像素的 R 通道(偏移 0、4、8、12)
        // SIMD 版本相比标量版本快约 4x
        // 实际 SIMD 实现较复杂,这里展示思路
        let _ = pixels;  // 简化示例
    }
    // 处理剩余不足 16 字节的部分(标量)
}

// 检测 SIMD 支持
#[wasm_bindgen]
pub fn check_simd_support() -> bool {
    // 编译时特性检测
    #[cfg(target_feature = "simd128")]
    { return true; }
    false
}

性能分析:Chrome Performance 面板

使用 User Timings 精确测量

// 使用 Performance API 创建可在 DevTools 中查看的自定义时间点

async function benchmarkWasm(wasmFn, jsFn, input, rounds = 5) {
  const results = { wasm: [], js: [] };

  for (let i = 0; i < rounds; i++) {
    // Wasm 测量
    performance.mark(`wasm-start-${i}`);
    wasmFn(input);
    performance.mark(`wasm-end-${i}`);
    performance.measure(`Wasm Round ${i}`, `wasm-start-${i}`, `wasm-end-${i}`);
    results.wasm.push(performance.getEntriesByName(`Wasm Round ${i}`)[0].duration);

    // JS 测量
    performance.mark(`js-start-${i}`);
    jsFn(input);
    performance.mark(`js-end-${i}`);
    performance.measure(`JS Round ${i}`, `js-start-${i}`, `js-end-${i}`);
    results.js.push(performance.getEntriesByName(`JS Round ${i}`)[0].duration);
  }

  // 计算中位数(比均值更能反映典型性能,去掉 JIT 热身影响)
  const median = arr => {
    const sorted = [...arr].sort((a, b) => a - b);
    return sorted[Math.floor(sorted.length / 2)];
  };

  console.log(`Wasm 中位数: ${median(results.wasm).toFixed(2)}ms`);
  console.log(`JS 中位数:   ${median(results.js).toFixed(2)}ms`);
  console.log(`加速比: ${(median(results.js) / median(results.wasm)).toFixed(1)}x`);
}

JS↔Wasm 跨越开销量化

调用开销(Call Overhead)
每次 JS 调用 Wasm 函数(或反向)约有 0.1-1 μs 的开销(取决于浏览器和参数数量)。对于 10ms 的计算任务,调用开销可忽略;但如果在一帧(16ms)内调用 10,000 次,开销就变得显著。
参数传递开销
数值类型(i32/f64)直接传递,几乎无开销。字符串和字节数组需要内存拷贝,开销与数据大小成正比。
内存分配开销
Wasm 模块内部的内存分配(如 Rust 的 Vec 增长)可能触发 memory.grow(),这会使所有 TypedArray 视图失效,并且比普通 JS 对象分配慢。
性能测量的 5 个最佳实践
  1. 先预热(Warm Up):JIT 编译器需要几次运行才能达到最优状态,丢弃前 2-3 次结果
  2. 用中位数而非平均数:单次异常(GC、操作系统调度)会拉高平均值,中位数更稳定
  3. 在无痕模式下测试:排除浏览器扩展的干扰
  4. 禁用 Throttle:开发者工具中的 CPU 降速模式会影响 Wasm 更多(因为优化程度不同)
  5. 在目标设备上测试:M1 Mac 上快 5x,在用户的低端 Android 手机上可能只有 2x

本章小结

本章核心要点