JavaScript 和 WebAssembly 之间的调用终于快了 🎉

在 Mozilla,我们希望 WebAssembly 尽可能快。

这从它的设计开始,它提供了极高的吞吐量。然后,我们通过流式基线编译器改进了加载时间。有了它,我们可以比网络传输速度更快地编译代码。

那么下一步是什么?

我们的主要优先事项之一是简化 JS 和 WebAssembly 的组合。但是,两种语言之间的函数调用并不总是很快。事实上,正如我在第一篇关于 WebAssembly 的系列文章中提到的,它们一直以缓慢著称。

现在情况正在改变,正如你所见

这意味着,在最新版本的 Firefox Beta 中,JS 和 WebAssembly 之间的调用速度比非内联 JS 到 JS 函数调用更快。太棒了! 🎉

Performance chart showing time for 100 million calls. wasm-to-js before: about 750ms. wasm-to-js after: about 450ms. JS-to-wasm before: about 5500ms. JS-to-wasm after: about 450ms. monomorphic JS-to-wasm before: about 5250ms. monomorphic JS-to-wasm before: about 250ms. wasm-to-builtin before: about 6000ms. wasm-to-builtin before: about 650ms.

所以这些调用现在在 Firefox 中很快。但,与往常一样,我不只是想告诉你这些调用很快。我想解释一下我们是如何让它们变快的。所以让我们看看我们如何改进了 Firefox 中每种类型的调用(以及改进了多少)。

但首先,让我们看看引擎最初是如何进行这些调用的。(如果你已经了解引擎如何处理函数调用,你可以跳到优化部分。)

函数调用是如何工作的?

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

  • 分配作用域在函数内的变量(称为局部变量)
  • 使用浏览器内置的函数,例如 Math.random
  • 调用你在代码中定义的其他函数
  • 返回一个值

A function with 4 lines of code: assigning a local variable with let w = 8; calling a built-in function with Math.random(); calling a user-defined function named randGrid(); and returning a value.

但这实际上是如何运作的?写这个函数是如何让机器做你真正想要做的?

正如我在第一篇 WebAssembly 系列文章中解释的,程序员使用的语言(比如 JavaScript)与计算机理解的语言非常不同。为了运行代码,我们需要将下载的 .js 文件中的 JavaScript 代码翻译成计算机理解的机器语言。

每个浏览器都有一个内置的翻译器。这个翻译器有时被称为 JavaScript 引擎或 JS 运行时。然而,这些引擎现在也处理 WebAssembly,所以这些术语可能令人困惑。在这篇文章中,我将把它简单地称为引擎。

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

  • Chrome 有 V8
  • Safari 有 JavaScriptCore (JSC)
  • Edge 有 Chakra
  • 而在 Firefox 中,我们有 SpiderMonkey

尽管每个引擎都不同,但许多通用理念都适用于它们。

当浏览器遇到一些 JavaScript 代码时,它会启动引擎来运行该代码。引擎需要遍历代码,访问所有需要调用的函数,直到到达最后。

我认为这就像一个角色在电子游戏中进行任务一样。

假设我们要玩生命游戏。引擎的任务是为我们渲染生命游戏棋盘。但事实证明,这并不简单…

Engine asking Sir Conway function to explain life. Sir Conway sends the engine to the Universum Neu function to get a Universe.

所以引擎会转到下一个函数。但下一个函数会通过调用更多函数,让引擎进行更多任务。

Engine going to Universum Neu to ask for a universe. Universum Neu sends the engine to Randgrid.

引擎不断地进行这些嵌套任务,直到它到达一个只返回结果的函数。

Rnadgrid giving the engine a grid.

然后它可以以相反的顺序返回到它与之对话的每个函数。

The engine returning through all of the functions.

如果引擎要正确地执行此操作(如果它要将正确的参数传递给正确的函数,并能够一直返回到起始函数),它需要跟踪一些信息。

它使用称为堆栈帧(或调用帧)的东西来实现这一点。它基本上就像一张纸,上面写着要传入函数的参数,说明返回值应该去哪里,以及跟踪函数创建的任何局部变量。

A stack frame, which is basically a form with lines for arguments, locals, a return value, and more.

