阅读 303
WebAssembly的简单实践

WebAssembly的简单实践


浏览器的性能

JavaScript 一开始就是动态类型解释性语言,动态类型解释性语言的一大特点就是灵活和慢。

所以JavaScript 和所有的动态类型语言一样,天生会比静态类型语言慢。

而随着网页应用越来越复杂,JavaScript的运行性能就必须跟着提高。

那么为什么动态类型语言,如PythonPHPJavaScript就会比C/C++等静态类型语言慢呢?

JavaScript慢在哪里

我们来看一个最简单的情况,实现c = a + b加法运算,如果是C/C++语言,实现步骤大致如下:

  1. 内存a里取值到寄存器
  2. 内存b里取值到寄存器
  3. 算加法
  4. 把结果放到内存c

如果是JavaScript大致会经历哪些步骤呢?

  1. 代码编译
  2. 当前上下文是否有变量a,没有的话去上一层寻找,直到找到,或到达最外层上下文
  3. 当前上下文是否有变量b,没有的话去上一层寻找,直到找到,或到达最外层上下文
  4. 判断变量a的变量类型
  5. 判断变量b的变量类型
  6. 变量a、变量b是否需要进行类型转化,并进行类型转化
  7. 算加法
  8. 运行结果赋值给c

我们可以看到,二者的整个流程还是有比较大的区别

浏览器的性能填坑

1. JIT

JIT(just-in-time compilation):如果在执行c = a + b的时候,ab几乎都是int类型,那么是否可以去掉类型判断,类型转化的步骤,用接近C/C++的方式来实现加法运算,并把执行代码直接编译成机器码,直接运行,不需要再次编译。

Google 在 2009 年在 V8 中引入了 JIT 技术,JavaScript 的执行速度瞬间提升了 20 - 40 倍的速度。 JIT的问题是并不是所有的代码都能得到很好的提升,因为JIT 基于运行期分析编译,而JavaScript是一个没有类型的语言,所以当代码中的类型经常变化的时候,性能提升是有限的。 比如

function add (a, b)
{
    return a + b
}
var c = add(1, 2);
复制代码

JIT 看到这里, 觉得好开心, 马上把 add 编译成

function add (int a, int b)
{
    return a + b
}
复制代码

但是,很有可能,后面的代码是这样的

var c = add("hello", "world");
复制代码

JIT 编译器的可能当时就哭了,因为add已经编译成机器码了,只能推到重来

2. asm.js

2012年,Mozilla 的工程师 Alon Zakai 在研究LLVM编译器时突发奇想:许多 3D 游戏都是用 C / C++ 语言写的,如果能将 C / C++ 语言编译成 JavaScript 代码,它们不就能在浏览器里运行了吗?

于是,他开始研究怎么才能实现这个目标,为此专门做了一个编译器项目 Emscripten。这个编译器可以将 C / C++ 代码编译成 JS 代码,但不是普通的 JS,而是一种叫做 asm.jsJavaScript 变体。

asm.js它的变量一律都是静态类型,并且取消垃圾回收机制。当浏览器的JavaScript 引擎发现运行的是 asm.js时,就会跳过语法分析这一步,将其转成汇编语言执行。asm.js的执行速度可以达到原生代码的50%

asm.js的一般工作流程为:

graph TD
C/C++ --> LLVM
LLVM --> Emscripten
Emscripten --> JavaScript

asm.js还是存在几个问题:

  1. 仅有FirFox的浏览器有良好的支持
  2. 代码传输还是与现有方式一样,传输源码,本地编译

3. WebAssembly

Mozilla,Google,Microsoft, 和Apple 觉得 Asm.js 这个方法有前途,想标准化一下,大家都能用。 便诞生了WebAssembly

有了大佬们的支持,WebAssembly asm.js 要激进很多。 WebAssembly 连编译 JS 这种事情都懒得做了,不是要 AOT 吗? 我直接给字节码好不好?(后来改成 AST 树)。对于不支持 Web Assembly 的浏览器, 会有一段 JavaScriptWeb Assembly 重新翻译为 JavaScript 运行。

2019年12月5日,万维网联盟(W3C)宣布 WebAssembly成为正式标准

什么是WebAssembly

  • WebAssembly是一种运行在现代网络浏览器中的新型代码,并且提供新的性能特性和效果。
  • 它设计的目的是为诸如C、C++Rust等低级源语言提供一个高效的编译目标。

