try ai
科普
编辑
分享
反馈
  • 线性扫描寄存器分配

线性扫描寄存器分配

SciencePedia玻尔百科
核心要点
  • 线性扫描寄存器分配(LSRA)是一种快速的单遍算法,它通过按时间顺序处理变量的活跃区间来分配寄存器。
  • 当寄存器供给不足时,LSRA 通过将变量溢出到内存,或通过重物质化那些重新计算成本低的值来管理资源争用。
  • LSRA 的有效性高度依赖于之前的编译器优化,如指令调度和静态单赋值(SSA)形式的使用,这些优化可以缩短活跃区间。
  • LSRA 的速度使其成为即时(JIT)编译器的理想选择,而其对寄存器使用的控制对于最大化 GPU 上的并行度(占用率)至关重要。

引言

在计算世界中,速度为王,而处理器可用的最快内存就是其寄存器组。如何有效管理这一稀缺资源——即决定程序中众多临时变量在任意时刻占用这些黄金位置中的哪一个——这一挑战被称为寄存器分配。虽然存在复杂且耗时的方法,但许多场景要求一种既简单又极速的解决方案。这正是线性扫描寄存器分配(LSRA)所填补的空白,它是一种优雅而强大的算法,已成为现代编译器的基石。

本文将对 LSRA 进行全面探讨。首先,在“原理与机制”一章中,我们将深入研究该算法直观的“扫描线”方法,揭示它如何跟踪变量的生命周期,并通过溢出和重物质化等技术来管理不可避免的寄存器耗尽危机。随后,在“应用与跨领域关联”一章中,我们将拓宽视野,观察 LSRA 如何与其他编译器阶段交互,如何适应多样的硬件架构,以及如何在即时编译器和大规模并行 GPU 等动态环境中充当关键的性能杠杆。

原理与机制

想象一下,你是一家非常奇特、非常小的旅馆的经理。你的客人不是按天住宿,而是按微秒住宿,并且他们到达时都带着确切的入住和退房时间。你的工作是为每位客人在其住宿期间分配一个房间。房间数量固定且很少。简而言之,这就是寄存器分配的挑战。“客人”是程序计算所需的临时值或变量。“房间”是 CPU 宝贵的高速内存槽,称为​​寄存器​​。“住宿”是变量的​​活跃区间​​——从其创建(“定义”)到其最后一次使用那一刻的时间跨度。

你会如何解决这个旅馆管理问题?最简单、最直观的方法是按时间顺序处理请求。你会按照入住的先后顺序来处理。这正是​​线性扫描寄存器分配(LSRA)​​背后优美而简单的思想。

扫描线:一个简单的思想

线性扫描算法将整个程序视为一条单一的时间线,一条从头到尾的直线指令序列。它通过一条“扫描线”在这条时间线上扫过。当扫描线遇到一个变量活跃区间的起点时,它会尝试为该变量寻找一个空闲的寄存器——一个空的旅馆房间。当扫描线经过一个活跃区间的终点时,该变量占用的寄存器就被释放,可供新的住户使用。

这是一种绝妙的贪心和局部方法。分配器只需要知道扫描线当前发生了什么;它不需要看得很远,无论是未来还是过去。但这引出了一个关键问题:我们总共需要多少个寄存器,或者说房间?答案取决于程序中最繁忙的时刻。如果在某个时间点,有七个变量同时处于活跃状态,那么你将需要至少七个寄存器才能毫无问题地容纳它们。这个同时活跃变量的峰值数量通常被称为​​寄存器压力​​,或程序的“高水位线”。如果你至少有这么多寄存器,那么简单的线性扫描将会顺利成功。

例如,考虑一个接一个创建新临时变量的计算序列。通过仔细追踪每个变量的诞生和最后一次使用,我们可以绘制出它们的活跃区间。将这些区间堆叠在时间线上,可以揭示出重叠最严重的时间点。在一个有11个临时变量的特定案例中,压力可能会上升和下降,但仔细分析会发现一个峰值,此时有7个变量同时活跃。要在没有任何特殊技巧的情况下运行这段代码,你需要一个至少有 k⋆=7k^{\star}=7k⋆=7 个寄存器的 CPU。

危机管理:溢出与重物质化

现实世界很少如此慷慨。我们常常面临活跃变量多于可用寄存器的情况。当一个新客人到达而所有房间都满了时会发生什么?分配器面临危机,必须做出选择:必须有人被“驱逐”。这个驱逐过程被称为​​溢出​​(spilling)。

当一个变量被溢出时,它的当前值会从其寄存器中保存到速度慢得多的主内存中的一个指定位置(“溢出槽”)。这样就为新变量腾出了寄存器。之后,如果程序再次需要这个被溢出的变量,就必须将其从内存中加载回寄存器——这个过程称为​​重加载​​(reloading)。每一次存储和加载操作都是一次对主内存的访问,这比访问寄存器要慢几个数量级。一个好的溢出策略的目标是最小化这种代价高昂的内存流量。

那么,我们应该驱逐谁呢?“牺牲品”的选择至关重要。一个绝妙而简单的启发式方法是​​最远未来使用​​(furthest-next-use)策略:驱逐在最长时间内不会再被使用的变量。这非常直观。如果你必须给某人带来不便,那就选择那个在很久以后才需要自己房间的人。这个策略与 Belady 的内存缓存最优算法是近亲,其表现通常非常出色。也存在其他启发式方法,比如驱逐最不常用的变量,但展望下一次使用的优雅性难以超越。

但溢出并非唯一选择。如果你的“客人”中有一个根本不是人,而是一个简单的食谱,比如“混合热水和茶包”,情况又如何呢?如果你需要腾出柜台空间,你不需要小心地把茶装瓶存入冰箱。你只需把它倒掉,在下次需要时再泡一杯新的即可。

这就是​​重物质化​​(rematerialization)的概念。有些变量持有的值重新计算起来非常便宜。一个常见的例子是通过将一个小的已知常量加到栈指针上计算出的地址。如果这样一个变量需要被驱逐,通常更好的做法是直接丢弃它的值,并在下次使用前重新执行原始指令来“重物质化”它。

这引入了一个有趣的经济学权衡。溢出需要内存访问时间(cmemc_{\text{mem}}cmem​),而重物质化需要计算时间(caluc_{\text{alu}}calu​)。哪个更好?这取决于它们的相对成本以及重新计算该值需要多少条指令。我们可以找到一个精确的​​交叉点​​,即一个比率 r∗=cmemcalur^{\ast} = \frac{c_{\text{mem}}}{c_{\text{alu}}}r∗=calu​cmem​​,在该点上两种策略的成本持平。对于一个场景,溢出涉及一次存储和五次加载,而重物质化需要在五个使用点各重新运行两条 ALU 指令,当 r∗=53r^{\ast} = \frac{5}{3}r∗=35​ 时成本相等。如果内存访问速度比 ALU 操作慢 1.67 倍以上,那么重物质化就胜出了。编译器不断地做出这些量化决策,以生成最快的代码。

现实世界的束缚:约束与预着色

我们那个填充任何可用房间的简单模型,因现实世界的规则而变得复杂。有些寄存器并非通用;它们被保留用于特定任务。这些约束创建了​​预着色​​(pre-colored)区间,即变量不仅是活跃的,而且必须驻留在特定的、预先分配的寄存器中。

这类约束的一个来源是 CPU 的指令集本身。例如,一个老式的整数除法指令可能被硬性规定使用寄存器 $r_0 和 $r_1 作为其输入和输出。当线性扫描分配器遇到这样的指令时,它别无选择。它必须确保 $r_0 和 $r_1 是可用的,即使这意味着要强行溢出碰巧占用它们的其他变量。

一个更常见的约束来源是​​调用约定​​(calling convention),即函数相互调用时必须遵循的严格协议。该约定规定了哪些寄存器用于传递参数,哪些用于接收返回值,以及哪些寄存器必须在整个调用过程中被保留。被调用函数(“callee”)可以覆盖的寄存器被称为​​调用者保存​​(caller-saved),这意味着如果我们(“caller”)在其中有一个活跃值,我们必须在调用前将其保存到安全的地方。被调用者必须保留的寄存器被称为​​被调用者保存​​(callee-saved)。

