Skip to content

Webassembly 简介 #4

Description

@penouc

Webassembly 简介

Webassembly 是什么

首先我们看一下官网上的解释:
WebAssembly,或者称作 wasm,是一项适用于 Web 编译的可移植的、体积小,加载高效的格式。
如果上面这段话是一个广告的话,几乎不有人来买这件商品(虽然现在就是这样),说白了这段话几乎很难让人明白 webassembly 是一项什么技术,
下面这个则对 Webassembly 进行了一个更突出重点以及更加具化的解释:
WebAssembly/wasm 是用来编写高性能的、浏览器有关的 Web 组件的一种字节码规范。
从中我们可以读到三个信息,字节码规范,Web,高性能。
首先,这是一个W3C Community Group 指定的一个规范,就像我们熟知的 ECMAScript 一样,这是一个规范。但是它相比于 ECMAScript 来更加的复杂,ECMAScript 规范约束的是各个浏览器厂商,对开发者来说接入都是统一的,而 wasm 是一项字节码,所以这导致它不仅需要各大浏览器厂商在浏览器内建中支持这项技术,还需要其他语言社区的支持,无论是C,C/+/+, Java, Rust 或者其他。
其次,它是在 web 上实现的这个字节码规范,Web 是 wasm 运行的平台。这也是我篇文章出现在这里的原因
第三,高性能?这是价值上的判断(一个广告),因为它的性能是由开发者来评价,而不是规范的制定者。至于它是不是高性能,后面会围绕这个主题展开这篇文章

Web 的性能历史

为了提高 Web 性能,从 1991 年 Web 被发明的那时直到现在,人们到底做了哪些努力呢,主要的努力方向大概可以分成这两类:
1,在浏览器内拓展其他语言
2,对 JS 进行优化

在浏览器内拓展其他语言

回顾一下历史上这些厂商在浏览器上做的一些事情,MS,Google,Adobe, Sun都做了些啥
MS:activeX,silverlight
Google:Nacl
Adobe:Flash
Sun:Java applet
无论是浏览器独占的 activeX或者 NaCl,还是跨平台的Flash ,silverlight,Java applet,内置或者外挂的模块,他们都因为各种各样的原因退出了历史的舞台。activeX 随着IE的衰落无人问津,只留下了我们在写 XHR 的兼容代码的记忆,silverlight 伴随着 .NET 出生,一直要与 Flash 竞争,.NET 在与 JAVA 竞争中失去很大市场,跟他一起推出的 Silverlight 也不能独善其身
Flash 由于其黑盒特性,发热,性能,安全性问题,在移动时代,在苹果领导发起的去 Flash 运动中被淘汰
Java applet,功能比较单一,而且存在安全性问题
NaCl,chrome 推出的可以使用 C/C++ 的模块,不能跨平台
注:当然这些技术的发明不一定都是出于提升性能的目的,可能还有些是为了扩展 Web 的一些 Native 能力。

对 JS 进行优化

在介绍各大厂商对 JS 做的优化之前,首先我们看下 JS 的性能历史

在 JS 发明的前十年,它的性能就像一条蚯蚓,直到 chrome 进入浏览器市场,V8 开启了浏览器市场的巨大竞争,同时也促进了 JS 性能的极大提升(大约10倍)。借助这个机会,这个时候 JS 也有了更多的机会进入其他的领域,比如 Server 端的NodeJs,桌面开发的 Electron。
那么在这个性能的极大提升是怎么做到的呢,要想弄明白这个,我们首先要知道 JS 在这个优化之前和之后分别是怎么工作的

优化前 JS 是怎么工作的 — 解释器和编译器

在程序语言的世界中,我们通过编译的时机不同将它们分为两种不同类型的语言,动态语言和静态语言,这两种语言分别会通过解释器和编译器来翻译机器语言。
如果是通过解释器,翻译是一行行地边解释边执行;

编译器是把源代码整个编译成目标代码,执行时不再需要编译器,直接在支持目标代码的平台上运行。