它通过将这些纸条放入一个堆栈中来跟踪它们。它当前正在处理的函数的纸条在最上面。当它完成该任务时,它会扔掉纸条。因为这是一个堆栈,所以在下面还有一张纸条(现在已经因为扔掉旧纸条而显露出来)。那就是我们要返回的地方。

这个帧堆栈称为调用堆栈。

a stack of stack frames, which is basically a pile of papers

引擎在运行时构建了这个调用堆栈。当函数被调用时,帧会被添加到堆栈中。当函数返回时,帧会被从堆栈中弹出。这个过程会一直持续,直到我们完全返回到底部,并将所有内容都弹出堆栈。

所以这就是函数调用工作原理的基础知识。现在,让我们看看是什么让 JavaScript 和 WebAssembly 之间的函数调用变慢,以及我们在 Firefox 中是如何让它变快的。

我们是如何让 WebAssembly 函数调用变快的

通过 Firefox Nightly 的最新工作,我们优化了两个方向的调用——从 JavaScript 到 WebAssembly 以及从 WebAssembly 到 JavaScript。我们还让从 WebAssembly 到内置函数的调用更快。

我们进行的所有优化都是为了让引擎的工作更容易。改进可以分为两组

  • 减少簿记——这意味着消除组织堆栈帧的无用工作
  • 消除中间人——这意味着在函数之间采取最直接的路径

让我们看看这些改进是如何发挥作用的。

优化 WebAssembly » JavaScript 调用

当引擎遍历你的代码时,它必须处理使用两种不同语言的函数,即使你的代码都是用 JavaScript 编写的。

其中一些函数——那些在解释器中运行的函数——被转换为一种称为字节码的东西。这比 JavaScript 源代码更接近机器代码,但它还不是机器代码(并且解释器负责执行工作)。这运行起来相当快,但还没有达到最佳速度。

其他函数——那些被频繁调用的函数——通过即时编译器 (JIT) 直接转换为机器代码。当这种情况发生时,代码不再通过解释器运行。

所以我们有使用两种语言的函数;字节码和机器代码。

我认为这些使用不同语言的函数就像在我们电子游戏中位于不同的大陆上。

A game map with two continents—One with a country called The Interpreter Kingdom, and the other with a country called JITland

引擎需要能够在这两个大陆之间来回切换。但当它进行这种跨大陆跳转时,它需要一些信息,比如它从另一个大陆的哪个地方离开(它需要回到那里)。引擎还希望将它需要的帧分开。

为了组织它的工作,引擎会获取一个文件夹并将它旅程所需的信息放入一个口袋中——例如,它从哪里进入该大陆。

它将使用另一个口袋来存储堆栈帧。当引擎在这个大陆上积累越来越多的堆栈帧时,这个口袋会膨胀。

A folder with a map on the left side, and the stack of frames on the right.

旁注:如果你查看 SpiderMonkey 中的代码,这些“文件夹”被称为激活。

每次切换到不同的大陆时,引擎都会启动一个新的文件夹。唯一的问题是,要启动一个文件夹,它必须通过 C++。而通过 C++ 会带来巨大的成本。

这就是我在第一篇关于 WebAssembly 的系列文章中提到的跳板。

每次你必须使用这些跳板之一时,你都会浪费时间。

在我们的大陆比喻中,这就像每次在两个大陆之间旅行时,都必须在跳板点进行强制性中途停留。

Same map as before, with a new Trampoline country on the same continent as The Interpreter Kingdom. An arrow goes from The Interpreter Kingdom, to Trampoline, to JITland.

那么,当使用 WebAssembly 时,这为什么会让事情变慢呢?

当我们第一次添加 WebAssembly 支持时,我们为它使用了不同类型的文件夹。因此,即使 JIT 编译的 JavaScript 代码和 WebAssembly 代码都被编译并使用机器语言,我们还是将它们视为使用不同的语言。我们把它们当作是在不同的大陆上。

Same map with Wasmania island next to JITland. There is an arrow going from JITland to Trampoline to Wasmania. On Trampoline, the engine asks a shopkeeper for folders.

