写点什么

让 JavaScript 在 WebAssembly 上快速运行

用户头像
代码先生
关注
发布于: 2021 年 06 月 07 日
让JavaScript在WebAssembly上快速运行

浏览器中的 JavaScript 运行速度比 20 年前快了许多倍。之所以会这样,是因为浏览器供应商把那段时间花在了密集的性能优化上。


今天,我们开始为完全不同的环境优化 JavaScript 的性能,我们也尝试了不同的方法。但因为有了 WebAssembly,这才成为可能。


我们应该清楚,如果你在浏览器中运行 JavaScript,简单地部署 JS 仍然是最直接的。浏览器中的 JS 引擎对于被它运行的 JS 进行了高度调优。

但如果你在无服务器功能中运行 JavaScript 呢?或者如果你想在一个不允许一般即时编译的环境中运行 JavaScript,比如 iOS 或游戏机?

对于这些用例,你会想关注一下这股新的 JS 优化浪潮。这项工作也可以作为其他运行时的模型,如 Python、Ruby 和 Lua,它们也想在这些环境中快速运行。


但在我们探讨如何使这种方法快速运行之前,我们需要看看它在基本层面上是如何工作的。

这是如何工作的呢?


每当你运行 JavaScript 时,JS 源代码需要以某种方式作为机器代码执行。这是由 JS 引擎使用各种技术完成的,如解释器和 JIT 编译器


每当你运行 JavaScript 时,JS 源代码都需要以这样或那样的方式作为机器代码来执行。这是由 JS 引擎使用各种技术完成的,如解释器和 JIT 编译器。(详情请见即时编译器(JIT)的速成课程)。


但是如果你的目标平台没有 JS 引擎呢?那么你就需要把 JS 引擎和你的代码一起部署。


为了做到这一点,我们将 JS 引擎部署为 WebAssembly 模块,这使得它可以在不同类型的机器架构中移植。有了 WASI,我们也可以使它在不同的操作系统中进行移植。


这意味着整个 JS 环境被捆绑在这个 WebAssembly 实例中。一旦你部署了它,你所需要做的就是输入 JS 代码,它就会运行这些代码。


JS 引擎没有直接在机器的内存上工作,而是将一切--从字节码到字节码正在操作的 GCed 对象--放在 Wasm 模块的线性内存中。


对于我们的 JS 引擎,我们选择了 Firefox 中使用的 SpiderMonkey。它是工业级的 JavaScript 虚拟机之一,在浏览器中经过了实战测试。当你运行不受信任的代码或处理不受信任的输入的代码时,这种极限测试和安全投资非常重要。


SpiderMonkey 还使用了一种叫做精确堆栈扫描的技术,这对我在下面解释的一些优化很重要。它还有一个非常容易接近的代码库,这很重要,因为来自三个不同组织的 BA 成员--Fastly、Mozilla 和 Igalia--正在合作开发这个项目。


到目前为止,我所描述的方法并没有什么革命性的意义。人们已经像这样用 WebAssembly 运行 JS 很多年了。


问题是,它很慢。WebAssembly 不允许你动态地生成新的机器代码,并从纯 Wasm 代码中运行它。这意味着你不能使用 JIT。你只能使用解释器。


鉴于这种限制,你可能会问...


我们应该怎么做呢?

因为 JIT 是浏览器让 JS 快速运行的方式(因为你不能在 WebAssembly 模块内进行 JIT 编译),所以这样做似乎有悖常理。


但是,如果尽管如此,我们还是可以让 JS 快速运行呢?


让我们看一下这种方法的快速版本可能真的有用的几个用例。


在 iOS 上运行 JS(和其他受 JIT 限制的环境)

出于安全考虑,有些地方不能使用 JIT--例如,无特权的 iOS 应用和一些智能电视和游戏机。



在这些平台上,你必须使用一个解释器。但是你在这些平台上运行的那种应用程序是长期运行的,它们需要大量的代码......而这些正是历史上你不想使用解释器的那种情况,因为它大大降低了执行速度。


如果我们能让我们的方法变得快速,那么这些开发者就可以在无 JIT 的平台上使用 JavaScript,而不会对性能产生巨大的影响。


瞬时冷启动的 Serverless

