在Cranelift中的代码生成底漆

2021-05-08 22:58:56

Cranelift是以锈迹编程语言编写的代码生成器,其旨在成为快速代码生成器,其输出以合理速度运行的机器代码。

Cranelift编译模型在一个逐个中组成,持有有关外部实体的额外信息,如外部函数,内存地址等。此模型允许同时和并行编译各个函数,支持快速编译的目标。它的设计方法可以在Firefox中允许在Firefox中的Webassembly二进制代码的立即(JIT)编译,尽管其范围已经扩大了一点。如今,它用于几个不同的Webassembly运行时间,包括WASMTime和Waser,而且还有一个替代后端,因为凭借CG_CLIF。

经典编译器设计通常包括运行解析器以将源转换为某种形式的中间表示,然后运行优化传递到它们上,然后将其送到机器代码生成器。

此博客文章侧重于最后一步,即涉及代码生成的概念,以及它们在Cranelift中映射的概念。为了使事情更具体,我们' ll采取特定的指令,看看它是如何翻译成代码生成的。在该过程的每个步骤中,I' ll提供了一个涉及概念的短(咳咳)高级解释,我' ll使用示例指令显示它们在Cranelift中映射到的映射。虽然这不是教程细节如何在Cranelift中添加新指令,这应该是一个有趣的阅读,对任何对编译器感兴趣的人,如果你'遗嘱感兴趣的人Cranelift Codegen Crate。

这是我们对此博客文章的计划:每个平方盒代表数据,每个框都是一个过程。我们将通过以下各自经历它们。

图TD; CLIF [优化CLIF]; vcode [vcode]; final_vcode [最终vcode]; machine_code [机器代码伪影];降低([降低]); Regalloc([注册分配]); Codegen([机器代码生成]); clif - >降低 - > vcode - > Regalloc - > Final_vcode - > codegen - > machine_code.

编译器使用中间表示(IR)来表示源代码。在这里,我们'对数据流的表示感兴趣,这是指令本身和才能。 IRS包含有关指令本身的信息,操作数,键入专业信息以及可能有用的任何其他元数据。 IRS通常映射到一定程度的抽象,因此,它们可用于解决需要不同抽象级别的不同问题。它们的形状(数据结构)和数字通常对编译器本身的性能产生巨大影响(即它在编译时的快速)。

一般来说,大多数编程语言在内部使用IRS,但这些语言是程序员是不可见的。原因是通常首先解析源代码(令授权,验证),然后翻译成IR。抽象语法树,AKA AST,是一个代表源代码本身的这样的IR,以' s非常接近源代码本身。由于Rairon D'être的Cranelift是一个代码生成器,具有辅助格式的代码生成器,并且仅用于测试和调试目的。那个'为什么嵌入者直接创建和操纵Cranelift' S IR。

Clif是Cranelift Embedders创造和操纵的IR。它由高级类型的操作组成,方便使用和/或可以简单地翻译成机器代码。它处于静态单分配(SSA)形式:由操作(SSA值)引用的每个值仅定义一次,并且可能具有根据需要的许多用途。 CLIF用于使用和操纵经典编译器优化通行证(例如,LICM),因为它在目标架构上是通用的,我们'重新编译为。

生成CLIF IR的生锈代码示例:使用IR Builder,创建两个常量64位整数SSA值x和y,然后加在一起。结果存储到SSA值中,然后可以被其他指令消耗。

WE&#39的IR Builder的代码; RE操纵上面由Cranelift-Codegen构建脚本自动生成。构建脚本使用域特定的元语言(DSL)1定义了指令,其输入和输出操作数,允许输入类型,输出类型如何推断出来等。我们赢得了这一点今天:这有点太远,从代码生成,但这可能是另一个博客文章的材料。

作为一个全吹的CLIF发生器的示例,在Cranelift项目中存在一个箱子,允许从WebasseMbly二进制格式转换为CLIF。 ForcC的Cranelift后端使用自己的CLIF发生器,该CLIF发生器从一个生锈编译器和#39; S IS转换。

最后,它'是揭示了什么' s将成为我们的跑步之例!选择的一个是IADD CLIF操作,它允许将任何长度的两个整数一起添加,具有包装语义。理解它既易于理解,在我们&#39的两个架构上展示有趣的行为;所以,让' s继续下推管道!

