MIR轻量级JIT编译器的代码专门化

2022-02-17 14:53:32

在过去的三年里,我把一半的工作时间花在和平号项目上。目标是创建一个通用的轻量级即时(JIT)编译器,以及该项目';s基石是一个独立于机器的中级中间表示(MIR)。有关该项目的更多信息,请参阅我之前关于Red Hat Developer的文章:

到目前为止,我在MIR项目上的工作重点是制作一个快速的JIT编译器,为几个主要目标生成合适的机器代码:x86-64 Linux和macOS、AARC64、s390x、riscv64 Linux和ppc64 big-and little-endian Linux。

当前状态下的项目是一个方法JIT编译器,可以有效地用于静态类型编程语言,如C,这是使用最广泛的静态类型语言。我们';我开发了一个基于C-to-MIR编译器的C语言JIT。

MIR项目的最初目标是实现更好的Ruby JIT编译器。(具体来说,我关注的是CRuby,默认的Ruby解释器,它是用C编写的。)Ruby是一种非常动态的编程语言,它非常灵活,甚至可以重新定义整数的加号方法。

为了获得更好的动态编程语言性能,您需要跟踪程序执行,从执行中做出各种假设,并根据这些假设生成代码。例如,您可能会发现给定方法中的给定加号运算只有整数操作数。您可以假设情况始终如此,并生成仅适用于整数操作数的专用加号运算代码。

你需要使用不同的技术来保证你的假设成立,比如实施假设检查或保护,或者证明在给定的执行路径上,假设总是正确的。如果警卫发现这个假设是错误的,你需要切换到适用于任何情况的代码。在JIT上下文中,从专用代码切换到通用案例代码的过程通常被称为去优化。

在本文中,我';我将讨论我计划如何支持在MIR中生成专门的、非优化的代码,以及在MIR项目中已经实现了哪些支持。

注:大多数JIT专门针对特定语言:有';例如,s V8代表JavaScript,luajit代表Lua,PHP代表PHP的PHP JIT。我更感兴趣的是语言无关性支持,以促进不同动态编程语言在JIT中实现专门化和去优化。(有关JIT编译器中去优化的更多信息,请参阅JIT编译器如何在OpenJDK中提高Java性能。)

为了让我们能够具体了解MIR编译器中的专门化和去优化,我将在CRuby实现中使用以下虚拟机(VM)指令plus的简化代码:

如果(固定资产)&;基本的_OP_undefined_P(BOP_PLUS,INTEGER_refined_OP_FLAG)){res=rb_fix_PLUS_fix(recv,obj);}否则,如果(FLONUM_2_P(recv,obj)&&;基本的_OP_undefined_P(BOP_PLUS,FLOAT_refined_OP_FLAG)){res=db2num(RFLOAT_值(recv)+RFLOAT_值(obj))}否则如果(特殊常数P(recv)| |特殊常数P(obj)){…}否则如果(RBASIC_CLASS(recv)=rb_cFloat&&;RBASIC_CLASS(obj)=rb_cFloat&&;基本的_OP_undefined_P(BOP_PLUS,FLOAT_refined_OP_FLAG)){…}否则如果(RBASIC_CLASS(recv)=rb_cString&&;RBASIC_CLASS(obj)=rb_cString&&;基本_OP_undefined_P(BOP_PLUS,STRING_refined_OP_FLAG)){…}否则,如果(RBASIC_CLASS(recv)=rb_cArray&&;RBASIC_CLASS(obj)=rb_cArray&&;基本_OP_undefined_P(BOP_PLUS,数组_refined_OP_FLAG)){…}else{..//object recv实现+的方法调用}

那么,什么';这里发生了什么事?首先,代码检查操作数是否为固定数,整数的加号方法是否未重新定义。如果是这种情况,代码将对固定数字执行加号操作。否则,它会对浮点数、字符串和数组进行类似的检查。最后,它为对象recv调用Ruby方法implementing+。

CRuby中的Fixed numbers(或FixNum)是目标机器可以有效支持的整数子集。较大的数字表示为GMP库实现的多精度数字。

CRuby中的所有值都由一个机器字表示并标记。例如,fixnum总是有1作为单词的最低有效位,而指向对象的指针总是有三个(或两个,在32位目标上)零最低有效位。因此,宏FIXNUM_2_P的实现如下代码所示:

如果省略检查溢出的代码,fixnum plus操作如下所示:

注意:为了进一步简化,在接下来的部分中,我将忽略对plus操作重新定义的检查,例如在上面的示例中调用宏BASIC_OP_undefined_P。

假设我们';我检查了一个特定加号运算的操作数类型,发现它们一直都是固定数。在这种情况下,我们可以生成以下内容:

乍一看,我们似乎没有';t改进代码,因为检查(FIXNUM_2_P)仍然存在。但是如果我们看一系列的加号运算,v1+v2+v3+v4,我们';我们有:

如果(!FIXNUM_2_P(v1,v2))转到一般情况;res=rb_fix_plus_fix(v1,v2)如果(!FIXNUM_2_P(res,v3))转到一般情况;res=rb_fix_plus_fix(res,v3)如果(!FIXNUM_2_P(res,v4))转到一般情况;res=rb_fix_plus_fix(res,v4)

智能编译器可以删除最后两个FIXNUM_2_P检查。不幸的是,GCC和Clang都不能确定res的最低有效位总是1。因此,这些编译器不知道应该删除最后两个FIXNUM_2_P检查。GCC和#39;随着Ranger项目的全面实施,未来的情况可能会发生变化。(顺便说一句,如果值是由一个包含两个成员(type和value)的结构表示的,那么GCC/LLVM现在甚至可以找出它们的类型并删除最后两个检查。)

即使不删除冗余检查,执行这种专用代码也是有益的,因为编译器可以成功地进行其他优化,例如删除冗余加载和存储。那';因为这样的代码形成了称为扩展基本块(EBB)的特定区域,而优化编译器在这样的区域上工作得特别好。这样的代码还具有更好的代码局部性和分支预测。

在固定数字示例中,我们如何实现由general_case标记的代码?有三种可能性:

JIT编译器调用,删除为Ruby方法生成的专用代码,然后为方法VM指令生成并使用通用代码。

转到一个特定位置,其中包含方法VM指令的所有类型案例的所有代码。

由于切换到CRuby中的解释器非常昂贵,因此最好在生成专用代码的同时生成通用案例代码,以及在警卫失败时从专用代码转到通用代码。在对一般案例代码执行了几次gotos之后,我们可以使用当前概要信息重建整个方法。

MIR-JIT编译器并不比GCC或Clang更聪明,它遇到了相同的问题,即删除对标记值的冗余检查。为了解决这个问题,我计划引入程序变量和MIR指令的属性,以及使用属性的内置C函数:

注:为简洁起见,I';我将跳过对MIR级别的财产支持的描述。

属性是整数常量。我们可以使用_builtin_prop_set在给定的执行点为程序变量分配属性。属性通过变量赋值传播到其他程序变量。

当我们无法计算程序变量在给定执行点的属性时,该变量在该点的属性为零。零属性是未知属性。

我们可以通过对描述程序变量类型的属性的新内置调用来注释plus代码,如下所示:

枚举属性{unknown=0,inttype,flotype,…};如果(uuu builtin_prop_cond(FIXNUM_2_P(recv,obj),recv,intype,obj,intype)){res=rb_fix_plus_fix(recv,obj);uuu builtin_prop_set(res,intype)}如果(uuuuu builtin_prop cond_cond(FLONUM_2_P(recv,obj),recv,flotype,obj,flotype))res=db2num__内置道具套装(res,flotype);]else{…//为对象recv实现的方法+调用}

如果(recv.prop==inttype&;obj.prop==inttype | | |(recv.prop==unknown&;obj.prop==unknown)&&;FIXNUM_2_P(recv,obj)){res=rb_fix\u plus_fix(recv,obj);res.prop=intype;}否则如果(_内置_属性条件(FLONUM_2_P(recv,obj),recv,flotype,obj,flotype))res=db2num(RFLOAT_值(recv)+RFLOAT_值(obj))__内置道具套装(res,flotype);]else{…//为对象recv实现的方法+调用}

因为我们在代码生成过程中总是知道属性,所以所有属性的赋值和比较都会在最终代码中消失。例如,如果我们发现recv和obj都具有inttype属性,那么最终的代码将是:

如果我们在分析过程中发现recv和obj都具有属性flotype,则最终代码将为:

如果recv和obj的值的属性为零,我们将获得与注释之前相同的原始代码。如果recv或obj只有一个属性为零,那么在最后的else部分中只有代码。

考虑上面讨论的分析之后的代码。分析后,用属性注释的类似代码如下所示:

如果(!uuuu内置属性条件(FIXNUM_u2_up(v1,v2),v1,intype,v2,intype))转到一般情况;res=rb_fix_加_fix(v1,v2)__内置道具(res、inttype);如果(!__内置_prop_cond(FIXNUM_2_P(res,v3),res,intype,v3,intype))转到一般情况;res=rb_fix_加_fix(res,v3)__内置道具(res、inttype);如果(!__内置_prop_cond(FIXNUM_2_P(res,v4),res,intype,v4,intype))转到一般情况;res=rb_fix_加_fix(res,v4)__内置属性集(res,inttype);。。。

现在很容易找到并传播上面扩展的基本块的程序变量属性。对于MIR寄存器中的变量,属性分析很简单,而对于由MIR内存操作数表示的变量,则需要更复杂的分析点。

注意:为了简洁起见,我省略了如何进行分析的描述,尤其是当代码在多个线程中执行时。

如果在我们前面看到的Ruby代码v1+v2+v3+v4中,一半情况下的所有变量都有fixnum值,另一半情况下的所有变量都有浮点数值,那么基于分析的专门化会发生什么?这是动态编程语言中多态函数的常见情况。

我们只能为其中一半的案例生成专门的代码,并在另一半的案例发生时进行去优化。或者,我们可能只生成并使用一般案例代码。如果对某个小函数的特定调用在大多数情况下具有特定的类型值,我们可以通过使用代码内联来改进生成的代码。那';然而,这是一个复杂的解决方案。解决这个问题的一种更简单的方法是基本块版本控制(BBV)。

基本块版本控制是如何工作的?假设我们有不同的路径来到达代码的一个基本块(比如通过对函数的不同调用),在这些不同的路径上,我们有特定类型(属性)的变量。我们可以克隆基本块,并为不同类型的变量生成不同版本的专用代码,如图1所示(请注意,基本块表示为BB)。

创建一个专门的基本块版本可以推断出输出变量值的类型,比如我们示例中的res值。这反过来会导致生成不同版本的后续基本块。

如果我们不';如果不限制基本块版本的数量,可能会出现不同版本的组合爆炸。实际上,只生成一个基本块的几个版本,其中一个基本块版本始终用于未知类型(零属性)。

有两种方法可以生成基本块版本。它可以用渴望的方式或懒惰的方式来完成。在渴望策略中,当我们为基本块版本生成代码时,我们也会创建后续基本块的版本,并为它们生成代码,以此类推。换句话说,我们一次生成整个方法的代码。

在惰性策略中,我们只在开始执行基本块版本时才为该版本生成代码。这意味着我们为程序中实际执行的方法基本块生成代码。显然,懒惰的基本块版本控制会减少生成的代码,通常也会减少编译时间。(如果我们需要使用这两种策略为相同数量的基本块版本生成代码,惰性代码编译可能需要更多时间,因为每次单独的基本块代码生成都需要更多时间进行数据初始化和最终确定。)

如前一节所述,与基于分析的专门代码生成相比,lazy basic块版本控制有几个优点:

我们没有';不需要为所有BBs生成机器代码,当只有JIT的一小部分时,这可能很有吸引力;执行一个新的程序。

我们没有';不需要特别实施去优化。它是通过BB版本控制自动完成的。

许多方法JIT只为执行次数超过某个阈值的方法生成代码。仅在函数调用时切换执行。如果一个方法很少被调用,但有一个非常长的循环,那么这种方法就不能很好地工作。在方法执行期间切换到新的方法代码称为堆栈替换(OSR)。懒惰的基本块版本控制不需要OSR的特殊实现;这是自动完成的。

有人可能会认为,基本块版本控制会导致代码大小激增。实际上,版本的平均数量非常少。根据Maxime Chevalier Boisvert和Marc Feely的一篇论文,大约95%的基本块在一组JavaScript基准上只有一个版本(参见通过延迟基本块版本控制删除简单有效的类型检查)。

在引导x86-64 C-to-MIR编译器(大约30000行C代码)时,生成的基本块版本仅占至少执行一次的编译器函数的所有基本块的51%(29131中有14737个),仅占编译器所有基本块的18%(81799中有14737个),如图2所示。

毫不奇怪,我首先开始为MIR项目实施lazy basic块版本控制。

MIR项目的基本块版本控制是如何实现的?MIR函数的所有调用都是间接的,通过名为thunks的运行时生成的小代码段实现,thunks通常由一条或两条机器指令组成。Thunks用于轻松更改任何MIR功能的机器代码;例如,我们需要将专用机器代码转换为非优化代码。

MIR已经有了一种惰性函数代码生成机制,它也通过thunks实现。在程序执行开始时,所有函数thunk都重定向到机器代码生成器。当函数第一次被调用时,MIR代码生成器优化函数并为其生成机器代码;然后将函数thunk重定向到生成的机器代码,并从生成的代码继续执行,如图3所示。

当我们使用延迟块版本控制时,函数thunk实现了一个到特定地址的切换,该地址取决于基本块开头的程序变量的属性。例如,该开关可以以多种方式实现为基于表的开关。任何开关的效率都低于之前使用的一条跳转指令。尽管如此,我们不能对一个函数使用几个简单的跳转thunk来代替开关,因为我们需要支持函数与其thunk之间的一对一关系。这是因为MIR中的函数由其thunk地址表示,函数是可以赋值和比较的一级值。

用于延迟基本块版本控制的函数thunk重定向到基本块版本的机器代码生成器或该版本的已生成机器代码。函数调用还通过一些寄存器传递调用参数属性的标识符,根据所使用的应用程序二进制接口(ABI),这些寄存器不是被调用函数保存的。

最初,函数thunk重定向到机器代码生成器,该生成器在特殊模式下工作。它只优化功能,但不生成机器代码。相反,它创建了函数的一个版本#39;s第一个基本块,将函数thunk重定向到基本块版本生成器,并调用它。下一个调用会在需要新的基本块版本时修改函数thunk。图4说明了当基本块版本的数量限制为三个时,函数thunk是如何变化的。

基本块版本生成器处理MIR属性指令,优化给定属性的代码,并生成基本块的机器代码。它查找在当前基本块末尾具有属性的后续基本块,并将该基本块末尾的跳转添加到后续基本块的机器代码中。

如果基本块版本生成器可以';找不到基本块的基本块版本';它创造了他们的继任者,以及他们的重击。生成器将跳转添加到当前基本块版本的机器代码末尾的新thunks,并从当前版本的机器代码继续执行。在极少数情况下,跳过基本块版本仍然可以通过基本块重击完成。在原始的基本块版本中,出现';s一个间接跳转或MIR switch指令,其中多个case具有相同的目标标签。

MIR项目中的懒惰基本块生成是受最近的Shopify YJIT for CRuby的启发。但两者之间有重要区别:

YJIT是一种专门的CRuby JIT,而MIR是一种通用的JIT,可以用于不同语言的JIT实现。

YJIT基本块是CRuby VM指令基本块,而MIR基本块是机器指令级基本块。

YJIT不会对基本块版本进行任何优化。MIR生成器首先优化整个功能,然后优化单个基本块版本。

YJIT仅支持x86-64代码生成。MIR支持x86-64、AARC64、ppc64、s390和64位RISCV。

作为你';我们已经看到,使用基本块版本生成的代码比为整个函数生成的代码包含更多的跳转。但我没有发现x86-64机器上的性能与我用于MIR和C-to-MIR编译器的基准测试有任何差异(只是一些噪音)。我的猜测是,在现代处理器上,直接跳转指令的成本可能很低,因为它们具有复杂的分支预测单元,任何额外的成本都可以通过使用基本块版本产生的更好的代码局部性来补偿。

到目前为止,我';我已经描述了支持动态编程语言方法JIT实现的方法。惰性基本块代码生成可能是实现跟踪JIT的一个步骤。

跟踪JIT记录通常在热循环中执行的VM指令,对它们进行优化,并为它们生成机器代码。图6左侧的方框图显示了一个循环执行的示例,最频繁执行的块中填充了dar

......