在程序中的每个位置上,寄存器分配器会确定哪些值将位于寄存器中,哪个寄存器将容纳哪些值。如果分配器无法将某个值在其整个生命周期均保持在寄存器中,那么在其生命周期的部分或全部时间,该值必须存储到内存中。分配器可能会将一个值逐出到内存,因为代码包含的活跃值数据超出了目标机寄存器集合的容量。另外,在一个值各次使用之间的间歇,它可能被保存到内存中,因为分配器无法证明它能够安全地驻留在寄存器里。
寄存器分配器的输入是一个可能会使用任意数目的寄存器的程序,输出是一个等价的程序,但是已经针对目标机的有限寄存器集合进行了相应的修改。
为简化编译器靠近前端的部分,大多数编译器所用IR的名字空间都没有绑定到目标处理器的地址空间或寄存器集合。为将IR代码转换为目标机汇编代码,IR中使用的名字必须映射到目标机ISA使用的名字空间中。
如果IR利用内存到内存的存储模型来模拟计算,那么寄存器分配器需要将绑定到内存的值在使用频繁处“提升”到寄存器中。在这种模型下,寄存器分配是一种通过消除内存操作来提高程序性能的优化。
如果IR利用寄存器到寄存器的存储模型来模拟代码中的计算,那么寄存器分配器必须对代码中的每个位置作出判断:哪些虚拟寄存器应该驻留在物理寄存器中,哪些可以移入内存。它会构建一个映射,从IR中虚拟寄存器映射到物理寄存器和内存位置的某种组合,并重写代码以体现该映射。在这种模型下,寄存器分配需要产生正确的目标机程序,它会向代码插人load
和store
操作,并试图将其放置在对性能影响最小的地方。
一般来说,寄存器分配器试图最小化其添加到代码中的load
和store
操作的影响,这些添加的load
和store
指令又被称为逐出代码(spill code)。具体的影响包括执行逐出代码所需的时间、逐出代码占用的代码空间、被逐出的值占用的数据空间。好的寄存器分配器会尽量将所有这三种影响最小化。
寄存器分配器的输人代码是几乎完全编译过的代码,这种代码已经进行过词法分析、语法分析、校验、分析、优化、重写为目标机代码,可能也已经调度过。分配器必须通过重命名值并插入在寄存器和内存之间移动值的操作,将该代码适配到目标机的寄存器集合。
在寄存器到寄存器的模型下,编译器的早期阶段直接将其歧义内存引用的相关知识编码到IR形式中,而将无歧义值置于虚拟寄存器中。因而,存储在内存中的值被假定具有歧义的,于是分配器继续将其保留在内存中。(值有无歧义的定义)
在内存到内存的模型下,分配器没有这种代码形式上的提示可用,因为IR程序将所有值都保存在内存中。在这种模型下,分配器必须决定哪些值可以安全地保持在寄存器中,即哪些值是无歧义的。接下来,它必须判断将其保持在寄存器中是否有利可图。在这种模型下,与等价的寄存器到寄存器代码相比,分配器的输人代码通常使用较少的寄存器并执行更多的内存操作。为获得良好性能,分配器必须将尽可能多基于内存的值提升到寄存器中。
因此,对内存模型的选择在根本上决定了分配器的任务。在这两种场景下,分配器的目标都是减少最终代码在寄存器和内存之间来回移动值所执行的load
和store
操作的数目。
在寄存器到寄存器的模型下,寄存器分配是生成“合法”代码的处理过程中一个必要的部分,它保证了最终的代码能够适应目标机的寄存器集合。分配器会插人load
和store
操作将某些基于寄存器的值移动到内存中,这大体上是在对寄存器的需求超过目标机供给的代码区域中。分配器试图最小化其插入的load
和store
操作的影响。
相比之下,如果编译器使用内存到内存的模型,那么可以将寄存器分配作为一种优化来执行。代码在寄存器分配之前就是“合法”的,寄存器分配只是通过将某些基于内存的值提升到寄存器中,并删除用于访问这些值的load
和store
操作来提高性能而已。分配器试图删除尽可能多的load
和store
操作,因为这可以显著提高最终代码的性能。
在现代编译器中,寄存器分配器解决两个不同问题:寄存器分配(register allocation)和寄存器指派(register assignment),这两个问题在过去有时候是分别处理的。二者有关联,但却是不同的。
k
,其中k
是物理寄存器的数目。指派生成可执行代码所需的实际寄存器名字。大部分现代计算机兼具通用寄存器和浮点寄存器。前一种容纳整数值和内存地址,而后者包含浮点值。寄存器不止通用和浮点,不同架构会有所不同,如PowerPC、MIPS对条件码使用1个单独的寄存器类别,而LoongArch使用8个条件标志寄存器。
如果两个寄存器类别之间的相互作用是有限的,那么编译器可以分别为二者分配寄存器。在大多数处理器上,通用寄存器和浮点寄存器不用来保存同类值。因而,编译器分配浮点寄存器时可以独立于通用寄存器。编译器使用通用寄存器来逐出浮点寄存器的事实,意味着它应该首先分配浮点寄存器。用这种方法将寄存器分配分成若干小问题,可以减小数据结构的长度,使得编译执行得更快。
浮点寄存器中的值具有不同的源语言类型,因此它们与存储在通用寄存器中的值是不相交的。如果不同寄存器类别是重叠的,那么编译器必须一同分配它们。
局部分配(local allocation )的术语源自于优化,局部分配器运行在单个基本程序块上。为简化讨论,我们假定所处理的基本程序块就是整个程序。它自行从内存加载所需的值,并将生成的值存储到内存。输人程序块使用单一类别的通用寄存器,该技术很容易推广到处理多个不相交的寄存器类别。目标机提供的寄存器集合包含k
个物理寄存器,使用 v r i vr_i vri来表示一个虚拟寄存器,使用 r i r_i ri表示一个物理寄存器。
从高层视角来看,局部寄存器分配的目标是产生一个等价的基本程序块,源程序块中对虚拟寄存器的每个引用都被替换为目标程序块中对具体物理寄存器的引用。如果所用虚拟寄存器的数目大于k
,分配器可能需要插人load
和store
指令,使代码能够适配到k
个物理寄存器。对该性质的另一种陈述是:输出代码在程序块中的任何位置上都不能有超过k
个值驻留在寄存器中。
本节探讨局部寄存器分配的两种方法:
自顶向下的局部分配器的工作机制基于一种简单的原则:使用得最多的值应该驻留在寄存器中。为实现这种启发式逻辑,该分配器会统计每个虚拟寄存器在程序块中出现的次数。接下来,它会按频数递减次序为虚拟寄存器分配物理寄存器。
如果虚拟寄存器的数目多于物理寄存器,那么分配器必须保留足够多的物理寄存器,以便在需要时加载、使用、并存储本来没有保存在寄存器中的值。它需要(保留)寄存器的精确数目取决于处理器,典型的RISC机器可能需要 2 至 4 个寄存器。我们将这个特定于机器的保留寄存器的数目称为 ϝ \digamma ϝ(读作“feasible”)。
如果程序块使用的虚拟寄存器数目少于k
个,则分配过程将是平凡的,编译器可以简单地为每个 v r vr vr指定其自身对应的物理寄存器。在这种情况下,分配器并不需要保留 ϝ \digamma ϝ个物理寄存器供逐出代码使用。如果程序块使用的虚拟寄存器多于k
个,那么编译器将应用以下简单算法:
load
和store
操作。自顶向下的局部寄存器分配将使用得最为频繁的虚拟寄存器保留在物理寄存器中。其主要弱点在于其分配方法:它在整个基本程序块中,都将某个物理寄存器专门用于某个虚拟寄存器。因而,如果有某个值在程序块的前半段大量使用,而在后半段根本不使用,那么与其对应的物理寄存器在程序块的后半段实际上浪费了。下一节将给出一种解决该问题的技术。这种技术采用了一个有着本质不同的方法进行分配:自底向上增量进行的方法。
自底向上的局部分配器背后的关键思想是:按逐个操作的方式来仔细考察值定义和使用的细节。自底向上的局部分配器开始时,所有寄存器都是空闲的;对于每个操作,分配器需要确保在其执行之前操作数已经在寄存器中;它还必须为操作的结果分配一个寄存器。下图13-1给出了它的基本算法以及它使用的3个支持例程。
自底向上的分配器会遍历程序块中的各个操作,并按需进行分配决策,但这里还是有一些微妙之处的。通过按顺序考虑 v r i 1 vri_1 vri1和 v r i 2 vri_2 vri2,分配器避免了对具有重复操作数的操作使用两个物理寄存器,如 a d d add add r y , r y → r z r_y, r_y \to r_z ry,ry→rz。类似地,试图在分配 r z r_z rz之前释放 r x r_x rx和 r y r_y ry,如果这里确实释放了一个物理寄存器,那么在为操作的结果分配寄存器时就可以避免将某个物理
Ensure
例程在概念上很简单。其输入为两个参数:包含了所需值的虚拟寄存器 v r vr vr,以及适当寄存器类别的表示class
。如果 v r vr vr己经占用了一个物理寄存器,则Ensure
的工作到此完成。否则,它会为 v r vr vr分配一个物理寄存器,并输出将 v r vr vr的值移动到该物理寄存器所需的代码。不论是哪种情况,该例程都会返回对应的物理寄存器。
Allocate
和Free
揭示了分配问题的细节。为理解这两个例程,我们需要某个寄存器类别的一种具体表示,如下图的C语言代码所示。一个类别有Size
个物理寄存器,每个都由以下三部分共同表示:
Name
);Next
);Free
)。为初始化Class
结构,编译器将每个寄存器设置为一种未分配的状态(假定,Class.Name
为无效名称,Class.Next
为 ∞ \infty ∞,Class.Free
为true
),并将每个寄存器都推入到该类别的栈中。
在这种细节层次上,Allocate
和Free
都很简明。每个寄存器类别都有一个由空闲物理寄存器组成的栈。Allocate
从class
的空闲列表返回一个物理寄存器(如果有的话)。否则,它会从class
存储的值中选择一个距离下一次使用处最远的值,将其逐出,并将对应的物理寄存器重新分配给 v r vr vr。Allocate
会将Next
字段设置为-1
,以确保在处理当前操作的寄存器分配时,不会选择该寄存器用于另一个操作数。在处理完当前操作之后,分配器会重置该字段。Free
只需要将被释放的寄存器推入栈中,并将其字段重置为初始值。函数Dist(vr)
返回当前程序块中引用 v r vr vr的下一个操作的索引。编译器可以在对该程序块的一趟反向遍历中预计算这一信息。
自底向上的局部分配器以一种直观的方式运作。它假定物理寄存器最初都是空的,并将其到放置到空闲列表上。它基于空闲列表来满足对寄存器的需求,直至列表用尽为止。此后,分配器通过将某个值逐出到内存并重用该值的寄存器,来满足对寄存器的需求,它总是逐出下一次使用处距离当前操作最远的值。直观上,分配器选择逐出的寄存器,原本会是当前操作之后持续未被引用时间最长的寄存器。在某种意义上,在付出逐出寄存器的成本之后,分配器会最大化为此而获取的利益。
实际上,该算法能够产生极佳的局部寄存器分配方案。甚至,有几位作者认为它能够产生最优分配方案(方案具有最少的逐出次数)。但有一些会导致该算法生成次优分配方案的复杂情况。在分配过程中的任何一个位置,在逐出寄存器时,可能寄存器中的一部分值需要存储到内存,而其他的值则不需要。例如,如果寄存器包含一个已知常量值,那么store
指令将是多余的,因为分配器无需内存中的副本即可在未来重新产生该值。类似地,由内存加载的值不必存储到内存。不必存储到内存的值是干净的(clean),而需要通过store
指令存储到内存的值是脏的(dirty)。
为产生最优的局部寄存器分配方案,分配器必须考虑逐出干净值 和逐出脏值 之间代价的差别。例如,考虑在具有两个寄存器的机器上进行寄存器分配,其中值x1
和x2
已经在寄存器中。假定x1
是干净的,x2
是脏的。如果程序块中其余部分引用各个值的次序是x3 x1 x2
,那么分配器必须逐出x1
或x2
,用来分配给x3
。因为x2
的下一次使用处距离当前操作更远,自底向上的局部算法逐出它,将产生如下图左侧所示的内存操作序列。如果反过来,分配器逐出x1
,将产生下图右侧所示较短的内存操作序列。
这个场景暗示分配器应该优先逐出干净值,而非脏值 ,但答案并非如此简单。考虑另一种引用次序x3 x1 x3 x1 x2
,初始条件相同。如果一贯地逐出干净值,将产生下图左侧所示4
个内存操作的序列。与此相反,如果一贯地逐出脏值,将产生下图右侧的代码序列,它具有较少的内存操作。
干净值 和脏值 的同时存在,使得最优局部寄存器分配成为一个NP难(NP-hard)问题。但是不管优先逐出哪个,自底向上算法的分配方案一般是要优于自顶向下的。
将寄存器分配器的运作转移到一个更大的范围上,主要的原因是要考虑值在程序块之间的流动,并产生能够高效处理此类流动的分配方案。分配器必须正确地处理在此前的程序块中计算的值,且必须保留供后续程序块使用的值。为达到这一目的,与局部分配器相比,新的分配器需要一种更精巧复杂的方式来处理“值”。
1)活跃性和活跃范围
关于活跃性在介绍《利用活跃信息查找未初始化变量》和《活跃变量分析》时都已经详细解释过。
对于变量x和程序点p,如果在CFG中沿着p开始能找到一条或多条会引用变量x在p点的值的路径,且变量x在该路径中没有被重新定义时,则称变量x在点p是活跃(live)的,否则称变量x在点p不活跃(dead)。在x活跃的任何位置,其值必须保持下来,因为后续的执行可能使用x,x既可以是源程序变量,又可以是编译器生成的临时值。
单个活跃范围由一组定义和使用组成,这些定义和使用是彼此相关的,因为其值会共同流动。这种组合在下述意义上是自包含的:任给其中一个使用,能够到达该使用处的所有定义也都包含在同一个活跃范围中。类似地,对于活跃范围中的每个定义,能够引用该定义结果的每个使用也都在同一个活跃范围中。
活跃范围的集合不同于变量的集合和值的集合。代码中计算的每个值都是某个活跃范围的一部分,即使它在原始的源代码中没有名字。因而,地址计算产生的中间结果是某个活跃范围的一部分,程序员命名的变量、数组元素和用作分支目标而加载的地址也都是如此。单个源语言变量可能形成多个活跃范围。处理活跃范围的分配器,可以将不同的活跃范围放置在不同的寄存器中。因而,在程序执行过程中的不同位置上,一个源语言变量可能驻留在不同的寄存器中。
在单个基本程序块中(无分支代码),我们可以将一个活跃范围表示为一个区间[i,j]
,其中操作i
定义了该值,而操j
是对该值的最后一次使用。如下图右侧的表给出了左侧单个程序块中不同的活跃范围, r a r p r_{arp} rarp是在操作1中定义的,其他每个对 r a r p r_{arp} rarp的引用都是一个使用,最后一次使用是在操作11,所以 r a r p r_{arp} rarp是在区间[1,11]
上活跃的。
与之相对, r a r_a ra有多个活跃范围。操作2定义了它,操作7使用了操作2定义的值;操作7,8,9,10每个都为 r a r_a ra定义了一个新值;对于每个操作来说,其后一个操作会使用由它定义的值。因而,原始代码中名为 r a r_a ra的值,对应于5个不同的活跃范围[2,7]
,[7,8]
,[8,9]
,[9,10]
,[10,11]
。寄存器分配器无需将这些不同的活跃范围保存在同一物理寄存器中。相反,它可以将程序块中的每个活跃范围视为一个独立的值,来分配和指派寄存器。
为在更大的区域中查找活跃范围,分配器必须明了何时一个值在其定义程序块结束后仍然处于活跃状态。在代码中的任何位置,只有活跃的值才需要寄存器,因而,LiveOut集合在寄存器分配中发挥了关键作用。
2)程序块边界处的复杂情况
使用局部寄存器分配方案的编译器可以计算每个程序块的LiveOut集合,用其向后者提供值在程序块人口/出口处的状态信息,使得分配器能够正确地处理程序块末尾条件。LiveOut(b)中的任何值,在其于b中的最后一个定义之后,都必须存储到为其在内存中分配的位置,以确保后续程序块有正确的值可用。与此相反,不在LiveOut(b)中的值,在其于b中最后一次使用之后即可丢弃,无需存储到内存。
虽然LiveOut信息使得局部分配器能够产生正确的代码,但这种代码将包含一些不必要的load
和store
指令,这些指令的唯一目的是将值跨越程序块边界连接起来。考虑下图给出的例子:
局部分配寄存器将变量x
分别指派到不同的寄存器 (r1、r2、r3、r4),如果要在程序块局部解决这些问题,唯一的机制是通过内存来传递x
的值,即在B1和B3末尾将x
存储到内存,在B2和B4起始处从内存加载x
。
将自底向上的局部寄存器分配扩展到超越单个程序块时,会出现一种问题。如果B1中使用x
之后,分配器还需要再分配寄存器,假设没有Free寄存器,则需要计算x
下一次使用之处的距离。单个程序块中,该距离是唯一确定的,但B1有多个后继程序块,所以距离取决于运行路径是(B1,B2)
还是(B1,B3,B4)
,这样的距离是躲值的,这会使算法的效果变得更难于理解和证明。
寄存器分配器试图最小化必须插入的那些逐出代码带来的影响,这种影响可能以(至少)三种形式出现:逐出代码的执行时间、逐出操作占用的代码空间以及逐出值占用的数据空间。大多数分配器专注于第一种效应,即最小化逐出代码的执行时间。
对于最小化逐出代码执行时间的问题,全局寄存器分配器无法保证最优解。同一代码的两种不同分配方案之间的差别在于:分配器插入的load
操作、store
操作、复制操作的数目,以及这些操作在代码中的位置。这些操作的数目会影响到代码占用的空间和执行时间,操作的位置也会有影响,因为不同程序块执行的次数不同,且在两次运行之间,程序块的执行频度也会有变化。
在两个基本方面,全局寄存器分配不同于局部寄存器分配:
u
来说, L R i LR_i LRi必须包含能够到达u
的每个定义d
。类似地,对于 L R i LR_i LRi中的每个定义d
, L R i LR_i LRi必须包含d
能够到达的每个使用u
。任何全局分配器都必须解决这两个问题。全局分配器会对寄存器分配和指派都作出决策,会针对每个活跃范围决定其是否应驻留在寄存器中,会针对每个已分配寄存器的活跃范围判断其是否能够与其他活跃范围共享寄存器,会为每个已分配寄存器的活跃范围选择一个具体的物理寄存器。
许多全局寄存器分配器使用图着色作为一种范式,来模拟底层的分配问题。对于任意的图G,G的一种着色会对G中的每个结点指派一种颜色,使得任何一对相邻结点均为不同颜色。使用k
种颜色的着色方案称为k
着色(k-coloring),对于给定的图来说,最小的k
值称作该图的色数(chromatic number)。考虑下列图。
左图可以用两种颜色着色。例如,我们可以将蓝色指派给结点1和5,将红色指派到结点2、3和4。如右图所示,在添加边(2,3)
之后,图是可以三着色的,但不再是可以二着色的。蓝色指派给结点1和5,红色指派给结点2和4,黄色指派给结点3。
对于一个给定图,查找其色数的问题是NP完全的。类似地,对于某个确定的k
来说,判断一个图是否可以k着色的问题,也是NP完全的。使用图着色为范式来分配资源的算法,实际上是使用近似方法查找适合于可用资源集的着色方案。
冲突图:结点表示活跃范围,边(i,j)
表明 L R i LR_i LRi和 L R i LR_i LRi无法共享同一个寄存器的图。
许多编译器使用类似于图着色的方法进行寄存器分配。图着色分配器会建立一个图,称为冲突图(interference graph),以模拟各个活跃范围之间的冲突。分配器试图为该图构造一个k
着色方案,其中k
是分配器可用物理寄存器的数目。(一些物理寄存器,如ra
,rsp
等,可能是专用于其他目的的。)冲突图的k
着色方案,可以直接转换为活跃范围到物理寄存器的指派方案。如果编译器无法为该图直接构造一个k
着色方案,可以修改所处理的代码,将某些值逐出到内存后再次尝试。因为逐出会简化冲突图,所以这个处理过程是保证会停止的。
不同的着色分配器以不同的方法处理逐出(或分配)。我们将考察使用高层信息作出分配决策的自顶向下分配器,以及使用底层信息作出分配决策的自底向上分配器。
为构造活跃范围,编译器必须发现不同定义和使用之间存在的关系。分配器必须推导一个名字空间,将能够到达一个使用的所有定义和一个定义能够到达的所有使用聚合为一个名字。这启发我们采用这样一种方法:编译器为每个定义指派一个不同的名字,将能够到达同一个使用处的名字合并起来。因而,将代码转换为SSA形式,会简化活跃范围的构造,假定分配器运行在SSA形式之上。
代码的SSA形式为活跃范围的构造提供了一个自然起点。在SSA形式中,每个名字定义一次,而每个使用引用一个定义。而插入的 ϕ \phi ϕ函数调和了这两条规则,并记录了控制流图中不同代码路径上的不同定义到达同一个引用处的事实。如果一个操作引用了 ϕ \phi ϕ函数定义的名字,实际上会使用 ϕ \phi ϕ函数的一个参数的值,具体是哪个参数则取决于控制流是如何到达 ϕ \phi ϕ函数的。所有这些定义应该驻留在同一寄存器中,因而属于同一活跃范围。 ϕ \phi ϕ函数使得编译器能够高效地建立活跃范围。
为根据SSA形式建立活跃范围,分配器使用不相交集(disjoint-set)的合并查找(union-find)算法,对代码进行一趟处理即可。分配器将每个SSA形式名字或定义视为算法中的一个集合。它会考察程序中的每个 ϕ \phi ϕ函数,对 ϕ \phi ϕ函数的每个参数相关联的集合与表示其结果的集合取并集。在处理过所有的 ϕ \phi ϕ函数之后,形成的各个集合表示代码中的活跃范围。此时,分配器可以重写代码以使用活跃范围名字,或创建并维护SSA形式名字和活跃范围名字之间的一个映射。
编译器可以将全局活跃范围表示为一个或多个SSA形式名的集合。下图a给出了半剪枝SSA形式下的一个代码片断,其中涉及源代码变量a
、b
、c
、d
。为找到活跃范围,分配器会为每个SSA形式名字指派一个包含其名字的集合。它会将 ϕ \phi ϕ函数中使用的名字相关联的集合取并集,即 { d 0 } ∪ { d 1 } ∪ { d 2 } \{d_0\} \cup \{d_1\} \cup \{d_2\} {d0}∪{d1}∪{d2}。这最终形成了4个活跃范围: L R a LR_a LRa为 { a 0 } \{a_0\} {a0}、 L R b LR_b LRb为 { b 0 } \{b_0\} {b0}、 L R c LR_c LRc为 { c 0 } \{c_0\} {c0}、 L R d LR_d LRd为 { d 0 , d 1 , d 2 } \{d_0,d_1,d_2\} {d0,d1,d2}。下图b给出了使用活跃范围名字重写后的代码。
由从SSA到其他形式的转换可知道,这种重写过程可能会引入复杂情况。如果分配器建立SSA形式,使用它来找到活跃范围,并在不进行其他变换的情况下重写代码,那么只需用活跃范围名字替换名字。如果分配器使用的是已经变换过的SSA形式,那么重写过程必须处理SSA转换为其他形式后的复杂情况。因为大多数编译器都在指令选择(还可能有指令调度)之后进行寄存器分配,分配器处理的代码可能不是SSA形式。这迫使分配器为代码建立SSA形式,以确保重写过程比较简明。
为作出合理的逐出决策,全局分配器需要估算逐出每个值的代价。逐出的代价有3个部分:地址计算、内存操作和估算的执行频度。
编译器编写者可以选择在内存中何处保存逐出的值。通常,这些值驻留在当前活动记录(AR)中指定的寄存器保存区中,以最小化地址计算的代价(见函数运行时结构)。在AR中存储逐出的值,使得分配器能够生成相对于r_arp
的操作(如loadAI
或storeAI
)来处理逐出。这种操作通常可以避免使用额外的寄存器来计算逐出值的内存地址。
一般来说,内存操作的代价是不可避免的。对于每个逐出的值,编译器必须在每次定义之后生成一个store
操作,在每次使用之前生成一个load
操作。随着内存延迟增加,这些逐出操作的代价也会增长。如果目标处理器具有快速暂时存储器(fast scratchpad memory),那么编译器通过将值逐出到暂时存储器(scratch-pad memory,专用的、不占用高速缓存的本机内存,是某些嵌入式处理器的一种特性)可以降低逐出操作的代价。更糟的是,分配器会向对寄存器的需求较高的区域插入逐出操作。在这些区域中,缺少空闲寄存器可能会限制调度器掩盖内存延迟的能力。因而,编译器编写者必须希望逐出位置驻留在高速缓存中。(自相矛盾的是,内存位置仅当被频繁访问时才会驻留在高速缓存中,而这暗示我们代码执行了过多的逐出操作。)
1)统计执行频度
为统计控制流图中各个基本程序块的不同执行频度,编译器应该对每个程序块标注一个估算的执行计数。编译器可以根据剖析数据或启发式逻辑来推导这些估算值。许多编译器简单地假定每个循环执行10次。这种假定向循环内部的load
指派权重10,而向双重嵌套循环内部的load
指派权重100,依此类推。不可预测的if-then-else
语句会使估算的频度减半。实际上,这种估算确保了逐出会偏向于外层循环(而非内层循环)中的值。
为估算逐出单个引用的代价,分配器将地址计算的代价与内存操作的代价相加,然后将和值乘以该引用的估算执行频度。对于每个活跃范围,分配器会将各个引用的代价求和。这需要一趟遍历代码中所有程序块的处理。分配器可以预计算所有活跃范围的这种代价,或等到必须逐出至少一个值时才进行计算。
2)负的逐出代价
如果活跃范围包含一个load
和一个store
,没有其他使用,且load
和store
引用同一地址,那么该活跃范围具有负的逐出代价。(这种活跃范围可能因意在改进代码的变换所致。例如,如果使用被优化掉,而store是过程调用而非定义新值所致。)有时候,逐出一个活跃范围可以消除比逐出操作代价更高的复制操作,这样的活跃范围也具有负的逐出代价。任何具有负的逐出代价的活跃范围都应该被逐出,因为这样做可以降低对寄存器的需求并从代码中删除指令。
3)无限的逐出代价
一些活跃范围是如此之短,以至于逐出它们毫无用处。考虑下图给出的很短的活跃范围。如果分配器试图逐出vr_i
,它需要在定义之后插入一个store
指令, 在使用之前插入一个load
指令,这创建了两个新的活跃范围。而这些新的活跃范围使用的寄存器数目都不少于原来的活跃范围,因此这里的逐出没有产生收益。分配器应该为原来的活跃范围指派一个值为无限大的逐出代价,确保分配器不会试图逐出它。一般来说,如果某个活跃范围的定义和使用之间没有其他活跃范围结束,那么该活跃范围应该具有无限大的逐出代价。这一条件保证了寄存器的可用性在定义和使用之间不会变化。
全局寄存器分配器必须模拟的基本效应,是各个值对处理器寄存器集合中空间的竞争。考虑两个不同的活跃范围 L R i LR_i LRi和 L R j LR_j LRj。如果 L R i LR_i LRi和 L R j LR_j LRj在程序中的某个操作期间都是活跃的,那么二者无法驻留在同一个寄存器中,我们说 L R i LR_i LRi和 L R j LR_j LRj是冲突的。
为模拟分配问题,编译器可以建立一个冲突图 I = ( N , E ) I=(N,E) I=(N,E),其中N中的结点表示各个活跃范围,而 E E E中的边表示活跃范围之间的冲突。因而,当且仅当对应的活跃范围 L R i LR_i LRi和 L R j LR_j LRj冲突时,无向边 ( n i , n j ) ∈ I (n_i,n_j) \isin I (ni,nj)∈I存在。下图给出了4.2图b 中的代码及其冲突图, L R a LR_a LRa与其他每个活跃范围都冲突,而其他活跃范围彼此并不冲突。
如果编译器可以用k
种或更少的颜色着色 I I I,那么它可以将颜色直接映射到物理寄存器,以产生一个合法的寄存器分配方案。在例子中, L R a LR_a LRa的颜色不能与 L R b LR_b LRb、 L R c LR_c LRc或 L R d LR_d LRd中任一结点的颜色相同,因为它与其中每一个都有冲突。但其他三个活跃范围都可以共享一种颜色,因为它们彼此并不冲突。因而,该冲突图是可以二着色的,该代码可以重写为只使用两个寄存器。
考虑一下,如果编译器的另一个阶段重排B1末尾处的两个操作,那么会发生什么呢?这一变化使得 L R b LR_b LRb在 L R d LR_d LRd的定义处也是活跃的。分配器必须将边 ( L R b , L R d ) (LR_b,LR_d) (LRb,LRd)添加到 E E E,这使得不可能只用两种颜色着色该图。
为处理该图,分配器有两种方案:使用3个寄存器;或者如果目标机只有两个寄存器,在B1中 L R d LR_d LRd的定义之前逐出 L R b LR_b LRb或 L R b LR_b LRb之一。当然,分配器还可以重排这两个操作并消除 L R b LR_b LRb和 L R d LR_d LRd之间的冲突,但寄存器分配器通常不会重排操作,假定操作的顺序是固定的,排序问题会由指令调度器解决。
1)建立冲突图
在分配器建立了全局活跃范围,并用基本程序块的LiveOut集合标注了代码中的各个基本程序块后,它可以通过一趟对各个程序块的简单线性遍历构建冲突图。下图给出了基本算法。随着算法自底向上遍历程序块,分配器会计算LiveNow(窥孔优化中见过),即当前操作处活跃值的集合。在程序块中最后一个操作处,LiveOut和LiveNow必定是相同的。随着算法反向遍历程序块中各个操作,它会向图添加适当的冲突边,并更新LiveNow集合以反映操作的影响。
算法实现了前文给出的冲突定义:对于活跃范围 L R i LR_i LRi和 L R j LR_j LRj,仅当其中一个在另一个的定义处活跃时,二者才会冲突。这一定义使得编译器可以在每个操作处,通过在操作的目标 L R c LR_c LRc与该操作之后仍处于活跃状态的各个活跃范围之间,分别添加一个冲突边来建立冲突图。
复制操作需要特殊处理。复制 L R i = > L R j LR_i => LR_j LRi=>LRj并不会导致 L R i LR_i LRi和 L R j LR_j LRj之间的冲突,因为这使得两个活跃范围具有相同的值,因此可以占用同一寄存器。因而,该操作不应该导致向 E E E添加边 ( L R i , L R j ) (LR_i,LR_j) (LRi,LRj)。如果后续的上下文导致了这两个活跃范围之间的冲突,那么应该由相应的操作来创建对应的边。同样地, ϕ \phi ϕ函数并不导致其任何参数与其结果之间的冲突。以这种方式处理复制操作和 ϕ \phi ϕ函数,所创建的冲突图正好可以捕获 L R i LR_i LRi和 L R j LR_j LRj能够占用同一寄存器的情况。
为提高分配器的效率,编译器应该同时建立一个下三角比特矩阵和一组邻接表(adjacency list )来表示 E E E。比特矩阵使得可以在常量时间内判断冲突,而邻接表允许高效遍历结点的邻居。这种双重表示的策略比单一表示占用更多的空间,但能够减少分配时间。
线性扫描分配器从这样的假定出发:它们可以将全局活跃范围表示为一个简单区间[i,j]
,正如我们在局部寄存器分配中所做的那样。这种表示会高估活跃范围的范围,以确保能够包括该活跃范围处于活跃状态的最早和最新的操作。这种高估保证了最终形成的冲突图是一个区间图。
区间图比全局寄存器分配中出现的一般图简单得多,例如,单个程序块的冲突图总是一个区间图。从复杂度角度来看,区间图向分配器提供了一些优势。虽然判定任意图是否可k
着色的问题是NP完全的,但区间图上的同样问题在线性时间内是可解的。
与精确的冲突图相比,建立区间表示的代价不那么昂贵。区间图本身支持的寄存器分配算法(如自底向上的局部算法)比全局分配器简单。由于寄存器的分配和指派都可以在对代码的一趟线性遍历中进行,这种方法又称线性扫描分配(linear scan allocation)。
线性扫描分配器避免了建立复杂的精确全局冲突图(这是图着色全局分配器中代价最昂贵的步骤),也不需要选择逐出候选者的O(N^2)
循环。因而,它们使用的编译时间比全局图着色分配器少得多。在某些应用中,如JIT(Just-In-Time)编译器,在寄存器分配速度和逐出代码增加之间的权衡,使得这种线性扫描分配器颇具吸引力。
线性扫描分配具有我们在全局分配器中所见的所有微妙之处。例如,可使用自顶向下局部算法在线性扫描分配器中对某个活跃范围的各个出现之处进行逐出处理,而使用自底向上局部算法在刚好需要逐出该活跃范围之处进行逐出处理。不精确的冲突概念意味着这种分配器必须使用其他机制合并复制操作。