编译器设计(十二)

您所在的位置:网站首页 面膜广告词简短吸引人 编译器设计(十二)

编译器设计(十二)

#编译器设计(十二)| 来源: 网络整理| 查看: 265

文章目录 一、简介二、代码生成三、扩展简单的树遍历方案四、通过树模式匹配进行指令选择4.1 重写规则4.2 找到平铺方案 五、通过窥孔优化进行指令选择5.1 窥孔优化5.2 窥孔变换程序 六、高级主题6.1 学习窥孔模式6.2 生成指令序列 七、小结和展望

一、简介

指令选择(instruction selection),将编译器的IR映射到目标ISA,这实际上是一个模式匹配问题,其复杂性源自常见的ISA为(即使是简单的)操作提供的大量备选实现方案。

在其最简单的形式下,编译器可以为每个IR操作提供一个目标ISA操作序列。由此形成的指令选择器提供了一个类似模板的展开,最终会生成正确的代码。遗憾的是,这种代码对目标机资源的利用比较糟糕。更好的方法会对每个IR操作考虑许多可能的候选代码序列,并从中选择预期代价最低的代码序列。

本章阐述指令选择的两种方法:一种基于树模式匹配,另一种基于窥孔优化。前一种方法依赖于对编译器IR和目标机ISA的一种高层次的树表示法。后一种方法将编译器的IR转换为一种底层线性IR,并对后者进行系统化地改进,然后将其映射到目标机ISA。每种方法都能产生高质量的代码,与局部上下文颇为适应。每种方法都已经集成到某些工具中,这些工具以目标机描述为输入,产生一个可工作的指令选择器。

二、代码生成

要为IR形式下的程序生成可执行代码,编译器后端必须解决3个问题。

后端必须将IR操作转换为目标处理器ISA中的操作,这个处理过程称为指令选择(instruction selection);它必须为这些操作选择一种执行顺序,这一处理过程称为指令调度(instruction scheduling);它还必须决定,在最终代码中的每个位置上,哪些值应该位于寄存器中,哪些值应该位于内存中,这一处理过程称为寄存器分配(registerallocation)。

大多数编译器分别处理这三个过程,这三个不同但相关的处理过程通常一同被置于术语“代码生成”的条目下,尽管指令选择器的首要职责是生成目标机指令。

指令选择的复杂性起因于常见的处理器都对同一计算提供了许多不同的方法。现在暂且不考虑指令调度和寄存器分配的问题,后面会再总结这两块内容的文章。如果每个IR操作在目标机上只有一种实现,那么编译器只需将每个IR操作重写为等价的机器操作序列即可。但在大多数情况下,目标机都提供了多种方法实现每一个IR结构。

例如,考虑从一个通用寄存器 r i r_i ri​向另一个通用寄存器 r j r_j rj​复制一个值的IR结构,假定目标处理器使用ILOC作为其本机指令集,可能的实现方式包括了如下图所示的操作: 在这里插入图片描述 每个备选指令序列都有自身的代价。大多数现代计算机都实现了简单操作如i2i、add和lshift,因此它们能够在单周期内执行;一些操作(如整数乘法和除法)可能会花费更长时间;而内存操作的速度则取决于许多因素,包括计算机内存系统的当前详细状态。

在某些情况下,一个操作的实际代价可能取决于上下文环境。例如,如果处理器有几个功能单元,使用复制以外的操作在一个利用率不足的功能单元上执行寄存器到寄存器的复制,可能是更好的选择。如果所选的功能单元是空闲的,那么这个操作在实际上是“免费的”。将操作移动到利用率不足的功能单元上,实际上可能会加速整个计算。如果代码生成器必须将复制操作重写为只能在利用率不足的功能单元上执行的某个特定操作,那么这是一个指令选择问题。如果同一个操作能够在任何功能单元上运行,那么这是一个指令调度问题。

在大多数情况下,编译器编写者想要后端生成运行快速的代码。但有可能有其他的度量方式。例如,如果最终的代码将运行在靠电池供电的设备上,那么编译器可能会考虑每个操作的典型能耗。(不同操作可能消耗不同的能量。)在试图针对能耗进行优化的编译器中所说的成本,可能与针对执行速度进行优化的编译器所指的成本有根本性的不同。处理器能耗严重地依赖于底层硬件的细节,因而在处理器的不同实现之间很可能发生变换。类似地,如果代码长度是关键因素,那么编译器编写者可能会完全基于代码序列长度来指派成本。另外,对于实现同一语义效果的多种代码序列中,编译器编写者可能会从中去除掉多指令序列而只留下单指令序列,因为较短的代码序列只需从内存获取较少的字节,减少代码长度同时也会降低能耗。

三、扩展简单的树遍历方案

考虑为赋值语句(如 a ← b − 2 ∗ c a \gets b-2*c a←b−2∗c)生成代码时可能出现的问题,该赋值语句可以表示为一个抽象语法树(AST),或表示为四元组组成的一个表,如下图所示。 在这里插入图片描述 有一种简单的树遍历例程能够从表达式的AST生成代码,该方法对特定AST结点类型的每个实例都生成同样的代码。虽然这样做生成了正确的代码,但它未能利用时机针对具体的环境和上下文来调整代码。如果编译器会在指令选择之后执行重要的优化,那这可能不是问题。但如果没有后续的优化,最终的代码很可能包含一些明显的低效之处。

下图是表达式的简单树遍历代码生成器,可以处理变量和数字的二元运算+、-、*、/。对于变量,该代码依赖于两个例程base和offset来获取基地址和偏移量,然后将返回值载入寄存器。该代码接下来输出一个loadAO操作,该指令会将基地址和偏移量相加产生一个有效地址,并取回内存中该地址处的数据。因为AST并不区分变量的存储类别,base和offset大概是查询符号表来得到其所需的额外信息的。 在这里插入图片描述 要推广此方案,使之能够处理一组更为实际的情况(包括具有不同表示长度的多种变量、传值和传引用参数、整个生命周期都驻留在寄存器中的变量等),需要编写显式代码来核对所有这些情况(对每个引用处都需要核对)。这将使处理IDENT的case语句代码长度大增(速度大降),使手工编码树遍历方案的简单性大打折扣。

树遍历方案中处理数字的代码同样颇为“朴素”。该代码假定数字在每种情况下都应该已经载入一个寄存器,且val能够从符号表检索该数字的值。如果使用该数字的操作(数字在树中的父结点)在目标机上有一种立即形式(immediate form),且数字常量的值能够载入到立即字段(immediate field)中,那么编译器应该使用立即形式,因为其使用的寄存器少一个。如果立即数操作不支持数字的类型,那么编译器必须安排将该值存储在内存中,并生成一个适当的内存引用操作来将该值加载到寄存器中,这进而可能为进一步的改进创造时机,诸如将常量保持在寄存器中。

考虑如下图所示的3个乘法操作,符号表注释显示在树中叶结点下面。对于标识符来说,注释包括名字、表示基地址的标号(或是表示当前活动记录的ARP)和相对于基地址的偏移量。每棵树下面分别是两个代码序列:有简单树遍历求值程序生成的代码,和我们希望编译器生成的代码。在处理第一种情况e*f时,树遍历方案的低效性在于,它并不生成loadAI操作。在处理IDENT的case语句中使用更复杂的代码可以解决这个问题。 在这里插入图片描述 而第二种情况e*2就更为困难了,代码生成器可以用一个multI操作实现乘法。但代码生成器必须从超出局部上下文的视角观察,才能发现这一事实。为将该功能集成到树遍历方案中,处理*的case语句需要识别求值结果为常量的子树。另外,处理NUM结点的代码可能需要判断其父结点是否能够用立即数操作实现。总之,对这一情形的有效处理需要得到非局部的上下文知识,而这破坏了简单的树遍历范式。

第三种情况g*h有另一个非局部的问题。*的两个子树都引用了相对于自身基地址偏移量为4处的变量,两个引用的基地址不同。原来的树遍历方案会为每个常最生成一个显式loadI操作:@G、4、@H、4。正如前面提到的那样,修订过的一个版本使用loadAI,但其或者会对@G和@H分别生成loadI操作,或者对4生成两个loadI操作。(当然,到这里,@G和@H两个值的位宽开始起作用了。但如果二者位宽太大,那么 编译器必须使用4作为loadAI操作的立即操作数。)

这第三个例子的基本问题在于,最终代码事实上包含了一个公共子表达式,而这一事实在AST中是隐藏的。为发现这个冗余并适当地处理它,代码生成器需要的是这样的实现代码:能够显式检查子树的基地址和偏移值,并对所有情况都能生成适当的代码序列。以这种方式处理单个情况颇显笨拙,而处理所有可能出现的类似情况,增加的代码编写工作量将达到不可接受的地步。

捕获此类冗余的更好方法是,在IR中暴露冗余细节并让优化器消除它们。对于例子中的赋值 a ← b − 2 ∗ c a \gets b-2*c a←b−2∗c,前端可能生成如下图所示的底层树。这个树有几种新结点。 在这里插入图片描述 Val结点表示已知驻留在寄存器中的一个值,如 r a r p r_{arp} rarp​中的ARP;Lab结点表示一个可重定位符号,通常是一个汇编层次的标号,用于代码或数据;一个 ⧫ \blacklozenge ⧫结点表示一层间接,其子结点是一个地址,该 ⧫ \blacklozenge ⧫结点获取存储在对应地址的值。这些新的结点类型要求编译器编写者规定更多的匹配规则。而作为回报,将能够优化额外的细节,如g*h中对4的重复引用。

相对于目标指令集ILOC来说,这一版本的树实际上是在更低的抽象层次上暴露了相关细节。例如,考察这个树可以发现,a是一个局部变量,存储在相对于ARP的偏移量4处;b是一个传引用参数(请注意两个 ⧫ \blacklozenge ⧫结点);而c存储在相对于标号@G的偏移量12处。此外,loadAI和storeAI操作中隐含的加法,在这个树中是显式给出的:作为 ⧫ \blacklozenge ⧫结点的子树或 ← \gets ←结点的左子结点。

在AST中暴露更多的细节应该能产生更好的代码,增加代码生成器考虑的备选目标机指令数目,也应该能产生更好的代码。但这些因素的共同作用导致了代码生成器可以发现许多不同方法来实现一个给定的子树。简单树遍历方案对每个AST结点类型有一种方案。为有效利用目标机指令集,代码生成器应该按实际情况考虑尽可能多的可能性。

这种增加的复杂性,并非起因于一种特定的方法学或具体的匹配算法;相反,它反映了实际的底层问题的一个基本特征:任何给定的机器都可能提供多种方法来实现一个IR结构。在代码生成器考虑给定子树的多种可能匹配时,它需要一种方法从中作出选择。如果编译器编写者可以将一个成本关联到每个模式,那么匹配方案可以用一种最小化成本的方式来选择候选模式。如果成本真实地反映了性能,那么这种成本驱动的指令选择应该能够产生良好的代码。

编译器编写者需要工具帮助管理对真实机器进行代码生成的复杂性。编译器编写者并不需要编写代码显式遍历IR并判断每种操作的可应用性,实际上他应该规定一些相关的规则,而由工具来生成相关的代码,将规则与代码的IR形式进行匹配。针对为现代机器指令集进行代码生成的复杂性,接下来的两节将探讨管理这种复杂性的两种不同方法。

四、通过树模式匹配进行指令选择

编译器编写者可以利用树模式匹配工具来处理指令选择的复杂性,为将代码生成转换为树模式匹配,程序的IR形式和目标机的指令集都必须表示为树。编译器可以将底层AST作为被编译代码的一个中间表示,也可以使用类似的树来表示目标处理器上可用的操作。例如,ILOC的加法操作可以通过像下图给出的操作树来模拟。通过系统化地将操作树匹配到AST的子树,编译器可以发现子树的所有可能的实现。 在这里插入图片描述 为利用树模式,使用前缀表示法来描述树。将add的操作树写为+(ri, rj),addI的操作树写为+(ri, cj)。操作树的叶结点包含了有关操作数存储类型的信息。例如,在+(ri, cj)中,符号r表示寄存器中的一个操作数,而符号c表示已知的常量操作数。将上图11-3中的AST重写为前缀形式,它将变为: 在这里插入图片描述

给定一个AST和一组操作树,目标是将操作树平铺(tiling)到AST上,从而将AST映射到操作。平铺是一组 < a s t − n o d e , o p − t r e e > 对,其中 a s t − n o d e ast-node ast−node是AST中的一个结点,而 o p − t r e e op-tree op−tree是一个操作树。平铺中的一个 < a s t − n o d e , o p − t r e e > 对表明: o p − t r e e op-tree op−tree表示的目标机指令可以实现 a s t − n o d e ast-node ast−node结点。当然,对 a s t − n o d e ast-node ast−node实现的选择取决于其子树的实现,平铺将对 a s t − n o d e ast-node ast−node的每个子树指定一个“连接”到 o p − t r e e op-tree op−tree的实现。

当一个平铺实现了AST中的每个操作,且每个平铺元素都与其邻元素相连接,那么平铺就实现了AST。对于平铺元素 < a s t − n o d e , o p − t r e e > ,如果 a s t − n o d e ast-node ast−node被平铺中的另一个操作树 o p − t r e e op-tree op−tree的一个叶结点涵盖,或者 a s t − n o d e ast-node ast−node是AST的根结点,那么称该平铺元素与其邻元素相连。当两个操作树重叠时(在 a s t − n o d e ast-node ast−node处),二者公共结点的存储类别必须是一致的。例如,如果二者都假定公共值位于寄存器中,那么两个操作树的代码序列是兼容的。如果一个假定值位于内存中,另一个假定其值位于寄存器中,那么两个操作数的代码序列是不兼容的,因为它们无法正确地将该值从较低的树传输到较高的树。

给定一种实现AST的平铺,编译器可以通过自底向上遍历轻易地生成汇编代码。因而,这种方法实用性的关键在于,是否有算法能够快速地为AST找到良好的平铺。已经出现了几种将树模式针对底层AST进行匹配的高效技术。所有这些系统都将成本关联到操作树,并试图生成代价最小的平铺。它们的不同之处在于用来进行匹配的技术及其成本模型的通用性,匹配技术可能有树匹配、文本匹配和自底向上的重写系统,而成本可以是静态的固定成本,有可能是在匹配过程期间数值发生改变的成本。

4.1 重写规则

编译器编写者将操作树和AST子树之间的关系编码为一组重写规则。规则集合包含一个或多个规则,用于处理AST中的各种结点。重写规则由树状文法中的一个产生式、一个代码模板和一个相关成本组成。下图给出了一组重写规则,在用ILOC操作平铺我们的底层AST时使用。 在这里插入图片描述 上图中的规则形成了一个树形文法,类似于我们用于规定编程语言语法的那种文法。

每个重写规则或产生式的左侧是一个非终结符。在规则16中,这个非终结符是Reg。Reg表示该树形文法可能产生的一组子树,在这种情况下应使用规则6~规则25。规则的右侧是一种线性化的树模式。在规则16中,该模式是+(Reg1,Num2),表示Reg和Num这两个值的加法。

文法中的非终结符考虑到了抽象的可能性,它们用来连接文法中的各条规则。其中也编码了有关对应值在运行时以何种形式存储在何处的有关知识。例如,Reg表示子树生成的存储在寄存器中的一个值,而Val表示巳经存储在寄存器中的一个值,Val可以是全局值,如ARP。它可以是在另一个不相交子树中执行的计算结果,即一个公共子表达式。

与一个产生式相关的成本,向代码生成器提供了对运行时执行模板中代码所需代价的现实估算。对于规则16,代价1反映了该树可能用单个操作实现,在单周期执行完成。代码生成器利用成本因素从可能的备选方案中选择。一些匹配技术所用成本的形式仅限于数值。而其他技术则允许成本在匹配期间改变,以反映先前的选择对当前备选方案成本的影响。

树模式可以用简单树遍历代码生成器无法做到的方式来捕获某些上下文环境。规则10~规则14中的每个都能匹配两个运算符( ⧫ \blacklozenge ⧫和+)。这些规则表明了可以使用ILOC运算符loadAO和loadAI的具体条件。任何能够匹配这五个规则之一的子树,都可以用其余规则的组合实现。匹配规则10的子树,还可以用规则15和规则9组合平铺:用规则15生成一个地址,用规则9从该地址加载值。这种灵活性使得这一组重写规则具有歧义,而这种歧义反映了目标机有几种方法实现这一特定子树的事实。因为树遍历代码生成器每次匹配一个运算符,它无法直接产生这些ILOC操作。

为将这些规则应用到树上,我们需要寻找一组重写步骤的序列,将树归约为一个符号。对于表示一个完整程序的AST,最终的这个符号应该是目标符号。对于内部结点,最终的符号通常表示对以表达式为根的子树求值的结果。该符号还必须规定这个值存储在何处,通常是在寄存器中、某个内存位置或是一个已知的常量值。

以下是对图 11-3 中的引用变量c的子树给出了一个重写序列。其左子树与规则6匹配,所以将左子树重写为Reg;重写后的树又可以与规则11匹配,所以最终将这个树重写为Reg。这个重写序列记作,它将整个子树归约为Reg。 在这里插入图片描述 对于这一平凡的子树,相关的规则也会产生许多重写序列,这反映了底层语法的二义性,下图给出了这些重写序列中的8个。前两个序列和,其成本为2;接下来的四个序列,其成本为3;最后两个序列,其成本为4。 在这里插入图片描述 为生成汇编代码,指令选择器需要使用与每个规则相关联的代码模板,一个规则的代码模板由一系列汇编代码操作组成,实现了由该产生式生成的子树。对AST的一个平铺规定了代码生成器应该使用哪些规则。代码生成器使用与规则相关的模板,在自底向上的遍历中生成汇编代码。它会按需提供名字以便将存储位置联系到一起,并输出与树遍历对应的实例化操作。

指令选择器应该选择一种能够生成最低成本汇编代码序列的平铺,下图给出了与每个可能的平铺对应的代码。 在这里插入图片描述 如果loadAI只能接受有限范围内的参数,那么重写系列可能不可行,因为最终代替@G的地址可能位宽过大,无法置于该操作的立即字段中。为处理此类约束,编译器编写者可以向重写语法引人有界常量的概念。对此可以采用一个新的终结符,它只能表示给定范围内的整数,如对于位宽12的字段,范围是0 l10 改变为jumpI => l11后,l10处开始的基本程序块将变为不可达代码,可以被删除。但是要证明l10是不可达,所需分析超出了窥孔优化能力的范畴。 在这里插入图片描述 早期的窥孔优化器使用有限的一组手工编码的指令,它们使用穷举搜索来匹配指令序列但却运行得很快,这是因为特定序列的数目很少而窗口也很小,通常只有两三个操作。

日益复杂的ISA需要窥孔优化使用更系统化的处理方法,不再只能匹配少量特定序列。现代的窥孔优化器会将其处理过程划分为3个不同的任务:展开、简化和匹配。 在这里插入图片描述 展开程序重写输入IR,将操作逐个转换为一系列底层IR(Lower-Level IR,LLIR)操作。展开程序的结构比较简单,各个操作可以逐一扩展,而无需考虑上下文。这个处理过程可以对每个IR操作分别使用一个模板,并在重写时用适当的实际值替换模板中的寄存器名、常量和标号。

简化程序会利用窥孔优化对LLIR进行一趟处理,通过一个小滑动窗口分析LLIR中的各个操作,并试图以系统化的方式改进这些操作。简化处理的基本机制是前向替换(用前面指令内容替换后面指令的操作数)、代数化简(例如x + 0 => x)、对常量值表达式求值(例如2 + 17 => 19)以及删除无用效应(如创建未使用的条件码)。因而,简化程序会在LLIR上对窗口中的操作进行有限的局部优化。

匹配程序对着模式库比较简化过的LLIR,寻找能够以最佳方式捕获LLIR中所有效应的模式。最终的代码序列可以产生LLIR代码系列之外的一些效应,例如,可以创建一个新的无用的条件码值。但保证正确性所需的那些效应是必须保留的。这一过程无法删除活动值,无论该值是存储在内存中、寄存器中还是位于某个隐式设置的位置(如条件码)。

下图说明了窥孔优化处理前面使用过的例子 a ← b − 2 ∗ c a \gets b-2*c a←b−2∗c时的三个过程。图的左上角是底层AST的四元组表示,a存储在局部AR中偏移量4处,b存储为一个传引用参数,其指针存储在相对于ARP偏移量-16处,而c存储在相对于标号@G偏移量12处;展开程序会创建右上方的LLIR代码,简化程序将其简化成右下方LLIR,最终匹配程序将构建左下角所示的ILOC指令。 在这里插入图片描述 下图给出了窥孔优化器简化LLIR的过程,假定窗口可以容纳3个操作。

序列1给出的窗口包含前三个操作,没有可能进行的简化,优化器向前滚动,使第一个操作(定义r10)退出窗口,将定义r13的操作载入窗口;在这一窗口中,优化器可以向前替换r13定义中的r12,由于这使得r12的定义成为死代码,所以优化器将丢弃r12的定义,并将另一个操作载入窗口底部,此为序列3;接下来,优化器将r13合并到定义m的内存引用中,产生了序列r14。在序列4上,没有可能进行的简化,因此优化器向前滚动,使r11的定义退出窗口。序列5同样无法简化,因此继续向前滚动,使r14的定义也退出窗口。序列6可以简化,将-16向前替换到定义r17的加法中,这一处理产生了序列7。优化器用这样的方式继续处理,在可能的情况下简化代码,一直前进到处理完成。在到达序列13时,处理过程将停止,因为该序列无法进一步简化,而又没有额外的代码可以载入到窗口。

比较简化过的LLIR与原来的LLIR,简化过的LLIR包括在窗口向前滚动时退出窗口的LLIR,以及简化过程停止时窗口中余下的LLIR。在简化之后,整个计算需要8个操作,而不是原来的14个;它使用7个寄存器(除了rarp之外),而不是原来的13个。 在这里插入图片描述

有几个设计问题会影响窥孔优化器改进代码的能力。检测死亡值的能力在简化过程中发挥了关键作用;控制流操作的处理将决定在基本程序块边界上会发生什么;窥孔窗口的大小会限制优化器合并相关操作的能力。接下来将对此讨论。

1)识别死亡值

简化程序处理下图中的例子时,它可以用2代替第二个操作中的r12,但它无法删除第一个操作,除非它知道r12在第二个操作中使用之后不再处于活跃状态,即该值已经死亡。那该如何知道它是否处于活跃状态呢?

可以通过活跃变量分析计算每个程序块的LiveOut集合,然后通过一趟对程序块的反向操作,跟踪每个操作处哪些值是活跃的;还可以利用半剪枝静态单赋值(看原书9.3 静态单赋值形式,371页介绍了几种不同风格的SSA形式)隐含的信息,识别出在每个程序块中出口处活跃的名字。

有了程序块出口处的活跃变量集合LiveOut,就可以计算每个操作处活跃的变量集合LiveNow。展开程序将操作的LiveNow初始值设置为操作所在块的LiveOut集合,如果没有LiveOut集合就设置为包含所有全局名字;然后自底向上遍历程序块,处理操作 r i ← r j r_i \gets r_j ri​←rj​ op r k r_k rk​时,将 r j r_j rj​和 r k r_k rk​添加到LiveNow,并将 r i r_i ri​从中删除;该算法在每一步都会产生一个LiveNow集合,可以利用其识别死亡代码。

2)控制流操作

控制流操作的存在将使简化程序复杂化,处理此类指令最容易的方式是在遇到分支、跳转或 带标号指令(被标号的指令可能会作为分支或跳转的目标)时清空简化程序的窗口,这防止了简化程序将效应移动到它们本不存在的代码路径上。

通过考察围绕分支的上下文环境,简化程序可以实现更好的结果,但这会向处理过程引入几个特例。如果输入语言中的分支指令具有单一目标和落空路径,那么简化程序应该跟踪并消除死亡的标号。如果它删除一个标号的最后一次使用,且此标号之前的程序块具有一个落空出口,那么可以删除该标号,合并两个程序块,并跨越旧的程序块边界进行简化。如果输入语言中的分支指令具有两个目标,或之前的程序块结束于跳转指令,那么死亡的标号意味着一个不可达程序块,该程序块可以完全删除。不论是哪种情况,简化程序都应该跟踪每个标号被使用的次数,并删除不再被引用的标号。(展开程序可以统计标号引用的次数,这使简化程序可以使用一种简单的引用计数方案来跟踪剩余引用的数目。)

一种更激进的方法可以考虑分支指令两个目标处的操作。一些简化可能会跨越分支指令进行,将紧接分支指令之前各个操作的效果与分支指令目标处操作的效果合并考虑。但简化程序必须考虑到能到达带标号操作的所有代码路径。

