译者:Mactavish(博主本人)
链接:http://www.zcfy.cc/article/4078
原文:https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers
这是 “WebAssembly 以及为什么它这么快” 这个系列的第二部分。如果你还没阅读其他的部分,我们建议你 从头开始阅读。
JavaScript 刚出现时的运行速度是很慢的,多亏了 JIT,它的运行速度变得快了起来。JIT 是如何工作的呢?
JavaScript 是如何在浏览器当中运行的
当开发者将 JavaScript 添加到页面当中,既有一定的目的也会遇到一定的困难。
目标: 告诉电脑要做什么
困难: 计算机和人类说着不同的语言
你说着人类的语言,而计算机使用的则是机器语言。就算你把或者其他的高级编程语言看作是人类的语言,它们也确实是。这些语言设计的初衷是方便人类识别,而不是方便计算机识别。
所以引擎的作用便是将人类语言转化成机器所能理解的东西。
我把这个过程想象成电影《[降临](https://en.wikipedia.org/wiki/Arrival_(film)》中的场景 —— 人类和外星人尝试着互相沟通。
在那部电影里,人类和外星人并不是纯粹地文字对文字地翻译。这两个种族对世界有着不一样的思考方式。对于人类和计算机来说也是一样的。 (我会在下一篇文章中做更多的解释)。
所以人类和计算机的沟通是如何翻译的呢?
在编程中,有两种方式转换成机器语言——使用解释器或者编译器。
使用解释器,这个翻译几乎是一行紧接着接着一行的。
另一方面,编译器是不会逐行翻译的。它会在运行前提起翻译并且记录下来。
这两种翻译方式各有其利弊。
解释器的利与弊
解释器很快就能准备好并且运行。在运行代码之前,解释器不必将整个编译过程都进行完。它在翻译完第一行时就开始执行。
正因为如此,编译器和 JavaScript 就好像一拍即合。对于 web 开发者来说,让他们能够快速的运行他们的代码这点是非常重要的。
这就是为什么浏览器在一开始使用 JavaScript 解释器。
但是在你多次使用解释器解释运行相同的代码时,它的弊端就出现了。比如,使用循环。它总是会反复地去翻译相同的东西。
编译器的利与弊
编译器对此有着相反的权衡。
它启动需要更多的时间,因为它必须在开始时就完成整个编译阶段。但是循环里的代码会运行地更快,因为它不需要去每次重复地翻译那个循环。
另外一个不同的地方是编译器会花一些时间对代码做出编辑,让代码能够更快地运行。这些编辑的行为被称之为优化。
解释器是在运行时工作的,所以它无法在翻译阶段花费很多时间去优化。
即时编译器:两全其美
为了摆脱解释器的重复翻译的低效行为,浏览器开始将编译器混入其中。
不同的浏览器有着不同的实现,但是基本思想都是一样的。他们会给 They added a new part to the JavaScript 引擎添加一个新的部分叫做监视器(也称之为分析器)。监视器会观察这些代码的运行,然后记录这些代码运行的次数以及他们使用的类型。
一开始,监视器会观察所有经过解释器的东西。
如果其中一行代码运行了几次,这段代码称之为温和的,如果它运行了很多次,那么它被称之为激烈的。
基线编译器
当一个函数开始变得温和起来,JIT 会将它发送至编译器,然后将编译结果储存下来。
这个函数的每一行都会被编译到 “存根” 里。 这些存根根据行号和变量类型来编入索引。(稍后我会解释这一点的重要性)。如果监视器发现有着同样变量类型的同一段代码被重复执行了,那么它会将已经编译好的版本直接提取出来。
这有助于代码的快速运行。但是正如我所说的,编译器还能够做更多的事情。它会花费一些时间来找出最有效的运行方式,从而达到优化。
基线编译器会进行一些优化(我会在下面给出一些例子)。这个过程不会花费很多时间,因为它不会将代码的执行时间拖得太久。
然后,如果代码被执行的频率很高,执行花费的时间很多,那么花费一些额外的时间来对它进行优化是非常值得的。
优化编译器
当一部分代码出现的频率非常高时,监视器会将它们发送至优化编译器。这会创建一个更快的版本,并存储起来。
为了创建出一个更快的版本,优化编译器必须做出一些假设。
打个比方,如果它能假设所有通过某个特定的构造器创建出来的对象都拥有同样的结构,也就是说,他们总是拥有相同的属性名,属性被添加的顺序也相同,那么它就能够走捷径。
优化编译器会使用监视器观察代码执行收集到的信息来做出决定。如果在之前所有的循环中的条件都为真,它会假设循环条件会继续为真。
不过对于 JavaScript 来说,没有什么是绝对的。你可能会有99个具有相同结构的对象,然后第100个对象可能就少了个属性。
所以被编译的代码需要在执行之前检查,确定之前编译器的猜测是否是正确的。如果是,那么这些代码就可以直接运行,反之,JIT 会假定它做了错误的猜测并将这些优化过的代码销毁。
然后执行又将回到解释器或者基线编译的版本。这个过程叫做去优化(或者是摆脱)。
通常来说,优化编译器会让代码能够更快地运行。但是有时候它们也会导致一些预料之外的性能问题。如果一段代码反复地处于优化和去优化的过程,那么最后它反而会比直接执行基线编译版本来得更慢。
大多数浏览器会限制优化/去优化循环的次数。比如说,JIT 执行了超过 10 次优化,而这 10 次优化尝试都失败了,那么它会对它停止优化。
一个优化的例子: 类型特化
优化的方式有很多种,但是我想看一下其中一种类型的优化,这样你就能够感受到优化是怎么发生的。 优化编译器的最大优势之一称之为类型特化。
JavaScript 所使用的动态类型系统在运行时需要做一些额外的工作。比如,考虑以下代码:
function arraySum(arr) {
var sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
循环中的 +=
这一步骤看起来非常简单,似乎你都可以一步计算出来,但是因为动态类型,它可能比你预期要花费更多的步骤。
让我们假设 arr
是一个有着 100 个整数的数组。一旦代码变得“温和”,基线编译器会为函数内的每次操作都创建一个存根。所有会有对 sum += arr[i]
的存根,它掌管着对整数的 +=
加法操作。
然而,sum
和 arr[i]
并不总保证是整数。因为 JavaScript 的类型是动态的,可能在之后循环中的某一步中,arr[i]
变成了一个字符串。整数加法和字符拼接这两种操作有着很大的不同,所以他们会被编译成非常不一样的机器码。
JIT 处理这种情况的方式是编译多个基线存根。如果一段代码是单型的(每次都是调用同样的类型),那么它会得到一个存根。如果这段代码是多态的(一种类型通过代码调用传递到另一种类型),那么每种类型的组合的操作产生的结果都会得到一个存根。
这意味着 JIT 在选择一个存根的时候要询问很多次。
因为每一行代码在基线编译器中都有自己的存根,JIT 在每行代码执行的时候都会去检查它的类型。所以对于循环中的每一次迭代,JIT 都会去询问相同的问题。
如果 JIT 不需要重复这些类型检查的话,那么代码的执行会变得更快。这就是优化编译器所做的事情之一。
在优化编译器当中,整个函数是一起编译的。类型检查被移动到循环之前以便于执行。
一些 JIT 更进一步地进行了优化。比如,在 Firefox 当中,对于只包含整数的数组有特殊的分类。如果 arr
是它们其中之一,那么 JIT 就不需要检查 arr[i]
是否是整数。这意味着 JIT 可以在进入循环之前就做完所有的类型检查。
总结
以上就是对 JIT 的概括。它通过监测运行的代码并将运行频率高的的代码拿去优化来加快 JavaScript 的运行速度。这使得大多数 JavaScrip t应用程序的性能成倍地提高。
就算有了这些提升,JavaScript 的性能也是难以预计的。为了使得运行速度更快,JIT 使用了一些过度开销,包括:
-
优化和去优化
-
用于监视器记录以及去优化发生时恢复信息所占用的内存
-
用于存储基线和函数优化版本的内存
这里仍然有改进的空间:移除这些过度的开销,使得性能变得更好预测。这也是 WebAssembly 所做的事情之一。
在 下一篇文章当中,我会讲述更多关于汇编的内容,以及编译器是如果协同它工作的。
关于
Lin Clark
Lin 是 Mozilla Developer Relations 团队的一名工程师。 She 专注于 JavaScript, WebAssembly, Rust, 以及 Servo,同时也绘制一些关于编码的漫画。
-
code-cartoons.com
-
@linclark
Lin Clark 的更多文章