这在两个方面造成了不必要的成本

  • 它创建了一个不必要的文件夹,并带来了随之而来的设置和拆卸成本
  • 它要求通过 C++ 进行跳板(以创建文件夹并进行其他设置)

我们通过将代码泛化以对 JIT 编译的 JavaScript 和 WebAssembly 使用相同的文件夹来修复这个问题。这就像我们将这两个大陆推到一起,这样你就无需离开大陆了。

SpiderMonkey engineer Benjamin Bouvier pushing Wasmania and JITland together

有了它,从 WebAssembly 到 JS 的调用速度几乎与 JS 到 JS 的调用一样快。

Same perf graph as above with wasm-to-JS circled.

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

优化 JavaScript » WebAssembly 调用

即使在 JIT 编译的 JavaScript 代码的情况下,JavaScript 和 WebAssembly 虽然使用相同的语言,但它们仍然使用不同的约定。

例如,为了处理动态类型,JavaScript 使用了一种称为装箱的东西。

由于 JavaScript 没有显式的类型,因此需要在运行时确定类型。引擎通过为值附加一个标签来跟踪值的类型。

就好像 JS 引擎在该值周围放了一个盒子。盒子包含一个标签,指示该值的类型。例如,末尾的零表示整数。

Two binary numbers with a box around them, with a 0 label on the box.

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

Two lines, the first with boxed numbers from the last image. The second with unboxed numbers.

然后它将未装箱的值加在一起。

Three lines, with the third line being the two numbers added together

然后它需要将该盒子加回到结果周围,以便系统知道结果的类型。

Four lines, with the fourth line being the numbers added together with a box around it.

这将你期望的 1 个操作变成了 4 个操作……因此,在不需要装箱的情况下(例如静态类型语言),你不希望添加这种开销。

旁注:JavaScript JIT 在许多情况下可以避免这些额外的装箱/拆箱操作,但在一般情况下,如函数调用,JS 需要回退到装箱。

这就是 WebAssembly 期望参数为未装箱的原因,也是它不装箱返回值的原因。WebAssembly 是静态类型的,因此它不需要添加这种开销。WebAssembly 还期望值在特定位置传递——在寄存器中,而不是 JavaScript 通常使用的堆栈中。

如果引擎获取从 JavaScript 获取的参数(包装在盒子中),并将其传递给 WebAssembly 函数,则 WebAssembly 函数将不知道如何使用它。

Engine giving a wasm function boxed values, and the wasm function being confused.

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

为此,它将再次遍历 C++。因此,即使我们不需要通过 C++ 来设置激活,我们仍然需要这样做来准备值(从 JS 到 WebAssembly)。

The engine going to Trampoline to get the numbers unboxed before going to Wasmania

转向这种中间层是一个巨大的成本,尤其是对于不太复杂的事情而言。因此,如果我们能完全去除中间层,那就更好了。

这就是我们所做的。我们获取了 C++ 运行的代码——入口存根——并使其直接可从 JIT 代码调用。当引擎从 JavaScript 到 WebAssembly 时,入口存根会拆箱值并将它们放在正确的位置。通过这样做,我们消除了 C++ 跳板。

我认为这就像一张备忘单。引擎使用它,这样它就不必去 C++。相反,当它在调用 JavaScript 函数和 WebAssembly 被调用方之间进行切换时,它可以立即拆箱值。

The engine looking at a cheat sheet for how to unbox values on its way from JITland to Wasmania.

所以这使得从 JavaScript 到 WebAssembly 的调用变得很快。

Perf chart with JS to wasm circled.

但在某些情况下,我们可以使其更快。实际上,在许多情况下,我们可以使这些调用比 JavaScript » JavaScript 调用更快。

更快的 JavaScript » WebAssembly:单态调用

当 JavaScript 函数调用另一个函数时,它不知道另一个函数期望什么。因此,它默认将东西放入盒子中。

但是,当 JS 函数知道它每次都以相同类型的参数调用特定函数时会怎样?然后,该调用函数可以提前知道如何以被调用方想要的方式打包参数。

JS function not boxing values

