查看原文
其他

【第1451期】在 JavaScript 和 WebAssembly 之间调用执行速度终于快了

前端开发-晓 前端早读课 2019-09-12

前言

WebAssembly可能离的有点远,做个大概的了解。今日早读文章由@前端开发-晓翻译分享。

正文从这开始~~

在 Mozilla,我们希望 WebAssembly 的执行速度能够达到它应该达到的速度。

这与它的设计(有很大的吞吐量)有关。然后我们用一个流基线编译器(streaming baseline compiler)改进加载时间。使用这项技术,我们编译代码的速度比从网络上加载到本地还要快。

那么,接下来呢?

我们项目重点之一 - 很容易地将 JS 和 WebAssembly 结合起来。但是一直以来两种语言之间调用函数并不是很快。实际上,两种语言之间的函数调用速度是出了名的慢,我之前的WebAssembly 系列中讲到过。

如你所见,这一切已经发生了变化。

这意味着在 Firefox 最新 beta 版本中, 在 JS 和 WebAssembly 之间函数调用比非行内 JS 到 JS 函数的调用要快。好哇🎉

因此目前这些调用在 Firefox 很快了。但是,一如既往,我并不仅仅是告诉你这些调用很快。我想要解释我们是如何做到的。因此,让我们看下是如何在 Firefox 中改进每一种调用(嗨还有改进的程度)。

但是,首先我们看下引擎是如何处理这些调用的。(如何你已经知道引擎是如何处理函数调用的,你可以跳过这一部分)

函数调用如何工作的?

函数是 JavaScript 的重点之一。函数可以做很多处理,比如:

  • 函数作用域内的变量赋值(被称为本地变量)

  • 使用浏览器内置的函数,像 Math.random

  • 调用在代码中定义的函数

  • 返回一个值

但是这是如何工作?你编写的函数是如何让机器按你的想法执行?

像我之前的第一个 WebAssembly 系列中解释的那样,使用像 Javascript 编程语言和计算机理解的语言不一样。为了执行代码,我们下载的 .js 文件需要转换为机器能够理解的机器码。

每个浏览器都有内置的翻译器。翻译器有时候被称为 JavaScript 引擎或者是 JS 运行时。然而,这些引擎现在也能处理 WebAssembly ,这样的术语可能混淆。在这篇文章中,我们称它为引擎。

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

  • Chrome 浏览器有 V8

  • Safari 有 JavaSciptCore(JSC)

  • Edge 有 Chakra

  • Firefox 有 SpiderMonkey


尽管每种引擎不一样,但它们都适用于同样的总体思路。


当浏览器遇到一些 JavaScipt 代码,它会启动引擎运行代码。引擎会用自己的方式,到达需要调用的函数直到文件结束。


假设我们想玩康威的生活游戏。引擎为我们渲染画面,但是事实证明并没有那么简单…

因此引擎检查下一个函数,但是下一个函数将会通过调用更多的函数发送给引擎更多的人物。

引擎继续到嵌套的任务,知道得到函数的返回。


然后它以相反的方向返回到每一个经过的函数。

如果引擎之前的步骤正确 - 如果给了正确的函数正确的参数,能够用它自己的方式回到起始函数 - 它需要追踪一些信息。

它通过使用一种被称为栈帧(调用帧)的方式实现的。每个函数对应一张表单,其中有函数的参数,还有返回值的地址,并且包含这个函数创建的本地变量。

它通过将这些带有表单的纸张放在一个栈里面来追踪它们。栈顶的纸张是当前函数正在处理的。当处理完一个函数,丢掉函数对应的表单。因为是一个栈,有一张在栈最下面的一张纸。我们需要返回值到这个地方。

这个堆栈被称为调用堆栈

引擎建立这个堆栈。随着函数调用,帧被添加到栈中。随着函数返回,帧被从栈中移除。一直保持这种变化,直到一切帧从栈中弹出。

这就是函数调用基本的工作方式。目前,让我们看下是为什么在 JavaScript 和 WebAssembly 之间的函数调用如此之慢,并且谈下我们是如何在 Firefox 中让它变的很快。

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

在最新的 Firefox Nightly 版本中,我们优化了调用 - JavaScript 到 WebAseembly 和 WebAssembly 到 JavaScript。我们也让 WebAssembly 到内置函数的调用很快。

我们所有做的优化是让引擎的工作变得简单。改进可以分为两部分:

  • 减少工作薄计(减少帧的维护) - 意味着摆脱不必要组织栈帧的工作。

  • 砍掉中介 - 函数之间调用走最直接的路径


让我们看下都在哪里派上了用场。

优化 WebAssembly 到 JavaScript 的调用

当引擎经过你的代码,它不得不处理有两种语言编写的函数 - 即使你的函数都是用 JavaScript 编写的。

那些运行在解释器中的代码 - 被转换为字节码。这是一种比 JavaScript 跟接近机器码的一种源码,但并不是机器码。这运行的相当快,但是并没有达到理想的状态。

其他的一些函数 - 那些被频繁调用的 - 被JIT(just-in-time)编译器直接转换为机器码。这些被转换为机器码的代码不会在解释器中运行。

