在 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.- 具体的测试结果:
总体来说,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,故我们需要使用其他方式来传入数组,在编译时我们暴露了标准库的 malloc 和 free 方法在 WebAssembly 内存空间中手动分配内存,具体的,调用流程如下:
- 通过
Module._malloc为传入数组分配内存空间,并获取分配空间的指针 - 将数据写入 JavaScript 上下文内的
Int32Array后,通过 Emscripten 提供的HEAP32.set方法传入 WebAssembly 地址空间中第一步分配空间的指针位置 - 将指针传入 C++ 的对应函数,获取返回值,为返回数组在 WebAssembly 地址空间中的指针
- 根据返回指针,通过
HEAP32.subarray方法将数据从 WebAssembly 地址空间取回 JavaScript 上下文内 - 通过
Module._free释放分配后未释放的空间,需要注意的是,查看 C++ 代码可知,mallocOffset和offset是同一地址,故我们只需要释放其中一个即可
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重复优化直到没有可优化项
具体测试结论和数据见文末
结论
可以看到,总体来说 WebAssembly 对大量数据传输,做简单处理后返回的性能确实还是不如 JavaScript 的,但是也没有那么不堪,特别是在使用 Emscripten 手动 malloc 分配内存时,速度可以达到 2000 MB/s 以上,基本已经与。
另一方面也可以看到,对于 AssemblyScript, Emscripten 来说,要传输大量数据还是比较复杂的,要手动进行空间的分配,释放等操作,并手动封装原函数。而 AssemblyScript 不仅传输复杂,速度也较慢,特别是在单次传输 256MB 以上数据时性能下降严重。
但另一方面,Rust 在有不错的性能 (~500MB/s) 的同时,也有非常方便的操作,不需要手动对函数进行封装,在一般情况下,使用 Rust 编写需要传输一定量数据的 WebAssembly 在折中时间复杂度与开发复杂度的情况下,是一个非常不错的选择。