还有一些地方,JIT 不是问题,但启动时间是问题,比如在 Serverless 函数中。这就是你可能听说过的冷启动延时问题。


即使你使用的是最简单的 JS 环境--只是启动一个裸露的 JS 引擎的隔离器,你也会发现至少有 5 毫秒的启动延迟。这甚至不包括初始化应用程序的时间。


有一些方法可以隐藏传入请求的启动延时。但是,随着网络层的连接时间被优化,在 QUIC 等提议中,掩盖这个问题越来越难。当你在做诸如将多个 Serverless 函数连接在一起的事情时,也更难掩盖这一点。


使用这些技术来隐藏延迟的平台也经常在请求之间重复使用实例。在某些情况下,这意味着全局状态可以在不同的请求之间被观察到,这是一个安全隐患。


由于这个冷启动问题,开发人员往往不遵循最佳实践。他们把大量的功能塞进一个 Serverless 部署中。这就造成了另一个安全问题--更大的爆炸半径。如果无服务器部署中的一个部分被利用,攻击者就可以访问该部署中的一切。


但如果我们能在这些情况下把 JS 的启动时间弄得足够低,那么我们就不需要用任何技巧来隐藏启动时间。我们可以直接在微秒内启动一个实例。


这样一来,我们就可以在每次请求时提供一个新的实例,这意味着在两次请求之间不会有任何状态。


而且,由于实例是如此轻量级,开发人员可以自由地将他们的代码分解成细粒度的部分,将任何单一代码的爆炸半径降到最低。



而且,这种方法还有另一个安全上的好处。除了轻量级和使更细粒度的隔离成为可能之外,Wasm 引擎提供的安全边界也更加可靠。


因为用于创建隔离的 JS 引擎是大型代码库,包含大量的低级代码,进行超复杂的优化,所以很容易引入错误,使攻击者能够逃脱虚拟机,并获得对虚拟机运行的系统的访问。这就是为什么像 Chrome 和 Firefox 这样的浏览器不遗余力地确保网站在完全分离的进程中运行。


相比之下,Wasm 引擎需要的代码要少得多,所以它们更容易被审计,而且许多引擎正在用 Rust(一种内存安全的语言)编写。而且,从 WebAssembly 模块生成的本地二进制文件的内存隔离是可以被验证的。


通过在 Wasm 引擎内运行 JS 引擎,我们有这个外部的、更安全的沙盒边界作为另一道防线。


因此,对于这些用例,让 Wasm 上的 JS 快速运行有很大的好处。但我们如何才能做到这一点?


为了回答这个问题,我们需要了解 JS 引擎花费的时间。


JS 引擎花时间的两个地方

我们可以将 JS 引擎的工作大致分为两部分:初始化和运行时。

我认为 JS 引擎是一个承包商。这个承包商被保留下来以完成一项工作--运行 JS 代码并得到一个结果。


初始化阶段

在这个承包商真正开始运行项目之前,它需要做一些初步的工作。初始化阶段包括一切只需要发生一次的事情,在执行之初。

应用初始化

对于任何项目,承包商需要看一下客户希望它做的工作,然后设置完成该任务所需的资源。

例如,承包商阅读项目简报和其他支持性文件,并把它们变成它可以使用的东西,例如,建立一个项目管理系统,把所有的文件储存和组织起来。


在 JS 引擎的情况下,这项工作看起来更像是阅读源代码的顶层,并将函数解析为字节码,为已声明的变量分配内存,并在已定义的地方设置值。


引擎初始化

在某些情况下,如 Serverless,还有一个初始化的部分,在每个应用程序初始化之前发生。

这就是引擎初始化。JS 引擎本身需要首先被启动,并且需要将内置的功能添加到环境中。

我认为这就像在开始工作之前,先把办公室本身设置好,比如组装宜家的椅子和桌子。


这可能需要相当长的时间,这也是使无服务器用例的冷启动成为问题的部分原因。


运行时阶段

一旦初始化阶段完成,JS 引擎就可以开始运行代码的工作。


这部分工作的速度被称为吞吐量,而这个吞吐量受到很多不同变量的影响。比如说。


使用了哪些语言特性

  1. 从 JS 引擎的角度看,代码的行为是否可以预测

  2. 使用什么样的数据结构

  3. 代码的运行时间是否足够长,以便从 JS 引擎的优化编译器中获益。