稍后,CLIF中间表示降低,即从高级1转变为较低级别。这里较低的级别是指为机器架构专业的形式。这个较低的IR称为Cranelift中的vcode。它引用的值称为虚拟寄存器(更多关于下面的虚拟位)。它们'重新在SSA形式中不再是:每个虚拟寄存器都可以根据需要重新定义多次。此IR用于编码寄存器分配约束和引导机器代码生成。事实上,由于此信息与机器代码' S表示,此IR也是特定于目标的:每个CPU架构的vcode的一种vcode'重新编译。

让'重新追溯到我们的示例,我们将在两个指令集架构上编译: - ARM 64位(AKA AARCH64),用于大多数移动设备,但开始成为笔记本电脑上的主流(Apple' s Mac M1,一些Chromebook) - 英特尔' s x86 64位(aka x86_64,也缩写x64),用于大多数桌面和笔记本电脑机器)。

AARCH64上的整数加法机指令将需要三个操作数:两个输入操作数(其中一个必须是寄存器),另一个第三个输出寄存器操作数。虽然在X86_64架构上,等效指令涉及总共两个寄存器:一个是只读源寄存器的寄存器,另一个是一个输入的修改寄存器,包含第二个源和目标寄存器。我们' ll回到这个。

所以考虑IADD,让我们看看):

///使用两个寄存器源和寄存器目的地的ALU操作。阿拉尔尔{Alu_op:aluop,rd:可写的< reg> ,rn:reg,rm:reg,},

ALU_OP定义了ALU(算术逻辑单元)中使用的子操作码。对于64位整数添加,它将是aluop :: Add64。

RD是目标寄存器。看看它是如何标记为可写的,而另两种是不是?可写是一个普通的生锈包装器,确保我们可以静态区分可写寄存器的只读寄存器;一个整洁的技巧,让我们在编译时捕获更多问题。

所有这些信息都直接连接到AARCH64上的加法指令的机器代码表示:稍后使用每个字段来选择将在代码生成期间生成的一些字节。

如前所述,vcode特定于每个架构,因此x86_64具有不同指令的不同vcode表示(如cranelift / codegen / src / isa / x64 / inst / mod.rs所定义):

///整数算术/位 - 绑定:(添加sub和mul adc?sbb?sbb?)(32 64)(reg addr imm)reg Alurmir {is_64:bool,op:alurmiropcode,src:Regmemimm,DST:可写&lt ; reg> ,},