想象一下,我们有 4 个变量需要在函数调用后继续存在,但调用约定只提供了 2 个被调用者保存寄存器。我们别无选择,只能在调用前将另外 2 个变量溢出到内存,并在调用后重加载它们,这会产生两对溢出和重加载的成本。

正是在这里,线性扫描贪心性质的根本局限性被鲜明地揭示出来。因为它只着眼于一个时间点,它可能会做出一个局部最优的选择,却导致全局灾难性的后果。例如,它可能在函数开始时愉快地将变量 f 分配给寄存器 R1,却在很久之后才发现,一条指令要求在 f 仍然活跃的同时,另一个变量 a 必须在 R1 中。结果就是一次强制溢出。一种更复杂的方法,如​​图着色分配​​,则采用全局视角。它构建一个“冲突图”,其中每个变量是一个节点,任何两个同时活跃的变量之间都有一条边连接。然后,它尝试用等于可用寄存器数量的颜色来“着色”这个图,确保没有两个相连的节点获得相同的颜色。这种全局视角可能使其能够预见到未来对 a 的约束,并从一开始就巧妙地将 f 分配到不同的寄存器,从而完全避免溢出,并生成快得多的代码。其代价是复杂性:图着色比线性扫描优雅、简单的扫描过程要慢得多,也复杂得多。

区间的艺术:分裂与合并

最后一层复杂性来自于我们认识到可以操纵活跃区间本身。一个变量的生命周期并不总是一个单一、连续的跨度。

​​活跃范围分裂​​(Live-range splitting)的洞见在于,如果一个变量被使用后,在下一次使用前有很长一段休眠期,那么在这段休眠期间没有必要让它占用寄存器。我们可以将其活跃范围分裂成两个或多个更小的片段。这个变量实际上是“退房”了它的寄存器旅馆,稍后再“入住”。这可以极大地降低寄存器压力的峰值。在某些情况下,一个拥有许多临时变量的程序似乎需要比可用寄存器更多的寄存器。但如果每个临时变量的活跃范围都非常短——定义后立即被消费——那么同时活跃的变量数量可以保持非常低。如果它们的生命周期被巧妙地交错,用少数几个寄存器处理几十个变量是可能的。重物质化是这种形式的一种,我们通过在高压力区域创建“空洞”来分裂一个活跃范围。

相反,编译器有时会尝试做相反的事情。如果它看到一条像 $y \leftarrow x$ 这样的指令,这只是一个副本,它可能会尝试通过为 x 和 y 使用相同的寄存器来消除这条 move 指令。这就是​​副本合并​​(copy coalescing)。它将它们两个独立的、不重叠的活跃区间合并成一个更大的、统一的区间。虽然这节省了一条指令,但可能会适得其反。新的、更长的区间现在覆盖了过去存在于 x 和 y 生命周期之间的“空洞”。这可能会增加该区域的寄存器压力,可能导致本不会发生的溢出。编译器再次面临经济决策。它必须权衡消除一条 move 指令的好处与引发溢出的潜在成本。通过分析成本和收益,它可以确定一个最优的“合并预算”以最大化性能。

从简单的扫描线到在约束下进行溢出、重物质化、分裂和合并的复杂舞蹈,线性扫描寄存器分配揭示了现代编译器的真正本质:它不仅仅是一个翻译器,而是一个复杂的经济引擎,不断地进行精细的权衡,以从底层硬件中榨取每一滴性能。

应用与跨领域关联

在了解了线性扫描寄存器分配的原理之后,我们可能会觉得它只是一个聪明但谦逊的算法,是编译器后台办公室里一项整洁的簿记工作。但这就像只看到国际象棋的规则,却错过了棋盘上展开的策略交响乐。线性扫描算法的真正美妙之处不在于其简单的单遍特性,而在于其卓越的通用性。它是编译舞台上的核心角色,与从程序的抽象表示到其运行的芯片的原始性能等一切事物进行交互、适应并产生深远影响。它是程序员意图与处理器现实之间的桥梁。

伟大的谈判者:与其他编译器阶段的相互作用

编译器不是一个单一的实体,而是一条由专家组成的流水线,每个专家都以自己的方式转换代码。寄存器分配器并非孤立工作;它继承了其前面阶段所创造的世界,其性能直接反映了这份遗产。