解释器的利弊
解释器启动和执行的更快。你不需要等待整个编译过程完成就可以运行你的代码。从第一行开始翻译,就可以依次继续执行了。
正是因为这个原因,解释器看起来更加适合 JavaScript。对于一个 Web 开发人员来讲,能够快速执行代码并看到结果是非常重要的。
这就是为什么最开始的浏览器都是用 JavaScript 解释器的原因。
可是当你运行同样的代码一次以上的时候,解释器的弊处就显现出来了。比如你执行一个循环,那解释器就不得不一次又一次的进行翻译,这是一种效率低下的表现。
编译器的利弊
编译器的问题则恰好相反。
它需要花一些时间对整个源代码进行编译,然后生成目标文件才能在机器上执行。对于有循环的代码执行的很快,因为它不需要重复的去翻译每一次循环。
另外一个不同是,编译器可以用更多的时间对代码进行优化,以使的代码执行的更快。而解释器是在 runtime 时进行这一步骤的,这就决定了它不可能在翻译的时候用很多时间进行优化。

JavaScript本质上是一种解释型语言,JavaScript代码是在浏览器端解析和执行的,如果需要时间太长,会影响用户体验。那么提高JavaScript的解析速度就是当务之急。JavaScript引擎和渲染引擎的关系如下图所示:

现在JavaScript引擎的执行过程大致是:
源代码-→抽象语法树-→字节码-→JIT-→本地代码(V8引擎没有中间字节码)。一段代码的抽象语法树示例如下:
function demo(name) {
console.log(name);
}
抽象语法树如下:

JIT 对 JS 做了什么

Just-in-time 编译器:综合了两者的优点
为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,形成混合模式。
不同的浏览器实现这一功能的方式不同,不过其基本思想是一致的。在 JavaScript 引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。
起初,监视器监视着所有通过解释器的代码。

如果同一行代码运行了几次,这个代码段就被标记成了 “warm”,如果运行了很多次,则被标记成 “hot”。

基线编译器
如果一段代码变成了 “warm”,那么 JIT 就把它送到编译器去编译,并且把编译结果存储起来。
代码段的每一行都会被编译成一个“桩”(stub),同时给这个桩分配一个以“行号 + 变量类型”的索引。如果监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push 出来给浏览器。
通过这样的做法可以加快执行速度,但是正如前面所说的,编译器还可以找到更有效地执行代码的方法,也就是做优化。
基线编译器可以做一部分这样的优化(下面我会给出例子),不过基线编译器优化的时间不能太久,因为会使得程序的执行在这里 hold 住。
不过如果代码确实非常 “hot”(也就是说几乎所有的执行时间都耗费在这里),那么花点时间做优化也是值得的。

优化编译器
如果一个代码段变得 “very hot”,监视器会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储之。

为了生成一个更快速的代码版本,优化编译器必须做一些假设。例如,它会假设由同一个构造函数生成的实例都有相同的形状——就是说所有的实例都有相同的属性名,并且都以同样的顺序初始化,那么就可以针对这一模式进行优化。
整个优化器起作用的链条是这样的,监视器从他所监视代码的执行情况做出自己的判断,接下来把它所整理的信息传递给优化器进行优化。如果某个循环中先前每次迭代的对象都有相同的形状,那么就可以认为它以后迭代的对象的形状都是相同的。可是对于 JavaScript 从来就没有保证这么一说,前 99 个对象保持着形状,可能第 100 个就少了某个属性。
正是由于这样的情况,所以编译代码需要在运行之前检查其假设是不是合理的。如果合理,那么优化的编译代码会运行,如果不合理,那么 JIT 会认为做了一个错误的假设,并且把优化代码丢掉。

这时(发生优化代码丢弃的情况)执行过程将会回到解释器或者基线编译器,这一过程叫做去优化。
通常优化编译器会使得代码变得更快,但是一些情况也会引起一些意想不到的性能问题。如果你的代码一直陷入优化<->去优化的怪圈,那么程序执行将会变慢,还不如基线编译器快。
大多数的浏览器都做了限制,当优化/去优化循环发生的时候会尝试跳出这种循环。比如,如果 JIT 做了 10 次以上的优化并且又丢弃的操作,那么就不继续尝试去优化这段代码桩了。

一个优化的例子:类型特化(Type specialization)
有很多不同类型的优化方法,这里我介绍一种,让大家能够明白是如何优化的。优化编译器最成功一个特点叫做类型特化,下面详细解释。
JavaScript 所使用的动态类型体系在运行时需要进行额外的解释工作,例如下面代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
	    sum += arr[i];
  }
}

