写这几个优化之前,我先把话放在前头:复制传播、常数折叠、常数传播这三兄弟,单拎出来每一个都不难,但它们在编译器前端到后端之间承上启下的作用,很多初学者是低估了的。我自己最开始写教学编译器的时候,总觉得“优化”是后端寄存器分配、指令选择这些重活的事情,前面几个小优化不过是在 IR 上做点文字替换。直到真正动手实现、统计它们对后续优化产生的连锁反应,才意识到这一层化简做得好不好,直接决定后面所有优化能捡到多少肉。
这篇文章就围绕这三个“前期优化”展开,适合正在学编译原理、或者自己手写 IR 优化通道的人看。我会把每个优化的原理、实现思路、安全边界讲透,再演示三个优化串起来的效果,最后整理一些我实际调试中踩过的坑。不需要你有多深的编译器基础,但至少得知道什么是基本块、什么是数据流分析——如果你已经知道 IR 有 SSA 形式,那读起来会更顺手。
1. 为什么说它们是“前期优化”
这三者通常被归为机器无关优化,跑在目标指令生成之前,也就是中端 IR 阶段。所谓“前期”,是因为它们的共同目标就一句话:在还不涉及寄存器分配、指令调度这些后端问题的时候,先把代码里的冗余信息清掉,让程序的数据流图更短、更干净。
1.1 三个优化各自解决什么问题
先拆开看。
常数折叠做的事情最简单——编译期直接算出能算的表达式结果。例如代码里写了a = 3 * 4,那直接折叠成a = 12,无需生成任何乘法指令。它处理的是“字面上的常数表达式”,不关心变量在运行时是什么值。
常数传播就往前多走了一步:它通过数据流分析,判断某个变量在某个程序点上是不是恒定等于某个常数。如果是,就把后面用到这个变量的地方替换成这个常数。典型场景是:
x = 42; y = x + 1;常数传播分析后知道x在那里就是 42,于是把y = x + 1改写为y = 42 + 1。注意,这还没完,改写之后又给了常数折叠机会,最终变成y = 43。
复制传播则处理另一种情况:代码里有一串变量到变量的直接赋值,比如b = a; c = b + 1。复制传播会把c = b + 1中的b直接替换成a,即c = a + 1。如果替换之后b再也没有人用了,那条b = a就成了死代码,可以被后续的死代码消除流程清理掉。
我习惯用一句话概括这三个优化的分工:常数折叠是“局部手算”,常数传播是“全局带信”,复制传播是“顺手把中间的传话人干掉”。
1.2 三者组合的放大效应
单独看任何一个优化,收益都极其有限。但把它们串起来效果完全不同。
我举个例子。假设 IR 里有这么一串:
x = 100; y = x; z = y + 8;如果只做常数传播,会发现x恒为 100,于是把所有用x的地方替换成 100,得到:
x = 100; y = 100; z = y + 8;y还是个未知数吗?不,其实y也被赋了常数值 100,继续传播就能把z = y + 8里的y换成 100。但如果你先做复制传播,把z = y + 8里的y换成x,得到z = x + 8,再配合常数传播把x换掉,最后常数折叠算出z = 108。两种顺序都能到终点,但路径长度不同。
在实际优化流水线里,编译器通常会对这三者做若干次迭代:复制传播开道,常数传播跟上,常数折叠收尾,然后死代码消除来处理那些被“架空”的复制语句。一轮不够就多来几轮,直到数据流图不再变化。
1.3 在编译器整体流程中的位置
聊完整体思路,还得把它放回编译器的坐标系里。一般编译器的流程是三段式:前端负责词法、语法、语义分析,生成中间表示;中端跑各种目标无关优化;后端把 IR 映射成目标指令,做寄存器分配、指令调度。
前端刚生成的未经优化的 IR,往往带着大量原始痕迹:冗余的临时变量、反复赋值的常数、层层包装的复制语句。这些痕迹不清理,后端的寄存器分配会把这些变量都当成需要分配寄存器的对象,导致寄存器压力暴涨;指令选择也会因为表达式太长而很难匹配到好的指令模板。
所以“前期优化”这个名字,名副其实——它们是给后端铺路的。材料里的杂质不筛掉,后面做精细加工是事倍功半的。
2. 常数折叠:最朴素的表达式化简
常数折叠是所有优化里最好理解、也最好实现的一个,但别因为它简单就掉以轻心。它的安全边界、触发点、与编译器其他模块的协作方式,里头的弯弯绕不少。
2.1 基本思想与实现场景
常数折叠的本质,是把编译期已知的运算提前完成。它在任意一级 IR 上都能做:AST 上可以,三地址码上可以,SSA 形式的 IR 上也可以。理论上讲,凡是操作数全部是常数的表达式都可以尝试折叠。
最常见的触发场景有这么几类:
- 字面量运算:
3 + 4、7 * 8这类编译期就能算出结果的算术表达式。 - 布尔表达式合并:
true && (x < 0),如果true &&部分可以归约,条件表达式就变短了。 - 数组下标计算:
arr[2 * 4]折叠成arr[8],省掉一次乘法,还可能为后续的地址计算提供便利。 - 常量字符串拼接:某些语言允许
"hello" + "world",编译期可以直接生成"helloworld"的常量。 - 位运算归约:
x & 0xff中0xff是常数,但x不是,这种情况通常不折叠,但如果整个表达式的操作数全是常数,(1 << 4) | 0x0f这类还是可以直接算。
实现方式也不复杂:遍历 IR 指令时,检查每个操作,如果所有源操作数都是编译期常数,就直接计算结果、更新目标操作数,并把当前指令标记为可删除。关键实现时要用一张“常数结果表”来记录折叠后的值,因为折叠后的结果可能复用到多处。
伪代码大概是这个思路:
def fold_expression(op, lhs_val, rhs_val): if not (is_constant(lhs_val) and is_constant(rhs_val)): return None # 无法折叠 if op == 'ADD': return lhs_val + rhs_val if op == 'MUL': return lhs_val * rhs_val # 更多 op2.2 折叠的安全边界
这里必须强调:折叠不是无脑把所有操作数都是常数的表达式都算掉。有几个场景是必须谨慎的。
第一是浮点运算的舍入问题。0.1 + 0.2在编译期以编译器的浮点格式算一遍,和在运行时由目标机器的浮点单元算一遍,结果在某些极端场景下可能不完全一致。好在现代 IEEE 754 标准的实现已经相当统一,大部分编译器默认对浮点常量表达式做折叠,但如果你写了-ffast-math之外的严格控制选项,某些编译器会刻意避免折叠浮点运算,以防止“优化后结果和未优化结果不同”的诡异现象。我在自己实现的玩具编译器里,浮点运算一律不折,保守,但安全。
第二是溢出行为的语义变化。无符号整数溢出在 C 里是定义好的回绕行为,有符号溢出则是未定义行为。如果你的中间表示不区分有符号、无符号,折叠INT_MAX + 1就可能遇到麻烦:编译器算出来的常数是INT_MIN(二进制回绕),但运行时如果按有符号算术执行,在目标机器上可能触发不同的溢出陷阱。
第三是可能有副作用的表达式。这是新手最容易踩的坑。考虑f(3) + 4,f虽然参数是常数,但调用本身可能有副作用,不能把f(3)折叠成某个常数。常数折叠只能处理那些“纯表达式”——没有函数调用、没有内存读取,或者至少你确认读取位置没有副作用。
我把可折叠的条件总结成三条:
操作数必须都是编译期常数;运算必须是无副作用的纯函数;结果的值必须是良定义的、不会因为折叠而改变语义。
2.3 实操演示:一个简单常量表达式的折叠过程
用一个简单例子演示完整流程。假设原始 IR 是:
%0 = add i32 3, 4 %1 = mul i32 %0, 2第一步,遍历到%0 = add i32 3, 4,发现两个操作数都是常数,计算3 + 4 = 7,把%0的改写成%0 = 7,原add指令标记删除。
第二步,遍历到%1 = mul i32 %0, 2,通过跟踪发现%0已经是常数 7,于是把%1的源操作数替换成7和2,继续折叠得到%1 = 14。
最终 IR 就一句:
%1 = 14这一步做完,原本两条算术指令全部消失。但请注意我上面流程里的“通过跟踪发现%0已经是常数”——这就是常数传播在做的事。常数折叠能访问到的“常数”,一部分来自字面量,更多部分来自常数传播替它挖掘出来的信息。这也是我不建议新手一上来就实现一个“万能折叠器”的原因,先跑通常数传播,再叠加上折叠,整体效果好得多。
3. 常数传播:让常数“跑起来”
常数传播是这三个优化里的重头戏,也是我第一次写数据流分析算法的启蒙案例。它讲的是一个很朴素的问题:一个变量可能在很多地方被赋值,但如果所有到达某个使用点的赋值路径都把它赋成同一个常数,那这个使用点就可以放心地用这个常数替换。
3.1 单向数据流分析的基本模型
常数传播属于前向数据流分析问题——信息从程序的入口向出口方向流动。每个程序点都关联一个“抽象状态”,记录每个变量的当前常量信息。
为了对“是否常数”这件事建模,文献里常用三值格(lattice)来描述:
undef:未定义,或者尚未确定值,表示可能没被赋值过,也可能是信息不足。constant(c):确定等于常数c。notconstant(也常叫bottom):不是一个常数。
初学的时候我总觉得这个格是玄学,换成大白话说就是:某个变量在某个程序点上,要么我知道它等于几,要么我知道它“不可能是固定值”,要么我暂时还没跑到这里、不知道。数据流分析就是一个不断把“不知道”变成“知道”的迭代过程。
合并规则也很直观:如果多条路径到达同一个程序点,且某个变量在路径 A 上是常数 1、在路径 B 上是常数 1,那合并后它还是常数 1;如果路径 A 上是 1、路径 B 上是 2,那合并后就是“不是常数”。这就是所谓 meet(交)操作:常数c与常数c相交为c,任何常数与另一个常数相交都变成notconstant,undef与任何值相交都等于那个值。
undef的处理是整个算法里最难讲清楚、也是最容易实现错的地方。如果某个变量在一条路径上还没被赋值(undef),但在另一条路径上是常数 2,合并结果应该是常数 2,而不是 notconstant。这就是“乐观”语义在起作用——先把所有变量当作未定义,然后在可证的情况下逐步提升为常数。
3.2 两种经典实现路径
常数传播的实现大致分两个流派。
第一个流派是经典数据流分析:先做到达定值(reaching definitions)分析,拿到每个使用点对应的定值集合,然后循环迭代:如果某个定值是x = c且 c 是常数,且它是到达该使用点的唯一定义,就把该使用点替换为常数 c;否则标记该变量在该点为非常数。这个方案直观,不需要控制流图的特殊结构,但一个根本弱点是:到达定值分析本身比较粗,变量和定值之间是“集合关系”,一旦某个变量在一个程序点有超过一个“可能定值”,就必须保守地退化为非常数,哪怕那些定值实际上来自不可达分支。
第二个流派是 SSA 形式下的稀疏传播(你可以叫它稀疏简单常数传播,或者用更朴素的说法,“只跟踪每个变量一个版本的传播”)。SSA 形式下每个变量只有一次赋值,所以变量天然只有一个“版本”。常数传播就变成了一个带权图上的工作列表传播问题:从常数的 def 出发,沿着 use 链传播;遇到phi节点时,把每个 incoming 的常数做 meet 合并;如果某个变量的所有 incoming 都收敛到同一个常数,就确定它是常数,否则标记为非常数。SSA 的好处是剔除了大量“这可能是那个定义、也可能是另一个定义”的集合推理,传播路径被压缩到极致,效率和精确度都更高。
我不会在这里给出完整的 SSA 构建代码,因为那本身就是一门大学问。但如果你的 IR 就是 SSA,常数传播的实现其实是相当清爽的:
def sparse_constant_propagation(cfg): # 初始化:把每条指令加入工作列表 worklist = list(cfg.all_instructions()) lattice = {instr: BOTTOM for instr in cfg.all_instructions()} while worklist: instr = worklist.pop() # 对每条指令,根据操作数和当前格子信息,计算其可能的值 new_val = evaluate_instruction(instr, lattice) if new_val != lattice[instr]: lattice[instr] = merge(lattice[instr], new_val) # 如果发生了变化,就把所有使用这条指令结果的地方重新加回工作列表 for user in instr.users(): worklist.append(user) return lattice这个循环看起来简单,但对初学者的劝告是:merge和evaluate_instruction里的细节才能决定正确性。phi节点怎么处理、undef怎么参与运算、call指令的结果怎么处理(通常直接保守标记为非常数),这些都是分分钟能埋坑的位置。
3.3 实操演示与边界问题
下面用一个简单的例子演示 SSA 下的常数传播。假设有如下 SSA IR:
entry: %a = add i32 2, 3 ; a = 5 br label %loop loop: %i = phi i32 [ 0, %entry ], [ %i.next, %loop ] %b = add i32 %a, %i %i.next = add i32 %i, 1 %cond = icmp slt i32 %i.next, 10 br i1 %cond, label %loop, label %exit exit: ret i32 %b%a是2 + 3,既能直接折叠成 5,也是常数传播的起点。%i在phi里有两个 incoming:entry 路径贡献 0,loop 路径贡献%i.next。第一次迭代时,%i.next还是 undef,所以%i乐观地取值为 0;接着传播到%b,得到%b = 5 + 0 = 5;%i.next算出后为 1,回到phi时,%i的 inflowing 变成 0 和 1,meet 的结果是“不是常数”;于是%b的传播也终止在非常数状态。
最后实际产生的替换是:%a被替换为常数 5,%b = %a + %i被替换为%b = 5 + %i,然后交给常数折叠。%i没有被传播掉,因为循环里它确实会变。
这个例子暴露出边界问题:常数传播的精确度受限于分支结构。循环、phi、不可达代码都会让传播变复杂。你在实现时一定要问自己几个问题:
- 函数参数能当作常数吗?通常不能,除非你做了过程间分析。
- 内存加载(load)结果能当作常数吗?一般不能,除非你能证明那块内存本轮没有被写。
- volatile 变量显然是传播的禁区,编译器不能假定它保持不变。
这个优化有个很经典的平衡点:传播得太大胆,可能把运行时才变化的值当成常数,导致语义错误;传播得太保守,则很多机会发现不了。真正的答案不是越激进越好,而是一定要遵循“只有数据流证明它不变,我才敢替换”的纪律。
4. 复制传播:不起眼的“接线工”
比起常数传播,复制传播在教材里存在感不高,但它带来的化简效果同样重要。我刚学编译器优化时甚至觉得复制传播有点蠢:把b = a这种赋值里的a替到后面去用,这不就是变量替换吗?后来在实现中才意识到,这项优化是“化简数据流图”的利器。
4.1 复制传播在做什么
复制传播的核心任务是把形式为x = y的复制语句的信息,传播到后续对x的使用点上去。也就是说,如果一个程序点上x一定等于y,那么后续用到x的地方,可以改写为使用y。
举个例子:
a = b; c = a + 1;复制传播把c = a + 1里的a替换成b,得到:
a = b; c = b + 1;替换之后,如果a没有被再次使用,那条a = b就变成死代码,交给 DCE(死代码消除)一删了之:
c = b + 1;为什么要费这个劲?看似多了一个中间变量又少了一个,有何收益?答案是:复制的存在不仅在指令层面多占用寄存器,还会斩断编译器对值的追踪能力。设想b本身是一个常数或者是一个复杂表达式的计算结果,如果没有复制传播,后续优化要对a做常数传播、对b做传播,结果要跑两遍;而复制传播先把“同值不同名”的多个变量收敛成一个,后续所有优化只需要围绕一个名字展开,计算和分析的量都降下来了。
4.2 实现要点与数据流分析
在非 SSA 的 IR 上做复制传播,需要做可用复制分析(available copies analysis)。这是一种前向数据流分析:在程序每个点记录当前成立的那些复制关系x = y。遇到未经过重新定义的指令时,复制关系继续保持;遇到给x重新赋值的指令,就把以x为左值的复制关系移除;遇到给y重新赋值的指令,把以y为右值的复制关系也移除;遇到函数调用,通常需要保守地使所有涉及被调用函数可能修改的变量的复制关系失效。
有了可用复制集合后,传播过程就顺理成章:在每个使用x的点,查询可用复制集合里是否存在x = y,如果存在,就把x替换为y。
但这里有一个细节很容易忽略:考虑多路径汇合时,如果一条路径上x = y成立,另一条路径上y已经被重新赋值或者x被重新赋值,那汇合处这个复制关系就不成立了。所以可用复制分析的交(meet)操作是集合交集——只有所有路径都成立的复制关系才能使用。
如果 IR 是 SSA 形式,复制传播可以被简化为一个几乎线性的过程:因为 SSA 下每个变量只有一次赋值,若定义是一条复制指令%x = %y,那%x的所有使用点都可以直接替换成%y,然后把那条定义指令标记为死代码。不需要额外的数据流迭代。这也是为什么现代编译器在 SSA 上实现复制传播时看起来非常简单。
4.3 注意事项与限制
复制传播虽然简单,有些坑必须反复提。
第一是别名问题。如果 IR 里有指针操作,x = y这条复制本身不涉及内存,但后续代码中可能有p = &x; *p = 42;这样的操作,它会隐式地修改x的值,这时复制关系x = y必须失效。别名分析做不好,复制传播就会把不该替换的地方替换掉,生成错误代码。
第二是 phi 节点与复制传播的交互。在 SSA 里,%x = phi [%w, %bb1], [%v, %bb2]这种节点,如果所有 incoming 都来自同一条复制关系的结果,那么%x可能也能被替换。但处理不好容易在 loop 里导致传播范围爆炸。
第三是不要把复制传播和死代码消除耦合太深。偶尔我在工程里见过同学写优化通道,复制传播一边替换一边顺手删掉“看起来没用了的复制指令”,这很危险。因为你以为没用的复制指令,可能在别的路径、别的优化尚未执行前还有定义性作用——比如它可能被某个 phi 节点引用,或者它自身带有 side effect(有些自定义 OP 指令即便结果是复制也可能有性能影响,但那是另一回事)。正确做法是:先做复制传播的替换,把定义标记为“可删除”,但不真正删除;等 DCE 作为一个独立通道在后续运行时,统一消灭所有不可达、无用的代码。
记住一个纪律:一个优化通道最好只做一件事,替换归替换,删除归删除。把多个职责混在一个 pass 里,短期写着爽,长期改起来就是噩梦。
5. 三个优化如何协同:实战代码效果对比
前面分别讲了三个优化的实现与边界,现在把它们串起来,用一个完整的小案例展示整个优化链的演进。这也是我在调试自己的编译实验时,最常用的一个验证方式。
5.1 一个综合示例的完整优化链
我用一段简化过的三地址码来演示,尽量贴近实际 IR 的感觉。假设初始代码是:
a = 10; b = a; c = b + 2; d = c * 3; return d;先看如果不做任何优化,后端要为它分配多少临时变量、生成多少指令。理想状态下,我们希望最终变成:
return 36;现在开始逐步过流程。
5.2 输出IR的变化过程
第一轮,先跑复制传播。发现b = a是一条复制指令,而b随后只在c = b + 2里用到,所以把那个b替换成a:
a = 10; b = a; // 现在无人使用 b 了 c = a + 2; d = c * 3; return d;第二轮,跑常数传播。a的定值是常数 10,可以传播到所有使用点。注意,虽然b = a中a的使用也符合传播条件,但它在复制传播后已经没有使用者了,所以要不要替换都行:
a = 10; b = 10; // 可能被上面复制传播清掉了,留着也无妨,之后 DCE 删 c = a + 2; // 替换成 c = 10 + 2 d = c * 3; // d 的使用 c,等下一轮传播 return d;第三轮,常数折叠。c = 10 + 2折叠成c = 12:
a = 10; c = 12; d = c * 3; return d;第四轮,常数传播再次发挥作用,把c = 12传播到d = c * 3:
a = 10; c = 12; d = 12 * 3; return d;第五轮,常数折叠把12 * 3折叠成36:
return 36;到这一步,前面那些赋值指令全部可以交给 DCE 清掉。最终的 IR 空前简洁。
我用表格记录一下每一轮后的活跃指令数,看起来更直观:
| 阶段 | 活跃指令数 | 说明 |
|---|---|---|
| 初始 IR | 5 条 | 含一处冗余复制 |
| 执行复制传播 | 4 条(1 条可删复制) | 把 b 的引用替换为 a |
| 执行常数传播 | 4 条(1 条可删复制) | 把 a、c 的值传递到使用点 |
| 执行常数折叠 | 4 条(1 条可删复制) | c 被折叠为 12,d 的表达式未折叠 |
| 再次传播与折叠 | 3 条 | d 被折叠为 36 |
| DCE 清理 | 1 条 | return 36 |
实际编译器的 pass pipeline 通常不一定是严格按这个顺序跑一轮就收工,而是多轮迭代,但逻辑是一样的。你可以在自己的 IR 工具里加一个统计函数,打印每个 pass 执行前后“可删除指令数”的变化,一眼就能看到协同效果。
5.3 实测性能影响与建议的优化顺序
从上面的例子可以总结出一个实战推荐顺序:
- 复制传播先行,把同值不同名的变量收敛;
- 常数传播跟上,把能确定的常量值带到使用点;
- 常数折叠收尾,把传播过来的常量和字面量一起算掉;
- DCE 断后,清掉所有无用的复制和赋值;
- 重复以上轮次,直到 IR 没有显著变化。
为什么要反复迭代?因为优化之间存在“释放机会”的关系。复制传播解锁常数传播的机会,常数传播又给常数折叠提供原料,折叠之后往往又冒出来新的死代码和新的可复制关系。现实中一个合格的优化 pipeline 不会只跑一遍,而是一个固定点迭代(fixed-point iteration)。
我个人的倾向是:在自己的编译实验项目里,先跑 2~3 轮“传播+折叠+DCE”的组合,再进入后端。跑 3 轮以上收益就开始边际递减了,除非代码里有特别复杂的嵌套结构。
6. 常见问题与排查技巧实录
写编译器实现,最难的不是算法本身,而是调试。下面这些坑都是我实际撞过的,整理成问题速查表,给你省点时间。
6.1 优化没生效:先查这几件事
优化跑完,发现 IR 一点变化没有,或者只优化了一部分。这时按以下顺序排查,命中率很高:
- 检查数据流分析的初值。很多“什么都没发生”的问题,根源是把 meet 操作的对象写反了,或者初值设错了。常数传播是乐观的,初值应为 undef(最乐观),而如果是可用复制分析这种 must 分析,初值应为“全集”(所有复制关系都成立),后续逐步收缩。
- 检查是否误把函数调用当成了无副作用。如果 IR 里函数调用后的变量没有被正确标记为非常数,所有的传播都会卡在函数边界。最简单的办法是先保守:遇到调用就把所有相关内存变量全部标记为非常数,后面再做更精细的分析。
- 检查是否漏了 phi 节点的数据处理。SSA IR 中,如果 phi 的 incoming 值没有参与 meet,循环中的常数传播就断了。这个问题在调试时很难直接观察,我建议写一个断言:所有 phi 节点在数据流迭代中必须至少被访问一次。
- 检查复制传播是否误删了活跃复制。如果 DCE 还没跑,就把复制指令直接删了,后续再使用它的地方就崩了。我前文特地强调过:替换归替换,删除归删除。如果你的 pass 把职责耦合在一起,出了这种问题,拆开重写反而是最快的解法。
6.2 调试优化器的实用方法
调试编译器优化和调试普通应用完全不一样:你不能打断点看“运行到这一步的值”,因为一个 IR 指令的执行语义需要你先把整个解释器或者编译流程跑起来。我的经验是提前准备好下面几件工具:
- IR 转储(dump)工具。每个 pass 执行前后都可以打印一份可读的 IR 文本。用 diff 对比优化前后的变化,能最快定位到哪条指令没被处理。
- 一个极简的解释器。给 IR 做一个按语义解释执行的小型解释器,优化前后运行结果应该完全一致。如果出现不一致,就拿到了一个极好的测试用例。
- 随机生成器或手工构造的坏 Case 库。我建议把输入控制在十个基本块以内,专门覆盖:单分支、双分支、循环、phi 汇聚、同值不同名、浮点运算不折叠等场景。
- 统计计数器。在每个 pass 里嵌入一个“成功替换次数”的计数器,优化流水跑完输出一份统计报告。这个能帮你快速判断哪个 pass 的执行效率差,哪个 pass 根本没跑出效果。
这里有个经验之谈:常数值传播的结果如果与分析结果对不上,十有八九是 meet 操作的顺序写反了——在常数格上,undef是最大值,notconstant是最小值,constant(c)在中间。你如果把 meet 实现成了“取最大值”而不是“取下确界”,整个分析就会变成“乐观得没边”,把非常数的变量也传播成常数,那问题就严重了。
6.3 容易踩的坑速查表
| 问题现象 | 可能原因 | 排查/修复思路 |
|---|---|---|
| 优化后 IR 比优化前还多指令 | 复制传播替换后没有标记原复制为可删 | 给 DCE 提供可删指令列表,由 DCE 统一删除 |
| 循环里的常数没传播出来 | phi 的 incoming 值没有 meet 合并 | 检查 phi 处理逻辑,确保所有 incoming 参与 meet |
| 两个不同的常数被 meet 成同一个常数 | 常数格的 meet 操作写反 | 复查 meet 函数,const(c1)meetconst(c2)应为notconstant(c1≠c2) |
| 浮点表达式优化后结果变了 | 编译器折叠了浮点运算 | 默认关闭浮点折叠,除非显式允许 |
| 复制传播把别名相关的内存值替换错了 | 没有失效被指针修改的复制关系 | 引入保守的别名失效,或在遇到 store 时清空相关复制集合 |
| 折叠把有副作用的表达式也算掉了 | 折叠条件没有排除 call / load | 折叠前检查“纯表达式”属性 |
这张表是我亲手踩过的浓缩版。别人的坑可能不同,但方向大抵一致。
7. 实操心得与扩展方向
到这里,三个优化的主干内容都讲完了。最后聊几句我自己的体会。
第一点是:这三个优化虽然是“前期优化”,但在教学编译器里把它们实现好,能帮你打通对数据流分析的整体理解。常数传播是“信息流动”的入门,复制传播是“值同构”的入门,常数折叠则是“树的化简”。三者中手里的任意一个,都能作为跳板延伸到更多高级优化:死代码消除建立在活跃性分析上,强度削减建立在表达式化简的能力上,全局寄存器分配则需要精确的活跃区间信息。
第二点是顺序的重要性远比单个优化的高级程度重要。我在自己做的一个模拟项目里,把一份 200 行左右的三地址码喂给优化通道。第一版我直接写了个庞大复杂的常数传播,效果普通;后来我改成先复制传播、再常数传播、再折叠、再 DCE 的几轮迭代,同样的代码在第三轮时已经比第一版少了大概两成左右的指令。排序对了,简单的优化能顶半个高级优化。
第三点是:这些优化真正的战场在 SSA 形式下。如果你的 IR 还没转 SSA,建议尽早考虑迁移。因为复制传播、常数传播在 SSA 下都能写成接近线性时间的算法,调试时看 IR 也清晰得多。非 SSA IR 上做这些优化,不仅慢,还容易在偏路径上出错。
最后再分享一个调试小技巧:每次跑完一轮优化,把优化前后的 IR 分别喂给同一个极简解释器,比对最终返回值。只要返回值不一致,就立刻把分析范围缩小到第一个返回值出现差异的指令上。这个做法我在每次优化改动之后都会跑一遍,比任何单元测试都管用。
这三个优化看似基础,却是编译器中端优化的灵魂骨架。能把这几个环节做得干净利落,后端优化才有足够的空间施展拳脚。如果你也在写自己的编译实验,不妨按这个顺序去实现一遍,跑完一轮看到 IR 从冗长变简练的那一刻,你会觉得前面调数据流的辛苦都值了。