故事始于​​中间表示(IR)​​,即编译器与自身对话的语言。一种为每个新值使用许多不同变量名的 IR,如静态单赋值(SSA)形式,对线性扫描分配器来说是一份礼物。在非 SSA 代码中,循环中反复使用的单个变量名会创建一个长而连续的活跃区间。对于线性扫描来说,这是一场噩梦——一个长寿命的区间就像旅馆里的一位常住客,占用了许多短住客人的空间。SSA 通过为每个新值赋予一个唯一的名称,从根本上执行了活跃范围分裂。它将那个长区间分解成一连串许多较短的区间。这极大地减少了任何给定点的最大重叠区间数量,降低了寄存器压力,使分配器的工作变得异常轻松。这种在编译器早期做出的表示选择,对于最终代码是精简快速还是因内存溢出而陷入困境具有决定性影响。

除了 IR 之外,指令的具体排序,即​​指令调度​​,也具有令人惊讶的直接影响。想象你有两个独立的任务:一个创建了一个很久以后才会使用的临时值,另一个是自包含的。你应该先做哪一个?常识性的答案——也是线性扫描所偏好的答案——是先把生命周期短的任务完成。通过将指令的使用调度得更靠近其定义,我们缩短了它的活跃区间。正如我们在一个简单的思想实验中所见,交换两条独立的指令可以缩短一个活跃范围,刚好足以防止它与高压力区域重叠,从而完全避免一次溢出。这揭示了一种深刻且时而紧张的关系:调度器和分配器常常处于冲突之中,但当它们合作时,结果便是优雅的效率。

这种紧张关系在​​循环展开​​这一经典优化中表现得最为明显。对处理器而言,循环就像一串紧密排列的命令。通过“展开”循环——即多次复制其主体——我们创建了一个更长的、直线式的指令序列。这为处理器提供了更广阔的工作视野,使其能够并行执行多个迭代的指令。问题在于,如果原始的每次迭代都创建了(比如说)两个临时值,那么将循环展开 uuu 倍意味着我们现在有 2u2u2u 个临时值需要管理。这些按顺序创建但稍后才被使用的值的活跃范围开始堆积起来,在它们被消耗之前造成了寄存器压力的巨大峰值。线性扫描分配器必须直面这个峰值。如果机器有足够的寄存器,展开就是纯粹的胜利。如果不够,分配器将被迫进行溢出,而这些内存操作的成本很容易抵消掉优化带来的任何收益。这种权衡是高性能计算的基础。

适应芯片画布:架构多样性

如果说编译器各阶段提供了剧本,那么硬件架构就是舞台。现代处理器并非简单、统一的机器;它们是各种专业单元的集合,每个单元都有自己的怪癖和规则。一个稳健的寄存器分配器必须是适应大师。

例如,并非所有数据都能恰好装入单个寄存器。一个 64 位整数在 32 位 CPU 上必须存储在一对相邻的寄存器中。线性扫描必须学会不仅考虑单个寄存器,还要考虑寻找可用的寄存器对。这使得寻找空闲空间和溢出逻辑变得复杂,因为溢出一个 64 位值可能比溢出两个占用所需寄存器对的无关 32 位值要好。类似地,在超长指令字(VLIW)架构中,单个指令“束”可能要求四到五个操作数在完全相同的时刻存在于寄存器中。在这里,分配器的工作不仅是管理整体压力,还要在特定的程序点满足硬性的、瞬时的需求,即使这意味着要故意溢出其他只是“路过”的变量。

随着​​异构寄存器文件​​的兴起,这种复杂性进一步加深。现代 CPU 通常有用于整数数学、浮点运算和向量(SIMD)计算的独立寄存器组。一条向量加法指令可能要求其所有操作数和结果都位于寄存器文件的“向量邻域”中。当一个在向量单元中创建的值后来被不同单元中的指令需要时会发生什么?分配器必须管理这一点,将每个寄存器组视为一个独立的资源池。如果某一类寄存器的压力过高,它可能会溢出一个值——不是到内存,而是通过将其“标量化”到几个通用寄存器中。当再次需要该值时,必须将其重构,可能直接重构到另一个寄存器类别中,或者用特殊指令在类别之间移动。

