JavaScriptCore中的推测

2020-07-30 02:31:00

这篇文章都是关于JavaScriptCore虚拟机上下文中的投机性编译,或者简称为投机性编译。推测性编译是使动态语言或任何具有足够动态特性的语言运行得更快的理想选择。在这篇文章中,我们将关注对JavaScript的猜测。从历史上看,这种技术或密切相关的变体已经成功地应用于Smalltalk、Self、Java、.NET、Python和Ruby等。从90年代开始,许多Java实现之间激烈的基准驱动竞争帮助创建了对如何为具有少量动态性的语言构建投机性编译器的理解。尽管比Java动态化得多,但从零开始的JavaScript性能战争通常倾向于使用对Java非常有用的相同投机性编译技巧的日益激进的应用程序。似乎推测可以应用于任何使用运行时检查的语言实现,这些检查很难静态推理。

这是一篇很长的帖子,试图揭开一个复杂的话题的神秘面纱。它基于两个小时的编译器讲座(幻灯片也有PDF格式)。我们假设您对中间表示(特别是静态单一赋值形式,简称SSA)、静态分析和代码生成等编译器概念有一定的了解。目标受众是任何想要更好地理解JavaScriptCore的人,或者任何考虑使用这些技术来加快自己的语言实现的人。这篇文章中描述的大多数概念都不是特定于JavaScript的,并且这篇文章不假定事先了解JavaScriptCore。

在进入推测的细节之前,我们将提供推测的概述和JavaScriptCore的概述。这将有助于为本文的主要部分提供上下文,通过将其分为五个部分来描述猜测:字节码(公共IR)、控制、分析、编译和OSR(堆栈替换)。最后,我们对相关工作进行了小幅回顾。

推测背后的直觉是利用传统的编译器技术来尽可能快地制作动态语言。构造高性能编译器是一门广为人知的艺术,所以我们希望尽可能多地重用它。但是对于JavaScript这样的语言,我们不能直接做到这一点,因为类型信息的缺乏意味着编译器无法对任何基本操作(甚至像+或==这样的操作)进行有意义的优化。推测性编译器使用分析来动态推断类型。生成的代码使用动态类型检查来验证分析的类型。如果程序使用的类型与我们分析的类型不同,我们将丢弃优化后的代码,然后重试。这使优化编译器可以使用动态类型程序的静态类型表示形式。

类型是这篇文章的一个主要主题,尽管我们描述的技术是用于实现动态类型语言的。当语言包含静态类型时,可以为程序员提供安全属性,也可以帮助优化编译器。我们只对性能类型感兴趣,JavaScriptCore中的推测策略可以大致认为是推断C程序将具有的类型,但使用专门为优化编译器构建的内部类型系统。更广泛地说,这篇文章中描述的技术可以用来实现任何类型的概要引导优化,包括那些与类型无关的优化。但是这篇文章和JavaScriptCore都关注于最自然地认为是关于类型的剖析和推测(变量是否是整数,指针指向什么对象形状,操作是否有效果,等等),但是这篇文章和JavaScriptCore都关注于最自然地认为是关于类型(变量是否是整数,指针指向什么对象形状,操作是否有效果等)的剖析和推测。

为了更深入地探讨这一问题,我们首先考虑类型的影响。然后我们看看投机是如何给我们类型的。

我们希望为动态类型语言提供通常在高性能静态类型语言(如C)的预编译器中可以找到的那种优化编译器流水线。这种优化器的输入通常是某种内部表示(IR),它精确地表示每个操作的类型,或者至少是一个可以推断每个操作类型的表示。

要了解类型的影响以及推测性编译器如何处理它们,请考虑下面的C函数:

在C中,像int这样的类型被用来描述变量、参数、返回值等。在优化编译器有机会破解上面的函数之前,类型检查器会填充空白,以便+运算将使用IR指令表示,该指令知道它正在添加32位有符号整数(即int)。此知识非常重要:

类型信息告诉编译器的代码生成器如何发出此指令的代码。由于int类型,我们知道要使用整数加法指令(而不是双重加法或其他指令)。

类型信息告诉优化器如何为输入和输出分配寄存器。整数表示使用通用寄存器。浮点表示使用浮点寄存器。

类型信息告诉优化器此指令可以进行哪些优化。确切地知道它的作用可以让我们知道可以使用哪些其他操作来代替它,允许我们对程序正在进行的数学进行一些代数推理,并允许我们在输入是常量的情况下将指令合并为常量。如果有+对其有影响的类型(如在C++中),则这是一个整数+这一事实意味着它是纯的。如果+不是纯的,那么许多适用于+的编译器优化都不会起作用。

我们不再有打字的奢侈。程序没有告诉我们a或b的类型。类型检查器不可能将+操作标记为任何特定的操作。它可以根据a和b的运行时类型执行一系列不同的操作:

它可能是一个带有方法调用的循环。这些方法可以是用户定义的,可以执行任意效果。如果a或b是对象,就会发生这种情况。

基于此,优化器不可能知道要做什么。指令选择意味着要么发出对整个事物的函数调用,要么发出昂贵的控制流子图来处理所有不同的情况(图1)。我们不知道哪个寄存器堆最适合输入或结果;我们可能会使用通用寄存器,然后执行额外的移动指令,以便将数据放入浮点寄存器,以防我们必须进行双重加法。不可能知道一个加法是否会产生与另一个加法相同的结果,因为它们都有有效方法调用的循环。无论何时发生+,我们都必须考虑到整个堆可能已经发生了变异。

简而言之,除非我们能以某种方式为所有值和操作提供类型,否则使用针对JavaScript的优化编译器是不切实际的。要使这些类型有用,它们需要帮助我们避免像+这样的基本操作,看起来它们需要控制流或效果。他们还需要帮助我们了解要使用哪些指令或寄存器文件。推测性编译器通过将这种推理应用于语言中的所有动态操作来提高速度-从表示为基本操作(如+或o.f和o[i]等内存访问)的操作,到涉及内部函数或可识别代码模式的操作(如调用Function.Prototype.Apply)。

这篇文章关注的是那些最自然地将收集到的信息理解为类型信息的推测,比如变量是否是整数以及指向的对象具有什么属性(以及按什么顺序)。让我们更深入地理解这一点的两个方面:分析和优化发生的时间和方式,以及推测类型的意义。

让我们考虑一下我们所说的针对JavaScript的推测性编译是什么意思。JavaScript实现伪装成解释器;它们接受JS源代码作为输入。但是在内部,这些实现结合使用解释器和编译器。最初,代码在执行引擎中开始运行,该执行引擎不进行基于类型的推测性优化,而是收集有关类型的分析。这通常是一名口译员,但并不总是这样。一旦函数具有令人满意的分析量,引擎将启动该函数的优化编译器。优化编译器基于与C编译器中相同的基本原理,但在这里,它不是从类型检查器接受类型并作为命令行工具运行,而是从分析器接受类型,并在与它正在编译的程序相同进程的线程中运行。一旦编译器完成发出优化的机器码,我们就将该函数的执行从性能分析层切换到优化层。运行JavaScript代码无法观察到这种情况发生在自身身上,除非它测量执行时间。(但是,我们用于测试JavaScriptCore的环境包括许多用于自省已编译内容的挂钩。)。图2展示了在运行JavaScript时如何以及何时进行性能分析和优化。

粗略地说,推测性编译意味着我们的示例函数将转换为如下所示:

棘手的是,投机到底意味着什么。一个简单的选择是我们所说的钻石投机。这意味着我们每次执行操作时,都会有一条专门用于分析器告诉我们的快速路径和一条处理一般情况的慢速路径:

在这里,我们使用了两次x,两次都将其与已知整数相加。假设分析器告诉我们x是一个整数,但是我们没有办法静态地证明这一点。我们还可以说,x的值在两种用途之间不变,我们已经静态地证明了这一点。

