在 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 调用内置函数的过程变得更快了。
我们所做的所有优化都是为了让引擎的工作更轻松。 改进分为两组:
减少簿记(bookkeeping) - 这意味着摆脱不必要的工作来组织堆栈帧
切断媒介 - 这意味着在功能之间采取最直接的途径
让我们来看看每个这些都是如何起作用的。
优化 WebAssembly » JavaScript 调用
当引擎运行你的代码时,它必须处理两种不同语言的函数 - 即使你的代码都是用 JavaScript 编写的。
其中一些 - 在解释器中运行的那些 - 已经变成了字节码。 这比 JavaScript 源代码更接近机器代码,但它不是机器代码(并且由解释器来处理)。 运行起来非常快,但速度还没达到它的极限。
其他函数 - 那些被大量调用的函数 - 由即时编译器(JIT)直接转换为机器代码。在这种情况下,代码不再通过解释器运行。
所以我们有两种语言的函数;字节码和机器码。
我把这些使用不同语言的不同的函数,视为我们电子游戏中不同的大陆。
引擎需要能够在这些大陆之间来回穿梭。 但是当它在不同大陆之间跳跃时,它需要有一些信息,比如它离开另一个大陆的位置(它需要回到它)。 引擎还想分离它需要的帧。
为了组织其工作,引擎获取一个文件夹,并将其旅行所需的信息放在一个口袋中 - 例如,从它进入大陆的位置。
它将使用另一个口袋来存储堆栈帧。 随着引擎在这个大陆积累越来越多的堆叠框架,口袋将会扩大。
旁注:如果您查看 SpiderMonkey 中的代码,这些“文件夹”称为激活(activations)。
每次切换到不同的大陆时,引擎都会启动一个新文件夹。 唯一的问题是,要启动一个文件夹,它必须通过 C++ 执行。 而通过 C++ 执行会增加成本。
这是我在 WebAssembly 的第一个系列中谈到的蹦床(trampolining)。
每次你必须使用其中一种蹦床,你就会浪费时间。
在我们的大陆比喻中,对于两个大陆之间的每次单程旅行,都必须在 Trampoline Point 进行强制停留。
那么用 WebAssembly 运行是如何导致速度变慢的呢?
当我们首次添加 WebAssembly 时,我们为它提供了一个不同类型的文件夹。 因此,即使 JIT 编译好的 JavaScript 代码和 WebAssembly 代码都被编译成了机器语言,我们仍将它们视为使用不同的语言。 我们把它们看作是在不同的大陆上。
这在两个方面造成了不必要的代价:
- 它会创建一个不必要的文件夹,其中包含设置和拆卸成本
- 它需要通过 C++ 进行蹦床(创建文件夹并进行其他设置)
我们通过将经 JIT 编译的 JavaScript 和 WebAssembly 代码都归纳到相同的文件夹来解决这个问题。 这有点像我们将两个大陆推到一起,这使得你根本不需要离开这个大陆。
有了这个,从 WebAssembly 到 JS 的调用几乎和 JS 到 JS 调用一样快。
不过,我们还有一些工作要做,以加快调用速度。
优化 JavaScript » WebAssembly 调用
即使 JIT 编译 JavaScript 和 WebAssembly 都使用相同的语言,它们也有不同的习惯。 他们有不同的处理方式。
即使是 JIT 编译的 JavaScript 代码,JavaScript 和 WebAssembly 已使用了相同的语言,它们仍然有不同的习惯。
例如,JavaScript 使用了装箱(boxing)来处理动态类型。
因为 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 优化的例子,称为“type specialization”。 当一个函数是专用的时候,它确切地知道它所调用的函数是什么。 这意味着它可以准确地准备其他函数想要的参数......这意味着引擎不再需要备忘清单,也不需要在拆箱上多花力气。
这种调用 - 每次调用相同的函数 - 称为单态调用。 在 JavaScript 中,对于单态调用,每次都需要使用完全相同类型的参数调用该函数。 但是因为 WebAssembly 函数具有显式类型,所以调用代码不需要担心类型是否完全相同 - 它们将在被调用时强制执行。
如果您可以编写代码来让 JavaScript 始终将相同的类型传递给相同的 WebAssembly 导出函数,那么您的调用将非常快。 实际上,这些调用比许多 JavaScript 到 JavaScript 调用要快。
只有一种情况,来自 JavaScript » WebAssembly 的优化调用并不比 JavaScript » JavaScript 快。 那就是当 JavaScript 内联了函数的时候。
内联背后的基本思想是,当你有一个函数是反复地调用同一个函数的时候,你可以采取更多的快捷方式。 编译器可以将该函数复制到调用函数中,而不是让引擎与其他函数通信。这意味着引擎不必去任何地方 - 它可以保持原位并继续计算。
我认为这是“被调用函数”在将其技能传授给“调用函数”。
这是 JavaScript 引擎在运行很多函数时所做的优化 - 当它是“hot”时 - 以及它调用的函数相对较小时。
未来,我们肯定可以把内联 WebAssembly 的支持到 JavaScript,这就是为什么让这两种语言在同一个引擎中运行很好的原因。 这意味着它们可以使用相同的 JIT 后端和相同的编译器中间表示,因此它们可以以一种在不同引擎中分割时无法实现的方式进行互操作。
优化 WebAssembly » 内置函数调用
还有一种调用比它需要的慢:当 WebAssembly 函数调用内置函数时。
内置函数是浏览器为我们提供的函数,如 Math.random。 我们很容易忘记这些只是像任何其他函数一样被调用的函数。
有时内置函数是用 JavaScript 本身实现的,在这种情况下,它们被称为自托管。 这可以使它们运行更快,因为这意味着不必使用 C++:一切都只是在 JavaScript 中运行。 但是有些函数用 C++ 实现会更快。
每个引擎已经就哪些内置函数应该用自托管 JavaScript 编写、哪些应该用 C ++ 编写做出了不同的决策。 并且引擎通常将两者混合用于单个内置。
在使用 JavaScript 编写内置函数的情况下,它将受益于我们上面讨论过的所有优化。 但是当用 C++ 编写该函数时,我们又回到了蹦床问题。
这些函数被大量调用,因此您需要优化对它们的调用。 为了加快速度,我们添加了一个特定于内置插件的快速路径。 当您将内置的内容传递给 WebAssembly 时,引擎会发现您传递的内容是其中一个内置函数,此时它知道如何使用快速路径。 这意味着你不必使用蹦床。
这有点像我们建造了一座通往内部大陆的桥梁。 如果从 WebAssembly 到内置,则可以使用该桥梁(旁注:JIT已经对这种情况进行了优化,即使它没有在图中显示)。
有了这个,对这些内置函数的调用就比以前快多了。
未来的工作
目前我们支持的唯一内置插件仅限于Math函数。 那是因为 WebAssembly 目前只支持整数和浮点值作为值类型。
这对于Math函数很有效,因为它们可以处理数字,但对于 DOM 内置函数等其他函数来说效果不佳。 因此,当您想要调用其中一个函数时,您必须通过 JavaScript 调用。 而那就是 wasm-bindgen 为我们做的。
不过 WebAssembly 很快就会变得越来越灵活。对于当前提案的实验性支持已经在 Firefox Nightly 中使用pref javascript.options.wasm_gc实现。 一旦这些类型到位,您就可以直接从 WebAssembly 调用这些内置函数,而无需通过 JS。
我们为优化 Math 内置函数而设置的基础架构也可以扩展适用于其他内置函数。 这将确保许多内置函数尽可能地变快。
但是仍然有一些内置函数你需要通过 JavaScript 调用。 例如,如果这些内置函数使用 new 调用或者他们使用的是 getter 或 setter。 这些剩余的内置函数将通过宿主绑定( host-bindings)进行处理。
结论
这就是我们在 Firefox 中使 JavaScript 和 WebAssembly 之间的调用变快的实现,你可以期待其他浏览器尽快实现相同效果。