+= 循环中这一步看起来很简单,只需要进行一步计算,但是恰恰因为是用动态类型,他所需要的步骤要比你所想象的更复杂一些。
我们假设 arr 是一个有 100 个整数的数组。当代码被标记为 “warm” 时,基线编译器就为函数中的每一个操作生成一个桩。sum += arri会有一个相应的桩,并且把里面的 += 操作当成整数加法。
但是,sum 和 arri 两个数并不保证都是整数。因为在 JavaScript 中类型都是动态类型,在接下来的循环当中,arri 很有可能变成了string 类型。整数加法和字符串连接是完全不同的两个操作,会被编译成不同的机器码。
JIT 处理这个问题的方法是编译多基线桩。如果一个代码段是单一形态的(即总是以同一类型被调用),则只生成一个桩。如果是多形态的(即调用的过程中,类型不断变化),则会为操作所调用的每一个类型组合生成一个桩。
这就是说 JIT 在选择一个桩之前,会进行多分枝选择,类似于决策树,问自己很多问题才会确定最终选择哪个,见下图:

正是因为在基线编译器中每行代码都有自己的桩,所以 JIT 在每行代码被执行的时候都会检查数据类型。在循环的每次迭代,JIT 也都会重复一次分枝选择。

如果代码在执行的过程中,JIT 不是每次都重复检查的话,那么执行的还会更快一些,而这就是优化编译器所需要做的工作之一了。
优化编译器中,整个函数被统一编译,这样的话就可以在循环开始执行之前进行类型检查。

一些浏览器的 JIT 优化更加复杂。比如在 Firefox 中,给一些数组设定了特定的类型,比如里面只包含整型。如果 arr 是这种数组类型,那么 JIT 就不需要检查 arri 是不是整型了,这也意味着 JIT 可以在进入循环之前进行所有的类型检查。

总结
简而言之 JIT 是什么呢?它是使 JavaScript 运行更快的一种手段,通过监视代码的运行状态,把 hot 代码(重复执行多次的代码)进行优化。通过这种方式,可以使 JavaScript 应用的性能提升很多倍。
为了使执行速度变快,JIT 会增加很多多余的开销,这些开销包括:
• 优化和去优化开销
• 监视器记录信息对内存的开销
• 发生去优化情况时恢复信息的记录对内存的开销
• 对基线版本和优化后版本记录的内存开销
这里还有很大的提升空间:即消除开销。通过消除开销使得性能上有进一步地提升,这也是 WebAssembly 所要做的事之一。

JS 的增强型语言

为了解决上述对变量为弱类型做优化复杂度太高的问题,大牛们就对 JS 进行了一些加强和改造,在 coffeescript 的wiki页面上有总结目前可编译为Javascript 的语言有345种、不过其中支持编译期做静态类型检查的语言只有14种分别是:Dart、Typescript、TeJaS、asm.js、Javascript++、Mascara、Roy、Elm、Swym、Typecast.js、PureScript、Flow、ActionScript、BukleScirpt

Wasm 是怎么工作的

编译器如何生成汇编

我们知道像 C, JAVA,Javascript 这类都是高级语言,还有一类专门和机器打交道的语言叫做汇编语言。汇编和机器的机器码之间有直接的映射关系。正是因为如此,拥有不同机器结构的计算机会有不同的汇编系统。如果你有一个机器,它有自己的内部结构,那么它就需要它所独有的汇编语言。
从上面的分析可以知道我们进行机器码的翻译并不是只有一种,不同的机器有不同的机器码,就像我们人类也说各种各样的语言一样,机器也“说”不同的语言。
人类和外星人之间的语言翻译,可能会从英语、德语或中文翻译到外星语 A 或者外星语 B。而在程序的世界里,则是从 C、C++ 或者 JAVA 翻译到 x86 或者 ARM。
你想要从任意一个高级语言翻译到众多汇编语言中的一种(依赖机器内部结构),其中一种方式是创建不同的翻译器来完成各种高级语言到汇编的映射。

这种翻译的效率实在太低了。为了解决这个问题,大多数编译器都会在中间多加一层。它会把高级语言翻译到一个低层,而这个低层又没有低到机器码这个层级。这就是中间代码( intermediate representation,IR)。

这就是说编译器会把高级语言翻译到 IR 语言,而编译器另外的部分再把 IR 语言编译成特定目标结构的可执行代码。
重新总结一下:编译器的前端把高级语言翻译到 IR,编译器的后端把 IR 翻译成目标机器的汇编代码。

wasm 在哪个阶段