这里,子操作码被定义为alurmiropode枚举的一部分(由此相同vcode生成的其他x86机器指令的注释提示)。看看那里'■只有一个SRC(源)寄存器(或内存或即时操作数),而指令概念上需要两个输入?那个' s是因为它的' s预期修改了dst(目的地)寄存器,即读取(所以它' s第二输入操作数)并写入(所以它' s结果寄存器)。在等效的c代码中,x86' s添加指令并不是' t实际上做一个= b + c。它确实是+ = B,即,其中一个来源被指令消耗。这是从1970年' S的旧X86机器设计继承的文物,当时围绕蓄能器模型设计了指令(并且在CISC架构中有效三个操作数将使编码比它更大更难)。

如前所述,从高级IR(CLIF)转换为低级IR(vcode)称为降低。由于vcode是目标相关的,因此此过程也是目标依赖的。那个'我们考虑到哪种机器指令最终用于给定的CLIF操作码。有很多方法可以实现给定语义的相同机器状态结果,但其中一些方式比其他方式更快,并且/或需要更少的代码字节来实现。问题可以如此概括:给定一些CLIF,我们可以创建哪些vcode以生成执行所需语义的最快和/或最小的机器代码?这称为指令选择,因为我们'重新选择一组不同可能的指令之间的vcode指令。

如何互相贴图?可以将给定的CLIF节点降低到1到N vcode指令中。给定的vcode指令可能导致代码生成1到M机器指令。没有规则管理映射的实体的最大值。例如,在64位上的整数加法CLIF操作码IADD将映射到AARCH64上的单个vcode指令。然后,vcode指令导致要生成单个代码指令。

其他CLIF操作码最终可能产生多个机器指令。考虑CLIF操作码用于签名整数id。它的语义定义它陷入零点的陷阱,如果整数溢出3.在AARCH64上,这降至:

两个vcode指令,用于将输入值与最小整数值和-1进行比较

一个vcode指令捕获两个输入值是否符合我们检查的内容

然后,这些vcode指令中的每一个都生成一个或多个机器代码指令,从而产生更长的序列。

让'查看AARCH64上的IADD的降低(在Cranelift / Codegen / SRC / ISA / AARCH64 / DESARSIONST.RS),编辑和简化为清楚起见。 i'在代码中添加了评论,解释了每行的作用:

OPCODE :: IADD => {//获取目标寄存器。让RD = GET_OUTPUT_REG(CTX,输出[0])。 only_reg()。 unwrap(); //获取添加的控制类型(32位int或64位int或// int矢量等)。让ty = ty。 unwrap(); //强制其中一个输入到寄存器中,而不是应用任何符号或// // // // / / // // // // // //零扩展。让RN = PUT_INPUT_IN_REG(CTX,输入[0],varlowValueMode :: none); //尝试看看我们是否可以将第二个操作数量编码为即时// 12位,也许通过否定它; //否则,将其放入寄存器中。让(RM,否定)= put_input_in_rse_imm12_maybe_negated(ctx,输入[1],ty_bits(ty),randvvaluemode :: none); //基于可能的否定和控制//类型选择Alu子码。让alu_op =如果!否定{选择_32_64(ty,aluop :: Add32,aluop :: Add64)} else {splect_32_64(ty,aluop :: sub32,aluop :: sub64)}; //在vcode流中发出vcode指令。 CTX。发射(ALU_INST_IMM12(ALU_OP,RD,RN,RM); }

实际上,alu_inst_imm12包装器可以在一组可能的vcode指令中创建一个vcode指令(自从我们'重新选择最好的一个)。为了简单起见,我们' LL假设将生成Alurrr,即,所选指令是仅使用输入值的寄存器编码的指令。

图td vcode_vreg [vcode with虚拟寄存器] regalloc([register分配])vcode_rreg [vcode与实际寄存器] codegen([代码生成])machine_code(机器代码)vcode_vreg - > Regalloc - > vcode_reg - > codegen - > machine_code.

嘿,曾经想过Vcode中的v意味着什么?回到绘图板。虽然程序可以参考理论上无限数量的指令,但是每个引用理论上无限数量的值作为输入和输出,物理机器只有一个固定的一组容器,用于这些值:

要么他们必须生活在机器寄存器中:非常快速地访问CPU,采取一些CPU房地产,因此昂贵,所以它们通常很少有。

或者他们必须生活在这个过程中'堆栈内存:'访问速度较慢,但​​我们几乎可以有多余的堆栈插槽。

在X86机器代码的这个例子中,%EDI,%RSI,%RBP,%EAX都是所有寄存器;堆栈插槽是计算为帧指针(%RBP)加上偏移值的存储器地址(此处遇到负面)。注意,堆栈槽可以由堆栈指针(%RSP)一般参考。

映射IR值的问题(在vcode中,这些是reg)到机器"容器"被称为寄存器分配(aka regalloc)。注册分配的输入可以像我们想要的那样众多,并将其映射到"虚拟"值,因此我们称为虚拟寄存器。和......那个vcode的v的v:vcode的v:vcode参考值中的指令,即在注册分配之前是虚拟寄存器的,所以我们说代码是虚拟化的寄存器表单。寄存器分配的输出是一组新指令,其中虚拟寄存器已被真实寄存器(物理版本,数量限制)或堆栈插槽引用(以及其他附加元数据)替换。

//在寄存器分配之前,使用无限制的虚拟寄存器:v2 = v0 + v1 v3 = v2 * 2 v4 = v2 + 1 v5 = v4 + v3返回v5 //一个可能的寄存器分配,在具有2寄存器%r0的机器上, %R1:%R0 =%R0 +%R1%R1 =%R0 * 2%R0 =%R0 + 1%R1 =%R0 +%R1返回%R1

当一切都很好,虚拟寄存器在概念上同时垂直,他们可以放入物理寄存器。当存在的问题时出现问题,没有足够的物理寄存器来包含同时居住的所有虚拟寄存器,这是......一个非常大的程序。然后,寄存器分配必须决定在给定程序点的寄存器中继续留在寄存器中,并且应该将其溢出到堆栈插槽中,有效地将它们存储到堆栈上以供以后使用。此后重用将暗示使用负载机指令从堆栈插槽重新加载它们。复杂性驻留在选择应该溢出的寄存器,在哪个程序点,他们应该溢出,并且如果我们需要这样做,我们应该重新加载它们。由于内存访问堆栈的内存访问额外的运行时,因此对生成的代码的速度产生了很大的影响。例如,常用在热环中使用的变量应该生活在整个环路的寄存器中,而不是在循环的中间溢出/重新加载。

//在寄存器分配之前,使用无限虚拟寄存器:V2 = V0 + V1 V3 = V0 + V2 v4 = V3 + V1返回V4 //一个可能的寄存器分配,在具有2寄存器%R0,%R1的机器上。 //我们需要溢出一个值,因为有一个点在3个值同时生活!溢出%R1 - > Stack_slot(0)%R1 =%R0 +%R1%R1 =%R0 +%R1重新加载Stack_Slot(0) - > %R0%R1 =%R1 +%R0返回%R1

而且,由于我们喜欢我们的蛋糕并吃它,寄存器分配器本身应该快速:它不应该采取无限的时间来制作这些分配决策。注册分配具有良好的味道,是NP完整的问题。具体而言,这意味着实现无法找到任意输入的最佳解决方案,但它们' LL估计基于启发式的好的解决方案,在最差的轮廓上,在输入的大小上的最坏情况。所有这一切都使得注册分配有自己的整个研究领域,并且现在已经广泛研究了一段时间。这是一个令人着迷的问题。

回到Cranelift。寄存器分配合同是,如果值必须在给定的程序点处生活在真实寄存器中,那么它确实存在它应该(除非寄存器分配不可能)。在vcode指令的代码生成开始时,我们保证输入值在实际寄存器中生活,并且输出实际寄存器可在下一个vcode指令之前获得。

您可能已经注意到vcode指令只引用寄存器,而不是堆栈插槽。但堆栈插槽在哪里?诀窍是堆栈插槽对vcode是不可见的。寄存器分配可以创建任意数量的溢出,重新加载和寄存器围绕vcode指令移动4,以确保满足其寄存器分配约束。这就是为什么寄存器分配的输出是一个新的指令列表,它不仅包括regalloc添加了填充有实际寄存器的初始指令,还包括额外的溢出,重新加载和移动(vcode)指令。

如前所述,这个问题是如此复杂,涉及并独立于代码的其余部分(假设正确的接口组!)其代码在单独的箱子Regalloc.rs中生活,具有自己的模糊和测试基础设施。我希望在某些时候揭示它。

今天对我们有趣的是寄存器分配约束。考虑AARCH64整数ADD指令添加RD,RN,RM:RD是' s写入的输出虚拟寄存器,而RN和RM是输入,从而读取。我们需要向寄存器分配算法通知这些约束。在Regalloc Jargon,"读到"被称为使用,"写入"已知定义。这里,AARCH64 vcode指令Alurrr确实使用RN和RM,并且它定义RD。在AARCH64_GET_REGS函数中收集此使用信息(CRANELIFT / CODEGEN / SRC / ISA / AARC64 / INST / MOD.RS):

fn aarch64_get_regs(Inst:& Inst,Collector:& mut RegusageCollector){匹配inst {& Inst :: Alurrr {Rd,Rn,Rm,..} => {收藏家。 add_def(rd);收藏家。 add_use(rn);收藏家。 add_use(rm); } // 等等。

然后,在注册分配已分配物理寄存器后,我们需要通过物理注册提到来指示如何替换虚拟寄存器提到。这是在AARCH64_MAP_REGS函数(如上所述的文件)中完成:

fn aarch64_map_regs< 朗姆酒:regusagemapper> (Inst:& mut Inst,Mapper:& rum){// ...匹配intr {& mut Inst :: Alurrr {ref mutrd,ref mutrn,ref mutrm,..} => {map_def(映射器,rd); Map_USE(Mapper,R ......