这是广义的 JS JIT 优化(称为“类型特化”)的一个实例。当函数被特化时,它确切地知道它所调用的函数期望什么。这意味着它可以完全按照另一个函数想要的方式准备参数……这意味着引擎不需要备忘单,也不必花费额外的精力来拆箱。

这种调用(每次调用相同的函数)称为单态调用。在 JavaScript 中,为了使调用成为单态调用,你需要每次都使用完全相同类型的参数来调用该函数。但由于 WebAssembly 函数具有显式类型,因此调用代码不需要担心类型是否完全相同——它们将在传入时进行强制转换。

如果你可以编写代码,使 JavaScript 始终将相同的类型传递给相同的 WebAssembly 导出函数,那么你的调用速度将会非常快。实际上,这些调用比许多 JavaScript 到 JavaScript 调用更快。

Perf chart with monomorphic JS to wasm circled

未来的工作

只有一种情况,从 JavaScript » WebAssembly 的优化调用不会比 JavaScript » JavaScript 更快。那就是当 JavaScript 内联了一个函数时。

内联背后的基本思想是,当你有一个函数反复调用同一个函数时,你可以采取更大的捷径。与其让引擎去与那个其他函数进行沟通,编译器可以简单地将那个函数复制到调用函数中。这意味着引擎不必去任何地方——它可以就地停留并继续计算。

我认为这就像被调用方函数将自己的技能传授给调用方函数。

Wasm function teaching the JS function how to do what it does.

当一个函数运行很多次(也就是“热点”)并且它所调用的函数相对较小时,JavaScript 引擎就会进行这种优化。

我们当然可以在未来的某个时候添加支持将 WebAssembly 内联到 JavaScript 中,这也是让这两种语言在同一个引擎中协同工作的原因。这意味着它们可以使用相同的 JIT 后端和相同的编译器中间表示,因此它们可以以一种在跨不同引擎分割的情况下无法实现的方式进行交互。

优化 WebAssembly » 内建函数调用

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

内建函数是浏览器提供给你的函数,例如 Math.random。很容易忘记它们只是像其他任何函数一样被调用的函数。

有时内建函数在 JavaScript 本身中实现,在这种情况下它们被称为自托管。这可以使它们更快,因为这意味着你不需要遍历 C++:一切都只是在 JavaScript 中运行。但有些函数在 C++ 中实现时速度更快。

不同的引擎在哪些内建函数应该用自托管 JavaScript 编写,哪些应该用 C++ 编写方面做出了不同的决定。引擎通常会对单个内建函数混合使用这两种方法。

在内建函数用 JavaScript 编写的情况下,它将受益于我们上面讨论过的所有优化。但是,当该函数用 C++ 编写时,我们将回到不得不使用跳板。

Engine going from wasmania to trampoline to built-in

这些函数被大量调用,因此你希望对它们的调用进行优化。为了使其更快,我们添加了一个特定于内建函数的快速路径。当你将内建函数传递给 WebAssembly 时,引擎会看到你传递给它的内容是其中一个内建函数,此时它知道如何走快速路径。这意味着你不需要遍历你否则会遍历的跳板。

这有点像我们在通往内建函数大陆的路上架了一座桥。如果你从 WebAssembly 到内建函数,就可以使用这座桥。(旁注:即使在图中没有显示,JIT 已经对这种情况进行了优化。)

A bridge added between wasmania and built-in

通过这样做,对这些内建函数的调用比以前快得多。

Perf chart with wasm to built-in circled.

未来的工作

目前,我们仅支持对大多数数学内建函数进行这种优化。这是因为 WebAssembly 目前只支持整数和浮点数作为值类型。

这对数学函数很有用,因为它们处理数字,但对其他函数(如 DOM 内建函数)就不那么好了。因此,目前,如果你想调用其中一个函数,你必须通过 JavaScript 进行调用。这就是 wasm-bindgen 为你做的。

Engine going from wasmania to the JS Data Marshall Islands to built-in

但是 WebAssembly 即将获得 更灵活的类型。当前提案的实验性支持已经在 Firefox Nightly 中完成,位于首选项 javascript.options.wasm_gc 后面。一旦这些类型到位,你将能够直接从 WebAssembly 调用这些其他内建函数,而无需通过 JS。

