Cranelift,第3部分:注册分配中的正确性

2021-03-20 09:15:22

这篇文章是关于Cranelift的三部分系列中的最后一个。在第一篇文章,Icovered整体背景和指令选择问题中;在第二个帖子中,通过仔细的algorithmicdesign,它深入潜入编译器性能。

在这篇文章中,我想潜入我们如何工程师和工作的核查正确性,这也许是编译项目最重要的方面。编译器通常是一个复杂的野兽:要获得可征性的性能,必须执行相当复杂的分析,并以保护其传派的方式进行全面转换任意程序。有可能会犯错误,特别是在Components之间的裂缝和裂缝中犯错误。尽管所有这一点,正确的代码生成都会生长动态,因为MISCompilation的后果可能是如此严重:基本上我们担保(安全相关或其他方式),我们将获得更高水平的系统堆栈依赖于计算机的(判断!)假设将执行源代码我们忠实写的。如果编译器转换我们的代码TOSOMETHETHETHETET,那么所有的投注都已关闭。

有一种方法可以应用良好的工程原则,使这种风险造成这种风险。一个极其强大的技术从考虑结果通常比计算器更容易,而且如果我们随机生成许多输入,则在这些输入上运行我们的编译器(Or其他程序),并检查其输出,我们可以达到统计近似索赔“对于所有输入,Compilergenerates正确的输出”。我们尝试的越随随机的输入,TheStronger这个陈述变成了。这种技术被称为模糊与程序特定的Oracle,我可以致以冗长的颂歌,以找到其不可思议的权力,以找到错误(已经其他众多)。

在这篇文章中,我将介绍我们如何努力确保在Ouregister分配器,Regalloc.rs中的正确性,通过抽取一个符号勾选操作,使用抽象解释来证明特定的分配结果的正确性。通过使用该检查器作为模糊的甲骨文,并以带有聚焦的模糊目标驾驶寄存器分配器,我们已经能够揭示一些非常有趣和微妙的错误,并对分配者的稳健性达到相当高的信心。

在我们潜入之前,我们需要涵盖一些基础知识。最重要的是:寄存器allocationProblem是什么,它很难?

在典型的编程语言中,程序可以在范围内具有Arbitrary数或值。这是一个非常有用的方法:当一个人不担心存储值时,它最容易描述一种算法。

void f(){int x0 = compute(0); int x1 = compute(1); // ... int x99 = compute(99); // ---消费(X0);消耗(x1); // ...消费(x99);}

在程序的中点(---标记)中,有100个界面尺寸的值已经计算,稍后使用。编译器为此函数生成机器代码,存储在哪里有ofonvalues?

对于只有少数值的小功能,很容易将每个值放在CPU寄存器中。但大多数CPU没有100个用于存储整数的普通普通标记器;通常,大多数语言不会对局部变量的数量或大量的限制放置很多,远远高于典型的函数的寄存器。因此,我们需要一些方法,即在使用中,约占16个值(x86-64)或约32个值(AARC64)。

一个非常简单的答案是为每个临时变量分配内存位置。事实上,这正是C编程模型产品:上面的所有XN变量,在语义上inmemory,我们可以拍摄地址& xn。如果这样做,则一个WillFind,即地址是堆栈的一部分。当函数已被配置时,它会分配一个名为Crack frameand的堆栈上的新区域,它将使用它来存储局部变量。

然而,这远未做到最好的事情!考虑这意味着什么,我们实际上在当地人上执行了一些操作。如果我们读过剖面,请执行此外,并将结果存储在第三个中,如下所示:

然后在机器代码中,因为大多数CPU没有指令OCAN读取两个内存值并回写第三个内存结果,我们需要发出以下内容:

LD R0,[X1的地址] LD R1,[X2地址]添加R0,R0,R1 // R0:= R0 + R1ST R0,[X0的地址]

以这种方式编译代码非常速度,因为我们需要最重要的是:例如,变量引用始终成为一个存储器。这就是“基线JITCompiler”通常是如何工作的,实际上:例如,在SpiderMoNkey JS和沃斯JIT编译器中,基线JIT层 - 这意味着非常快速地赚取代码 - 实际上保持了一个堆栈的值-to-o-js字节码或wasmbytecode的值堆栈。 (您可以在此处读取代码:它实际上,在固定寄存器和内存中的其余堆栈中,它实际上保留了一些最新的值。)

不幸的是,每次操作都有多次访问内存,isvery慢速。更重要的是,往往是在制作后的价值是重索的情况:例如,我们可能有

当我们使用X0计算X3时,我们是否在存储后重新加载X0的值,在存储后,我们会重新加载X0的值?更智能的编译器应该能够成名,它刚刚计算了该价值,并应将其保留在重大内存中,避免完全通过内存进行往返。

这是注册分配:它在程序中为存储寄存器分配值。什么使得(如上所述)的注册分配兴趣是,CPU寄存器比允许程序值的Numbers更少,所以我们必须选择一些value子集以保持在寄存器中。这通常在某些方面受到约束:例如,risc样CPU上的添加指令只能从寄存器中才能写入,并将值的存储位置必须在A +运算符使用之前立即寄存器。幸运的是,位置分配可以随着时间的推移而变化,因此在机器代码的不同点,寄存器可以被识别到保持不同的值。寄存器分配的作业,决定如何在内存和寄存器之间播放值,以便在任何给定时间在任何时候都需要在寄存器中的值。

在我们的设计中,寄存器分配器将接受为输入称为“虚拟寄存器代码”或vcode的最大机器代码。 ThisHas一系列计算机指令,但在TheInstructions中命名的寄存器是虚拟寄存器:编译器可以在需要时使用尽可能多的象限。寄存器分配器将(i)将注册器重写为实际机器寄存器名称的说明书,(ii)根据需要将说明插入数据。当它们将值从IsGister移动到内存时,XILLESSUSTIONS称为SPILLS;从存储器返回寄存器的值移动值时重新加载;并在移动重复之间移动值时移动。当没有INREGENTERS时存储值的内存位置称为SPILL槽。

该分配在具有两个寄存器的机器上执行(R0和R1)。在左侧,原始程序用虚拟寄存器以亚组件形式编写的。在右侧,已修改程序查询仅使用实际寄存器。

在每个指令之间,我们已将VirtualRegisters的映射写入真实寄存器。寄存器分配器的任务只是(“只是”!)来计算这些映射,然后编辑指令,通过这些映射获取其注册参考。

请注意,在一点,程序有三个实时值,仍然必须保留的orvalues,因为它们稍后将被使用:第一和第二条指令之间,所有V0,V1和V2ARE LIVE。该机器只有两个寄存器,因此它不能阻止它们中的所有值;它必须溢出至少一个。这是溢出指令的原因,写入堆栈插槽的存储[SP + 0]。

通常,寄存器分配器将首先分析程序磁带,其中值在哪个程序点处。这种满足信息和相关约束指定了组合aloptimizationProblem:某些值必须存储在每个点的某处,约束限制可以进行哪些选择,有些选择将与其他一些(例如,两个值不能同时占用Registy),以及一个设置选择意味着一些成本(在数据项中)。分配器将解决此优化问题,因为它可以使用某种排序的启发式,具体取决于注册放置。

这是一个难题吗?事实上,它不仅难以讨论口语意义,而且没有难以完成:这是它与任何其他NP问题一样艰难,我们知道最坏情况下的次为期态度蛮力算法。 2 3 Thereash是问题没有最佳状态:Itcannot被分解成无关的部分,每个都可以求差,然后建立在整体解决方案中;相反,决定因点在其他地方的决定而受到影响,可能是功能障碍体内的其他任何地方。因此,在最坏的情况下,如果我们想要最佳解决方案,我们就无法做得更好。

最佳寄存器分配有许多良好的近似值。 Acommon一个是线性扫描注册放置,它可以运行最碱性的时间(相对于代码大小)。分配者可以花费更多时间的时间更加复杂:例如,在Regalloc.rs中,除了线性扫描仪(由我的辉煌同事Benjamin Bouvier写),我们有一个“回溯”算法(由我的其他辉煌的同事朱利安写可以编辑Andimprove的Seward,因为它发现寄存器的更高优先级使用。

这些算法如何工作的细节在这里真的很重要,除了说它们非常复杂,难以完全。在概念概念或伪码中看起来相对简单的算法很快运行到有趣的和子节点中,作为真实的约束蠕变。Regalloc.RscodeBase是大约25k线的深度算法生锈码;可征定的工程师预计这将包括至少几宝人!在此处复制紧急性,一个寄存器分配错误CANRESULT,因为reglarallocator负责“修改”的所有数据流。如果我们在程序中与另一个ArbitraryValue交换一个任意值,则可能发生任何事情。

所以我们想写一个正确的寄存器分配器。我们甚至如何启动这样的任务?

它可能有助于分解我们的意思“正确”。请注意,在上生计分配问题具有很好的属性:分配之前和之后的程序都有明确定义的语义。以鞘内,我们可以将注册分配视为转换,转换在无限寄存器机器上运行的程序(我们可以使用正如我们想要的许多虚拟寄存器)到有限寄存器机器(CPU具有固定集的ofRegisters)。如果在无限寄存器机器上的原始程序与变换(寄存器分配)的结果相同的结果,则有限寄存器机器,那么我们已经实现了纠正符号分配。

测试两个程序是否等效的最简单方法是运行并比较结果!假设我们这样做:对于单个程序,请选择一些随机输入,并在替代interparters上与其寄存器分配的版本一起运行虚拟注册。将寄存器和内存状态进行比较。

如果最终机器态匹配,那意味着什么?这意味着对于这个程序,我们的寄存器分配器产生了对该一个程序输入正确的转换编程。请注意此处的两个。首先,我们并不一定表明,给出了另一个程序输入的原始分配。也许方面的输入使分支逐步下降另一个程序路径,并且寄存器分配器在该路径上引入了一个错误。其次,Wehave没有任何其他计划的任何东西;我们只测试了Asingle程序及其寄存器分配的输出。

我们可以尝试解决唯一输入的第一个限制 - 通过获取更多采样点来解决一个输入。例如,我们加入了一千个随机的程序输入,甚至用某种反馈驱动了这个OrceChoice,这些反馈试图最大化控制 - 流量计或其他“有趣的”行为(作为模糊DO)。我们可以允许合理的信心,即获得足够的测试用例的单一注射器位置是正确的。

但是,这仍然非常昂贵:我们要求运行whole程序n次以获得n的样本大小。即使是一个单调可能是昂贵的:我们具有conferenceRegister分配的程序可能是编译器或视频名例子。

我们可以避免需要运行该程序以测试Itsregister分配的版本是否正确?

答案令人惊讶的是简单:是的,我们可以通过简单地改变程序执行的域。通常,我们认为CPURYGISTERS包含具体数字 - 例如,64位值。它们包含符号吗?

通过用符号概括程序值,我们可以在没有关心这些输入的情况下的输入方面经常验证系统的状态。例如,给定程序:

没有象征推理,我们可以存储任意整数的Tomemory位置A,B和C,并模拟程序的刽子手和寄存器分配后,从未看到不匹配,除非我们通过所有可能的价值迭代,否则不会证明任何东西。但是,如果我们假设在三个负载之后,R0Contains v0(作为符号值,无论是什么),R1包含v1,R2包含v2,并且R0包含v3在第二个添加之后virst添加和v4之后,我们可以请参阅符号匹配符号的对应。

这是一个非常简单的例子,也许欠销售这种方法的洞察力;我们会在稍后在下面的抽象解释后回到它。

在任何情况下,我们所表明的是,对于那些在其中的单一实例分配问题,我们可以证明它以正确的方式转变为本方案。具体地,这意味着我们生成的机器编码将只是我们解释virtual-registic码的仿佛;如果我们可以正确生成虚拟注册码,那么我们的编译器是正确的。这很棒!我们可以讲什么?

我们可以证明a-priorti,即寄存器分配器将以正确的方式实现任何程序。换句话说,Wecould摘要不仅通过对程序的输入值,而且在程序本身上。

如果我们可以证明这一点,那么我们无需运行任何类型的检查Atruntime。摘要通过节目输入让我们避免需要运行程序;我们知道寄存器分配对于所有征价都是正确的。以类似的方式,通过该程序向注销分配的程序抽象,让我们避免运行注册器;我们知道寄存器分配器对所有程序都是正确的,并且对这些程序的所有输入都是正确的。

人们可以想象这更难。事实上,它已经完成了,但是一个重大的证明工程努力,并且是一个境界的境界:这基本上需要编写一个机器可验证的编译算法是正确的。存在这种经过验证的正确代表程序:例如,Compercert已正确编译C为机器代码,用于多功能表。不幸的是,这种努力受到必要的防护工程工作的强烈限制,因此这种方法对于编制者来说是可行的,除非是他们的主代数。

鉴于上述所有内容,我们选择我们认为的是MOSTOMABLE的权衡:我们为寄存器分配器的输出构建一个符号检查器。这不会让我们制作一个静态索赔,大家寄存器分配器是正确的,但它确实让我们证明任何给定的编译器运行都是正确的。如果我们使用此作为Oracle的Oracle,我们可以构建其对所有编译器运行的统计信心。

有两种方式可以在系统中添加ISGISTER分配器检查器。首先,在左侧,我们调用“运行时检查”:在此模式下,每个寄存器分配器执行都被检查和使用分配的机器代码不允许执行(iethe编译器不会返回结果),直到校验器验证等价这是最安全的模式:它提供了与经过验证的纠正符(上面的“全程程序等价”)相同的保证)。但是,它在每一个编译时都会施加一些可能是不可取的。出于这个原因,在使用Checker运行寄存器分配器的同时是Incranelift的支持选项,它不是默认值。

第二种模式是我们将验检器应用于模糊工作流程的模式,并且是我们通常优选的方法(我们有一个fuzztargetin regalloc.rs,它生成任意输入程序,并在每个方面运行Thechecker;我们正在运行这部分在谷歌的OSS-Fuzz持续模糊原因的瓦尔马斯的成员资格)。在此模式下,我们将Checker作为模糊发动机的特定于Anapplication的Oracle:作为模糊发动机开始随机程序(测试用例),我们运行寄存器分配器的溢断程序,运行结果,并告诉发动机是否有转储器分配器通过或失败。 Fuzzer将为人类开发人员标记任何未能的测试程序以调试。如果模糊器运行长时间发现任何问题,那么我们可以更有信心,即使没有运行检查器,也可以更有信心。速度越长,我们的信心就越越大。特定于应用程序特写镜在更加通用的模糊反馈机制上,Suchas程序崩溃或输出不正确:寄存器分配器错误可能在不正确的执行中明显清单,或者,结果克拉斯可能与实际错误分配没有明显的连接登记。 Thechecker能够指向特定的指令用途的特定寄存器,并说出“这个寄存器是错误”。这样的结果使得更加平滑!

现在让我们步行我们如何构建目标是难以确定特定寄存器分配的“检查器”。我们将以阶段的解决方案,首先推理最简单的案例-Sstraight-Line Code - 然后引入控制流程。最后,我们将具有一种简单的算法,其在线性时间(相对TADODE大小)运行,其简单性允许我们合理地信集其保证。

回想一下,我们描述了一种象征性的象征性解释:可以推理包含“符号”值的CPU寄存器,其中每个符号表示原始代码中的虚拟寄存器。例如,我们可以采取代码

MOV R0,1 [R0 = V0] MOV R1,2 [R1 = V1]添加R0,R0,R1 [R0 = V2]返回R0

但我们如何解决这些替换?回想一下,上面以一种在符号而不是符号而不是符号的执行形式。我们可以简单地获取原始指南的语义,并将其重新装述以在符号值上运行,而是通过代码进行步骤,以便立即找到族人的寓言。这被称为符号执行,并在下面描述的一些增强功能,是抽象的摘要4.it是一种非常强大的技术!

这里相关的指令集的语义是什么?事实证明,因为寄存器分配器没有修改任何程序的原始说明5,我们可以理解各个inecritystruction大多是一个任意的不透明运算符。唯一的河豚

......