所以这就是 JS 引擎花费时间的两个阶段。


我们怎样才能使这两个阶段的工作进行得更快?


大幅减少初始化时间

我们首先用一个叫 Wizer 的工具使初始化变得快速。我将解释如何做,但对于那些没有耐心的人来说,以下是我们在运行一个非常简单的 JS 应用时看到的速度提升。



当用 Wizer 运行这个小程序时,只需要 0.36 毫秒(或 360 微秒)。这比我们用 JS 隔离的方法所期望的要快 13 倍以上。


我们使用一种叫做快照的东西来获得这种快速启动。Nick Fitzgerald 在他关于 Wizer 的WebAssembly峰会演讲中更详细地解释了这一切。


那么,这是如何工作的呢?在代码部署之前,作为构建步骤的一部分,我们使用 JS 引擎运行 JS 代码到初始化结束。


在这一点上,JS 引擎已经解析了所有的 JS,并将其变成字节码,JS 引擎模块将其存储在线性内存中。在这个阶段,引擎还做了大量的内存分配和初始化。


因为这个线性内存是非常独立的,一旦所有的值都被填入,我们就可以把内存作为一个数据段附加到 Wasm 模块中。


当 JS 引擎模块被实例化时,它可以访问数据部分的所有数据。每当引擎需要一点内存时,它可以把它需要的部分(或者说,内存页)复制到自己的线性内存中。有了这个,JS 引擎在启动时就不需要做任何设置。所有预先初始化的值都已经准备好了,等待着它。


目前,我们把这个数据部分附加到与 JS 引擎相同的模块中。但在未来,一旦模块链接到位,我们将能够把数据部分作为一个单独的模块来运输,允许 JS 引擎模块被许多不同的 JS 应用程序重用。


这提供了一个真正干净的分离。


JS 引擎模块只包含引擎的代码。这意味着,一旦它被编译,这些代码可以有效地被缓存,并在许多不同的实例中被重用。


另一方面,特定应用模块不包含 Wasm 代码。它只包含线性内存,而线性内存又包含 JS 字节码,以及其余被初始化的 JS 引擎状态。这使得移动这些内存非常容易,并把它发送到它需要去的地方。


这有点像 JS 引擎承包商甚至根本不需要设立办公室。它只需要一个旅行箱就可以了。那个旅行箱里有整个办公室,里面有所有的东西,都已经设置好,准备好让 JS 引擎开始工作了。


最酷的是,它不依赖于 JS--它只是使用 WebAssembly 的一个现有属性。所以你也可以在 Python、Ruby、Lua 或其他运行时中使用这种技术。


下一步提高吞吐量

因此,通过这种方法,我们可以达到超快的启动时间。但是,吞吐量呢?

对于某些用例来说,吞吐量其实并不差。如果你有一个运行时间很短的 JavaScript 片段,它无论如何都不会通过 JIT--它会一直留在解释器中。因此,在这种情况下,吞吐量将与浏览器中的相同,并在传统 JS 引擎完成初始化之前完成。

但对于运行时间较长的 JS 来说,在 JIT 开始启动之前,并不需要那么久。而一旦发生这种情况,吞吐量的差异就开始变得很明显。

正如我上面所说的,目前不可能在纯 WebAssembly 中进行 JIT 编译代码。但事实证明,我们可以把 JIT 的一些思想应用到超前编译模型中。


快速 AOT 编译的 JS(无需剖析)

JIT 使用的一种优化技术是内联缓存。通过内联缓存,JIT 创建了一个包含快速机器代码路径的存根链接列表,该列表包含过去运行 JS 字节码的所有方式。更多细节请参见及时编译器(JIT)的速成课程)。


你需要一个列表的原因是 JS 中的动态类型。每当这行代码使用了不同的类型,你就需要生成一个新的存根并将其添加到列表中。但如果你以前遇到过这种类型,那么你可以直接使用已经为它生成的存根。


因为内联缓存(IC)通常用于 JIT,所以人们认为它们是非常动态的,而且是针对每个程序的。但事实证明,它们也可以应用于 AOT 环境中。


甚至在我们看到 JS 代码之前,我们已经知道了很多我们需要生成的 IC 存根。这是因为 JS 中的一些模式被经常使用。


