即时编译器速成课程(2017)

2020-07-06 21:19:11

这是关于WebAssembly的系列文章的第二部分,以及它的快速之处。如果您还没有阅读其他内容,我们建议从头开始。

JavaScript一开始速度很慢,但后来变得更快,这要归功于一种名为JIT的东西。但是JIT是如何运作的呢?

当您作为开发人员将JavaScript添加到页面时,您有一个目标和一个问题。

你说的是人类语言,而计算机说的是机器语言。即使您不认为JavaScript或其他高级编程语言是人类语言,它们确实是人类语言。它们是为人类认知而设计的,而不是为机器认知而设计的。

所以JavaScript引擎的工作就是把你的人类语言转化成机器能理解的东西。

我认为这就像电影“降临”,里面有人类和外星人试图相互交谈。

在那部电影中,人类和外星人不仅仅是逐字翻译。这两个群体对世界的看法不同。人类和机器也是如此(我将在下一篇文章中更多地解释这一点)。

在编程中,通常有两种方法可以转换为机器语言。您可以使用解释器或编译器。

另一方面,编译器不会在运行时进行翻译。它会提前创建该翻译并将其记录下来。

口译员很快就能上手并投入使用。在开始运行代码之前,您不必经历整个编译步骤。您只需开始翻译第一行并运行它。

正因为如此,解释器似乎自然而然地适合于JavaScript之类的东西。对于Web开发人员来说,能够快速开始并运行他们的代码是很重要的。

但是,当您多次运行相同的代码时,使用解释器的缺点就会出现。例如,如果您处于循环中。然后你必须一遍又一遍地做同样的翻译。

它需要多一点时间才能启动,因为它必须在开始时经过编译步骤。但是,循环中的代码运行得更快,因为它不需要为通过该循环的每一次传递重复转换。

另一个不同之处在于,编译器有更多的时间查看代码并对其进行编辑,以便运行得更快。这些编辑称为优化。

解释器在运行时执行其工作,因此在翻译阶段不需要花费太多时间就可以计算出这些优化。

作为摆脱解释器效率低下的一种方式-解释器在每次执行循环时都必须不断地重新翻译代码-浏览器开始混合编译器。

不同的浏览器执行此操作的方式略有不同,但基本思想是相同的。他们在JavaScript引擎中添加了一个新部件,称为监视器(也称为分析器)。该监视器在代码运行时监视它,并记录它运行了多少次以及使用了什么类型。

如果相同的代码行运行几次,则该代码段称为热代码段。如果经常跑,那就叫热。

当函数开始变暖时,JIT将把它送去编译。然后它将存储该编译。

函数的每一行都被编译成一个“存根”。存根是按行号和变量类型编制索引的(稍后我将解释为什么这一点很重要)。如果监视器发现执行再次命中具有相同变量类型的相同代码,它将直接取出其编译版本。

这有助于加快速度。但是就像我说的,编译器还可以做更多的事情。这可能需要一些时间来找出做事情最有效的方法…。进行优化。

基线编译器将进行其中的一些优化(下面我给出一个例子)。不过,它不想花费太多时间,因为它不想耽误执行太长时间。

然而,如果代码真的很热-如果它运行了一大堆次-那么花额外的时间进行更多的优化是值得的。

当一部分代码非常热时,监视器会将其发送给优化编译器。这将创建函数的另一个甚至更快的版本,该版本也将被存储。

为了更快地生成代码版本,优化编译器必须做出一些假设。

例如,如果它可以假设由特定构造函数创建的所有对象都具有相同的形状-即它们始终具有相同的属性名称,并且这些属性是以相同的顺序添加的-那么它可以在此基础上偷工减料。

优化编译器使用监视器通过观察代码执行所收集的信息来做出这些判断。如果某件事在之前通过循环的所有过程中都为真,则假定它将继续为真。

但当然,对于JavaScript,永远不会有任何保证。您可能有99个对象都具有相同的形状,但是第100个对象可能缺少一个属性。

因此,编译后的代码需要在运行前进行检查,以查看假设是否有效。如果是,则运行编译后的代码。但如果不是这样,JIT就会假设它做出了错误的假设,并将优化后的代码丢弃。

然后,执行返回到解释器或基准编译版本。这个过程被称为去最优化(或跳出)。

通常情况下,优化编译器会使代码更快,但有时它们可能会导致意想不到的性能问题。如果您的代码不断被优化,然后又被取消优化,那么它最终会比仅仅执行基线编译版本慢。

当这些优化/取消优化循环发生时,大多数浏览器都增加了一些限制来打破它们。比方说,如果JIT在优化方面进行了10次以上的尝试,并且一直不得不放弃,那么它就会停止尝试。

有很多不同种类的优化,但我想看一种类型,这样您就可以感受到优化是如何发生的。优化编译器的最大胜利之一来自一种称为类型专门化的东西。

JavaScript使用的动态类型系统在运行时需要做一些额外的工作。例如,请考虑以下代码:

函数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甚至进一步优化了这一点。例如,在Firefox中,对于只包含整数的数组有一个特殊的分类。如果arr是这些数组之一,那么JIT不需要检查arr[i]是否是整数。这意味着JIT可以在进入循环之前执行所有类型检查。

简而言之,这就是JIT。它通过在代码运行时监视代码并发送要优化的热代码路径,从而使JavaScript运行得更快。这使得大多数JavaScript应用程序的性能提高了许多倍。

但是,即使有了这些改进,JavaScript的性能也可能是不可预测的。为了加快速度,JIT在运行时增加了一些开销,包括:

用于监视器记账和救助发生时的恢复信息的内存。

这里还有改进的空间:这一开销可以消除,从而使性能更具可预测性。这是WebAssembly做的事情之一。

在下一篇文章中,我将更多地解释汇编以及编译器如何使用它。