WebAssembly 与 JavaScript 数据交换性能实测

4,212 阅读5分钟

在 WebAssembly 的实际应用中,常常被担心的一个问题是:WebAssembly 和 JavaScript 交换数据速度较慢,可能会导致使用 WebAssembly 后,性能反而比只使用 JavaScript 速度更慢。因此,也常常听到关于使用 WebAssembly 的一个建议:尽量减少数据交换。

但是 WebAssembly 到 JavaScript 的数据交换到底有多慢,如果我们尽可能使用最优的方案,交换速率的上限又是多少? 这个问题似乎一直没有一个准确的答案,在此,我们通过几种不同的 WebAssembly 使用方式,进行一次测试。

具体的,我们测试 Rust by wasm-pack, C++ by Emscripten, AssemblyScript, 以及纯 JavaScript 代码的性能在 Node.js 14 和 Chrome 96 上的表现,来进行定量的测试。测试方法是每次传输一定大小的 Int32 数组,接收到数据后,对数组每个元素进行加上其索引的操作,以避免被编译器优化,后将该数组返回。测试中,我们分别发送 1KB, 2KB, 4KB, 8KB ... 256MB 的数据包,并连续发送 10GB 数据,以测试大致的传输速度:

TL.NR.- 具体的测试结果: image.png 总体来说,WebAssembly 和 JavaScript 上下文间的传输速率还是较为优秀的,如果需要频繁的与 JavaScript Context 交换数据(如每帧渲染信息),只要尽量将数据合并一次传输,即可达到较好的性能

Rust by wasm-pack

Rust 是一种无 GC,内存安全的静态语言,通过 wasm-pack 早已拥抱 WebAssembly,对 JavaScript 外部函数绑定等有很好的支持,我们通过 wasm-pack 将 下面的 Rust 代码编译为 WebAssembly 进行测试:

Rust 编译为 WebAssembly 的代码

Rust wasm-pack 中,参数支持接收普通数组,不支持接收 Vec。但是返回值只能接收 Vec,不支持接收普通数组,这是比较奇怪的一点,也因此,函数参数和返回值定义如下:bandwidth(data: &mut [i32]) -> Vec<i32>

Rust 代码如下所示:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn bandwidth(data: &mut [i32]) -> Vec<i32> {
    let l = data.len();
    for i in 0..l {
        data[i] += i as i32;
    }
    return data.to_vec();
}

将数组传入/传出 Rust 编写的 WebAssembly 函数

Rust wasm-pack 支持直接传入 Int32Array 数组,传入后对应 Rust 中 &mut[i32] 类型的数组,并通过在 Rust 中返回 Vec<i32>,返回普通的 JavaScript 数组。基本上来说,Rust 已经帮我们封装好了一切,不需要再进行内存的分配等,直接调用即可:

bandwidth(new Int32Array(inputArr))

Rust 编译为 WebAssembly 时的编译优化

为了令 Rust 编译为 wasm 后有最佳的性能,在 Cargo.toml 中,我们需要加入如下的配置:

# Cargo.toml
[profile.dev]
lto = true
opt-level=3

[profile.release]
lto = true
opt-level=3

其中,

  • opt-level=3 表示启用最高级别优化
  • lto=true 启用链接时优化,允许优化依赖中的代码

C++ in Emscripten

用 Emscripten 将 C++ 编译为 WebAssembly 的方式由来已久,在 WebAssembly 出现之前,Emscripten 就有将 C/C++ 程序编译为 asm.js 的能力。

让我们通过下面的代码,来测试下公认最快的高级语言 C/C++ 在编译为 WebAssembly 后,在内存传输上有怎样的表现:

C++ 编译为 WebAssembly 的代码

我们用以下 cpp 代码来进行测试:从 int* data 参数接受数据,进行一次 +i 的处理后返回,以视作数据成功接收

#include <stdlib.h>
#include <string.h>
#include <stdio.h>

extern "C" {

int* bandwidth(int* data, int n) {
    for(int i = 0; i < n; i++) {
        data[i] += i;
    }
    return data;
}

}

/*
emcc -O2 lib.cpp -s TOTAL_MEMORY=1024MB -s EXPORTED_FUNCTIONS='["_bandwidth", "_malloc", "_free"]' -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' -o ./build/lib.js
*/

将数组传入/传出 C++ 编写的 WebAssembly 函数

