【WebGPU学习杂记】JS调用WebAssembly的延迟是多少?

本文通过 AssemblyScript — 通过写ts编译到wasm的语言(非常好用!)生成的wasm文件,用于做 A4×4⋅B4×4A_{4\times 4}\cdot B_{4 \times 4}A4×4B4×4 的矩阵乘法,用到了SIMD技术、Relaxed-SIMD技术。

结论

调用延迟:500ms / 100,000,000 次 = 5 纳秒/次

场景调用路径PC 端 (高性能桌面)移动端 (高端手机)移动端 (中低端手机)
简单类型热路径 (Hot Path)
(JIT 优化后, 像你的测试)
5 - 20 纳秒20 - 100 纳秒100 - 500 纳秒
(i32, f64)冷路径 (Cold Path)
(首次或少量调用)
50 - 200 纳秒200 - 800 纳秒1,000+ 纳秒 (1µs+)
复杂类型任何路径
(传递字符串、对象等)
几百纳秒到几微秒数微秒到数十微秒可能更长
  • 我的测试设备(没插电源😂)
    在这里插入图片描述

为什么会这么快?

  1. JIT 的威力:当你用一个 for 循环调用同一个 WASM 函数一亿次时,V8 的 JIT (Just-In-Time) 编译器会识别出这是一个 “超级热点” 。它不会傻傻地每次都走通用的调用流程。
  2. Trampoline:V8 会为这个特定的调用点(JS 调用 WASM 的地方)生成一段高度优化的、专门的机器码,我们称之为“蹦床”(Trampoline)或“调用存根”(Thunk)。这个蹦床知道确切的函数签名(3个 i32 参数,无返回值),所以它可以以最快的方式完成参数传递和上下文切换。
  3. 类型稳定:循环中传递给 WASM 函数的参数类型始终是数字,这让 JIT 的优化工作变得非常简单。

2. JS/WASM 调用的“正常范围”是多少?

这才是你问题的核心。这个开销不是一个固定的数字,它受多种因素影响,其中最主要的是硬件平台调用路径是否为热点

以下是一个大致的范围,可以作为参考:

场景调用路径PC 端 (高性能桌面)移动端 (高端手机)移动端 (中低端手机)
简单类型热路径 (Hot Path)
(JIT 优化后, 像你的测试)
5 - 20 纳秒20 - 100 纳秒100 - 500 纳秒
(i32, f64)冷路径 (Cold Path)
(首次或少量调用)
50 - 200 纳秒200 - 800 纳秒1,000+ 纳秒 (1µs+)
复杂类型任何路径
(传递字符串、对象等)
几百纳秒到几微秒数微秒到数十微秒可能更长

名词解释:

  • 纳秒 (ns): 十亿分之一秒。
  • 微秒 (µs): 百万分之一秒 (1 µs = 1000 ns)。

影响调用开销的其他因素:

  • 参数的复杂性:这是除了硬件之外最大的影响因素!
    • 传递简单的数字(如 i32, f64)是最快的。
    • 传递字符串、对象、anyref 等复杂类型,开销会急剧增加几个数量级。因为这涉及到更复杂的内存操作、数据编码/解码(如 TextEncoder/Decoder)、甚至垃圾回收(GC)的交互。
  • JavaScript 引擎:V8 (Chrome/Node.js), SpiderMonkey (Firefox), JavaScriptCore (Safari) 的实现不同,开销也略有差异,但总体趋势相似。
  • WASM 调用 JS:从 WASM 内部调用一个导入的 JS 函数,通常比 JS 调用 WASM 的开销更大,因为它需要中断 WASM 的执行流,进入 JS 引擎的运行时环境。

最重要的结论

  • WebAssembly 编程的第一原则:

最小化边界穿越,采用“粗粒度”调用而不是“细粒度”调用。

  • 把频繁的、小数据的调用,重构成“块状”(Chunky)的、单次的、处理大批量数据的调用。尽量将循环移入 WASM 内部。

  • 对于游戏循环、物理模拟、图像处理等场景,将核心循环和大量计算放在 WASM 内部是至关重要的。

操作步骤

  1. 使用 AssemblyScript 编写代码生成 *.wasm 文件
  2. 由于 wasm 是跨平台的,node/bun跑比浏览器跑我吞吐高个 5%~8%,注意跑的时候关闭浏览器开发模式(开发者工具),这个默认开启debug会测出比实际高 20~30% 的延迟(我的Mac M1pro上是这样的数据表现)。
  3. 我每个线程调用 一亿次 矩阵 A4×4⋅B4×4A_{4\times 4}\cdot B_{4 \times 4}A4×4B4×4 运算。

跑分

单线程、JS单次调用WASM、同步循环一亿次

在这里插入图片描述

单线程、纯JS运算一亿次

  • 已被V8高度优化 inline 后的JS方法
    在这里插入图片描述

单线程、JS单次调用WASM、循环在WASM内部

在这里插入图片描述

多线程、JS单次调用WASM、循环在WASM内部

在这里插入图片描述

多线程、JS单次调用WASM、JS同步循环一亿次调用

在这里插入图片描述

项目目录结构

.
├── assembly/
│   └── index.ts        # AssemblyScript 源代码
├── build/              # 编译后的 WASM 文件会在这里
├── asconfig.json       # AssemblyScript 编译器配置
├── package.json        # 项目依赖和脚本
└── index.js            # 用于加载和测试 WASM 的 JavaScript 文件

AssemblyScript 源代码

export function multiply(
  matA_ptr: usize,
  matB_ptr: usize,
  result_ptr: usize
): void {
  const rB0 = v128.load(matB_ptr);
  const rB1 = v128.load(matB_ptr + 16);
  const rB2 = v128.load(matB_ptr + 32);
  const rB3 = v128.load(matB_ptr + 48);

  // 计算每一行
  for (let i = 0; i < 4; ++i) {
    const row_offset = i * 16;

    // 加载A矩阵当前行的4个元素并splat成4个向量
    let sA0 = f32x4.splat(f32.load(matA_ptr + row_offset));
    let sA1 = f32x4.splat(f32.load(matA_ptr + row_offset + 4));
    let sA2 = f32x4.splat(f32.load(matA_ptr + row_offset + 8));
    let sA3 = f32x4.splat(f32.load(matA_ptr + row_offset + 12));

    // 使用 relaxed_fma (如果编译器支持) 或 mul/add 链
    // 这种写法能让现代编译器(如Binaryen)更好地优化,生成FMA指令
    let term0 = f32x4.mul(sA0, rB0);
    let term1 = f32x4.mul(sA1, rB1);
    let term2 = f32x4.mul(sA2, rB2);
    let term3 = f32x4.mul(sA3, rB3);

    let res = f32x4.add(f32x4.add(term0, term1), f32x4.add(term2, term3));

    v128.store(result_ptr + row_offset, res);
  }
}
  • asconfig.json
{
  "targets": {
    "release": {
      "target": "browser",
      "outFile": "build/release.wasm",
      "textFile": "build/release.wat",
      "sourceMap": false,
      "debug": false
    }
  },
  "options": {
    "bindings": "esm",
    "optimizeLevel": 3,
    "shrinkLevel": 1,
    "noAssert": true,
    "runtime": "stub",
    "enable": ["simd"]
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值