上面介绍到了从高级语言到汇编的过程,那么 wasm 在上图中处于什么样的位置呢。实际上,你可以把它看成另一种“目标汇编语言”。
每一种目标汇编语言(x86、ARM)都依赖于特定的机器结构。当你想要把你的代码放到用户的机器上执行的时候,你并不知道目标机器结构是什么样的。
而 WebAssembly 与其他的汇编语言不一样,它不依赖于具体的物理机器。可以抽象地理解成它是概念机器的机器语言,而不是实际的物理机器的机器语言。
正因为如此,WebAssembly 指令有时也被称为虚拟指令。它比 JavaScript 代码更直接地映射到机器码,它也代表了“如何能在通用的硬件上更有效地执行代码”的一种理念。所以它并不直接映射成特定硬件的机器码。

编译到 .wasm 文件

目前对于 WebAssembly 支持情况最好的编译器工具链是 LLVM。有很多不同的前端和后端插件可以用在 LLVM 上。
提示:很多 WebAssembly 开发者用 C 语言或者 Rust 开发,再编译成 WebAssembly。其实还有其他的方式来开发 WebAssembly 模块。例如利用 TypeScript 开发 WebAssembly 模块,或者直接用文本格式的 WebAssembly 也可以。
假设想从 C 语言到 WebAssembly,我们就需要 clang 前端来把 C 代码变成 LLVM 中间代码。当变换成了 LLVM IR 时,说明 LLVM 已经理解了代码,它会对代码自动地做一些优化。
为了从 LLVM IR 生成 WebAssembly,还需要后端编译器。在 LLVM 的工程中有正在开发中的后端,而且应该很快就开发完成了,现在这个时间节点,暂时还看不到它是如何起作用的。
还有一个易用的工具,叫做 Emscripten。它通过自己的后端先把代码转换成自己的中间代码(叫做 asm.js),然后再转化成 WebAssembly。实际上它背后也是使用的 LLVM。

Emscripten 还包含了许多额外的工具和库来包容整个 C/C++ 代码库,所以它更像是一个软件开发者工具包(SDK)而不是编译器。例如系统开发者需要文件系统以对文件进行读写,Emscripten 就有一个 IndexedDB 来模拟文件系统。
不考虑太多的这些工具链,只要知道最终生成了 .wasm 文件就可以了。后面我会介绍 .wasm 文件的结构,在这之前先一起了解一下在 JS 中如何使用它。

加载一个 .wasm 模块到 JavaScript

.wasm 文件是 WebAssembly 模块,它可以加载到 JavaScript 中使用,现阶段加载的过程稍微有点复杂。
function fetchAndInstantiate(url, importObject) {
return fetch(url).then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes, importObject)
).then(results =>
results.instance
);
}
如果想深入了解,可以在 MDN 文档中了解更多。
我们一直在致力于把这一过程变得简单,对工具链进行优化。希望能够把它整合到现有的模块打包工具中,比如 webpack 中,或者整合到加载器中,比如 SystemJS 中。我们相信加载 WebAssembly 模块也可以像加载 JavaScript 一样简单。

wasm 比 JS 快的原因

综上所述,我们可以得出的结论是,理论上的 wasm 是比 js 要快很多的,下面是 JS 和 Wasm 运行步骤:
源代码-→抽象语法树-→字节码-→JIT-→本地代码(V8引擎没有中间字节码)
源代码 ->抽象语法树 -> LLVM IR -> LLVM Bytecode -> WASM -> 本地代码

如上面的分析,JS 的整个流程都发生在客户端浏览器内,由浏览器的内核来帮我们处理这些事情,而在 wasm 中的直到 WASM 的步骤都是由服务端替我们处理,并且 llvm 的前端自身也会优化这个代码的性能。只有最后一步,从 WASM 到本地代码是由客户端处理,相比于 JS 来说不管是转化为本地代码的速度,还是执行都是略胜一筹。
https://zhuanlan.zhihu.com/p/25773367

事实是如此吗?

如上面的分析,按理说 wasm 可以说是大获全胜,那么到底是不是如此呢,我们可以用数据说话。
https://takahirox.github.io/WebAssembly-benchmark/

Wasm 的浏览器支持现状

Wasm 未来要做的事情

  1. 提升浏览器中 WebAssembly 的性能, JS 和 webassembly 相互调用的性能太差
  2. 直接操作 DOM, ( Rust 的 webplatform 库)
  3. 异常处理
  4. 一流的开发者工具(调试,处理异常)
  5. 垃圾回收
  6. ES6 模块集成(ESModule 支持)

Webassembly 可以适用的场景

Wasm 现在已经应用到的地方

  1. React小组准备用 wasm 来重写其 Reconciler 部分
  2. egret 用 wasm 优化游戏性能
  3. 地图渲染需要大量运算的地方可以使用这个

上手

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions