微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

Mozilla是如何提升JS和WASM之间的调用速度的?

2018-10-16 17:27 前端之巅
作者|Lim Clark
译者|无明
在 Mozilla,我们希望让 WebAssembly 尽可能快。我们从设计开始就朝着这个目标努力,它已经具备了很高的吞吐量。然后,我们使用流式基线编译器改进了加载时间。我们编译代码的速度比它们在网络上传输的速度还要快。

那么接下来该做些什么?

我们的一个重要优先事项是让 JS 和 WebAssembly 的结合变得更容易。但这两种语言之间的函数调用并不是很快。事实上,它们是出了名的速度慢。

不过,这种情况正在发生变化。

在最新的 Firefox Beta 版本中,JS 和 WebAssembly 之间的调用比非内联 JS 到 JS 函数的调用还要快。

现在这些调用在 Firefox 中非常快,但我不只是想告诉你这些,我想解释一下我们是如何让它们变得这么快的。

首先,让我们看看引擎是如何进行这些调用的。

函数调用的过程

函数是 JavaScript 的重要组成部分。一个函数可以做很多事情,例如:

  • 为函数作用域内的变量赋值(称为局部变量);

  • 调用浏览器内置的函数,如 Math.random;

  • 调用你在代码中定义的其他函数;

  • 返回一个值。

但函数是如何被调用的?函数是如何让计算机完成你想让它做的事情的?

程序员所使用的语言(如 JavaScript)与计算机能够理解的语言是不一样的。要运行我们下载的 JavaScript 代码,需要将其转换为机器可以理解的机器语言。

每个浏览器都有一个内置的转换器。这个转换器有时候被叫作 JavaScript 引擎或 JS 运行时。这些引擎现在也可以处理 WebAssembly,所以在术语方面可能会令人困惑。在本文中,我将其称为引擎。

每个浏览器都有自己的引擎:

  • Chrome 有 V8;

  • Safari 有 JavaScriptCore(JSC);

  • Edge 有 Chakra;

  • Firefox 有 SpiderMonkey。

尽管每个引擎都不同,但很多通用的想法适用于所有引擎。

当浏览器遇到 JavaScript 代码时,它会启动引擎来运行代码。引擎需要执行代码,调用所有需要调用的函数,直到结束。

我把这比作电子游戏中一个角色执行任务的过程。

假设我们想玩“生命游戏”。引擎的任务是为我们渲染游戏版图。事实证明,它这项任务并不简单……

引擎转到下一个函数,但下一个函数会调用更多的函数。

引擎必须继续执行这些嵌套任务,直到它得到一个只返回给它一个结果的函数。

然后它以相反的顺序返回到它所调用的每个函数。

如果引擎要正确地做完这件事——如果它要将为正确的函数提供正确的参数并一路返回到最开始的函数——它需要跟踪一些信息。

它使用栈帧(或调用帧)来达到目的。栈帧就像一张纸,上面有传给函数的参数、返回值的目标位置,还有函数创建的所有局部变量。

它跟踪这些纸张的方式是将它们放在一个栈中。当前函数对应的纸张位于顶部。当完成任务时,它会抛出顶部的纸张。因为这是一个栈,所以下面还有一张纸(因为扔掉了之前的纸张,所以这张就到了顶部)。这就是函数调用需要返回到的地方。

这些帧被称为调用栈。

引擎在运行过程中构建了这个调用栈。在调用函数时,帧被添加到栈中。当函数返回时,帧从栈中弹出。这个过程一直重复,直到完全退回并弹出栈中所有的内容。

这就是函数调用的基本过程。现在,让我们看看是什么让 JavaScript 和 WebAssembly 之间的函数调用变慢,并解释我们是如何在 Firefox 中让调用变快的。

如何让 WebAssembly 函数调用变快?

最近,我们在 Firefox Nightly 中对两个方向的调用进行了优化——从 JavaScript 到 WebAssembly,以及从 WebAssembly 到 JavaScript。我们还让从 WebAssembly 到内置函数的调用也变得更快。

我们所做的优化都是为了让引擎的工作更轻松。改进分为两组:

  • 减少簿记(bookkeeping)——摆脱了不必要的组织栈帧工作;

  • 移除中介——采用函数之间的直接途径。

优化 WebAssembly 到 JS 的调用

引擎在执行代码时,需要处理两种不同语言的函数——即使代码都是用 JavaScript 编写的。

其中一些代码——在解释器中运行的那些——已经变成了一种叫做字节码的东西。它比 JavaScript 代码更接近机器码,但它不是机器码。字节码运行速度非常快,但好不够快。

其他函数——那些被大量调用的函数——由即时编译器(JIT)直接转换为机器码。机器码不再通过解释器运行。

所以,我们就有了两种语言的函数:字节码和机器码。

我把这两种代码看成是视频游戏中不同的大洲。

引擎需要能够在这些大洲之间来回穿梭。但在不同大洲之间跳跃时,它需要一些信息,比如它从另一个大洲离开的地方(稍后需要回到这个地方)。引擎还需要分离它需要用到的帧。