3)物理窗口与逻辑窗口

到目前为止,我们的讨论专注于包含底层IR中相邻操作的一个窗口,这一概念具有良好的物理直观,并使得概念颇为具体。但底层IR中的相邻操作未必操纵的是同样的值。随着目标机提供更多的指令级并行性,编译器的前端和优化器必须生成具有更多独立计算或交错计算的IR程序,以使目标机处理器的各个功能单元保持忙碌状态。在这种情况下,窥孔优化器很少能找到改进代码的时机。

为改进这一情况,窥孔优化器可以使用逻辑窗口而非物理窗口。在使用逻辑窗口的情况下,窥孔优化器可以考虑通过代码内部值的流动而联系起来的各个操作,即综合考虑定义并使用同一个值的各个操作。这创造了合并及简化相关操作的时机,即使这些操作在代码中并不相邻或者位于不同的基本块。

在展开期间,优化器可以将每个定义连接到其值在程序块中的下一次使用,简化程序使用这些关联来填充其窗口。在简化程序到达操作i时,它会为i创建一个窗口,并将与i的结果有关联的操作载入窗口。(因为简化在很大程度上依赖于前向替换,所以除非下一个物理操作使用i的结果,否则没什么理由去考虑它。)使用程序块内部的逻辑窗口,可以使简化程序更为有效,既可以减少所需的编译时间,也可以减少简化之后剩余的操作数目。在我们的例子中,逻辑窗口可以使简化程序将常量2合并到乘法中。

5.2 窥孔变换程序

系统化的窥孔优化器的出现,使得有必要为目标机汇编语言创建更完备的模式集合。因为窥孔优化的三步过程会将所有操作转换为LLIR,并试图简化所有的LLIR代码序列,所以匹配程序需要有能力将任意的LLIR代码序列转换为目标机的汇编代码。因而,与早期的部分系统相比,这些现代的窥孔系统具有大得多的模式库。随着计算机从16位指令发展到32位指令,不同汇编操作数目的爆发性增长使得通过手工生成模式颇有问题。为处理这种激增,大多数现代窥孔系统包含了一种工具,能够根据对目标机指令集的描述自动生成匹配程序。

这种方案还减少了为编译器重定目标所需的工作量。为改变目标处理器,编译器编写者必须做到:

向模式生成程序提供一份适当的机器描述,使之能够生成一个新的指令选择器;改变编译早期阶段生成的LLIR序列,使之适合新的ISA;修改指令调度器和寄存器分配器,以反映新ISA的特征。

虽然这些描述包含了大量工作,但用于描述、操纵和改进LLIR代码序列的相关基础设施将保待不变。换句话说,针对完全不同的机器生成的LLIR代码序列必定能够捕获目标机的差别,但这些代码序列所属的基础语言是不变的。这使编译器编写者能够建立一组跨越许多体系结构使用的工具,且通过针对目标ISA生成适当的底层IR并向窥孔优化器提供一个适当的模式集,可产生一个特定于机器的编译器。

六、高级主题 6.1 学习窥孔模式

要生成快速的模式匹配窥孔优化器所需的显式模式表,一种有效的方式将窥孔优化器与带有符号简化程序(symbolic simplifier)的优化器成对配置。在这种方案中,符号简化程序将记录它简化的所有模式。每次简化一对操作时,它都会记录初始操作和简化过的操作。接下来,它可以将结果模式记录在查找表中,从而生成一个快速的模式匹配优化器。

通过在由样本应用程序构成的训练集上运行符号简化程序,优化器可以发现其所需的大部分模式。接着,编译器可以使用此过程中累积生成的查找表,作为快速模式匹配优化器的基础。这使得编译器编写者在编译器设计期间多花费一些计算机时间,从而加速编译器在交付后的例行使用。这大大减少了必须指定的那些模式引发的复杂性。

增加两种优化器之间的相互作用,可以进一步提高代码质量。在编译时,快速模式匹配程序会遇到在表中无法找到匹配模式的一些LLIR对。当出现这种情况时,它可以调用符号简化程序来查找可能的改进,仅将搜索的力量集中于现先存后模式的LLIR对。