我们为优化数学内建函数而建立的基础设施可以扩展到用于其他内建函数。这将确保许多内建函数都能达到最佳速度。

但是,仍然有几个内建函数需要通过 JavaScript 进行调用。例如,如果这些内建函数的调用方式就好像它们使用 new 一样,或者它们使用 getter 或 setter。这些剩余的内建函数将通过 主机绑定提案 解决。

结论

这就是我们在 Firefox 中如何使 JavaScript 和 WebAssembly 之间的调用速度变得很快,并且你可以预期其他浏览器很快也会这样做。

Performance chart showing time for 100 million calls. wasm-to-js before: about 750ms. wasm-to-js after: about 450ms. JS-to-wasm before: about 5500ms. JS-to-wasm after: about 450ms. monomorphic JS-to-wasm before: about 5250ms. monomorphic JS-to-wasm before: about 250ms. wasm-to-builtin before: about 6000ms. wasm-to-builtin before: about 650ms.

感谢

感谢 Benjamin Bouvier、Luke Wagner 和 Till Schneidereit 的投入和反馈。

关于 Lin Clark

Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。

更多 Lin Clark 的文章……


26 条评论

  1. Muhammad Adeel

    Mozilla 建议 Web 开发人员在未来使用什么语言编写 WebAssembly 代码?Rust?还是其他语言?

    2018 年 10 月 8 日 下午 10:33

    1. Lin Clark

      目前,Rust 工具链是最符合人体工程学的,而且 Rust 非常适合作为 WebAssembly 的目标语言,因此我们确实推荐它。

      2018 年 10 月 19 日 上午 9:26

  2. Guillaume Pelletier

    感谢你的文章,很有趣!

    在 wasm 中重新实现 C++ 内置函数是否可以进一步优化 wasm 到内置函数调用的速度?同时,(js -> wasm-内置) 函数调用的速度与 (js -> cpp-内置) 函数调用的速度相比如何?

    谢谢!
    Guillaume

    2018 年 10 月 8 日 下午 12:13

    1. Lin Clark

      关于这个问题已经有一些讨论,但有一些非常细微的权衡。一个问题是,许多内置函数直接对内存中的对象进行操作,而 wasm 无法访问这些对象。

      2018 年 10 月 19 日 上午 09:30

  3. Erkin Alp Güney

    为什么内置调用比对用户定义函数的调用速度慢?

    2018 年 10 月 8 日 下午 12:24

  4. Odin Hørthe Omdal

    一如既往的出色!我喜欢这些帖子。

    2018 年 10 月 8 日 下午 15:26

  5. Antwan

    精彩的帖子!非常有趣,而且表达得很好。

    2018 年 10 月 8 日 下午 15:30

  6. Ananth

    写得真好。它在介绍一些新手可能不了解的概念和深入探讨细节之间取得了平衡。

    2018 年 10 月 8 日 下午 22:11

  7. skierpage

    “如果你可以编写代码,使 JavaScript 始终将相同类型传递给同一个 WebAssembly 导出函数,那么你的调用速度将非常快。”
    JS 引擎是在运行时判断出来的吗?你能通过进行 asm.js 风格的类型声明(myNum|0,+myNum)来鼓励这种行为吗?

    2018 年 10 月 8 日 下午 22:52

  8. Taliesin Beynon

    我不敢相信我是第一个评论的人,因为这真的是技术解释领域中非常杰出的作品。我特别为“寻宝之旅”的比喻的成功所折服。它简单、有趣,并且承载了它需要承载的意义。你是想出来的吗?它在其他地方被使用过吗?它可以在其他领域得到很好的应用,例如操作系统中的系统调用。

    2018 年 10 月 9 日 上午 04:40

  9. Sam Tsai

    我只想说,这些图画和整体内容不仅有助于理解 WebAssembly 和 JIT,而且还使诸如调用栈之类的概念再次变得清晰(我似乎需要不断地提醒自己基础知识)。

    2018 年 10 月 9 日 上午 08:02

  10. NIKOLAY MOZGOVOY

    我的天啊,这篇文章真是太全面了,我简直无法想象!

    2018 年 10 月 10 日 上午 02:24

  11. Nipuna Gunathilake

    感谢你又写了一篇有见地的文章。

    我有一个问题。这些更改是否适用于未经 JIT 编译的 JS 代码?我的意思是,来自“解释器王国”的 JS 代码是否仍然需要经过跳板程序?

    2018 年 10 月 11 日 上午 08:29

    1. Lin Clark

      是的,从解释器传入的代码确实需要进行函数设置,但对于从解释器传入 JS JIT 的代码也是如此。当你在解释器中时,设置这些调用的成本可能不会那么明显,因为解释器本身的运行速度已经明显低于 JITed 代码。

      2018 年 10 月 19 日 上午 09:20

  12. daimonicstudio

    干得好!^.^

    2018 年 10 月 11 日 上午 09:48

  13. Eu

    我喜欢你用卡通来解释的方式。保持良好的工作状态。

    2018 年 10 月 11 日 上午 11:17

  14. Jason

    这必须是互联网历史上最好的文章之一。我从这篇文章中学到的东西比大学 4 年学到的还多!你应该为这篇文章感到自豪,@Lin_Clark!

    2018 年 10 月 12 日 上午 06:04

  15. Remington

    很棒的文章!:) 你是怎么画出你的代码卡通的?它们非常形象!

    2018 年 10 月 12 日 下午 19:50

    1. Lin Clark

      谢谢!我使用 Wacom Cintiq 平板电脑和 Photoshop。

      2018 年 10 月 19 日 上午 09:17

  16. 来自 dsibinski.pl 的 Dawid

    很棒的解释!:) 用大陆的比喻来展现一些复杂的概念非常棒 ;) 谢谢!

    2018 年 10 月 13 日 上午 02:36

  17. Paul Hale

    喜欢你的文章,Lin。总是期待着阅读你的文章。

    我是一个 Rust 和 WASM 的狂热粉丝。虽然与这篇文章没有直接关系,但对于任何其他对使用 Rust 构建面向 WASM 的网站感兴趣的人,请查看 Denis Kolodin 的 Yew 存储库…

    https://github.com/DenisKolodin/yew

    这是一个受 Elm 和 ReactJS 启发的 Rust 框架,用于创建多线程 Web 应用程序。我目前正在尝试使用 Rust 构建一个完整的网站,它由自定义的 Yew Web 组件组成,编译成单个轻量级 .wasm 文件。现在还处于早期阶段,但到目前为止,结果看起来很有希望。我只是想在这里推荐一下 Yew 存储库,以防任何阅读这篇文章的人发现它有用。

    Paul

    2018 年 10 月 13 日 上午 07:38

  18. Michael R Basher

    这对新手(我自己)来说非常有趣。理解、认识或只是欣赏 Web 浏览器本身的重要性及其功能,特别是在考虑到长期有用的开发应用程序时,这些应用程序需要遵守浏览器和机器语言的必要条件,这些条件已经存在并不断发展…感谢你 Lin 等杰出人士的技能和经验。(' ',)

    2018 年 10 月 16 日 上午 05:30

  19. Mike Toole

    卡通以及它们所支持的比喻非常出色——它们使我们很容易理解所解释内容的逻辑。
    谢谢。
    Mike

    2018 年 10 月 16 日 上午 11:18

  20. jrjdudue

    嗨,Lin。谢谢你的卡通,但是…我不明白以下内容。

    1. “经过 C++”是什么意思?
    2. 为什么它被称为慢?C++ 是最快的语言之一,Firefox 本身就是用 C++ 编写的。

    2018 年 10 月 18 日 下午 23:02

    1. Lin Clark

      与其说 C++ 本身很慢,不如说是 JIT 生成的代码和 C++ 之间的转换很昂贵。JIT 概述了执行上下文的当前状态,包括哪些值存储在哪些寄存器中。就 JIT 代码而言,被调用的 C++ 代码可能会对状态执行任意操作。因此,它需要在调用 C++ 之前存储该状态,并在之后恢复该状态。

      2018 年 10 月 19 日 上午 09:13

  21. Michael

    有趣且全面的文章!非常感谢 :)

    2018 年 10 月 19 日 上午 02:40

本文的评论已关闭。