
move 指令,但前提是它能证明合并不会使寄存器分配变得更糟。move 指令的收益与高昂溢出成本、硬件约束和软件约定(ABI)相互作用等风险。在追求程序最高性能的过程中,几乎没有哪个编译器任务比寄存器分配——即将程序变量分配给 CPU 有限的、超高速的寄存器的过程——更为关键。效率低下的一个常见来源是简单的 move 指令,它们在变量之间复制值。虽然通过一种称为“合并”的优化来消除这些移动指令似乎是显而易见的胜利,但一种朴素的方法可能会产生灾难性的反效果,导致性能下降而非提升。本文旨在通过探讨保守合并的原则来应对这一根本性挑战,这是一种在享受 move 消除带来的好处的同时规避其风险的策略。
接下来的章节将引导您了解这项复杂的技术。“原理与机制”一章将深入探讨理论,解释干涉图和图着色如何为该问题建模,并详细介绍确保合并安全有效的巧妙启发式方法。随后,“应用与跨学科联系”一章将拓宽视野,展示合并如何与硬件现实、软件约定及其他优化相互作用,揭示其在管理约束和经济权衡方面堪称典范。
一个变量在计算机程序中的生命,从其诞生(一次定义)到其最终使用(最后一次读取),被称为其活跃范围。在程序执行的宏大舞台上,处理器只为这些变量提供了少数几个黄金位置:寄存器。寄存器是可用的最快、最宝贵的空间。所有其他变量都必须存储在速度慢得多的主内存中。编译器的任务,即寄存器分配,就是审慎地将尽可能多的变量分配到这些珍贵的寄存器中。
不妨将其想象成一位在工作台前的大师级工匠。寄存器是工作台上为数不多的几个位置,可以将工具放在手边随时取用。内存则是一个巨大但遥远的工具箱。目标是将最常用的工具放在工作台上,以便尽可能高效地工作。
在我们的程序中,我们经常编写像 y = x 这样的指令。这是一条 move 指令。它的意思是:“复制 x 中的值,并将其放入 y 中。”通常,这发生在原始变量 x 不再需要之前。在我们的工作台上,这就像拿起一把螺丝刀(x),制作一个它的精确复制品(y),将复制品放在工作台上,然后立即将原件收起来。这似乎很浪费。为什么不干脆同意从现在起把原来的螺丝刀叫做 y,省去制作复制品的麻烦呢?
这正是副本合并(copy coalescing)背后的思想。我们“合并” x 和 y 的活跃范围,将它们视为可以共享一个寄存器的单一实体。我们有效地消除了冗余的 move 指令,使代码更快、更简单。
为了对此进行推理,编译器构建了一个优美的结构,称为干涉图。每个变量(或更准确地说,每个活跃范围)是一个节点。如果在两个节点的对应变量在同一时间都需要被使用——即它们的活跃范围重叠——那么就在它们之间画一条边。以这种方式“干涉”的两个变量不能共享同一个寄存器,就像你不能在你工作台的同一个位置同时放置两个你都需要使用的工具一样。寄存器分配因此等价于著名的数学问题——图着色:为每个节点分配一种颜色(一个寄存器),使得没有两个相连的节点共享相同的颜色。所需的最少颜色数是图的色数。
在这个图的世界里,合并 y = x 意味着将 x 和 y 的节点合并成一个单一的新节点。这个新节点必须连接到两个原始节点的所有邻居。这就像合并两个人的社交圈:新的组合体继承了原始双方的所有朋友。
这种合并听起来像是一次绝妙的整理。到底能出什么问题呢?事实证明,问题大了。一种朴素的、“激进”的、试图合并所有可能移动指令的方法可能是灾难性的。通过合并两个节点,新的组合节点最终可能会有太多的邻居,以至于我们有限的寄存器数量无法为其着色。
假设我们有 个可用寄存器。我们拿到一段代码,它被转换成如下所示的干涉图。这个图的构造很巧妙,虽然有些节点有很多连接,但我们仍然可以为它找到一个有效的 4-着色方案。例如,变量 a、b、c 和 d 形成一个“4-团”(每个都与其他三个相连),因此它们需要四个不同的寄存器。但剩下的变量 p 和 q 连接较少,可以用这四个寄存器中的一个来着色。程序可以完全在快速寄存器中运行,没有任何到慢速内存的“溢出”。
现在,假设代码中有一条移动指令 q = p。一个激进的合并器看到 p 和 q 互不干涉,便欣喜地将它们合并成一个单一节点,我们称之为 r。但看看后果!新节点 r 继承了 p 的所有邻居(a 和 b)以及 q 的所有邻居(c 和 d)。所以 r 连接到了 a、b、c 和 d。但 a, b, c, d 本身已经相互连接!结果是一个 5-团:五个变量全都相互干涉。只有 4 个寄存器,从根本上就不可能为这五个变量中的每一个分配一个唯一的寄存器。图的色数从 4 跃升到 5。其中一个必须被“溢出”到内存中。
这就是核心危险:一个看似无害的优化会使图变得更难着色,从而强制进行代价高昂的溢出。在最坏的情况下,这可能导致溢出级联。溢出一个变量涉及添加新的指令来从内存中加载和存储它。这些新指令本身可能会创建新的、短生命周期的变量,并延长其他变量的活跃范围,从而增加整个图的干涉。这可能迫使另一次溢出,而这次溢出又导致下一次,如此循环,形成一场灾难性的连锁反应,使性能陷入停滞。 这就是现代编译器必须屠戮的恶龙,而它们用的不是激进,而是谨慎和远见。
为了避免这种病态情况,编译器采用了保守合并策略。它们只会在能够证明合并不会危及图的可着色性的情况下,才合并与移动指令相关的节点对。这是通过使用巧妙的经验法则,即启发式方法来实现的,这些方法提供了安全保证。其中最著名的两个以其发明者 Preston Briggs 和 David George 的名字命名。
Briggs 测试是一个极其简单的想法。在合并两个节点 u 和 v 之前,它会审视它们合并后的邻居集合。然后,它会计算这些邻居中有多少是“显著的”——即,有多少节点的度数已经大于或等于 (其中 是寄存器的数量)。规则是:
Briggs 规则:仅当 u 和 v 合并后的邻居集合中,显著度数节点的数量严格小于 时,才合并 u 和 v。
其直觉在于,合并后的节点将不得不与这些显著邻居竞争。如果显著邻居的数量少于 个,那么之后很有可能为合并后的节点找到一个颜色。让我们回到那个病态的例子。 当 时,要合并 p 和 q,我们查看它们合并后的邻居:a, b, c, d。这四个节点的度数都是 4,即 。显著邻居的数量是 4。Briggs 规则要求这个计数小于 4。由于 4 不小于 4,测试失败。Briggs 启发式方法明智地禁止了这次危险的合并,防止了溢出的发生。严格不等式提供了一个至关重要的安全边际。如果显著邻居的数量等于 ,合并可能仍然不安全,因为其他非显著邻居也可能合谋用尽所有可用的颜色。
George 测试是另一种确保安全性的方法,它从一个略有不同的角度看待问题。要合并 u 和 v,它关注其中一个节点(比如 v)的邻居,并对每个邻居检查一个条件。
George 规则:仅当对于 v 的每个邻居 t,要么 t 已经是 u 的邻居,要么 t 的度数小于 时,才合并 u 和 v。
这里的逻辑也相当优雅。当我们合并 u 和 v 时,我们实际上是从 u 向 v 的所有邻居添加边。对于 v 的一个邻居 t,如果 t 已经是 u 的邻居,那么没有增加新的约束。如果不是,我们就正在合并后的节点和 t 之间创建一个新的干涉。George 测试认为,只有当 t 是“不显著的”(低度数)时,这才是安全的,这意味着它本身就很容易着色,不会因为增加一个额外的约束而陷入麻烦。
在我们的例子中,让我们尝试合并 p 和 q。考虑 q 的邻居:c 和 d。对于邻居 c,它已经是 p 的邻居了吗?不是。它的度数小于 4 吗?不是,它的度数是 4。由于这两个条件对 c 都不成立,George 测试立即失败。与 Briggs 测试一样,它正确地将这次合并识别为不安全的。
当处理预着色节点时,这些启发式方法变得更加有趣。一些指令,特别是与函数调用相关的指令,可能要求一个值必须位于某个特定的、固定的寄存器中(例如,返回值必须放在寄存器 R0 中)。这个变量在图中的节点是“预着色”的。任何试图将另一个节点与它合并的尝试都必须极其小心。George 启发式方法对此做了很好的调整:要将 v 合并到一个与预着色节点 p 干涉的节点 u 中,我们会检查 v 的邻居。如果一个邻居 t 已经受到 p 的约束(即 t 也与 p 干涉),或者 t 是低度数的,那么这个邻居 t 就是安全的。这确保了合并不会给一个之前并不关心特殊寄存器 p 的邻居带来新的、棘手的问题。
虽然这些保守规则是安全合并的基石,但故事并未就此结束。现代编译器采用更为复杂的策略,将寄存器分配视为一个成本与收益的经济问题,而非单一的谜题。
有时,最好的举动是反直觉的。考虑这样一种情况:一个图的约束非常强,以至于没有任何移动指令可以被安全地合并。一个选择是接受这些 move 指令。另一个选择是故意溢出一个变量。溢出有已知的成本,即额外的加载/存储指令。但如果溢出那一个变量能极大地简化干涉图,从而使得多个其他的移动指令能够被合并呢?我们现在面临一个权衡:溢出的成本是否值得它所带来的合并收益?编译器可以并且确实会进行这些计算。如果总成本(溢出成本 + 剩余移动指令的成本)低于什么都不做的成本(零溢出成本 + 所有原始移动指令的成本),它们可能会选择溢出一个变量。 这揭示了一个更深层次的真理:优化的目标是找到全局最小成本,即使这需要采取一个局部上代价高昂的步骤。
我们常把一个变量的活跃范围看作一个单一的、不可分割的块。但如果一个变量只在其生命周期的一小部分时间内被大量使用——从而产生大量干涉——该怎么办?仅仅因为一个暂时的度数“尖峰”就阻止一次有益的合并,似乎很可惜。解决方案是外科手术式的:活跃范围分裂。我们可以将活跃范围分裂成多个较小的片段。在一种场景中,一个变量 v 可能在一个代码区域的中间与大量其他变量干涉,但在开始和结束时干涉很少。如果我们想将 v 与另一个只在开始和结束时活跃的变量 u 合并,我们就会陷入困境。但如果我们将 v 分裂成三部分——、 和 ——我们就可以隔离高度干涉的中间部分。现在,我们可以安全地将 u 与低度数的 和 片段合并,而让有问题的 保持原样。这使我们能够在不损害代码最受约束部分的可着色性的情况下消除移动指令。
也许合并最美妙、最令人惊讶的方面出现在我们考虑静态单赋值(SSA)形式的代码时,这是一种每个变量只被定义一次的表示形式。在这个世界里,像 b_1 = a_1 这样的副本指令会产生一个悖论。因为 在这次复制后可能仍然需要使用,它的活跃范围现在与 的新活跃范围重叠。它们相互干涉!根据规则的朴素解读,既然它们干涉,就不能合并。
但这是在倒果为因!合并操作不仅仅是图的转换;它是一种程序转换。当我们把 合并到 中时,我们重写了程序,用 替换所有 的使用,并完全删除了副本指令。如果我们现在在这个新程序中重新分析活跃性,我们可能会发现干涉格局已经完全改变。那个看似禁止合并的干涉,可能只是我们刚刚消除的副本指令造成的假象。在一次惊人的转折中,合并一对相互干涉的节点可以打破干涉图中的一个环,降低其色数,并将一个不可着色的图变成可着色的图。 这是一个深刻的提醒:我们的模型必须遵循代码的现实,而有时,改变代码本身才是最强大的举动。
这个由相互作用的决策构成的网络,从局部启发式到全局成本效益分析,证明了现代编译器的复杂性。为了管理这种复杂性,尤其是在不能一次性看到整个程序的增量编译器中,我们甚至可以从其他领域汲取灵感。作为垃圾回收基石的三色标记算法,提供了一个强大的心智模型。我们可以将潜在的合并分类为“白色”(未见)、“灰色”(待定)和“黑色”(已完成)。通过强制执行“没有已完成的决策可以依赖未知信息”(“没有黑色节点指向白色节点”)这一不变量,即使面对不确定性,我们也能做出稳健的决策。 正是在这些时刻——看到一个科学领域的深刻原理照亮了另一个领域——我们瞥见了计算的内在美和统一性。
在探索了编译器如何谨慎地消除 mov 指令的原理之后,您可能会倾向于认为这只是一个相当小众的、机械的数字整理工作。但如果这样想,那就只见树木,不见森林了。保守合并并非孤立的技巧;它是一场宏大戏剧中的核心角色,是一场微妙而美丽的舞蹈,将编程逻辑的抽象世界与硅片无情的具体现实联系在一起。这里是可能性的艺术与效率的科学相遇的地方。它的影响向外辐射,触及从 CPU 的物理设计到我们构建大规模软件的根本方式等方方面面。
让我们踏上一段旅程,看看这个看似简单的想法会将我们带向何方。我们将看到,最初为了删除一条指令的探索,如何展开成一个关于约束、妥协和巧妙解决方案的迷人故事。合并的真正美妙之处不在于它做了什么,而在于它揭示了计算的互联特性。一条位于高频使用循环内的 mov 指令看似无害,但当该循环运行数百万次时,通过消除这一条指令所节省的时钟周期可以累积成数秒的实际性能增益。这就是我们关心它的原因,也是这场舞蹈如此重要的原因。
在施展才华之前,编译器必须首先保证正确性。合并的第一课,也是最重要的一课,是学会什么时候不该做。世界充满了硬性边界,一个好的编译器必须对它们了如指掌。
最刚性的边界之一是硬件本身。现代处理器不是单一的实体;它们是专业化单元的联合体。一个 CPU 可能有一组用于整数运算的寄存器,和另一组完全独立的用于浮点计算的寄存器。这些在物理上是截然分开的,就像一个工具箱里有一个抽屉放扳手,另一个抽屉放螺丝刀。如果我们的代码需要将一个值从整数寄存器移动到浮点寄存器,这并非简单的重新标记——这是一次数据的物理传输,通常还伴随着表示方式的改变。一个试图合并这两个虚拟寄存器——即合并扳手和螺丝刀——的编译器,无异于要求硬件做一些不可能的事情。最终的“合并”寄存器将面临矛盾的需求:它的值必须源自一个整数操作,但又被一个浮点操作所消费。由于没有单个物理寄存器能同时满足两者,它们需求的交集为空,合并因此被禁止。在这种情况下,mov 指令不仅仅是一个提示;它是一次必要的转换,连接了两个不同的世界。
另一个边界并非物理上的,而是社会性的——一个约定俗成的问题。软件由相互调用的模块或函数构成。为了让这一切正常工作,必须有一个关于如何通信的共识,一份“社会契约”。这份契约就是应用二进制接口(ABI),它规定了(除其他事项外)哪些特定寄存器必须用于向函数传递参数,以及哪一个寄存器持有返回值。这些寄存器是“预着色”的;它们的命运在编译器开始其主要分析之前就已经决定了。一个复杂的合并算法必须对这些 ABI 寄存器怀有极大的敬意。它可以尝试将函数内部的变量合并到这些预着色寄存器中,以避免函数入口和出口点的 shuffling(数据搬移),但它永远不能改变 ABI 寄存器本身的颜色。现代技术使用加权图来表达对此类合并的“偏好”,根据函数被调用的频率来确定它们的优先级,同时仍然保守地检查合并是否安全。这使得函数调用问题变成了一个优美的优化谜题:如何在遵守使通信成为可能的严格协议的同时,最大限度地减少通信开销。
保守合并并非在真空中运作。它是一首由多种优化组成的交响乐的一部分,其性能与合作伙伴的行为深度交织。编译器一个部分的变化可能会在别处创造或破坏合并的机会。
考虑指令选择的任务,特别是在具有“双地址”指令的架构上,其中一个源寄存器同时也是目标寄存器(例如,x = x + y)。当编译器遇到像 这样的三地址语句时,它必须选择哪个操作数( 或 )将作为目标。这个选择创造了一个新的、隐式的 mov 需求。如果它选择 ,它就希望合并 和 。如果它选择 ,它就希望合并 和 。最佳选择取决于上下文。如果 是一个长生命周期的变量,与许多其他变量干涉,试图将它与 合并是有风险的。一个更聪明的策略是通过短生命周期的临时变量链式地进行合并,从一系列计算中创建一个单一、统一的活跃范围。这揭示了合并的顺序至关重要;这是一个精细的过程,需要选择先编织哪些线索,才能最终织出最简约的织锦。
程序的“路线图”,即控制流图(CFG)的形状,也具有深远的影响。有时,这个图中的一条边是“关键边”——它连接一个有多个出口的块和一个有多个入口的块。在这样的边上放置副本指令是有问题的,因为它会不必要地延长变量的生命周期。这时,另一项优化——关键边分裂——就派上用场了。通过在关键边上插入一个新的空块,编译器为副本指令创建了一个专用的空间。这缩短了相关变量的活跃范围,减少了它们与其他变量的干涉。这种干涉的减少可能正好足以使一个之前不安全的合并操作变得安全。这是一个优美的例子,展示了两个看似无关的优化如何协同工作:重塑图的结构使得图内的数据流更加高效。
这场复杂舞蹈的回报可能出人意料地直接。想象一下在两个代码块的边界处,一整套寄存器需要重新排列——一个排列。例如,寄存器 中的值需要去 , 的去 , 的回到 。这形成了一个环。如果没有临时寄存器,实现这一点需要一系列交换操作(例如,swap(r1, r2); swap(r2, r3))。然而,如果编译器能够安全地合并这个环中的哪怕一个移动——比如说,通过证明 新值的来源可以直接存在于 中——那么这个环就被打破了。排列得以解开,所需的交换次数也减少了。这是一个纯粹算法优雅的时刻,消除一个逻辑上的副本节省了多个物理机器操作。
从本质上讲,编译是一种经济活动。每一个决策都是在编译成本与生成代码的性能之间,以及在优化的收益与它可能让事情变得更糟的风险之间进行的权衡。保守合并正是这种经济思维的体现。
有时,尽管编译器尽了最大努力,仍然没有足够的寄存器。这会导致溢出,即变量的值被暂时从寄存器中驱逐并存储到内存中。这是编译器的最后手段,并且是有代价的:代码现在散布着 load 和 store 指令。但即便在这里,在这个失败的时刻,合并再次回归,在损害控制中扮演关键角色。一种标准的溢出实现方式会在每个 load 和 store 周围引入微小的新临时变量和 mov 指令。溢出后合并可以立即清理这些辅助性的移动,使溢出代码更紧凑。在一些支持在算术指令中使用内存操作数的架构上,这种清理是使编译器能够将 load 指令直接“折叠”到后续的 add 或 mul 指令中的关键步骤,从而将两个指令变为一个。虽然这没有减少内存访问的次数,但它减少了指令数量并使代码更紧凑。
这种经济思维最深刻的应用出现在编译器必须决定是否执行一个技术上“安全”但带有高风险的合并时。想象一个位于关键循环内的 mov 指令。消除它将是巨大的胜利。一个简单的保守检查(如 Briggs 启发式方法)甚至可能给出绿灯。但如果所涉及的变量本身极其重要,具有非常高的溢出成本呢?合并它们会创建一个新的、更受约束的变量。如果编译器的赌博没有成功,而这个新的、高价值的变量必须被溢出,那么性能损失可能是灾难性的——远比原始 mov 的成本更糟。正是在这里,一个真正智能的编译器会像一个精明的投资者一样行事。它不仅着眼于潜在的回报,还会权衡风险。一个感知溢出成本的合并策略会拒绝合并两个高溢出成本的变量,即使移动指令很频繁且图论规则允许这样做。它明智地选择忍受一个小的、已知的成本,而不是去冒一个大的、未知的风险。
通过使用来自现实世界的数据,这种经济推理可以变得更加精确。并非所有的代码路径都是平等的。性能分析工具可以告诉编译器程序的哪些部分是“热”的(执行频繁),哪些是“冷”的。现代编译器利用这些信息来指导其决策。它为每个潜在的合并操作分配一个权重,该权重与其对应 mov 指令的执行频率成正比。当面临两个相互排斥的合并选择时,它会优先考虑位于更热路径上的那个,因为这个选择最有可能减少运行时执行的总移动次数。这就是剖面引导优化(PGO),它将合并从一种静态分析转变为一种动态的、基于证据的策略。
最终,我们看到保守合并远不止是一种简单的优化。它是一个镜头,通过它我们可以审视整个编译过程。它教导我们关于硬件的硬性限制、软件的社会契约、不同优化之间微妙的相互作用,以及管理风险所需的经济智慧。从解开寄存器排列 到决定何时对高成本变量进行冒险,甚至到寻找并行执行多个合并以使编译器本身更快的方法,合并证明了在人类意图和机器执行之间架起桥梁所需的独创性。它是让我们的数字世界运行得快一点点的、默默无闻的英雄之一,一次节省一个时钟周期。