因此我们有两种语言编写的函数;字节码和机器码。

我把这些由两种语言编写的函数看作我们游戏中不同的大洲。

引擎需要能够在这些大洲之间来回穿梭。但是当它在这些大洲之间来回跳跃的时候,需要一些信息,比如:另一个大洲的相对位置(它需要跳回来)。引擎也会按需要分离这些帧(引擎也需要在帧与帧之间来回穿梭)。

为了组织工作,引擎会创建一个文件夹,然后把需要的信息放在旅行的时候的口袋里 - 例如:它从哪里进入大陆。

它将会使用口袋去存储栈帧,口袋会随着引擎在大洲上产生越来越多的栈帧扩大。

每当它切换到一个新的大洲,引擎会新建一个文件夹。新建一个文件夹唯一的问题是,它必须通过 C++。通过C++ 增加大量成本。

这是一个在我的第一个 WebAssembly 系列中谈到的一个蹦床运动。

每次你都必须使用一个蹦床,浪费了时间。

在我们的大洲游戏比喻中,在每趟旅行两个大洲之间的蹦床点都有一个强制性的短暂的停留。

那么,这些是如何让和 WebAssembly 一起工作时变慢的?

当我们第一次添加 WebAssembly 的支持时,我们有不同类型的文件夹。因此尽管经过 JIT 的代码和 WebAssembly 代码都被编译为机器语言,但我们把它们看作不同的语言。我们将它们看作是在分割的大洲上面。

这样的花费是不必要的,主要体现在两个方面:

  • 它创建了一个不必要的文件夹,和基于此的设置和销毁

  • 它需要通过 C++ 来做蹦床运动(创建文件夹及其他一些设置)


我们通过为 JIT 过的代码和 WebAssembly 的代码归纳为一个文件夹来修复这个问题。就像是我们将两个大洲组合在了一起,让你在这两个大洲之间切换不需要蹦床。


通过这项技术 WebAssembly 调用 JS 的函数几乎和 JS 调用 JS 函数一样快。

尽管我们也使用了其他的小技术来加速调用。

优化 JavaScript » WebAssembly 的函数调用

尽管经过 JIT 的 JavaScript 代码,和 WebAssembly 说同样的语言,但它们仍然有不同的习俗。

比如,为了处理动态类型,JavaScript 使用了以一种称为“装箱”的操作。

因为 JavaScript 中变量没有明确的类型,类型需要在运行时确定。引擎通过为值添加一个标志,来追踪值的类型。

就好像 JavaScript 在值周围放了一个箱子,箱子包含那个代表值类型的标志。例如,末尾的 0 代表整型。

为了计算两个整数的和,系统需要移除箱子。比如,为变量 a 移除箱子,然后为变量 b 移除箱子。

然后将两个移除箱子的值加在一起。

然后再在所求值的周围把箱子加回去,以便系统知道所求结果的类型。

这将你期望的1个操作变成了4个操作.. 即使在某些情况下,你并不需要“装箱”操作(比如静态类型的语言),不想让这成为负担。

旁注:JavaScript JITS 在很多情况下可以避免这种 “封箱解箱” 的操作,但在一般情况下,比如函数调用,需要回到”封箱“操作。

这就是为什么 WebAssembly 期望”解箱“ 过的参数,和不”封箱“函数返回值。因为 WebAssembly 是静态类型语言,它没必要添加这一开销。WebAssembly 也期望传的值在特定的地方 - 寄存器,而不是 JavaScript 常用的栈里面。

如果引擎获取一个来自 JavaScript 的参数,用箱子封装一下,并把它给 WebAssembly 函数,WebAssembly 并不知道如何使用它。

因此,在将参数给 WebAssembly 之前,引擎需要”解箱“这个值,然后放在寄存器里面。

为了执行这个步骤,会再一次使用 C++。尽管我们不需要通过 C++ 将蹦床设置为激活,仍然需要为传递的值做一些准备工作(从 JavaScript 到 WebAssembly)。

来到中间人这里是一个很大的开销,尤其是对于那些没那么复杂的。因此,减少中间商会更好。

这就是我们做的事情。我们把 C++ 运行的代码 - 入口存根,让它直接被 JIT 代码调用。入口存根”解箱值“然后放在正确的地方(寄存器)。通过这样做,我们摆脱了 C++ 的蹦床运动。

我把这看作一个备忘录,引擎不用去 C++ 就可以使用。相反,当引擎在 WebAssembly 调用 JavaScript 函数的时候,会”解箱“值。

因此,让 JavaScript 到 WebAssebly 的调用变得快了。

但在某些情况下,可以更快。事实上,我们可以做到,在某些情况下,JavaScript 到 WebAssembly 的调用比 JavaScript 到 JavaScript 的调用还快。

更快 JavaScript » WebAssembly: 单一类型的调用

当一个 JavaScript 调用另一个 JavaScript 函数的时候,它不知道另一个期望什么样的参数。因此,默认对传入的参数做“封箱”操作。

但是,如果 JavaScript 函数知道它每次调用的函数每次传入的参数类型都是一样的会怎么样?JavaScript 函数就可以提前按所期望的方式打包参数。

