命名空间
约 1709 字大约 6 分钟
2025-01-15
大多数关于命名空间的讨论都集中在源程序的命名空间上:词法作用域和继承规则。从编译代码质量的角度来看,编译器的中间表示(IR)中创建的命名空间具有同等的重要性。特定的命名规范可以揭示优化的机会或使这些机会变得模糊。编译器在命名方面所做的选择在很大程度上决定了哪些计算可以被分析和优化。
当编译器编写者设计中间表示(IR)时,他们也应该为编译器设计一套命名规范。命名空间的选择与IR的选择相互影响;某些IR在命名上允许较大的自由度,而另一些IR则使大多数名称在表示中隐含
这一节将会重点介绍Static single-assignment form(SSA) 静态单赋值形式的IR。
Static single-assignment form(SSA)
SSA形式是一种具有基于值的命名系统的中间表示(IR),通过重命名和使用称为 φ 函数的伪操作创建。SSA编码了控制流和值流。它在优化中被广泛应用。
虚拟寄存器都是只读的!一次性赋值完毕后,就不允许再次修改,所有的虚拟寄存器只能在定义的地方赋值。
如果需要能修改的变量,需要用 IR 中的 alloca 指令在函数栈上分配(静态大小的)空间,其会返回一个指针。
在 Clang 中,会先把所有局部变量都定义为 alloca 的,当初始化变量时,用 IR 中的内存写指令 store 写入初始值。
然后,在开启了优化的情况下,LLVM 中端一个叫做 mem2reg 的优化 pass,会检测到所有只有一次性赋值的常量变量的 alloca,并尝试把他们优化为虚拟寄存器。
对于存在 if 条件赋值,以及 for 循环的,则会利用 Phi 节点,依然可以优化称一次性赋值的虚拟寄存器。(后面的章节中会专门介绍 Phi 的知识)
如果实在避免不了 alloca(比如对变量用到了取地址运算符,而虚拟寄存器没有内存地址),那就会放弃优化这个变量,依然保持 alloca + store 的状态。
最终,所有能优化成的,都变成无限供应且只读的虚拟寄存器了。
这种寄存器只读的 IR,被称为 SSA IR(Static Single Assignment IR),中文就是是“静态单赋值 IR”。
- 静态:寄存器的数量和序号是确定的,在生成 IR 的时候就已经固定。
- 单赋值:所有寄存器只能赋值一次,之后不得修改。
一个程序处于SSA形式时,它需满足两个约束条件:(1) 每个定义都有一个不同的名称;以及 (2) 每个使用都指向单一的定义。为了将中间表示(IR)程序转换为SSA形式,编译器在不同控制流路径合并的点插入 φ 函数,并重新命名变量使得单赋值属性得以保持。 为了澄清这些规则的影响,考虑图(a)中所示的小循环。图(b)展示了同一段代码的SSA形式。变量名称包含了下标以确保每个定义都有独特的名称。在多个不同值可以到达一个块的起点的位置插入了Φ函数。最后,while结构被改写成较低层次的抽象,揭示了初始测试引用的是xo,而循环结束时的测试引用的是x2这一事实。
φ 函数具有不寻常的语义。它充当复制操作,选择作为其参数的那个值,该值对应于控制进入该块时所沿的边。因此,当控制从循环上方的块流入循环时,位于循环体顶部的 φ 函数分别将xo和yo的值复制到x1和y1中。当控制从循环底部的测试流入循环时,φ 函数则选择它们的其他参数,即x2和y2。 φ 函数的执行语义与其他操作不同。在进入一个块时,所有的 φ 函数并行地读取它们适当参数的值。接下来,它们也并行地定义它们的目标SSA名称。以这种方式定义它们的行为使得操纵SSA形式的算法可以忽略位于块顶部的 φ 函数的顺序——这是一种重要的简化。然而,这也确实使将SSA形式翻译回可执行代码的过程变得复杂,
提示
SSA IR 的好处是方便优化,例如:
x = 1;
x = 2;
y = x;
return y;
很明显,我们可以把 x = 1 这一行优化掉,因为后面的 x = 2 已经把他覆盖了。但是如何确定这一点?很困难。
而如果先通过 mem2reg 转成 SSA IR 的虚拟寄存器,这时他会注意到 x = 1 和 x = 2 是两个独立的赋值,生命周期互不重叠,可以拆成两个常量,安全放进只读的虚拟寄存器。
x1 = 1; // 检测到“不可达”的寄存器
x2 = 2;
y = x2;
return y;
然后,由于 x1 根本没有使用过,在后端的“寄存器分配”阶段,很容易就把 x1 这个未使用的变量剔除掉。即使不是后端,中端的一些其他优化 pass 也很容易清除这些未使用的常量寄存器。
总之,通过 SSA 规则,把“寄存器被覆盖”这个比较难检测的条件,变成了“寄存器只定义,没人使用”这个很容易检测的事实,大大方便了优化。
最后,在“分配寄存器”阶段,把硬件寄存器数量容纳不下或无法变成单次静态赋值的部分变量,再选择性地“打翻”到内存中去。这样一来一回,在中端方便了优化,后端又一样能正常生成汇编,对于寄存器用量较少的函数则完全避免了内存读写。为了保证所有用到的变量都存到寄存器中,你可以自己数一下,所有变量生命周期重叠的最多的数量是多少,是否超过的硬件寄存器的数量上限:对 x86 来说,就是除 rsp 和 rbp 外所有的通用寄存器(整数变量)和所有 xmm 系列寄存器(浮点变量);如果该函数中还调用了其他函数,那么就只有非易失寄存器可用。
更新日志
e0e13
-中间代码结束于