最后,函数必须和平共存。这由​​调用约定​​来规定,这是一份“社会契约”,规定了函数可以覆盖哪些寄存器(调用者保存)以及它必须为其调用者保留哪些寄存器(被调用者保存)。线性扫描分配器必须是这份契约的执行者。它优先将跨越函数调用的变量放入被调用者保存的寄存器中;这只需要在函数的入口和出口进行一次保存/恢复。如果没有空闲的被调用者保存寄存器,它就必须使用一个调用者保存的寄存器,这会产生在每一次调用前后保存和恢复值的更高成本。这一选择具有显著的性能影响,并揭示了为什么完全相同的程序在两台不同机器上会以不同速度运行。一个拥有许多被调用者保存寄存器的架构可能更适合有许多跨调用活跃值的代码,而一个拥有更多调用者保存寄存器的架构可能更适合有许多短生命周期临时变量的函数。事实证明,性能并非完全可移植。

动态前沿:JIT 编译与并行计算

线性扫描算法在动态的、即时(JIT)编译的世界里真正大放异彩,这项技术驱动着像 Java、C# 和 JavaScript 这样的语言。在 JIT 中,编译器与程序并行运行,编译所花费的时间就是用户等待的时间。速度至关重要。线性扫描的单遍、低开销特性使其成为完美的选择。

JIT 甚至可以采用一种经济模型来决定在优化上投入多少精力。一个将被执行数百万次的“热”代码区域可能值得采用更复杂(也更慢)的寄存器分配策略来最小化溢出。一个较“冷”的区域可能会得到一个快速粗糙的分配。一个自适应 JIT 可以进行成本效益分析,权衡更好编译的一次性成本与未来因更少溢出而节省的运行时成本。线性扫描为这种分析提供了一个框架,允许编译器做出有根据的猜测:寄存器压力 r^\hat{r}r^ 是否足够高,以至于一个更强大的减少溢出的启发式方法,在未来 MMM 次执行中运行所带来的好处,会超过额外的编译成本?

此外,JIT 可以利用运行时信息来生成效率高得多的代码。例如,一个​​追踪 JIT​​ 观察到一个热路径,并发现一个变量总是包含一个整数。然后,它可以生成一个专门版本的代码,消除所有类型检查和装箱/拆箱的临时变量。对于线性扫描分配器来说,这简直是个奇迹。它接收到的代码更简单,变量更少,活跃范围更短,从而极大地降低了寄存器压力,并常常完全消除了溢出,带来了显著的速度提升。

也许线性扫描最引人注目的应用是在​​图形处理器(GPU)​​领域。GPU 通过大规模并行计算实现其巨大威力,同时运行数千个线程。其关键在于​​占用率​​(occupancy):即可以同时驻留在流式多处理器(SM)上的线程块数量。占用率的主要限制因素通常是寄存器文件。一个 SM 有一个巨大但有限的物理寄存器池,必须在所有线程之间进行分区。如果单个线程使用 rrr 个寄存器,而一个线程块包含 TTT 个线程,那么该线程块就消耗 r⋅Tr \cdot Tr⋅T 个寄存器。每个线程的寄存器数 rrr 是由编译器的寄存器分配器决定的——它是在程序中任何点的活跃变量的峰值数量。

在这里,我们看到了一个局部决策的全局影响。假设线性扫描确定一个内核每个线程需要 r=12r=12r=12 个寄存器。在一个典型的 GPU 上,这可能允许 212121 个线程块并发运行。如果一个巧妙的编译器优化,比如有针对性的活跃范围分裂,能够将峰值活跃度降低到仅 r=8r=8r=8 个寄存器,占用率可能会跃升到 323232 个线程块——并行度增加了超过 50%50\%50%,这直接转化为更高的吞吐量。在 GPU 上,寄存器分配器不仅仅是清理临时变量的勤杂工;它是控制整台机器性能的主要旋钮。

从程序的抽象结构到 JIT 编译器的经济决策,再到 GPU 的原始并行能力,线性扫描算法证明了它的价值。其优雅在于其简单性,但其力量源于其适应性,使其成为编译艺术与科学中最实用、最 consequential 的思想之一。