在 Mozilla,我们希望 WebAssembly 尽可能快。
这从它的设计开始,它提供了极高的吞吐量。然后,我们通过流式基线编译器改进了加载时间。有了它,我们可以比网络传输速度更快地编译代码。
那么下一步是什么?
我们的主要优先事项之一是简化 JS 和 WebAssembly 的组合。但是,两种语言之间的函数调用并不总是很快。事实上,正如我在第一篇关于 WebAssembly 的系列文章中提到的,它们一直以缓慢著称。
现在情况正在改变,正如你所见。
这意味着,在最新版本的 Firefox Beta 中,JS 和 WebAssembly 之间的调用速度比非内联 JS 到 JS 函数调用更快。太棒了! 🎉
所以这些调用现在在 Firefox 中很快。但,与往常一样,我不只是想告诉你这些调用很快。我想解释一下我们是如何让它们变快的。所以让我们看看我们如何改进了 Firefox 中每种类型的调用(以及改进了多少)。
但首先,让我们看看引擎最初是如何进行这些调用的。(如果你已经了解引擎如何处理函数调用,你可以跳到优化部分。)
函数调用是如何工作的?
函数是 JavaScript 代码的重要组成部分。一个函数可以做很多事情,比如
- 分配作用域在函数内的变量(称为局部变量)
- 使用浏览器内置的函数,例如
Math.random
- 调用你在代码中定义的其他函数
- 返回一个值
但这实际上是如何运作的?写这个函数是如何让机器做你真正想要做的?
正如我在第一篇 WebAssembly 系列文章中解释的,程序员使用的语言(比如 JavaScript)与计算机理解的语言非常不同。为了运行代码,我们需要将下载的 .js 文件中的 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 到内置函数的调用更快。
我们进行的所有优化都是为了让引擎的工作更容易。改进可以分为两组
- 减少簿记——这意味着消除组织堆栈帧的无用工作
- 消除中间人——这意味着在函数之间采取最直接的路径
让我们看看这些改进是如何发挥作用的。
优化 WebAssembly » JavaScript 调用
当引擎遍历你的代码时,它必须处理使用两种不同语言的函数,即使你的代码都是用 JavaScript 编写的。
其中一些函数——那些在解释器中运行的函数——被转换为一种称为字节码的东西。这比 JavaScript 源代码更接近机器代码,但它还不是机器代码(并且解释器负责执行工作)。这运行起来相当快,但还没有达到最佳速度。
其他函数——那些被频繁调用的函数——通过即时编译器 (JIT) 直接转换为机器代码。当这种情况发生时,代码不再通过解释器运行。
所以我们有使用两种语言的函数;字节码和机器代码。
我认为这些使用不同语言的函数就像在我们电子游戏中位于不同的大陆上。
引擎需要能够在这两个大陆之间来回切换。但当它进行这种跨大陆跳转时,它需要一些信息,比如它从另一个大陆的哪个地方离开(它需要回到那里)。引擎还希望将它需要的帧分开。
为了组织它的工作,引擎会获取一个文件夹并将它旅程所需的信息放入一个口袋中——例如,它从哪里进入该大陆。
它将使用另一个口袋来存储堆栈帧。当引擎在这个大陆上积累越来越多的堆栈帧时,这个口袋会膨胀。
旁注:如果你查看 SpiderMonkey 中的代码,这些“文件夹”被称为激活。
每次切换到不同的大陆时,引擎都会启动一个新的文件夹。唯一的问题是,要启动一个文件夹,它必须通过 C++。而通过 C++ 会带来巨大的成本。
这就是我在第一篇关于 WebAssembly 的系列文章中提到的跳板。
每次你必须使用这些跳板之一时,你都会浪费时间。
在我们的大陆比喻中,这就像每次在两个大陆之间旅行时,都必须在跳板点进行强制性中途停留。
那么,当使用 WebAssembly 时,这为什么会让事情变慢呢?
当我们第一次添加 WebAssembly 支持时,我们为它使用了不同类型的文件夹。因此,即使 JIT 编译的 JavaScript 代码和 WebAssembly 代码都被编译并使用机器语言,我们还是将它们视为使用不同的语言。我们把它们当作是在不同的大陆上。
这在两个方面造成了不必要的成本
- 它创建了一个不必要的文件夹,并带来了随之而来的设置和拆卸成本
- 它要求通过 C++ 进行跳板(以创建文件夹并进行其他设置)
我们通过将代码泛化以对 JIT 编译的 JavaScript 和 WebAssembly 使用相同的文件夹来修复这个问题。这就像我们将这两个大陆推到一起,这样你就无需离开大陆了。
有了它,从 WebAssembly 到 JS 的调用速度几乎与 JS 到 JS 的调用一样快。
不过,我们还需要做一些工作来加快反向的调用速度。
优化 JavaScript » WebAssembly 调用
即使在 JIT 编译的 JavaScript 代码的情况下,JavaScript 和 WebAssembly 虽然使用相同的语言,但它们仍然使用不同的约定。
例如,为了处理动态类型,JavaScript 使用了一种称为装箱的东西。
由于 JavaScript 没有显式的类型,因此需要在运行时确定类型。引擎通过为值附加一个标签来跟踪值的类型。
就好像 JS 引擎在该值周围放了一个盒子。盒子包含一个标签,指示该值的类型。例如,末尾的零表示整数。
为了计算这两个整数的总和,系统需要移除该盒子。它移除 a 的盒子,然后移除 b 的盒子。
然后它将未装箱的值加在一起。
然后它需要将该盒子加回到结果周围,以便系统知道结果的类型。
这将你期望的 1 个操作变成了 4 个操作……因此,在不需要装箱的情况下(例如静态类型语言),你不希望添加这种开销。
旁注:JavaScript JIT 在许多情况下可以避免这些额外的装箱/拆箱操作,但在一般情况下,如函数调用,JS 需要回退到装箱。
这就是 WebAssembly 期望参数为未装箱的原因,也是它不装箱返回值的原因。WebAssembly 是静态类型的,因此它不需要添加这种开销。WebAssembly 还期望值在特定位置传递——在寄存器中,而不是 JavaScript 通常使用的堆栈中。
如果引擎获取从 JavaScript 获取的参数(包装在盒子中),并将其传递给 WebAssembly 函数,则 WebAssembly 函数将不知道如何使用它。
因此,在将参数传递给 WebAssembly 函数之前,引擎需要拆箱值并将它们放入寄存器中。
为此,它将再次遍历 C++。因此,即使我们不需要通过 C++ 来设置激活,我们仍然需要这样做来准备值(从 JS 到 WebAssembly)。
转向这种中间层是一个巨大的成本,尤其是对于不太复杂的事情而言。因此,如果我们能完全去除中间层,那就更好了。
这就是我们所做的。我们获取了 C++ 运行的代码——入口存根——并使其直接可从 JIT 代码调用。当引擎从 JavaScript 到 WebAssembly 时,入口存根会拆箱值并将它们放在正确的位置。通过这样做,我们消除了 C++ 跳板。
我认为这就像一张备忘单。引擎使用它,这样它就不必去 C++。相反,当它在调用 JavaScript 函数和 WebAssembly 被调用方之间进行切换时,它可以立即拆箱值。
所以这使得从 JavaScript 到 WebAssembly 的调用变得很快。
但在某些情况下,我们可以使其更快。实际上,在许多情况下,我们可以使这些调用比 JavaScript » JavaScript 调用更快。
更快的 JavaScript » WebAssembly:单态调用
当 JavaScript 函数调用另一个函数时,它不知道另一个函数期望什么。因此,它默认将东西放入盒子中。
但是,当 JS 函数知道它每次都以相同类型的参数调用特定函数时会怎样?然后,该调用函数可以提前知道如何以被调用方想要的方式打包参数。
这是广义的 JS JIT 优化(称为“类型特化”)的一个实例。当函数被特化时,它确切地知道它所调用的函数期望什么。这意味着它可以完全按照另一个函数想要的方式准备参数……这意味着引擎不需要备忘单,也不必花费额外的精力来拆箱。
这种调用(每次调用相同的函数)称为单态调用。在 JavaScript 中,为了使调用成为单态调用,你需要每次都使用完全相同类型的参数来调用该函数。但由于 WebAssembly 函数具有显式类型,因此调用代码不需要担心类型是否完全相同——它们将在传入时进行强制转换。
如果你可以编写代码,使 JavaScript 始终将相同的类型传递给相同的 WebAssembly 导出函数,那么你的调用速度将会非常快。实际上,这些调用比许多 JavaScript 到 JavaScript 调用更快。
未来的工作
只有一种情况,从 JavaScript » WebAssembly 的优化调用不会比 JavaScript » JavaScript 更快。那就是当 JavaScript 内联了一个函数时。
内联背后的基本思想是,当你有一个函数反复调用同一个函数时,你可以采取更大的捷径。与其让引擎去与那个其他函数进行沟通,编译器可以简单地将那个函数复制到调用函数中。这意味着引擎不必去任何地方——它可以就地停留并继续计算。
我认为这就像被调用方函数将自己的技能传授给调用方函数。
当一个函数运行很多次(也就是“热点”)并且它所调用的函数相对较小时,JavaScript 引擎就会进行这种优化。
我们当然可以在未来的某个时候添加支持将 WebAssembly 内联到 JavaScript 中,这也是让这两种语言在同一个引擎中协同工作的原因。这意味着它们可以使用相同的 JIT 后端和相同的编译器中间表示,因此它们可以以一种在跨不同引擎分割的情况下无法实现的方式进行交互。
优化 WebAssembly » 内建函数调用
还有一种调用比预期慢:当 WebAssembly 函数调用内建函数时。
内建函数是浏览器提供给你的函数,例如 Math.random
。很容易忘记它们只是像其他任何函数一样被调用的函数。
有时内建函数在 JavaScript 本身中实现,在这种情况下它们被称为自托管。这可以使它们更快,因为这意味着你不需要遍历 C++:一切都只是在 JavaScript 中运行。但有些函数在 C++ 中实现时速度更快。
不同的引擎在哪些内建函数应该用自托管 JavaScript 编写,哪些应该用 C++ 编写方面做出了不同的决定。引擎通常会对单个内建函数混合使用这两种方法。
在内建函数用 JavaScript 编写的情况下,它将受益于我们上面讨论过的所有优化。但是,当该函数用 C++ 编写时,我们将回到不得不使用跳板。
这些函数被大量调用,因此你希望对它们的调用进行优化。为了使其更快,我们添加了一个特定于内建函数的快速路径。当你将内建函数传递给 WebAssembly 时,引擎会看到你传递给它的内容是其中一个内建函数,此时它知道如何走快速路径。这意味着你不需要遍历你否则会遍历的跳板。
这有点像我们在通往内建函数大陆的路上架了一座桥。如果你从 WebAssembly 到内建函数,就可以使用这座桥。(旁注:即使在图中没有显示,JIT 已经对这种情况进行了优化。)
通过这样做,对这些内建函数的调用比以前快得多。
未来的工作
目前,我们仅支持对大多数数学内建函数进行这种优化。这是因为 WebAssembly 目前只支持整数和浮点数作为值类型。
这对数学函数很有用,因为它们处理数字,但对其他函数(如 DOM 内建函数)就不那么好了。因此,目前,如果你想调用其中一个函数,你必须通过 JavaScript 进行调用。这就是 wasm-bindgen 为你做的。
但是 WebAssembly 即将获得 更灵活的类型。当前提案的实验性支持已经在 Firefox Nightly 中完成,位于首选项 javascript.options.wasm_gc
后面。一旦这些类型到位,你将能够直接从 WebAssembly 调用这些其他内建函数,而无需通过 JS。
我们为优化数学内建函数而建立的基础设施可以扩展到用于其他内建函数。这将确保许多内建函数都能达到最佳速度。
但是,仍然有几个内建函数需要通过 JavaScript 进行调用。例如,如果这些内建函数的调用方式就好像它们使用 new
一样,或者它们使用 getter 或 setter。这些剩余的内建函数将通过 主机绑定提案 解决。
结论
这就是我们在 Firefox 中如何使 JavaScript 和 WebAssembly 之间的调用速度变得很快,并且你可以预期其他浏览器很快也会这样做。
感谢
感谢 Benjamin Bouvier、Luke Wagner 和 Till Schneidereit 的投入和反馈。
关于 Lin Clark
Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。
26 条评论