Archipelago

Lec 21寄存器分配 I(Register Allocation I)——活跃性分析、死代码消除与活跃区间

讲义完成更新于 2026-09-16

Lec 21 寄存器分配 I(Register Allocation I)——活跃性分析、死代码消除与活跃区间

寄存器分配的两讲共用一份 slides《Register Allocation (+ Liveness, Dead Code Elimination)》。本讲(L21)打地基:从栈式 IR 转三地址码,做常量传播 / 死代码消除,并用活跃性分析 (liveness analysis) 这一数据流分析支撑它们;最后引出活跃区间 (live range / live interval) 与寄存器分配问题的形式化。算法(线性扫描、图着色)放到 L22。

TL;DR

  • 将栈式 IR 转成三地址码会把隐含在操作数栈中的数据依赖显式化,使常量传播、死代码消除和寄存器分配可共享同一表示。
  • 变量在程序点活跃,当且仅当从该点出发存在一条路径,在被重新定义前使用它;因此活跃性是后向、并集的数据流问题。
  • 活跃区间近似值占用寄存器的时间段,重叠区间不能共享寄存器,为线性扫描与干涉图提供输入。

1. 回顾:为什么需要寄存器分配

上一讲的"栈缓存"版本把每个栈位死板地绑到固定寄存器 r13,结果满屏都是 mov %r13 %rdi / mov %rax %r13——"为什么这么多 mov?" 因为固定映射不会利用"值本来就在某个寄存器"的事实。

对比寄存器分配后的版本(y 已在 rdi、返回值要在 rax):

// rdi = load_local 0  (y 已在 rdi → 空操作)
call assert_integer;  mov %rax, %rdi
call get_integer;     mov %rax, %rdi
sub $2, %rdi;  call new_integer    // rax = ...
// return rax

目标:尽量把变量保存在寄存器里,减少内存读写与总内存占用。要回答两个问题:变量放哪个寄存器?寄存器不够时怎么办?


2. 栈式 IR → 三地址码(Three-Address Code / RTL)

定义(IR 操作数三类 + 三地址码)

操作数:常量、真实寄存器 r1,r2,…(受 ISA 限制有限)、虚拟寄存器/临时值 t1,t2,…(无限)。指令是三地址码:<r|t> = op <operand> <operand>。

把栈式指令转 3AC:每条压栈对应"定义一个新临时值",每次弹栈对应"读某临时值"。模拟抽象栈,栈位 ↔ 临时名:

load_local 0          t1 = load_local 0
assert_integer    →   t2 = assert_integer t1
get_integer           t3 = get_integer t2
sub_int32_const 2     t4 = sub_int32 t3 2
return                return t4

这一步把"隐式的栈数据流"变成"显式的命名数据流",后续所有数据流分析(常量传播、活跃性)都建立在 3AC 上。延伸阅读:Shi/Gregg/Beatty/Ertl《Virtual Machine Showdown: Stack vs Registers》比较两种 VM 规格在表示大小、性能、可做的翻译/优化(复制传播、死代码消除)上的差异。


3. 常量传播与死代码消除(3AC 上的版本)

3.1 常量传播(Constant Propagation)

把"加载常量"得到的临时值,用其已知常量值替换后续使用:

t4 = load_const 1        t4 = 2
t5 = get_integer t4   →  t5 = get_integer 2
t6 = sub_int32 t3 t5     t6 = sub_int32 t3 2

t4、t5 一旦被常量替换,其使用点都不再引用它们。

3.2 死代码消除(Dead Code Elimination)

定义(死代码)

若某定义计算出的值之后不再被使用(不活跃 / dead),则该定义是死代码,可删除。

常量传播后,t4 与 t5 都不再活跃 → 删除:

t1 = load_local 0
t2 = assert_integer t1
t3 = get_integer t2
t6 = sub_int32 t3 2     # 直接用常量 2
return

要判断"之后是否还被使用",需要一个能回答"某点哪些变量还活着"的分析——活跃性分析。


4. 活跃性分析(Liveness Analysis)

定义(活跃 Live / 死 Dead)

变量 v 在程序点 p 活跃,当且仅当:

  • 从 p 出发的某条路径上 v 被使用,且

  • 在该使用之前路径上没有对 v 的重新定义。

反之 v 在 p 死:从 p 到出口的任何路径都不再使用 v,或所有路径在使用前都重定义了 v。

定义(活跃性分析的用途)

  • 死代码消除;

  • 计算变量间的干涉 (interference / webs),为寄存器分配服务。 :::

4.1 概念:从出口反向模拟

"未来是否被用"是关于后继的性质,所以从 CFG 的出口出发、反向传播,自基本块尾向首计算活跃信息。

4.2 用位向量表示

例子(变量顺序 a b c x y t,假设 a,b,c 方法外可见→出口活跃,x,y,z,t 不可见):

a = x+y;
t = a;
c = a+x;
x == 0
b = t+z;    c = y+1;

每个点的活跃集合用位向量表示,如 1 1 0 0 1 0 0 表示 {a, b, y} 活跃。反向沿 CFG 传播即可得到每点的位向量。

4.3 数据流框架