为使这种方法更实用,符号简化程序应该记录成功和失败的情形。这使它能够拒绝处理此前见过的LLIR对,不用承担符号解释的开销。在其成功改进一对操作之后,它应该将新模式添加到优化器的模式表,这样在以后遇到同样的指令实例时可以通过更高效的机制处理。

这种用于生成模式的学习方法有几个优点。它只处理此前未见过的LLIR对,而且弥补了用训练集覆盖目标机指令集时出现的漏洞。它提供了更高昂系统的彻底性,而仍然保持了模式导向系统的大部分速度优势。

不过在使用这种方法时,编译器编写者必须确定符号优化器应该在何时更新模式表,以及如何适应这些更新。允许任意一次编译重写所有用户的模式表似乎并不明智,肯定会出现同步和安全问题。编译器编写者可以选择定期更新,即以分离方式存储新发现的模式,使之以日常维护的方式添加到模式表中。

6.2 生成指令序列

这个学习方法有一种内在的偏爱:它假定底层的模式应该能够指导查找等价指令序列的搜索。一些编译器采取穷举方法来处理同一个基本问题。它们并不试图从底层模型合成所需的指令序列,而是采用一种先生成后测试的方法。

思想很简单。编译器或编译器编写者识别出应该改进的一个比较短的汇编语言指令序列,编译器接下来生成所有成本为1的汇编语言序列,并将原来指令序列的各个参量代入生成的序列。它会一一测试各个序列,判断其是否与目标指令序列具有同样的效果。在编译器排查完给定成本的所有序列时,它会将成本参量加1,然后继续同样的过程。这个过程会一直持续下去,直至:

编译器发现了一个等价的代码序列,或已经达到了原来的目标指令序列的成本值,或达到了一个外部规定的对成本或编译时间的限制。

虽然这种方法本来代价高昂,但用于判断等价性的机制,对于测试每个候选序列所需的时间有着巨大影响。显然,我们需要一种形式化方法,使用底层模型来描述指令在机器上执行的效应,这样才能筛选出微小的不匹配,但如果能够更快速地判断等价性,将可以捕获出现最频繁、数量最多的那部分不匹配。如果编译器只是生成并执行候选序列,它可以将其结果与目标序列得到的结果比较。针对少量精选的输入应用这种简单的方法,应该能够通过一个最低成本的测试来删除大部分不适用的候选序列。

七、小结和展望

指令选择的核心是一个模式匹配问题。指令选择的困难程度取决于编译器IR的抽象层次、目标机的复杂性以及对编译器生成代码质量的要求。在某些情况下,一个简单的树遍历方法可以生成符合要求的结果。但对于更困难的问题实例,通过树模式匹配或窥孔优化而进行的系统化搜索能够得到更好的结果。而创建一个能实现同样结果的手工的树遍历代码生成器,就得花费多得多的工作。虽然这两种方法在几乎所有的细节上都是不同的,但二者拥有一个共同的愿景,即对于任何给定的IR程序,在无数种可能的代码序列中,使用模式匹配发现一个良好的代码序列。

树模式匹配程序通过在每个决策点采用最低成本的选择来发现最低成本的平铺方案,而由此得到的代码实现了IR程序规定的计算。窥孔变换程序会系统化地简化IR程序,并将剩余的操作针对一组目标机模式进行匹配,因为缺乏显式的成本模型,我们无法对其最优性作出论断。它们会为一个与原始IR程序具有相同效果的计算生成代码,而非逐字逐句实现原始的IR程序。因为这两种方法之间的微妙区别,我们无法直接比较对二者质量的断言。实际上,每种方法都获得过极好的结果。

真实的编译器已经向我们展示了这些技术的实际收益,LCC和GCC都运行在许多平台上。前者使用树模式匹配,后者使用窥孔变换程序。而两个系统对自动工具的使用,使得它们易于理解、易于重定目标,并最终在领域中被广为接受。

同样重要的是,读者应该认识到,这两类自动模式匹配程序都能够应用到编译领域的其他问题上。窥孔优化起源于为改进编译器生成的最终代码而开发的一种技术。同样,编译器可以应用树模式匹配来识别并重写AST中的计算。BURS技术提供了一种特别高效的方式来识别并改进简单模式,包括通过值编号识别的代数恒等式。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3