引擎创建了一个文件夹,将旅行所需的信息放在其中的一个格子中——例如,它从哪里进入大洲的。

它使用另一个格子来保存栈帧。随着引擎在这个大洲积累越来越多的栈帧,这个格子会逐渐扩大。

在 SpiderMonkey 中,这些“文件夹”被称为 activation。

每次切换到不同的大洲时,引擎都会创建一个新文件夹。唯一的问题是,它必须通过 C++ 来创建新文件夹,而通过 C++ 会增加很大的成本。

它们就像蹦床,每次你必须使用其中的一种蹦床,这样会浪费时间。

对于两个大洲之间的旅行,都必须在“蹦床点”进行强制停留。

那么在使用 WebAssembly 时这是如何让使速度变慢的呢?

当我们添加 WebAssembly 支持时,就有了一个不同类型的文件夹。因此,尽管 JavaScript 代码和 WebAssembly 代码都被编译成机器码,但我们仍然认为它们讲的是不同的语言。也就是说,我们把它们看作是在不同的大洲上。

这带来了两方面不必要的成本:

  • 它创建了一个不必要的文件夹,其中包含了初始化和销毁的成本;

  • 它需要通过 C++ 这个“蹦床”(创建文件夹和其他设置)。

我们使用了通用代码来解决这个问题,让 JIT 编译的 JavaScript 和 WebAssembly 使用相同的文件夹。这有点像我们将两个大洲推到一起,这样就不需要离开大洲就能旅行。

有了这个,从 WebAssembly 到 JS 的调用几乎和 JS 到 JS 的调用一样快。

不过,我们需要做一些工作来加快另一个方向的调用速度。

优化 JS 到 WebAssembly 的调用

即使在 JavaScript 和 WebAssembly 讲的是相同语言的情况下,它们的习俗仍然是不一样的。

例如,为了处理动态类型,JavaScript 需要进行拆装箱。

因为 JavaScript 没有显式类型,需要在运行时判断类型。引擎通过将标记附加到值来跟踪值的类型。

就好像 JS 引擎在这个值外面放了一个盒子。这个盒子中包含了说明这个值的类型的标记。例如,最后的零表示整数。

为了计算这两个整数的和,系统需要移除外面的盒子。它移除 a 的盒子,然后是 b 的盒子。

然后它将未装箱的值相加。

然后,它需要在结果外面再次添加盒子,以便让系统知道结果的类型。

这样一来,1 个操作就变成了 4 个操作……但如果不需要进行拆装箱(就像静态类型语言那样),就不会有这些开销。

在很多情况下,JavaScript JIT 可以避免这些额外的拆装箱操作,但在一般情况下,比如函数调用,JS 需要拆装箱。

这就是为什么 WebAssembly 希望参数是没有装箱的,以及它为什么不对返回值进行装箱。WebAssembly 是静态类型的,因此不需要这些额外的开销。WebAssembly 还希望将值传到寄存器中而不是 JavaScript 通常使用的栈中。

如果引擎拿到了一个经过装箱的 JavaScript 参数,并将其传给 WebAssembly 函数,WebAssembly 函数就会不知道如何使用它。

因此,在将参数传给 WebAssembly 函数之前,引擎需要将值拆箱并放入寄存器中。

要做到这一点,需要再次通过 C++。因此,尽管我们不需要通过 C++ 来设置 activation,仍然需要通过它来准备参数值(从 JS 到 WebAssembly)。

经过这个中介是一个巨大的成本,特别是对于那些本身并不复杂的东西来说。所以,如果能够完全移除中间人,那就更好了。

这就是我们所做的事情。我们让 C++ 运行的代码——入口点(stub)可以直接从 JIT 代码中调用。当引擎从 JavaScript 转到 WebAssembly 时,入口点对值进行拆箱并将它们放在正确的位置。有了这个,我们就摆脱了 C++ 蹦床。

我把这看成是一个备忘单。引擎因此不需要再转到 C++ 蹦床,相反,它可以在原地对值进行拆箱。

这样就加快了从 JavaScript 中调用 WebAssembly 的速度。

但在某些情况下,我们可以让它更快。事实上,在很多情况下,我们可以让这些调用比在 JavaScript 中调用 JavaScript 还要快。

更快的 JS 到 WebAssembly 调用:单态

当 JavaScript 函数调用另一个函数时,它并不知道另一个函数想要什么样的参数,所以它默认把东西都放在盒子里。

但如果 JS 函数每次在调用函数时都知道被调用函数的参数类型呢?这样的话,调用函数就可以事先知道如何以被调用函数想要的方式打包参数。

这是 JS JIT 优化(“类型特化”)的一个例子。如果一个函数是专用的,引擎会确切地知道它需要什么样,这样就可以准备好它需要的参数……这意味着引擎不需要在拆箱上做额外的工作。

这种调用——每次调用的是相同的函数——称为单态调用。在 JavaScript 中,每次都需要使用完全相同类型的参数调用函数才叫单态调用。但因为 WebAssembly 函数具有显式类型,所以不需要担心参数类型是否完全相同。

