图说 WebAssembly(五):高性能原因
本文是图说 WebAssembly 系列文章的第五篇。如果您还未阅读之前的文章,建议您从第一篇入手。
在上一篇文章中,我们说到了使用 WebAssembly 和 JavaScript 并不是两选一的选择。我们并不希望太多开发者只使用 WebAssembly 。
我们希望开发者可以把部分 JavaScript 代码替换为 WebAssembly 。
例如,React 团队可以把虚拟 DOM 改用 WebAssembly 来实现。这样的话,使用 React 的开发者也不需要做任何适配,但是它们却能获得更高性能。
能够促使 React 团队这么做的原因最可能是 WebAssembly 的高性能。但是到底是什么使它有高性能呢?
JS 性能分析
在我们理解 JavaScript 和 WebAssembly 之间的性能差异原因之前,我们需要先理解 JavaScript 引擎所做的工作。
下图给了一个粗糙的描述,概括了当前 JS 应用的启动性能。
JS 引擎在这些任务上所耗费的时间取决于页面所用的 JS 代码。该图并不是用来准确的衡量其性能的。相反,它是一种高度抽象的模型,用来比较实现相同功能的 JavaScript 和 WebAssembly 之间的性能差异。
图中的每一块表示该任务所耗费的时间。
- 解析:把 JavaScript 源码解析为解释器能够运行的代码的时间。
- 编译+优化:基准编译器和优化编译器所耗费的时间。优化编译器的部分优化工作并不是在主线程上进行的,这部分耗费的时间不包含在这里。
- 重新优化:当假设不成立时,JIT 作出重新调整所耗费的时间,包括重新优化和回退到基准代码的时间。
- 执行:运行代码耗费的时间。
- 垃圾回收:清理内存耗费的时间。
要注意的是,这些过程并不会以离散块或者特定的顺序发生。相反,它们是交叉进行的。
可能会解析完一小段,就会运行一段,然后编译一段;接着解析更多代码,然后执行更多代码等等。
这种分段交叉进行的设计相比早期的 JavaScript 来说是一种很大的性能提升,早期的 JavaScript 执行更像是下图中的情形。
在最开始的时候,只有解析器来跑 JavaScript ,执行速度是相当慢的。当引入 JIT 后,执行速度得到了大幅提升。
当然,引入 JIT 的代价就是在监视器和编译器上投入了更多资源。
如果开发者还是按照以前的方式来编写 JavaScript 应用,那么其实解析和编译时间是很小的。
只不过随着性能提升,开发者开发出了更大型的 JavaScript 应用,对性能要求又变高了。
因此,性能还是有提升空间的。
WebAssembly 性能分析
下图是与典型网页应用相比时,WebAssembly 的大致过程。
不同浏览器的处理可能略有不同,下面我们以 SpiderMonkey 引擎为例来说明各个过程。
加载
加载这部分并没有体现在上图中,但这部分所耗费的时间就是从服务器下载文件的时间。
因为 WebAssembly 代码比 JavaScript 代码更加的精简,所以加载 WebAssembly 文件是更快的。
尽管压缩算法能够极大减小 JavaScript 代码的体积,但是 WebAssembly 压缩后的二进制代码仍然比它要小。
这就意味着下载将耗费更少时间,尤其是在低网速情况下。
解析
JavaScript 代码一旦下载到浏览器,它会被解析为抽象语法树(AST)。
浏览器通常采用的策略是惰性处理,即只解析真正被用到的代码以及只为还没被调用的函数创建存根。
之后,AST 被转化为引擎相关的中间代码:字节码(Bytecode)。
而 WebAssembly 则不需要这种转换,因为它本身已经是一种中间代码了。它只需要经过解码,并且验证解码没有发生错误即可。
编译+优化
正如前面关于 JIT 的文章所说,JavaScript 的编译时发生在代码运行期间的。根据运行时所用的不同数据类型,相同代码可能需要被编译为多种代码。
不同的浏览器编译 WebAssembly 时使用不同方式。一些浏览器会在运行代码前先进行基准编译,其他浏览器则会使用 JIT 。
但不管是哪种方式,WebAssembly 都是从里机器码比较近的地方开始的。比如说,程序本身就包含了数据的类型信息,这样的话就会有更高的性能,因为:
- 编译器再进行优化编译之前不需要耗费时间来检查数据类型
- 编译器并不需要基于不同类型来编译出相同代码的不同类型版本代码
- 有很多优化已经在 LLVM 之前完成了,所以这里可以减少编译和优化的开销
重新优化
有时候 JIT 必须丢弃之前已经优化的代码并且重新编译。
这种情况就发生在 JIT 之前的假设都不成立时。比如说,当循环中使用了与之前不一样的变量类型,或者原型链上新增了一个函数。
这种去优化带来了两个性能损耗。一是,JIT 需要丢弃已编译的代码并回退到基准代码;二是,如果这段代码仍然会被调用很多次,那么又得重新花费时间去再次优化它。
而在 WebAssembly 中,数据类型是很明确的,所以 JIT 不需要对运行时的数据类型做任何假设。也就意味着,它不不存在重新优化可能。
运行
编写出高性能的 JavaScript 代码是可能的。为此,你需要知道 JIT 是如何做优化的。
比如,你需要知道如何写出让编译器特定化数据类型的代码。
但是,大多数开发者并不知道 JIT 的内部实现。即便是了解 JIT 内部实现的人,也很难直接击中要害。
许多我们为了让代码更具可读性的编程模式(比如抽出公共函数来处理多种类型)反而阻碍了编译器的优化。
而且,不同浏览器的 JIT 所采用的各种优化手段是不同的,这就导致了可能在某款浏览器上是最优的,但是在另一款浏览器中则是很差的。
正因为这个,运行 WebAssembly 通常是更加快速的。许多 JIT 做的优化在 WebAssembly 中根本不存在。
此外,WebAssembly 是被设计为一个编译目标的。也就是说,它是被用来作为编译器输出的,而不是用来供开发者编码的。
因为开发者不需要直接对 WebAssembly 编码,所以它能够使用更适合机器的指令,而这些指令通常能做到 10% ~ 800% 的性能提升。
垃圾回收
在 JavaScript 中,开发者并不需要专门去清理那些不再使用的变量所占用的内存。这种清理工作由 JavaScript 引擎自动进行,称为垃圾回收(Garbage Collection)。
但是,如果你想要得到可预期的性能,这可能会成为阻碍。
你并不能控制什么时候进行垃圾回收,所以它随时可能发生。尽管大多数浏览器在垃圾回收的调度方面做的相当不错,但是它仍然可能阻碍你的代码运行。
至少现在,WebAssembly 根本不支持垃圾回收。所有内存都是手动管理的。
虽然这样会让编码变得更加困难,但是它也让性能变得更加稳定。
结论
所以,WebAssembly 之所以比 JavaScript 拥有更好的性能,是因为以下原因:
- 更精简的体积让加载 WebAssembly 耗费更少时间
- 解码 WebAssembly 比解析 JavaScript 更快
- 编译和优化节省了时间开销,因为 WebAssembly 比 JavaScript 更接近机器码,而且 WebAssembly 是已经提前做过优化的
- 不会发生重新优化的过程,因为 WebAssembly 自带数据类型和其他信息
- 代码的运行耗费更好时间,因为它没有那么多编译器陷阱,而且它的指令集对机器更友好
- 不存在垃圾回收的过程,因为它是手动管理内存的
以上就是为什么在大多数时候,WebAssembly 都比 JavaScript 性能好的原因。
当然,WebAssembly 也存在表现并不如期望的那样好的时候。同时,也有一些正在进行的改变使得它变得更快。这些我们会在下一篇中讨论。