深入了解 WebAssembly

深入学习和理解 WebAssembly 相关的知识。

Wasm 到底是什么,可以调用 Web api(document 等)吗,怎么调试,性能如何…

WebAssembly 简称为 Wasm。

历史

history

WebAssembly 起源于一个叫 Emscripten 的个人项目,该项目主要是将 C++编译为 js 的。后面 Mozila 觉得这个项目有未来,就组建了一个团队专门搞出了 asm.js,一种特殊的 js,由 Emscripten 生成,看一下下面这段 asm.js 代码:

function MyAsmModule(stdlib, foreign, heap) {
  "use asm";
  function add(a, b) {
    a = a | 0; // 整数
    b = b | 0; // 整数
    return (a + b) | 0; // 整数
  }
}

可以发现,asm.js 跟我们正常写的 js 基本相同,但是在函数的参数和返回值上都有特殊的x | 0来标记数值类型,并且在顶部有use asm注解。这么一来,可以处理这些类型信息的浏览器就可以做更多的编译优化从而有更快的执行速度,而不认识的浏览器也可以正常执行。该方式的主要缺憾就是仍然不够快,因为其躲不开 js 的解析编译过程。

在 Mozila 推出 asm.js 的时候,Google 内部也在做 NaCl/PNaCl 项目,这个项目是支持直接在浏览器上执行本机代码,虽然这个肯定拥有最快的速度,但是失去了跨平台的特性。

最后两拨人一凑合,就一起推出了 WebAssembly,目标就是保持跨平台特性的同时具有极快的执行速度。

定义

wasm
2019 年 12 月 5 日 — 万维网联盟(W3C)宣布 WebAssembly 核心规范成为正式标准,为 Web 带来一种功能强大的新语言。

上面是来自 W3C release 里的一句话,简单来说就是 WebAssembly 是除了传统三剑客以外第四门可以再浏览器上运行的语言。

一门什么语言?

从现代编译器角度看 WebAssembly 所处的位置
llvm

llvm 架构简介:不同的高级语言(C++/Rust 等)通过不同的编译器前端编译为中间表示 IR,再通过不同的编译器后端生成对应平台的汇编代码,最后再由汇编编译器编译为对应平台的本机代码。

可以看到 WebAssembly 跟汇编同处于汇编语言的位子上,也就是说他主要是作为一种目标代码由其他高级语言直接生成的。但是跟传统汇编的一个区别是,其运行的环境并不是真实的物理机器,而是虚拟机,所以他是一门类汇编的低级语言。

模块

在继续其他内容之前,首先引入一个新的概念:模块。模块是 es5 定义的概念,可以很方便的管理和组织我们的 js 文件,Wasm 也遵循了这一规范,未来有望可以像加载 js 一样轻松加载和使用 wasm 文件。

  • 模块是 Wasm 程序编译、传输和加载的单位
  • Wasm 规范定义了两种模块格式:二进制格式和文本格式

下面是这两种格式的关系:
import

高级语言通过编译器生成二进制格式的.wasm 文件,通过 Wasm 的工具可以实现文本格式.wat 和二进制格式的互相转换。最后还有一种内存格式就是.wasm 在运行的时候由虚拟机实例化为相应的结构体。

特点

高效

WebAssembly 是一种运行在栈式虚拟机上得紧凑的二进制代码,后面会详细介绍。

安全

WebAssembly 的运行环境是一个沙盒,是内存安全的并且遵守浏览器的同原协议和其他安全策略。

开放可调试

刚刚有提到 WebAssembly 有一种文本格式,通过文本格式可以比较便捷的阅读、学习和调试 WebAssembly 的代码。

web 平台的一部分

WebAssembly 可以和 js 相互调用两者存在的方法(也就是说 docuemnt、window 不是 webassembly 标准的一部分,需要通过 js 间接的调用)。可以像其他 api 一样,通过 js 检测是否支持特定功能,若不支持弹窗提示用户或者做其他回滚操作即可。野心大,不止用于 web。

字节码

一条指令的通常有操作码和 n 个操作数组成
command

Wasm 的代码是一种特殊的二进制代码 – 字节码。
汇编语言通常与具体的 CPU 指令集相关,而字节码是和和具体的虚拟机指令集相关。
字节码的特点是操作码由一个字节组成。

栈式虚拟机与寄存器式虚拟机

栈式和寄存器式指的都是虚拟机主要指令源与目标(操作数)的形式。wasm 中大部分是数值指令,操作数又分为两种:静态操作数和动态操作数。静态操作数直接编码在指令里,跟在操作码的后面。动态操作数在运行时从操作数栈获取)。栈式虚拟机的操作数隐含在栈上,而寄存器式的则是明确的指定操作数的寄存器

以下面的 c 语言为例子,对比不同的伪代码来理解这两种虚拟机的运行方式。

int a = 666;
a += 888;

wasm(栈式):

i32.const 666
i32.const 888
i32.add

寄存器式:

mov  eax, 666
add  eax, 888

可以看到栈式虚拟机的 add 操作是没有任何操作数的,这是因为其额外维护了一个操作数栈,在执行 add 操作的时候自动从栈上获取了两个操作数进行计算并将结果推回栈中,整个过程如下:

stack

主要区别&特点

通过上面的例子可以看出:

  • 表示同样程序逻辑的代码大小:基于栈 < 基于寄存器
  • 表示同样程序逻辑的指令条数:基于栈 > 基于寄存器

栈式虚拟机因为省略了操作数,所以代码一般会更小,而寄存器式虚拟机一般执行更快,这是因为对于解释器来说,解释器开销主要来自解释器循环(fetch-decode/dispatch-execute 循环)中的 fetch 与 decode/dispatch,因而减少指令条数可以导致 F 与 D 的开销减少,于是就提升了解释器速度。

且为了让二进制格式尽可能紧凑,WebAssembly 段的字节数、各种索引等整数值在二进制模块中是按 LEB128 格式编码后存储的。

WebAssembly 的值类型

Wasm 1.1 规范只定义了 4 种基本的值类型:32 位整数(简称 i32)、64 位整数(简称 i64)、32 位浮点数(简称 f32)和 64 位浮点数(简称 f64)。高级语言所支持的一切类型(比如布尔值、数值、指针、数组、结构体等),都必须由编译器翻译成这 4 种基本类型或者组合。

代码结构

code

上图左侧为 Wasm 的代码结构,右边是辅助我们理解的一段 go 代码。

可以看到 Wasm 的代码是以魔数与版本号开头,然后模块的主体内容由 12 种段组成。除了自定义段以外,其他所有的段都最多只能出现一次,且必须按照段 ID 递增的顺序出现(图中不包含自定义段 ID.0,该段是给编译器等工具使用的,里面可以存放函数名等调试信息)。

WebAssembly 模块中的函数、内存、表、全局变量都各自有自己的索引空间,用于记录各自的顺序,方便相关指令引用访问。

下面简单介绍一下各个段

类型段

类型段保存的是函数的签名,如示例中导入了 fmt 方法,定义了 Add,Sub,Mul,Div 和 main 方法,因为中间的数学方法的参数和返回值都相同,所以他们的签名也相同,所以该模块就有三个函数签名,也就是图中的三个 type。

导入导出段

记录导入导出项目。示例中导入就是 fmt,然后其他定义的函数如果编译器没有优化的话(“死码消除”)则都会默认导出。

函数段和代码段

函数段是一个索引表,列出内部函数所对应的签名索引;代码段存储内部函数的局部变量信息和字节码。

表段和元素段

表段列出模块内定义的所有表,可以理解为一个类数组,每一个元素都是函数。元素段列出表初始化数据(目前只能是函数)。主要用于函数的间接调用,js 和 Wasm 互相调用的场景。

内存段

声明模块的初始内存大小最大内存大小

全局段

全局变量信息,包括值类型、可变性和初始值。示例中就是 PI

起始段

该段给出模块的起始函数索引

数据段

内存初始化数据。示例中就是 Hello World!

若某个段对其他段有依赖,那么他一定在后面,所以浏览器可以实现在下载模块的同时进行解码、验证和编译,以达到更快执行的目的。

二进制代码简析

完整的一个 Wasm 模块代码
binary

前四个字节为魔数,翻译为 ASCII 码就是 asm,紧跟着四个字节的版本号01 00 00 00,数字是小段编码的,所以需要倒过来解码,就是00 00 00 01也就是 1,Wasm 的 MVP 版本号。

后面跟着的就是主体内容了,每一个段主要都由一个字节的 ID 和 一个 32 位可变长整数的内容段大小组成。如版本号后的01就是 ID 为 1 的类型段,后面的88 80 80 80 00表示的就是段的大小,前面有提到段的大小使用 LEB128 编码存储的,所以将这五个字节解码过来就是 8,说明后面八个字节都是类型段的,顺着数就会发现类型段之后就是02,也就是第二个代码段开始的地方了。这里用了五个字节来存储段的大小,在开启编译优化之后就会变成一个字节了。

最后简单看一下最后面的0D也就是 11,数据段,一开始是一些指令,从48往后,转成对应的 ASCII 码就是我们熟悉的 Hello World!了。