如果你编写的代码中让 JavaScript 始终将相同的类型传给相同的 WebAssembly 导出函数,那么调用将非常快。实际上,这些调用比很多 JavaScript 到 JavaScript 的调用要快得多。

只有一种情况从 JavaScript 到 WebAssembly 的优化调用不会比 JavaScript 到 JavaScript 的调用快,就是当 JavaScript 带有内联函数的时候。

内联的意思是说,当你有一个多次调用同一个函数的函数时,你可以采取更大的捷径。编译器可以将被调用函数复制到调用函数中,而不是让引擎进进出出被调用函数。这样引擎就不需要去任何地方——它可以待在原地不动并继续执行其他计算。

我把这想成是被调用函数将技能传授给了调用函数。

这是 JavaScript 引擎对被重复多次调用函数所做的优化——也就是“热”函数——以及调用的函数相对较小时。

在将来的某个时候,我们肯定会添加对在 JavaScript 中内联 WebAssembly 的支持,这就是为什么要让这两种语言运行在同一个引擎中。这样它们就可以使用相同的 JIT 后端和相同的编译器中间表示,并进行互操作,而这种互操作在不同引擎中是无法实现的。

优化 WebAssembly 到内置函数的调用

还有一种调用比预期的要慢:当 WebAssembly 函数调用内置函数时。

内置函数是浏览器提供的函数,如 Math.random。

有时候,内置函数是用 JavaScript 实现的,在这种情况下,它们被称为是自托管的。这可以让它们运行得更快,因为不必通过 C++:一切都在 JavaScript 中运行。但一些使用 C++ 实现的函数会更快。

不同的引擎已经决定了哪些内置函数应该用自托管 JavaScript 编写,哪些应该用 C++ 编写,并且通常会使用两种语言的混合来编写单个内置函数。

对于使用 JavaScript 编写内置函数,它们将从上述的所有优化获益。但对于使用 C++ 编写的函数,我们又要使用蹦床。

这些函数被大量调用,因此你需要优化这些调用。为了加快速度,我们添加了一个特定于内置函数的快速路径。当你将内置函数传给 WebAssembly 时,引擎会发现你传递的是一个内置函数,此时它知道如何采取快速路径。这意味着你不需要经过蹦床。

这有点像我们建造了一座通往内置函数大洲的桥梁。如果式从 WebAssembly 到内置函数,可以走这座桥。(JIT 已经对这种情况进行了优化,即使它没有在图中出现。)

有了这个,对这些内置函数的调用比以前快了很多。

目前,我们支持的主要限于数学相关的内置函数。因为 WebAssembly 目前只支持整数和浮点值作为值类型。

这对于数学函数来说很有效,因为它们可以处理数字,但对于 DOM 内置函数等其他东西来说效果不佳。因此,如果你想要调用这些函数,需要通过 JavaScript。

这个时候可以使用 wasm-bindgen(https://hacks.mozilla.org/2018/03/making-webassembly-better-for-rust-for-all-languages/#wasm-bindgen)。

但 WebAssembly 很快就会带来更灵活的类型(https://github.com/WebAssembly/reference-types)。 

对于当前提案的实验性支持已经包含在 Firefox Nightly 中(javascript.options.wasm_gc)。一旦这些类型确定,就可以直接从 WebAssembly 调用其他内置函数,而无需通过 JS。

用于优化 Math 内置函数的基础设施也可以扩展为用于其他内置函数,这将确保很多内置函数能够尽可能地快。

但仍然有一些内置函数需要通过 JavaScript,例如,通过 new 或 getter/setter 调用的内置函数。

这些内置函数将通过宿主绑定(https://github.com/WebAssembly/host-bindings) 来解决。

  结 论  

以上就是我们如何在 Firefox 中加快 JavaScript 和 WebAssembly 之间的函数调用,你也可以期待其他浏览器也会尽快做出类似的优化。

英文原文:https://hacks.mozilla.org/2018/10/calls-between-javascript-and-webassembly-are-finally-fast-%F0%9F%8E%89/

 活动推荐

今年12 月 7-8 日在北京国际会议中心举办的 ArchSummit 全球架构师技术峰会邀请了超过百位的国内外专业讲师,并设置了前端技术专题,分享他们的最新黑科技和研发经验。

目前大会 8 折优惠购票火热进行中,扫描以下图片二维码或点击“阅读原文”了解更多详情!

如有疑问欢迎咨询票务经理灰灰:17326843116(微信同号)


 
前端之巅 更多文章 Vue CLI 2&3 下的项目优化实践:CDN + Gzip 浏览器页面渲染机制,你真的弄懂了吗? 每个前端开发者都要了解的发布和订阅模式 2018年,最常见的26个JavaScript面试题和答案 JavaScript私有属性要来了,但实现方式惹争议
猜您喜欢 技术派:谁说API网关只能集成REST APIs? 详解Object.defineProperty() 不惜一切代价,远离这 10 种不靠谱的同事 《DevOps实践指南》封面设计定稿 如果没有了PS,世界将会怎样?