WebAssembly的目标

  • 高性能——能够以接近本地速度运行。
  • 可移植——能够在不同硬件平台和操作系统上运行。
  • 保密——WebAssembly是一门低阶语言,是一种紧凑的二进制格式,具有良好的保密性。
  • 安全——WebAssembly被限制运行在一个安全的沙箱执行环境中。像其他网络代码一样,它遵循浏览器的同源策略和授权策略。
  • 兼容——WebAssembly的设计原则是与其他网络技术和谐共处并保持向后兼容。

浏览器兼容性

兼容性

使用方法

官网有非常详细的使用说明 官网 MDN

1.安装依赖(Ubuntu 20 .04)

sudo apt install python3
sudo apt install cmake
复制代码

2. 安装Emscripten

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
git pull
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
复制代码

3. Hello World

创建一个文件hello.c:

#include <stdio.h>
int main() {
  printf("Hello, WebAssembly!\n");
  return 0;
}
复制代码

编译C/C++代码:

emcc hello.c -s WASM=1 -o hello-wasm.html 
复制代码

会生成三个文件:hello-wasm.html, hello-wasm.js, hello-wasm.wasm,然后浏览器打开hello-wasm.html,就能看到输出。

4. 调用C/C++函数

  1. 创建一个文件add.cpp:
extern "C" {

int add(int a, int b) {
  return a + b;
}

}
复制代码
  1. 执行编译
emcc add.cpp -o add.html -s EXPORTED_FUNCTIONS='["_add"]' -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
复制代码

这里的EXPORTED_FUNCTIONS参数指定需要暴露的函数接口名字,需要在名字前单独加一个下划线_EXPORTED_RUNTIME_METHODS指定可以被调用的方式

使用cwrap的方式调用,在生成的add.html中加入如下代码:

Module.onRuntimeInitialized = () => { 
    add = Module.cwrap('add', 'number', ['number', 'number']);
    result = add(9, 9);
}
复制代码

因为调用对应的C/C++接口前,还需要先初始化,所以要在Module.onRuntimeInitialized事件后,才能通过JS调用C/C++的内容

5.测试性能

我们再实现一个JavaScript版本的加法函数

function js_add(a, b) {
	return a + b;
}
复制代码

分别调用1000000次,对比分别的耗时,实现代码如下:

Module.onRuntimeInitialized = () => { 
    add = Module.cwrap('add', 'number', ['number', 'number']);
    const count = 1000000;
    let result;
    	
    console.time("js call");
    for (let i = 0; i < count; i ++) {
    	result = js_add(9, 9);
    }
    console.timeEnd("js call");
    	
    console.time("c call");
    for (let i = 0; i < count; i ++) {
    	result = add(9, 9);
    }
    console.timeEnd("c call");
}
复制代码

大家觉得哪个更快?为什么?

现实可能和我们想象的不一样,在多次调用后,JavaScript的调用速度反而更快。

first_test.png

这是为什么呢?

其实是在我们多次调用JS函数时,由于多次调用输入,输出参数都是同样的类型,所以V8引擎会自动的优化我们的代码,

而我们调用WebAssembly的模块代码,中间的传输还需要一定时间,如果调用次数很多,中间的传输过程需要的时间就更多了,

所以会出现JavaScript的调用更快的情况。

6.另一个测试

这次换一个思路,直接在C中实现累加,修改上一步的add.cpp,并保存为add_all.cpp

extern "C" {

long add_all(int count) {
    long result = 0;
    for(int i = 0; i < count; i++){
        result += i;
    }
    return result;
}
}
复制代码

用同样的命令进行编译

emcc add_all.cpp -o add_all.html -s EXPORTED_FUNCTIONS='["_add_all"]' -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
复制代码

我们再实现一个JavaScript版本的js_add_all

function js_add_all(count) {
    let result = 0;
    for(let i = 0; i < count; i++){
    	result += i;
    }
    return result;
}
复制代码

然后进行运行测试:

Module.onRuntimeInitialized = () => { 
    add_all = Module.cwrap('add_all', 'number', ['number']);
   	const count = 50000;
    	
   	console.time("js call");
   	console.log(js_add_all(count));
   	console.timeEnd("js call");
   	
   	console.time("c call");
    console.log(add_all(count));
   	console.timeEnd("c call");   	
}
复制代码

这次谁更快?当count = 100000时会怎么样?为什么?

second_test.png

这次我们就可以看到明显的速度差异了

文章分类
前端