Wasm 文本格式与二进制格式的区别

  • 二进制格式是以段(Section)为单位组织数据的,文本格式则是以域(Field)为单位组织内容。域相当于二进制段中的项目,但不一定要连续出现,WAT 编译器会把同类型的域收集起来,合并成二进制段。

  • 在二进制格式中,除了自定义段以外,其他段必须按照 ID 递增的顺序排列,文本格式中的域则没有这么严格的限制。不过,导入域必须出现在函数域、表域、内存域和全局域之前。

  • 文本格式中的域和二进制格式中的段基本是一一对应的,但是有两种情况例外。第一种是文本格式没有单独的代码域,只有函数域。WAT 编译器会将函数域收集起来,分别生成函数段和代码段。第二种是文本格式没有自定义域,没办法描述自定义段。

  • 为了便于编写,文本格式提供了多种内联写法。例如:函数域、表域、内存域、全局域可以内联导入或导出域,表域可以内联元素域,内存域可以内联数据域,函数域和导入域可以内联类型域。这些内联写法只是“语法糖”,WAT 编译器会做妥善处理。

相关生态

后面会在实例中介绍他们的使用

WebAssembly Explorer

在线编译 C 为 Wasm,同时还能看到 X86 汇编代码,方便学习

Emscripten

官网地址

示例编译 c 代码,同时使用 template.html 为模板生成 html 自动引入生成的胶水代码,并在 Module 上挂载 ccall 方法,该方法用于 js 调用 Wasm

emcc hello.c -o hello.html --shell-file template.html -s "EXPORTED_RUNTIME_METHODS=['ccall']"

WABT

Wasm 二进制工具箱/WebAssembly Binary Toolkit

示例 wasm 转 wat

wasm2wat hello.wasm -o hello.wat

js 与 Wasm 互相调用

实现的方法不止示例的方式~

js 调用 Wasm

先看一段简单的 C 代码,我们的目标就是要可以在 js 中调用 add 方法

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

使用 WebAssembly Explorer 将其编译,可以看到编译结果把方法名改了,手动修正一下即可,点击下载保存为 math.wasm
code

首先需要实例化 WebAssembly 模块,这边使用的是 instantiateStreaming 方法,该方法的第一个参数就是请求的 math.wasm 文件,该方法会返回一个 promise,resolve 之后就能拿到 WebAssembly 实例了。示例代码中将模块导出的内容保存到了全局的 moduleExports 中,方便后面查看

let moduleExports;
WebAssembly.instantiateStreaming(fetch("math.wasm")).then((obj) => {
  console.log(obj);
  moduleExports = obj.instance.exports;
});

接下来就可以进行正常调用了

console.log(moduleExports.add); // native code
moduleExports.add(1, 2); // 3

Wasm 调用 js

观察下面一段 c 代码,会发现 c 中并没有实现 say_hello,那么编译会发生什么呢

void say_hello(int num);

int main()
{
  say_hello(666);
  return 0;
}

查看编译的结果就会看到,say_hello 需要从外部导入,wasm 调用 js 就是靠这个实现的

sayhello

那么 js 如何给到 wasm 所需要的东西呢,答案就是第二个参数

function sayHello(num) {
  console.log(`Hello: ${num}!`);
}
WebAssembly.instantiateStreaming(fetch("main.wasm"), {
  env: {
    _Z9say_helloi: sayHello,
  },
}).then((obj) => obj.instance.exports.main());

第二个参数是一个对象,该对象包含了所有需要导入给 Wasm 模块的信息,比如示例中import "env" "_Z9say_helloi",所以在定义对象的 key 值时也按照这个顺序嵌套定义即可 env -> _Z9say_helloi

目前 Wasm 主要就是通过导入的形式来执行 js 的方法,包括 document,window 等等也是需要用这种途径实现。

Emscripten

在其他高级语言中导入了一些库文件如 stdio.h 并使用了 printf 方法打印日志,那在 web 平台肯定是没有的,编译一下就会发现其实 printf 也需要开发者自行导入,例如使用 console 来映射 printf 的实现。其他的也是这样,用了 opengl 就需要用 webgl 的方法映射一遍。这看起来工作量就很大,所以才需要用到 Emscripten,使用该编译器去编译 C 代码,除了生成 wasm 文件以外,还会额外生成一个 js 文件,该文件会帮我们实现各种方法的映射还有 Wasm 模块的实例化,我们需要做的就是在 html 中引入该文件,然后专注于业务开发即可。所以这个 js 文件又有胶水代码之名。

