前言
WebAssembly是近年出现在Web平台的一项新技术,甚至有的人视WebAssembly技术为解决传统js无法解决的问题「比如:密集计算场景下要求高性能的问题,js的弱类型问题等等」的银弹。但是事实真的如此么?WebAssembly到底是什么?为什么WebAssembly可以运行的比js更快?或者说WebAssembly真的比js更快嘛?今天我们会先介绍少量的概念,然后通过一个例子来对比js与WebAssembly之间到底有多大的性能差距。
WebAssembly是什么
“WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集。Wasm 被设计成为一种编程语言的可移植编译目标,并且可以通过将其部署在 Web 平台上,以便为客户端及服务端应用程序提供服务”
以上是wasm官网给出的一段解释。可以看到WebAssembly依然运行在虚拟机中,它并不是真正意义上的native代码。它运行在虚拟机中,所以它执行的指令也是虚拟指令,这一点与汇编代码十分相似。想必WebAssembly的名字可能也是由此而来的吧!(汇编语言被称为:Assembly)
如何在Web页面中使用WebAssembly
我们了解到wasm是需要经过编译的,那么首先我们需要一个编译器,以支持我们将某种语言编译成wasm。本文中采用emscripten工具链将C/C++代码编译为wasm。
安装完毕后,我们直接在命令行中输入emcc [文件名] 的方式就可以使用emscripten编译器了。
比如,我们编译一段简单的C程序
#include <stdio.h>
int main () {
int a = 1;
int b = 2;
printf("%d\n", a + b);
return 0;
}
控制台中输入以下命令进行编译:
emcc test.c -o test.html
编译完成后我们可以发现生成了三个文件
test.html
, test.js
, test.wasm
。test.wasm
是C代码编译后的wasm文件,test.js
是将js与wasm“粘合”起来的胶水代码。在这里面帮我们进行了wasm的加载,分配了wasm与js的共享内存空间。还包含一些处理不同数据类型的转换函数等等。
打开test.html
页面我们可以看到在页面的控制台中输出了3,这正是我们C程序中所期望的结果。
上面展示了一个非常简单的例子。如何使用C代码中提供的一些函数呢?比如我们编写一个add函数
int add (int a, int b) {
return a + b;
}
在命令行中输入以下编译命令
emcc test.c -o test.html -s EXPORTED_FUNCTIONS='["_add"]'
我们采用-s EXPORTED_FUNCTIONS='["_add"]'
来指定需要导出的函数,否则emscripten会把没有使用的函数DCE(Dead Code Eliminate)
我们打开test.html,在浏览器的控制台中输入Module._add(1, 2)
可以得到输出3。达到了我们的期望目标。
我们现在改变C代码中的add
函数中的参数类型,把int
改为long
long add (long a, long b) {
return a + b;
}
现在,让我们传入大一些的数字来测试这个函数,Module._add(2 ** 32, 2 ** 32)
,我们发现最后的结果是0!为什么结果会是0呢。我们改变以下参数以测试边界Module._add(2 ** 31, 0)
,结果是-2147483648。这个数字为什么是负数了?我传入的明明是一个整数,说明运算发生了溢出。现在我们再传入Module._add(2 ** 31 - 1, 0)
结果是2147483647。这说明传入的数字类型的边界值是-(2 ** 31) ~ 2 ** 31 - 1
。但是我们明明在C代码中已经修改了int
类型为long
类型,这依然使我们传入的参数范围变大。 这难道表示long类型无效吗?我们再进行一次尝试,我们把类型改为unsinged int
,再一次进行尝试Module._add(2 ** 31, 0)
。结果依然是-2147483648。unsigned int
声明依然无效。
接着,我们再一次修改类型声明,将int
改为double
。Module._add(1.2, 3.4)
的结果是4.6。说明double
类型有效。
通过上面的尝试笔者发现,js与wasm能够直接交互的数据类型只有int类型与double类型,在js中体现为Number类型。
那么对于js中其他的数据类型如何与wasm进行交互呢?比如说将js中的数组传入wasm中进行运算。
接下来,我们用一个更复杂的例子来说明如何利用wasm进行数组的一些运算操作。这里以矩阵乘法为例,C代码如下:
int multiple (double m[], int mr, int mc, double n[], int nr, int nc, double result[]) {
if (mc != nr) {
return -1;
}
for (int y = 0; y < mr; y++) {
for (int x = 0; x < nc; x++) {
double sum = 0;
for (int k = 0; k < mc; k++) {
sum += m[y * mc + k] * n[k * nc + x];
}
result[y * nc + x] = sum;
}
}
return 0;
}
之前我们提到了,emcc编译后生成的XXX.js
胶水文件中帮助我们分配了js与wasm的共享内存空间。我们正是要利用这样的一块内存空间来进行复杂数据类型的交互。
我们先使用Module._malloc
函数分配一块内存空间,该函数返回一个内存地址,我们往这个内存地址中填充数据。这个内存地址就对应了double m[]
这个参数。
下面是完整的使用wasm中multiple
函数的代码
const assemblyMatrixMultiple = function (mr, mc, nr, nc) {
// 分配内存空间
const mPtr = Module._malloc(Float64Array.BYTES_PER_ELEMENT * mr * mc);
const nPtr = Module._malloc(Float64Array.BYTES_PER_ELEMENT * nr * nc);
const result = Module._malloc(Float64Array.BYTES_PER_ELEMENT * mr * nc);
return function (m, n) {
// 填充数据
Module.HEAPF64.set(m, mPtr / Float64Array.BYTES_PER_ELEMENT);
Module.HEAPF64.set(n, nPtr / Float64Array.BYTES_PER_ELEMENT);
// 调用wasm中的multiple函数
Module._multiple(mPtr, mr, mc, nPtr, nr, nc, result);
// 取出结果
return Module.HEAPF64.subarray(result / 8, result / 8 + mr * nc);
};
};
好了,我们在js中对wasm中的multiple进行了一层封装,以方便我们在js中进行调用。比如我们需要进行20x20的矩阵运算。先生成一个20x20运算的矩阵乘法函数
const matMultipleAsm = assemblyMatrixMultiple(20, 20, 20, 20);
我们可以调用matMultipleAsm
函数来进行20x20的矩阵乘法了。
我们再编写一个js版本的矩阵乘法函数以进行测试。
function jsMultiple(m, mr, mc, n, nr, nc, result) {
if (mc !== nr) {
return -1;
}
for (let y = 0; y < mr; y++) {
for (let x = 0; x < nc; x++) {
let sum = 0;
for (let k = 0; k < mc; k++) {
sum += m[y * mc + k] * n[k * nc + x];
}
result[y * nc + x] = sum;
}
}
return 0;
}
我们来进行测试,随机生成两个矩阵并相乘,循环10000次。
function test(loop = 1000) {
const result = new Array(400);
console.time("js");
for (let i = 0; i < loop; i++) {
const m = [];
const n = [];
for (let i = 0; i < 400; i++) {
m.push(Math.random());
n.push(Math.random());
}
jsMultiple(m, 20, 20, n, 20, 20, result);
}
console.timeEnd("js");
console.time("asm");
for (let i = 0; i < loop; i++) {
const m = [];
const n = [];
for (let i = 0; i < 400; i++) {
m.push(Math.random());
n.push(Math.random());
}
matMultipleAsm(m, n);
}
console.timeEnd("asm");
}
经过测试,我们居然发现调用 matMultipleAsm
的速度居然比 jsMultiple
的速度还要慢!
那如果我们将循环放在wasm内部进行呢,结果会怎样呢?
int test() {
double m[16] = {0};
double n[16] = {0};
double newM[16] = {0};
srand((unsigned)time(NULL));
for (int i = 0; i < 10000; i++) {
for (int j = 0; j < 16; j++) {
m[j] = rand();
n[i] = rand();
}
multiple(m, 4, 4, n, 4, 4, newM);
}
return 0;
}
循环计算10000次的结果如下:
看起来wasm的结果似乎比js快了一倍。
我们增加循环次数到100000次的结果:
继续增加循环次数到1000000次的结果:
通过以上的实验我们可以看出,当循环在js中时,js与wasm需要频繁的进行数据交换,这部分的开销是非常大的。这导致wasm的速度远远慢于js!所以,我们要尽量的减少js与wasm的数据交换。
当我们将循环放在wasm中进行,发生在循环次数较少的情况下,wasm的速度比js的速度要快。但随着循环次数的增加,js的速度反而比wasm的速度更快了!这里笔者猜测是由于V8的JIT产生了作用,将这段js代码标记为了热点代码,将其直接编译为了机器代码。它直接运行在计算机硬件中,而wasm依然运行在虚拟机中。所以这也是随着循环次数的增多,js代码的运算速度反正更快的原因。
已经应用生产的WebAssembly案例
AutoCAD 是一款由 Autodesk 公司设计研发的,用于进行 2D 绘图设计的应用软件,它被广泛地用于土木建筑、装饰装潢、工业制图等多个领域中。这类制图软件的代码中充斥着大量的数学计算,对于性能提出了很高的要求,WebAssembly的出现无疑让AutoCAD搬上Web的舞台提供了无限的可能。
使用WebAssembly中的一些困难
由于wasm是需要经过编译的,你可能会遇到编译过程中的各类问题。这对于前端工程师其实是非常不友好的,如果你没有相关的native开发经验的话。虽然emscripten工具链的文档中对于如何编译现成的源代码写的很简单,就是 emconfigure ./configure
和 emmake make
两句命令。但是有的库是依赖于别的库,或者说是代码中内联了汇编代码,或者是使用了多线程等,这些问题对于前端工程师都是非常头痛的问题。建议大家从简单的代码入手,先自己尝试编写,然后不断的学习编译的相关知识,再去尝试将这些复杂的类库编译为wasm。
小结 & 一些思考
本文简单的讲述了WebAssembly是一门什么样的技术,以及我们如何在Web页面中使用它。通过上面的实验我们也看到了,如果我们能够让js代码进入JIT的话,js代码的执行效率并不比wasm的效率低。那么既然如此,如果我们能够让js代码跑在JIT中,那使用WebAssembly的意义又在哪里呢?
在笔者看来,WebAssembly的意义其中之一是它能够利用前辈们编写好的各类现成的库(C/C++沉淀了无数的类库),避免重复且繁重的工作(比如:各类解析文件、协议的类库)。目前,POST-MVP标准也在提案当中,POST-MVP标准一旦实现,则会赋予WebAssembly更加强大的能力。未来,让我们拭目以待。