图3显示了如果我们使用菱形推测来推测x是整数这一事实会发生什么:我们得到一条执行整数加法的快速路径和一条跳出到助手函数的慢速路径。像这样的投机可以以适度的成本产生适度的加速。成本是适中的,因为如果投机是错误的,只有x上的操作才会付出代价。这种方法的问题是重复使用x必须重新检查它是否为整数。重新检查是必要的,因为控制流合并发生在Things块上,并且再次发生在更多的事情上。

这个问题的原始解决方案是拆分,在拆分过程中,为了避免分支,程序在事物和更多事物之间的区域会被复制。这方面的一个极端版本是跟踪,即在任何分支之后复制函数的整个剩余部分。这些技术的问题是复制代码成本很高。我们希望将编译同一段代码的次数降到最低,这样我们就可以快速编译大量代码。JavaScriptCore做的最接近拆分的事情是尾部复制,如果代码很小,它通过在菱形推测之间复制代码来优化菱形推测。

与菱形投机或拆分相比,OSR(堆栈替换)是一种更好的选择。使用OSR时,失败的类型检查会从优化后的函数退出,返回到未优化代码中的等价点(即性能分析层的函数版本)。

图4显示了当我们使用OSR推测x是整数时会发生什么。因为在x是int的情况和x不是int的情况之间没有控制流合并,所以第二次检查变得多余并且可以消除。没有合并意味着到达第二个检查的唯一方法是第一个检查是否通过。

OSR推测为我们的传统优化编译器提供了静态类型。在任何基于OSR的类型检查之后,编译器可以假定检查的属性现在是事实。此外,因为OSR检查失败不影响语义(我们退出到相同代码中的相同点,只是优化较少),所以我们可以将这些检查提升到我们想要的高度,并通过使用相应的类型检查保护变量的所有赋值来推断变量总是具有某种类型。

请注意,我们在本文和JavaScriptCore中所称的OSR出口在其他地方通常称为去优化。我们更喜欢在代码库中使用术语OSR出口,因为它强调重点是使用外来技术(OSR)退出优化函数。术语“取消优化”使我们看起来像是在撤消优化,这只在狭义上是正确的,即特定的执行从优化的代码跳到未优化的代码。在这篇文章中,我们将遵循JavaScriptCore行话。

JavaScriptCore使用OSR或菱形推测,这取决于我们对推测是否正确的信心。OSR推测有更高的收益和更高的成本:收益更高,因为可以消除重复检查,但成本也更高,因为OSR比调用助手函数更昂贵。然而,只有在退出实际发生的情况下,才会支付成本。OSR投机的好处是如此优越,以至于我们将其作为我们的主要投机策略,如果我们的侧写表明我们对投机缺乏信心,钻石投机是我们的后备选择。

基于OSR的推测依赖于这样一个事实,即传统编译器已经擅长对侧边退出进行推理。陷阱指令(比如Java虚拟机中的null check优化)、异常和多个返回语句都是编译器已经支持退出函数的示例。

假设我们使用字节码作为未优化的概要执行层和优化层之间共享的公共语言,那么退出目的地可以只是字节码指令边界。图5显示了这可能是如何工作的。优化编译器生成的机器代码包含针对不太可能的条件的推测检查。我们的想法是做大量的推测。例如,序言(图中的Enter指令)可能推测参数的类型-即每个参数一个推测。加法指令可以推测其输入的类型以及没有溢出的结果。我们的类型分析可能会告诉我们,某些变量往往总是具有某种类型,因此其源代码未被证明具有该类型的mov指令可能会推测该值在运行时具有该类型。访问数组元素(我们称之为get_by_val)可能会推测该数组实际上是一个数组,索引是一个整数,索引在边界内,并且索引处的值不是一个空洞(在JavaScript中,从从未赋值的数组元素加载意味着遍历数组的原型链以查看是否可以在那里找到该元素-这是我们在大多数情况下避免做的事情,因为我们推测不需要这样做)。调用函数可能会推测被调用者就是我们预期的被调用者,或者至少它有适当的类型(这是我们可以调用的东西)。