Emscripten 也提供了四种方法方便我们直接从 wasm 调用 js,其会在编译的时候正确处理,非常省心,这里做一下简单展示:

#include <emscripten.h>

EM_JS(void, say_hello, (), {sayHello(3)});

int main()
{
  emscripten_run_script("sayHello(1)");
  EM_ASM(
      sayHello(2););
  say_hello();
  return 0;
}

WASI

WebAssembly System Interface。接上文提示我们有提到为了实现不同平台接口的映射,需要在编译器在编译的时候生成一个胶水代码以实现相应的功能,在跨平台的时候其实并不友好,所以 W3C 又提出了这一份接口规范,主要就是 Wasm 运行的平台只要将一些常用的接口按照规范实现,那么 Wasm 在运行的时候就可以不需要胶水代码了。node 14 版本开始已经较好的支持了该规范。

性能对比

v8

在开始性能对比前先简单看一下 v8 的架构,来看看为什么 wasm 可以执行得更快
v8

可以看到在有 wasm 之前,v8 只有下面一条编译基线,主要就是解析 js 生成 ast,编译为字节码然后解释为机器码执行,当一段代码执行多次成为热点代码之后就会被丢到 TurboFan 中生成优化后的机器码以达到更快的执行速度。

加入 wasm 之后就多了一条编译基线,也就是上面的 Liftoff,为了让 wasm 代码更快的执行,引擎一开始会直接对 wasm 代码进行解码然后直接就解释成机器码运行,因为本身就是字节码,对比 js 一开始就赢在了起跑线上。同时会在后台独立开启一个线程将 wasm 生成优化后的机器码,以达到起飞的速度。

斐波那契数列

第一个测试的是斐波那契数列,测试获取第 42 项所需的时间,测试五组计算平均值

简单展示一下代码:

long long fib(int n)
{
  if (n <= 1)
    return 1;
  return fib(n - 1) + fib(n - 2);
}
C WebAssembly Javascript
7.5504 9.2279 20.3418

可以看到 WebAssembly 真的非常快,远快于 js 并逼近本机代码的执行速度

MD5

这边我还测试了一个比较有趣的案列,也是我们日常中可以业务比较常用的场景,加密

这是 js 部分的测试代码,wasm 只是替换了一下调用而已,就不贴出来了

const test = "test";
const testMult10 = "testtesttesttesttesttesttesttesttesttest";
const testMult50 =
  "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest";
const testMult100 =
  "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest";
// CryptoJS.MD5('demo')
console.time("md5-js");
for (let i = 0; i < 100000; i++) {
  CryptoJS.MD5(testMult10);
}
console.timeEnd("md5-js");

主要的测试方式就是加密不同长度的字符串十万次,看他们的执行速度,结果还是比较出人意料的

md5

可以看到,在字符串比较短的时候,js 是远快于 wasm 的,只有当字符串长度达到一定量级的时候 wasm 的优势才体现出来。从这个测试就可以看出,并不是所有想要快的场景 wasm 都是适用的,往往 js 和 wasm 的耗时、类型的转换也是不可忽略的。

调试

wasm 现在的调试已经相对完善了,例如刚刚的 sum 的例子,只需要打开控制台,查看 wasm 代码,就会发现控制台已经帮你转成文本格式了,在代码中打断点也可以正常的步进调试,在右侧还能看到有个表达式栈,就是我们说的操作数栈,求值栈,一个东西。用鼠标悬浮再变量上也可以查看值和类型

debug

调试源码

在复杂的情况下,调试文本格式的 wasm 代码其实也是无异于自杀的,所以控制台进一步的支持了调试源码。目前仅支持 C 代码。

可以打开控制台的实验选中打开调试 wasm 的选项,可以根据其后面问号指示的文档安装一下插件和构建 wasm 代码,重启一下浏览器就可以调试了,这边仅做简单展示:

cdebug

在你调试的时候你可能会发现断到莫名其妙的地方了,此时根据调用栈往上找就能找到对应的 c 源码了。可以看到在浏览器中已经可以正确的显示 c 源码,并像 js 那样查看变量和调用栈了。

还有其他调试方法,有兴趣的同学可以直接查看官网的 debug 模块。

应用场景

  • Figma
  • Google Earth
  • Web-DSP
  • 游戏引擎

可以看到,很重计算的场景(大文件处理,图像处理,音视频渲染,3D 游戏),Wasm 都很好的发挥了作用,并且 caniuse 上现代浏览器对 Wasm 的支持也非常高了,若对速度有需求完全可以用起来了
caniuse

最后贴一个官网的坦克游戏,Happy Gaming!

参考