初探WebAssembly & WebAssembly v.s. JavaScript性能比较

4,198 阅读9分钟

前言

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。

Emscripnte下载安装地址

安装完毕后,我们直接在命令行中输入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.wasmtest.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改为doubleModule._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 Web App

AutoCAD 是一款由 Autodesk 公司设计研发的,用于进行 2D 绘图设计的应用软件,它被广泛地用于土木建筑、装饰装潢、工业制图等多个领域中。这类制图软件的代码中充斥着大量的数学计算,对于性能提出了很高的要求,WebAssembly的出现无疑让AutoCAD搬上Web的舞台提供了无限的可能。

使用WebAssembly中的一些困难

由于wasm是需要经过编译的,你可能会遇到编译过程中的各类问题。这对于前端工程师其实是非常不友好的,如果你没有相关的native开发经验的话。虽然emscripten工具链的文档中对于如何编译现成的源代码写的很简单,就是 emconfigure ./configureemmake make 两句命令。但是有的库是依赖于别的库,或者说是代码中内联了汇编代码,或者是使用了多线程等,这些问题对于前端工程师都是非常头痛的问题。建议大家从简单的代码入手,先自己尝试编写,然后不断的学习编译的相关知识,再去尝试将这些复杂的类库编译为wasm。

小结 & 一些思考

本文简单的讲述了WebAssembly是一门什么样的技术,以及我们如何在Web页面中使用它。通过上面的实验我们也看到了,如果我们能够让js代码进入JIT的话,js代码的执行效率并不比wasm的效率低。那么既然如此,如果我们能够让js代码跑在JIT中,那使用WebAssembly的意义又在哪里呢?

在笔者看来,WebAssembly的意义其中之一是它能够利用前辈们编写好的各类现成的库(C/C++沉淀了无数的类库),避免重复且繁重的工作(比如:各类解析文件、协议的类库)。目前,POST-MVP标准也在提案当中,POST-MVP标准一旦实现,则会赋予WebAssembly更加强大的能力。未来,让我们拭目以待。