但是,在 Emscripten 编译后的 C++ 中,我们无法直接从 JavaScript 将数组传入 Emscripten,故我们需要使用其他方式来传入数组,在编译时我们暴露了标准库的 mallocfree 方法在 WebAssembly 内存空间中手动分配内存,具体的,调用流程如下:

  1. 通过 Module._malloc 为传入数组分配内存空间,并获取分配空间的指针
  2. 将数据写入 JavaScript 上下文内的 Int32Array 后,通过 Emscripten 提供的 HEAP32.set 方法传入 WebAssembly 地址空间中第一步分配空间的指针位置
  3. 将指针传入 C++ 的对应函数,获取返回值,为返回数组在 WebAssembly 地址空间中的指针
  4. 根据返回指针,通过 HEAP32.subarray 方法将数据从 WebAssembly 地址空间取回 JavaScript 上下文内
  5. 通过 Module._free 释放分配后未释放的空间,需要注意的是,查看 C++ 代码可知,mallocOffsetoffset 是同一地址,故我们只需要释放其中一个即可
const _bandwidth = Module.cwrap('bandwidth', 'number', ['number', 'number']);
function bandwidth(arr) {
	const mallocOffset = Module._malloc(arr.length * 4);
	const int32Array = new Int32Array(arr);
	Module.HEAP32.set(int32Array, mallocOffset / 4);
	const offset = _bandwidth(mallocOffset, arr.length);
	let ret = Module.HEAP32.subarray(offset / 4, offset / 4 + arr.length);
	Module._free(mallocOffset);
	return ret;
}

C++ 使用 Emscripten 编译为 WebAssembly 时的编译优化

emcc 直接生成的 wasm 代码运行效率是很低的,我们需要在编译时加入一些优化指令:

emcc -O2 lib.cpp -s TOTAL_MEMORY=1024MB -s EXPORTED_FUNCTIONS='["_bandwidth", "_malloc", "_free"]' -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' -o ./build/lib.js
  • -O2 表示启用 O2 级别的优化
  • TOTAL_MEMORY=1024MB 为 WebAssembly 运行时分配足够的内存,这里为 1024MB

具体测试结论和数据见文末

AssemblyScript

AssemblyScript 是一种语法与 TypeScript 极其类似的语言,是可被编译为 WebAssembly 的 TypeScript 子集,我们用下面的代码对它进行测试:

AssemblyScript 编译为 WebAssembly 的代码

AssemblyScript 的编写方式和 TypeScript 很像,但是还是有许多方法是不完全一致的。且作为参数和范围值时,AssemblyScript 只能接收和返回 Int32Array 定义的数组

需要注意的是,这里的 Int32Array 是 AssemblyScript 自行定义的类型,和 JavaScript 中的 Int32Array 并不相同

export function bandwidth(data: Int32Array): Int32Array {
  const arr = new Int32Array(data.length);
  for(let i = 0; i < data.length; i++) {
    arr[i] = (data[i] + i);
  }
  return arr;
}


export const Int32Array_ID = idof<Int32Array>();

将数组传入/传出 Assembly 编写的 WebAssembly 函数

AssemblyScript 在通过 JavaScript 调用传入数组参数或者返回数组相对比较麻烦,和 C++ 有一定类似:

  • 首先,我们使用 __newArray 在 WebAssembly 地址空间中初始化一个数组
  • 通过 __pin 方法来防止它的地址被移动/回收
  • 传入这个数组的引用(指针)来调用函数
  • 得到调用的返回值后,使用 __pin 固定,并通过 __getArray 获取数组内容
  • 最后,__unpin 创建的数组引用和返回值,以使他们能被正确回收
const {__pin, __unpin, __newArray, bandwidth: _bandwidth, Int32Array_ID, __getArray } = wasmModule.exports;

const bandwidth = (data) => { 
    const inputRef = __pin(__newArray(Int32Array_ID, data));
    const ref = __pin(_bandwidth(inputRef));
    const t = __getArray(ref);
    __unpin(ref);
    __unpin(inputRef);
    return t;
};

AssemblyScript 生成 WebAssembly 时的编译优化

npx asc assembly/index.ts --target release -Os --converge --exportRuntime
  • -Os 优化执行速度,此外还有 Oz 优化大小等
  • -converge 重复优化直到没有可优化项

具体测试结论和数据见文末

结论

image.png

可以看到,总体来说 WebAssembly 对大量数据传输,做简单处理后返回的性能确实还是不如 JavaScript 的,但是也没有那么不堪,特别是在使用 Emscripten 手动 malloc 分配内存时,速度可以达到 2000 MB/s 以上,基本已经与。

另一方面也可以看到,对于 AssemblyScript, Emscripten 来说,要传输大量数据还是比较复杂的,要手动进行空间的分配,释放等操作,并手动封装原函数。而 AssemblyScript 不仅传输复杂,速度也较慢,特别是在单次传输 256MB 以上数据时性能下降严重。

但另一方面,Rust 在有不错的性能 (~500MB/s) 的同时,也有非常方便的操作,不需要手动对函数进行封装,在一般情况下,使用 Rust 编写需要传输一定量数据的 WebAssembly 在折中时间复杂度与开发复杂度的情况下,是一个非常不错的选择。