这是通用 JS JIT 优化的实例之一 - 类型特殊化(type specialization)。 当一个函数特殊化,它能确切知道调用的函数期望什么类型的参数。这意味着它可以提前准备参数…,意味着引擎不再需要备忘单和在“解箱”上面花费额外的开销。

这种调用 - 每次都调用同样的函数 - 被称为“单一状态的调用”。在 JavaScript 中,对于一个单一状态的调用,你需要每次用相同类型的参数调用这个函数。但是 WebAssembly 函数有明确的类型,调用代码不需要担心参数类型是否一致 - 它们会用强迫的方式让你每次都传相同类型的参数。

如果你能组织你的代码始终用相同类型的参数,调用 WebAssembly 导出的函数,那么你的调用将会非常快。实际上,这些调用比很多 JavaScript 调用 JavaScript 还要快。

未来的工作

这里有一个例外的情况,优化过的 JavaScript 》》WebAssembly 并不比 JavaScript 》》JavaScript 快。就是 JavaScript 有内联化函数的时候。

内联化基本的概念是当你有一个函数一遍又一遍的调用相同的函数时,你可以有更大的捷径。编译器直接复制一份放在调用的地方,而不是让 引擎去和其他的函数沟通。这意味着引擎每必要到处跑 - 自需要待在原地,执行计算。

我把这看作被调用函数将自己的技能教给了调用函数。

当一个函数被调用多次时,这是一个 JavaScript 引擎所做的优化 - 当它“hot”- 并且当函数调用次数相对少的时候。

我们很明确要在未来对内联化的 WebAssembly 到 JavaScript 中添加支持,并且这也是为什么两种语言在一个引擎上工作的很好的原因。这意味着在后台它们可以使用同意的 JIT 和相同的编译器中间表示形式,因此它们之间可以进行交互操作,如果它们分割在不同的引擎,交互操作根本不可能。

优化 WebAssembly » 内置函数 调用

这里有一种调用比较慢:当 WebAssembly 函数调用 JS 内置函数时。

内置函数 是浏览器提供的, 像 Math.random。很容易忘记它们也是可以被调用的普通函数。

有时候内置函数是由 JavaScript 本身实现的,这种情况被称为自托管(self-hosted)。这可以让它们执行的很快,因为意味着你不需要通过 C++:所有的都是通过 JavaScript 执行的。但是有些函数只是在用 C++ 实现的时候执行的更快。

不同的引擎对于哪些内置函数用 JS 实现,哪些由 c++ 实现有自己的策略。引擎经常混合使用两种语言编写内置函数。

在用 JavaScript 编写的内置函数的情况下,会受益于我们谈论的所有的优化。但是使用 C++ 编写的函数,我们会后退不得不使用蹦床。

这些函数会被调用很多次,因此你想要调用优化。为了让调用更快些,我们为内置的函数添加了特定的路径。当你传递一个内置的函数到 WebAssembly 中,引擎会发现你传递的是一个内置函数,这个时候它会知道如何获取快速路径。这意味着你没必要通过蹦床。

就好像我们建造了一个通往内置大洲的桥。如果你从 WebAssembly 到 内置函数你可以使用那个桥。(旁注:JIT 已经为这种情况做了优化,尽管没在图上显示。)

使用这项技术,调用内置函数比原先更快了。

未来的工作

目前唯一支持的仅限于 math 的内置函数。因为 WebAssembly 当前只支持整型和浮点型作为值类型。

math 类的函数工作的很好,因为它们都处理的是数字,但其他内置的函数如 DOM 类的工作并不好。因此当前你想调用它们其中一个函数,你不得不使用 JavaScript。这就是 wasm-bindgen 做的事情。

但是 WebAssembly 会马上在更多类型上面做优化。当前的提案实验性支持已经在 FireFox Nightly 版本中了 - 通过 javascript.options.wasm_gc 开启。一旦这些类型获得支持,你将能够直接从 WebAssembly 调用,而不用使用 JS。

我们实施的优化 Math 内置函数的基础设施可以扩展到其他的内置函数,这将会保证很多内置函数更快。

仍然有一些内置函数需要通过 JavaScript,例如,调用这些内置函数,如果它们使用了 new 关键字或者如果它们使用了 getter 或者 setter。剩余的内置函数将会通过 宿主绑定提案解决。

结论

这就是我们在 FireFox 中关于 JavaScript 和 WebAssembly 之间调用所做的优化,你应该会很快在其他的浏览器中看到了。

关于本文
译者:@前端开发-晓
译文:https://zhuanlan.zhihu.com/p/47089990
作者:@Lin Clark
原文:https://hacks.mozilla.org/2018/10/calls-between-javascript-and-webassembly-are-finally-fast-%F0%9F%8E%89/

最后,为你推荐


【第1449期】WebAssembly 后 MVP 时代的未来:卡通技能树


【图书】深入浅出WebAssembly


【活动】2018 ngChina 开发者大会,杭州见!!


【活动】第13届D2前端技术论坛,2019年1月6日杭州见

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存