这是关于 WebAssembly 以及它为什么这么快的一系列文章的第五篇。如果您还没有阅读其他文章,我们建议您从头开始阅读。
在上一篇文章中,我解释了用WebAssembly 还是 JavaScript 编程并不是非此即彼的选择。我们不希望太多的开发者编写完整的 WebAssembly 代码库。
因此,开发者不需要在 WebAssembly 和 JavaScript 之间为他们的应用程序选择。但是,我们确实希望开发者能够将部分 JavaScript 代码替换为 WebAssembly。
例如,React 团队可以将他们的协调器代码(又名虚拟 DOM)替换为 WebAssembly 版本。使用 React 的人无需做任何事情……他们的应用程序将像以前一样工作,只是他们将获得 WebAssembly 的好处。
React 团队这样的开发者进行这种替换的原因是 WebAssembly 更快。但是是什么让它更快呢?
如今 JavaScript 的性能如何?
在我们理解 JavaScript 和 WebAssembly 之间的性能差异之前,我们需要理解 JS 引擎所做的工作。
这张图粗略地描述了如今应用程序启动性能可能的样子。
JS 引擎在任何一项任务上花费的时间取决于页面使用的 JavaScript。这张图并不意味着代表精确的性能数据。相反,它旨在提供一个高层次的模型,说明相同功能在 JS 和 WebAssembly 中的性能差异。
每个条形图显示了花费在特定任务上的时间。
- 解析 - 处理源代码以将其转换为解释器可以运行的内容所需的时间。
- 编译 + 优化 - 在基线编译器和优化编译器中花费的时间。一些优化编译器的操作不在主线程上,因此这里没有包含。
- 重新优化 - JIT 在其假设失败时花费的时间,包括重新优化代码和从优化代码退回到基线代码。
- 执行 - 运行代码所需的时间。
- 垃圾回收 - 花费在清理内存上的时间。
需要注意的一点是:这些任务不会以离散的块或特定的顺序发生。相反,它们会交织在一起。解析会进行一点,然后执行一些操作,然后编译一些操作,然后解析更多操作,然后执行更多操作,等等。
这种细分带来的性能是 JavaScript 早期的巨大进步,当时看起来更像是这样
最初,当它只是一个解释器运行 JavaScript 时,执行速度相当慢。当引入 JIT 时,它极大地提高了执行时间。
权衡是监控和编译代码的开销。如果 JavaScript 开发人员以当时的方式继续编写 JavaScript,那么解析和编译时间将非常小。但性能的提升促使开发者创建了更大的 JavaScript 应用程序。
这意味着仍然有改进的空间。
WebAssembly 如何比较?
以下是对 WebAssembly 与典型 Web 应用程序比较的近似值。
浏览器在处理所有这些阶段的方式上略有差异。我在这里使用 SpiderMonkey 作为我的模型。
获取
图中没有显示这一点,但占用时间的因素之一是简单地从服务器获取文件。
由于 WebAssembly 比 JavaScript 更紧凑,因此获取速度更快。即使压缩算法可以显著缩小 JavaScript 包的大小,WebAssembly 的压缩二进制表示仍然更小。
这意味着在服务器和客户端之间传输它需要更少的时间。这在慢速网络上尤其如此。
解析
到达浏览器后,JavaScript 源代码将被解析为抽象语法树。
浏览器通常会延迟执行此操作,只在开始时解析真正需要的部分,并为尚未调用的函数创建存根。
从那里,AST 将转换为特定于该 JS 引擎的中间表示形式(称为字节码)。
相反,WebAssembly 不需要经历这种转换,因为它已经是中间表示形式。它只需要被解码和验证以确保其中没有错误。
编译 + 优化
正如我在关于JIT 的文章中解释的那样,JavaScript 在代码执行期间被编译。根据运行时使用的类型,可能需要编译同一代码的多个版本。
不同的浏览器以不同的方式处理 WebAssembly 的编译。一些浏览器在开始执行 WebAssembly 之前会对其进行基线编译,而另一些浏览器使用 JIT。
无论哪种方式,WebAssembly 都比机器码更接近。例如,类型是程序的一部分。这更快的原因有几个
- 编译器不需要花费时间运行代码来观察正在使用的类型,然后才开始编译优化代码。
- 编译器不需要根据观察到的不同类型编译同一代码的不同版本。
- LLVM 已经提前完成了更多的优化。因此,编译和优化它所需的工作更少。
重新优化
有时 JIT 必须放弃优化版本的代码并重新尝试。
当 JIT 根据运行代码收集的数据做出的假设被证明是错误的时候,就会发生这种情况。例如,当进入循环的变量与先前迭代中的变量不同,或者当在原型链中插入新函数时,就会发生反优化。
反优化有两个成本。首先,从优化代码退回到基线版本需要一些时间。其次,如果该函数仍然被频繁调用,JIT 可能会决定再次将其发送到优化编译器,因此第二次编译它也会有成本。
在 WebAssembly 中,类型是显式的,因此 JIT 不需要根据运行时收集的数据对类型做出假设。这意味着它不需要经历重新优化循环。
执行
有可能编写执行效率高的 JavaScript。要做到这一点,您需要了解 JIT 所做的优化。例如,您需要知道如何编写代码,以便编译器可以对其进行类型特化,如关于JIT 的文章中所述。
然而,大多数开发者并不了解 JIT 的内部机制。即使对于那些了解 JIT 内部机制的开发者来说,也很难达到最佳点。人们用来使代码更易读的许多编码模式(例如将常见任务抽象到跨类型工作的函数中)在编译器尝试优化代码时会妨碍它。
此外,JIT 使用的优化在不同的浏览器之间是不同的,因此针对一个浏览器的内部机制进行编码可能会使您的代码在另一个浏览器中性能下降。
因此,在 WebAssembly 中执行代码通常更快。JIT 对 JavaScript 进行的许多优化(例如类型特化)在 WebAssembly 中是不必要的。
此外,WebAssembly 被设计为编译目标。这意味着它被设计用于编译器生成,而不是用于人类程序员编写。
由于人类程序员不需要直接编程它,因此 WebAssembly 可以提供一组更适合机器的指令。根据您的代码正在执行的工作类型,这些指令的运行速度可以快 10% 到 800% 之间。
垃圾回收
在 JavaScript 中,开发者不需要担心在不再需要旧变量时将其从内存中清除。相反,JS 引擎会使用一种叫做垃圾回收器的东西自动完成这个工作。
但是,如果您想要可预测的性能,这可能是一个问题。您无法控制垃圾回收器何时执行其工作,因此它可能在不方便的时候出现。大多数浏览器在安排垃圾回收方面做得很好,但它仍然是可能阻碍代码执行的开销。
至少现在,WebAssembly 根本不支持垃圾回收。内存是手动管理的(就像在 C 和 C++ 等语言中一样)。虽然这可能会使开发者的编程变得更加困难,但也确实使性能更加一致。
结论
WebAssembly 在许多情况下比 JavaScript 更快,因为
- 获取 WebAssembly 的时间更短,因为即使在压缩后,它也比 JavaScript 更紧凑。
- 解码 WebAssembly 比解析 JavaScript 更快。
- 编译和优化所需时间更短,因为 WebAssembly 比 JavaScript 更接近机器代码,并且已经在服务器端经过优化。
- 不需要重新优化,因为 WebAssembly 内置了类型和其他信息,因此 JS 引擎在优化时不需要像处理 JavaScript 那样进行推测。
- 执行通常所需时间更短,因为开发人员需要了解的编译器技巧和陷阱更少,才能编写始终如一的高性能代码,此外,WebAssembly 的指令集更适合机器。
- 由于内存是手动管理的,因此不需要垃圾收集。
这就是为什么在许多情况下,WebAssembly 在执行相同任务时会比 JavaScript 性能更高。
在某些情况下,WebAssembly 的性能并不如预期,也有一些即将到来的变化将使其更快。我将在下一篇文章中介绍这些内容。
关于 Lin Clark
Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。
20 条评论