虽然在不违反优化编译器的基本假设的情况下退出函数很简单,但进入却非常困难。在函数的主入口点之外的其他位置进入函数会使入口点之间的任何合并点的优化变得悲观。如果我们允许在每个字节码指令边界进入,这将迫使每个指令边界对类型做出最坏的假设,从而否定OSR退出的好处。即使只允许OSR条目位于循环标头,也会破坏大量循环优化。这意味着退出后通常不可能重新进入优化执行。我们只支持在奖励很高的情况下进入,比如分析器告诉我们在编译时循环尚未终止。简而言之,传统编译器是为单入口、多出口过程设计的,这意味着OSR进入很难,但OSR退出很容易。

JavaScriptCore和大多数推测编译器支持在热循环中进入OSR,但是由于它不是大多数应用程序的基本特性,我们将把理解如何做到这一点留给读者作为练习。

本文的主要部分从五个组件(图6)来描述推测:虚拟机的字节码或公共IR,它允许在未优化的性能分析层和优化层之间对性能分析和退出站点的含义有共同的理解;未优化的性能分析层,用于在启动时执行函数,收集关于它们的性能分析,并用作退出目的地;控制系统,用于决定何时调用优化编译器;优化层,将传统的优化编译器与增强功能相结合,以支持基于。以及OSR退出技术,该技术允许优化编译器在推测检查失败时将剖析层用作退出目的地。

JavaScriptCore接受了分层的思想,它有四层JavaScript(三层WebAssembly,但这超出了本文的讨论范围)。分层有两个好处:前一节描述的支持推测的主要好处;以及允许我们在每个功能的基础上微调吞吐量-延迟权衡的次要好处。有些函数运行的时间非常短--比如直线运行一次的初始化代码--以至于在这些函数上运行任何编译器都比解释它们更昂贵。有些函数被频繁调用,或者有如此长的循环,以至于它们的总执行时间远远超过了使用积极的优化编译器编译它们的时间。但在这两者之间也有很多函数:它们运行的时间不足以让激进的编译器盈利,但足够长,以至于一些中间编译器设计可以提供加速。JavaScriptCore有四层,如图7所示:

LLInt或低级解释器,它是遵循JIT编译器ABI的解释器。它在与JIT相同的堆栈上运行,并将一组已知的寄存器和堆栈位置用于其内部状态。

基线JIT,也称为字节码模板JIT,它为每个字节码指令发出一个机器代码模板,而不会试图推断函数中多个指令之间的关系。它编译整个函数,这使得它成为一种方法JIT。Baseline没有OSR推测,但确实有一些基于LLInt的剖析的钻石推测。

DFG JIT或数据流图JIT,它根据LLInt、基线的评测执行OSR推测,在极少数情况下甚至使用DFG JIT和FTL JIT收集的评测数据进行OSR推测。它可以OSR退出到基线或LLInt。DFG有一个称为DFG IR的编译器IR,它允许对推测进行复杂的推理。DFG避免进行昂贵的优化,并做出许多妥协以实现快速代码生成。

FTL JIT,或比LIGHT JIT更快,后者进行全面的编译器优化。它是为峰值吞吐量而设计的。FTL从不会在吞吐量上妥协,从而缩短编译时间。此JIT重用了DFG JIT的大部分优化,并添加了更多优化。FTL JIT使用多个IR(DFG IR、DFG SSA IR、B3 IR和Assembly IR)。

";使用严格的";;设Result=0;for(设i=0;i<;10000000;++i){let o={f:i};result+=o.f;}print(Result);

由于循环中的对象分配,它将运行很长时间,直到FTL JIT可以编译它。FTL JIT将终止该分配,因此循环很快结束。优化前的长时间运行几乎保证了

.