定义(活跃性作为数据流问题)

  • 事实 (Facts):位向量(变量集合);其格是标准布尔格 / 超立方体 P = {000,…,111};

  • 转移函数:用 DEF(块内定义的变量)与 USE(块内"向上暴露使用"的变量);

  • 汇合算子 (Join):并集 (union)。 :::

::: definition 定义(每个基本块的四个集合)

  • IN:块入口处活跃的变量集合;

  • OUT:块出口处活跃的变量集合;

  • USE:块内"向上暴露使用"的变量(在被本块重定义前就被读);

  • DEF:块内被定义的变量。

例:USE[x = z; x = x+1;] = {z};DEF[x=z; x=x+1; y=1;] = {x, y}。编译器扫描每个基本块求出 USE 与 DEF。

数据流方程(后向、并集):

OUT[n] = ⋃_{s ∈ succ(n)} IN[s]
IN[n]  = USE[n] ∪ (OUT[n] − DEF[n])

4.4 工作表算法(Worklist)

for all n in N − {Exit}:  IN[n] = ∅
OUT[Exit] = ∅;  IN[Exit] = use[Exit]
Changed = N − {Exit}
while Changed ≠ ∅:
    choose n in Changed;  Changed −= {n}
    OUT[n] = ⋃_{s ∈ succ(n)} IN[s]
    IN[n]  = use[n] ∪ (out[n] − def[n])
    if IN[n] changed:
        for all p in pred(n):  Changed ∪= {p}

这是经典的不动点迭代:反复套用方程直到所有 IN/OUT 稳定。L23–L24 会把它一般化为通用数据流/格分析框架。


5. 寄存器分配问题的形式化

定义(寄存器分配)

把变量分配到寄存器、并管理数据进出寄存器的过程。物理机寄存器很少(amd64 有 16 个通用寄存器 + 若干专用)。

5.1 两大挑战

::: example 挑战

  • 寄存器稀缺:IR 变量常远多于寄存器,必须尽量复用——放不下就溢出 (spill) 到内存;

  • 寄存器复杂:x86 某些指令结果必须落在特定寄存器;多数架构有些寄存器需跨函数调用保留 (callee-saved)。 :::

5.2 寄存器一致性

::: definition 定义(Register Consistency)

  • 在每个程序点,每个变量必须位于唯一位置——但不意味着变量始终在同一位置;

  • 若两个变量从不同时被读,可分配到同一寄存器;

  • 在每个程序点,每个寄存器至多保存一个活跃变量。 :::

5.3 活跃区间(Live Range / Live Interval)

::: definition 定义(活跃区间)

  • 变量的 live range(活跃范围):它活跃的那些程序点的集合(由活跃性分析得到);

  • 变量的 live interval(活跃区间):包含其全部活跃范围的最小连续区间——比 live range 粗(可能含它实际不活跃的点),但更简单好处理。 :::

5.4 例:计算活跃区间

对如下 CFG(含分支),反向传播活跃集合:

{a,b,c,d}  e = d + a
{b,c,e}    f = b + c
{b,e,f}    f = f + b
{e,f}      IfZ e Goto _L0
{e,f}      d = e + f        |   _L0: d = e - f
{d}        Goto _L1         |   {d}
           _L1: g = d
{g}

反向逐条计算每点活跃集合(如 g=d 处 {g}→其上 {d}→……→入口 {a,b,c,d})。这些区间彼此重叠的变量不能共用寄存器——这正是 L22 图着色里"干涉"的来源。


6. 接下来(L22 预告)

::: definition 定义(目标:两种分配算法)

  • 线性扫描 (Linear Scan) 寄存器分配;

  • 图着色 (Graph-Coloring) 寄存器分配。 :::

并把寄存器分配视为约束优化问题:假设 load_local 0 的 y 在 rdi,约束(t1/t2/t3 用 rdi、返回 t4 用 rax)下最小化内存访问与寄存器间 mov,得最优分配 t1..t3→rdi、t4→rax。


7. 与参考资料的对应(补充)

  • 活跃性数据流框架出自经典编译原理(slides 注明 Saman Amarasinghe, 6.035;图着色部分 credit CS143@Stanford)。
  • index.md 推荐的寄存器分配论文:Chaitin《Register Allocation & Spilling via Graph Coloring》(1982)、Poletto & Sarkar《Linear Scan Register Allocation》、George & Appel《Iterated Register Coalescing》(1996)——分别对应 L22 的图着色、线性扫描、合并优化。
  • Crafting Interpreters 不涉及寄存器分配(clox 是栈式字节码解释器),本主题属课程超出书的部分,可读《Engineering a Compiler》。

8. 本讲小结

  • 把栈式 IR 转三地址码,让数据流显式化;据此做常量传播 + 死代码消除。
  • 活跃性分析:变量 v 在 p 活跃 = 从 p 出发某路径在重定义前用到 v;它是后向、并集的数据流问题,用 IN/OUT/USE/DEF 方程 + 工作表算法在 CFG 上求不动点。
  • 寄存器分配 = 把变量映射到稀缺寄存器并管理溢出;要点是寄存器一致性(每点每变量唯一位置、不同时活跃的变量可共用寄存器)。
  • 活跃区间(live range 的连续化近似)刻画变量何时占用寄存器,重叠的变量不能共用——这是 L22 干涉图的基础。
  • 下一讲(L22):线性扫描 与 图着色 两种分配算法。