这方面的一个很好的例子是访问对象的属性。这在 JS 代码中经常发生,可以通过使用 IC 存根来加快速度。对于具有某种 "形状 "或 "隐藏类 "的对象(也就是以相同方式布置的属性),当你从这些对象中获得一个特定的属性时,该属性将始终处于相同的偏移量。


传统上,JIT 中的这种 IC 存根会硬编码两个值:形状的指针和属性的偏移量。而这需要我们提前掌握的信息。但我们可以做的是将 IC 存根参数化。我们可以把形状和属性的偏移量当作变量,传给存根。


这样,我们可以创建一个单一的存根,从内存中加载值,然后在所有地方使用相同的存根代码。我们可以将这些常见模式的所有存根烘烤到 AOT 编译的模块中,而不管 JS 代码实际做什么。即使在浏览器中,这种 IC 共享也是有益的,因为它可以让 JS 引擎生成更少的机器代码,改善启动时间和指令缓存的定位。


但对于我们的用例来说,这一点尤其重要。这意味着我们可以将这些常见模式的所有存根植入 AOT 编译的模块中,而不管 JS 代码实际做什么。


我们发现,只要有几千字节的 IC 存根,我们就可以覆盖绝大多数的 JS 代码。例如,用 2KB 的 IC 存根,我们可以覆盖 Google Octane 基准测试中 95%的 JS。从初步测试来看,这一比例似乎也适用于一般的网页浏览。


因此,使用这种优化,我们应该能够达到与早期 JIT 相同的吞吐量。一旦我们完成了这项工作,我们将增加更多的细粒度优化,并提高性能,就像浏览器的 JS 引擎团队对其早期 JIT 所做的那样。


下一步,下一步:也许再做一些 profiling?

这就是我们可以提前做的事情,而不知道一个程序是做什么的,有哪些类型流经它。

但是,如果我们能够获得与 JIT 相同的剖析信息呢?那么我们就可以完全优化代码。

但这里有一个问题--开发人员往往很难对他们自己的代码进行分析。很难拿出有代表性的工作负载样本。所以我们不确定我们是否能得到好的剖析数据。

如果我们能想出一个办法,把好的工具放在剖析的位置上,那么我们就有可能使 JS 的运行速度几乎和今天的 JIT 一样快(而且没有预热时间!)。


对于其他想支持 JS 的平台

要在你自己的平台上运行 JS,你需要嵌入一个支持 WASI 的 WebAssembly 引擎。我们正在使用 Wasmtime 来做这个。

然后你需要你的 JS 引擎。作为这项工作的一部分,我们已经在 Mozilla 的构建系统中加入了对 SpiderMonkey 编译 WASI 的全面支持。而且 Mozilla 即将把 SpiderMonkey 的 WASI 构建添加到用于构建和测试 Firefox 的同一 CI 设置中。这使得 WASI 成为 SpiderMonkey 的生产质量目标,并确保 WASI 构建能够长期持续工作。这意味着你可以以与我们这里相同的方式使用 SpiderMonkey。

最后,你需要用户带来他们预先初始化的 JS。为了帮助解决这个问题,我们还开源了 Wizer,你可以把它集成到一个 buildtool 中,这个 buildtool 会产生特定于应用程序的 WebAssembly 模块,为 JS 引擎模块填入预初始化的内存。

对于其他的语言

如果是 Python、Ruby、Lua 或其他语言社区的一员,你也可以为你的语言建立一个这样的版本。

首先,你需要将你的运行时编译成 WebAssembly,使用 WASI 进行系统调用,就像我们对 SpiderMonkey 所做的那样。然后,为了获得快照的快速启动时间,你可以将 Wizer 集成到一个构建工具中,以产生内存快照,如上所述。



参考资料:

https://bytecodealliance.org/articles/making-javascript-run-fast-on-webassembly

用户头像

代码先生

关注

一起提升技术,刷新认知 2018.03.22 加入

10年技术工作者+打杂产品经理,死磕互联网技术与面试

评论 (1 条评论)

发布
用户头像
亲,文章质量不错,写作平台首页添加文字君微信,申请推送文章
2021 年 06 月 09 日 14:50
回复
没有更多了
让JavaScript在WebAssembly上快速运行