搜索
您的当前位置:首页正文

八、代码优化,《编译原理》(本科教学版),第2版

来源:步旅网


一、代码优化概述

1.1 优化的位置

代码优化应该处在编译的什么位置?

可以在语义分析之前吗?

假如我们有这样的代码:

if (1) {
    ...
} else {
    printf(2);
}
  • else 分支中的 printf(2) 显然是有类型错误的,因为 2 并非字符串
  • 程序员可能希望编译阶段检测出该错误,但是代码优化会将else 分支优化掉,这样导致后续语义分析检查不出这个错误
  • 显然,代码优化至少应该放在语义分析后面

我们经过语义分析后得到一棵抽象语法树,然后进入中间表示阶段:

  • 我们可以在抽象语法树上进行早期优化
  • 然后可以在中间表上上进行中间代码优化
  • 然后可以在 汇编/目标代码上进行目标代码优化
  • 不同的 优化 适用于 不同阶段的优化

1.2 什么是 “代码优化”

  • 代码优化是对被优化的程序进行的一种语义保持变换
  • 语义保持:
    • 程序的可观察行为不能改变
  • 变换的目的是让程序能够比变换前:
    • 更小
    • 更快
    • cache 行为更好
    • 更节能
    • 等等

1.3 不存在”完全优化“

  • 等价于停机问题(判断一个程序是否能在有限时间内结束运行的问题):

    • 给定程序p,把 Opt§ 和下面的程序比较:

      • L:
        	jmp L
        
      • 如果能将 一个程序 优化成上述程序,那么该程序就是无终止的

      • 而我们总能加入优化让编译程序更好,所以不存在“完全优化”

    • 这称为”编译器从业者永不失业定理

1.4 代码优化很困难

  • 不能保证优化总能产生**“好”**的结果
  • 优化的顺序和组合很关键
  • 很多优化问题是非确定的
  • 优化的正确性论证很微妙

1.5 正确的观点

  • “把该做对的做对”
    • 不是任何程序都会同概率出现
      • 所以处理大部分常见情况的优化就可以接受
  • “不期待完美编译器”
    • 如果一个编译器有足够多的优化,则就是一个好的编译器

下面将介绍:

  • 前端优化
    • 局部的、流不敏感的
    • 常量折叠、代数优化、死代码删除等
  • 中期优化
    • 全局的、流敏感的
    • 常量传播、拷贝传播、死代码删除、公共字表达式删除等
  • 后端优化
    • 在后端(汇编代码级)进行
    • 寄存器分配、指令调度、窥孔优化等**(本文只介绍寄存器分配)**

二、前端优化

  • 前端优化即早期优化
  • 读入抽象语法树
  • 输出抽象语法树

2.1 常量折叠

注:常量折叠也可以在中间端和后端来做,但是在前端来做有其优势。

  • 基本思想
    • 在编译期计算表达式的值
      • 例如:a = 3 + 5 ==> a = 8
      • 例如:if (true && false) … ==> if (false)
  • 可以在整型、布尔型、浮点型等数据类型上进行

算法

// 语法制导的常量折叠算法
// 在语法树上
const_fold(Exp_t e) 
    while (e is still shrinking)			// 找不动点
        switch (e -> kind)
            case EXP_ADD:
				Exp_t l = e->left;
				Exp_t r = e->right;
				if (l->kind == EXP_NUM && r->kind == EXP_NUM)	// 都是常量
                      e = Exp_Num_new (l->value + r->value);		// 表达式优化成常量
				break;
            default:
		         break;

常量折叠优化小结

  • 容易实现、可以在语法树或者中间表示上进行
  • 通常被实现成公共子函数被其它优化调用
  • 必须要很小心遵守语言的语义
    • 例如:考虑溢出或异常
      • 例子:0xffffffff + 1 ==> 0( ??? )

2.2 代数化简

  • 基本思想
    • 利用代数系统的性质对程序进行化简
    • 示例:
      • a = 0+b ==> a = b
      • *a = 1 b ==> a = b
      • 2*a ==> a + a(强度削弱)
      • 2*a ==> a << 1(强度削弱)
  • 同样必须非常仔细的处理语义
    • 示例:(i - j) + (i - j) ==> i + j - j - j
// 代数化简的算法
alg_simp(Exp_t e)
	while (e is still shrinking)
		switch (e->kind)
			case EXP_ADD:
				EXP_t l = e->left;
				Exp_t r = e->right;
				if (l->kind == EXP_NUM && l->value==0)
					e=r;
				break;
			case ...: ...;				// 类似

2.3 死代码删除

  • 基本思想:

    • 静态移除程序中不可执行的代码

    • 示例:

      • if (false)
        	s1;
        else s2;	==> s2;
        
  • 在控制流图上也可以进行这些优化,但在早期做这些优化可以简化中后端

// 不可达代码删除算法
deadcode (Stm_t s)
	while (s is still shrinking)
		switch (s->kind)
			case STM_IF:
				Exp_t e = s->condition;
				if (e->kind == EXP_FALSE)
					s = s->else;
				break;
			case ...: ...; 			// 类似

三、中间表示上的优化

  • 依赖于具体所使用的中间表示:
    • 控制流图(CFG)、控制依赖图(CDG)、静态单赋值形式(SSA)、后续传递风格(CPS)等
  • 共同的特点是需要进行程序分析
    • 优化是全局进行的,而不是局部
    • 通用的模式是:程序分析→程序重写
  • 在这部分中,我们将基于控制流图进行讨论
    • 但类似的技术可以用于其它类型的中间表示

3.1 优化的一般模式

  • 程序分析
    • 控制流分析,数据流分析,依赖分析,……
    • 得到被优化程序的静态保守信息
      • 是对动态运行行为的近似
  • 程序重写
    • 以上一步得到的信息制导对程序的重写

3.2 常量传播优化

  • 先进行到达定义分析,如果这个定义 ”x = 3“ 是唯一能够到达使用**“a = x”** 的定义,那么可以进行这个替换!

算法如下:

// 常量传播算法
const_prop(Prog_t P)
	// 第一步:程序分析
	reaching_definition (p);
	// 第二步:程序改写
	foreach (stm s in p: y = x1, ..., xn)
		foreach (use of xi in s)
			if (the reaching def of xi is unique: xi = n)
				y = x1, ..., xi-1, n, xi+1, ..., xn

示例:

1: x = 1;
2: y = 2;			{1}
3: z = x + y;		{1, 2}
4: a = z + 5;		{1, 2, 3}
5: print(a);		{1, 2, 3, 4}

我们发现在3可以做常量传播优化:

1: x = 1;
2: y = 2;			{1}
3: z = 1 + 2;		{1, 2}
4: a = z + 5;		{1, 2, 3}
5: print(a);		{1, 2, 3, 4}

我们发现在3接着可以做常量折叠优化:

1: x = 1;
2: y = 2;			{1}
3: z = 3;		{1, 2}
4: a = z + 5;		{1, 2, 3}
5: print(a);		{1, 2, 3, 4}

我们发现在4可以做常量传播优化:

1: x = 1;
2: y = 2;			{1}
3: z = 3;		{1, 2}
4: a = 3 + 5;		{1, 2, 3}
5: print(a);		{1, 2, 3, 4}

我们发现在4接着可以做常量折叠优化:

1: x = 1;
2: y = 2;			{1}
3: z = 3;		{1, 2}
4: a = 8;		{1, 2, 3}
5: print(a);		{1, 2, 3, 4}

最后在5做常量传播优化:

1: x = 1;
2: y = 2;			{1}
3: z = 3;		{1, 2}
4: a = 8;		{1, 2, 3}
5: print(8);		{1, 2, 3, 4}

3.3 拷贝传播优化

  • 注意与常量传播优化的异同
  • 先进行到达定义分析,如果这个定文**“x=y”唯一能够到达使用"a=x”**的定义,那么可以进行这个替换!

算法如下:

// 拷贝传播算法
copy_prop(Prog_t p)
// 第一步:程序分析
reaching_definition(p);
// 第二步:程序改写
foreach(stms s in p: y = x1, ..., xn)
	foreach(use of xi in s)
		if (the reaching def of xi is unique: xi = z)
			y = x1, ..., xi-1, z, xi+1, ..., xn

3.4 死代码删除优化

  • x = v 下一步是 return 0,也就是说该赋值没有效果
  • 所以问题是:可以移除该语句吗?
  • 注意:前面前端优化的死代码删除指的是:不可执行的代码的删除,而这里的死代码删除指的是:会执行,但是无意义
  • 进行活性分析,如果x不是该语句的live_out,则可以将该语句移除。

算法如下:

// 死代码删除算法
dead_code(Prog_t p)
	// 第一步:程序分析
	liveness_analysis(p);
	// 第二步:程序改写
	foreach(stm s in p: y = ...)
		if (y is NOT in live_out[s])
			remove(s);

3.5 公共子表达式提取(CSE)

  • 我们可以通过到达定义分析判断 “a + b” 是否可达 z 的定义位置
  • 到达定义分析可以得到 “a + b" 的计算位置
  • 我们是否可以将 z 替换成 x?

  • 在各个基本块 t 是一个新定义的变量
  • 我们能将 z 换成 x 吗?

我们先给出一些定义:

四元式等价

设(w1,A1,B1,t1)和(w2,A2,B2,t2)是非赋值型运算类四元式。

若 w1 = w2,并且 A1 和 A2,B1 和 B2 的值彼此相等,则称这两个四元式等价。

公共子表达式优化

如果在基本块中出现多个等价的四元式,则除了第一个外其它的等价的四元式,称为第一个四元式的公共子表达式

然而,公共子表达式的关健在是判断两个分量的值是否相等

我们这里只介绍值编码的方式来帮助我们解决该问题。

基本思想:对于中间代码中出现的每个分量,确定一个值编码,使得具有相同值编码的分量等价。

3.5.1 动机

  • 我们发现上述几条具有公共子表达式"x + y" 的语句,得到的值可能是不同的
  • 我们怎么去区分值不同的x + y?

  • 怎样用合适的数据结构去维护变量的值?
3.5.2 值编码(VN)
  • 一种表达式的深层语义属性
    • 在判断"x + y" 是否被计算过时,不能仅通过语法搜索表达式,还需要一些语义属性(指的是“x+y”的值编号)
      • x + y = a + b <==> V(x) = V(a) && V(y) = V(b)
      • V() 指的是值编号
    • 一种抽象的解释形式

示例

优化前:
t = b * c
c = b * c + b * c
d = b * c + d

优化后:
t = b * c
c = t + t
d = b * c + d

我们用V(x)来代表变量x的值编码,对于值编码的分配,是全局递增,首次产生1,其次2……

会用到三张表:

值编码表:ValuNum,存放变量(或常数)及其值编码

可用表达式代码表UsableExpr,存放基本块中的可用于匹配的表达式编码四元式(四元式中的变量用其编码替换)

临时变量等价表PAIR,存放等价的临时变量偶对。如(ti,tj)是等价的,需要用ti 替换 tj

3.5.3 算法描述
  • 从基本块的第一条四元式开始
    • 临时变量等价表PAIR替换,(ti,tj) 用 ti 替换 tj
    • 对四元式(w, A, B, t)中运算分量A,B查值编码表进行替换,新出现的运算分量进行新值编码,生成编码四元式
    • 遇到运算型四元式,在可用表达式代码表UsableExpr 中查与当前编码四元式运算符,运算分量都相同的
      • 若有,则说明当前四元式为 冗余公共子表达式,删去当前四元式,把结果临时变量填入等价表PAIR
    • 遇到赋值(=, a, -, b), 将 b 的编码赋值为 a 的编码

示例

a = b * c + b * c;
d = b;
e = d * c + b * c;

过程如下:

3.6 循环不变式外提优化

循环不变式:如果一个表达式E在一个循环中不改变其值,则称它为该循环的不变表达式。

循环不变式外提优化

  • 将循环不变式外提到循环外面进行。
for (int i = 1; i <= 1000; ++ i) {
	a *= x * y;
}
  • 上述代码 中 x * y 的值始终不变,但是每次循环都要计算,显然是很浪费的。

我们做如下优化:

int t = x * y;
for (int i = 1; i <= 1000; ++ i) {
	a *= t;
}

这样减少了1000次运算

循环不变式的关键

  • 识别循环结构(循环入口,循环体,循环出口)
    • 基于程序文本
    • 基于程序流图(控制流分析)
  • 检查循环体中哪些变量的值被改变过
  • 根据这个结果来看哪些表达式是不变的表达式
  • 建立变量定值表,将循环体中值被改变的变量都填入表内,若某运算型四元式中两个运算分量都不出现在这个表内,就说明其值不变,可以外提。

安全性:不可外提类

  • 除法表达式不可外提(除0溢出)

  • while E do
    	if y == 0 
    		then x = y;
    	else
    		x = a / y
    

    假如 a / y 是循环不变式,我们外提,又恰好 y = 0,这个时候就会出问题了

  • 赋值表达式不能外提(即使赋值表达式的左部和右部都是循环不变式)

    • 不一定执行该循环
    • 循环体若一次都不执行,我们外提内部赋值表达式,造成了左值的额外赋值,改变了源程序的语义问题。

外提策略:

  • 策略1有些时候效率比2高
  • 但是,有些情况:
    • 循环体一次不执行
    • 某些循环不变式的计算结果只在循环体内部
    • 进行引用的时候
  • 循环不变式外提会变成冗余运算,降低效率

循环不变式外提算法

  • 对循环体四元式进行第一遍扫描
  • 把循环内被定义的变量填到变量信息表LoopDef
    • 运算型四元式(w,A,B,t1),t1填入表内
    • 赋值型四元式(=,a,-,b),把 b 填入表内
  • 循环不变式外提为第二遍扫描
    • 每遇到一个运算型四元式(w,A,B,t),若A,B,都不在LoopDef内,则将其外提
    • 同时在LoopDef中删去t,把t 从本层LoopDef 表 外提到 外层 LoopDef 表,删除原代码(w,A,B,t)

示例1

我们显然可以及那个 2 * k + 2 * k * 2 外提,对应到中间代码中的三条语句外提

示例2

四、后端优化

后端优化的特点

  • 与目标机器相关
    • 寄存器数量、指令pipeline
    • 需要对目标ISA足够了解
  • 对程序的执行效率影响很大
    • 决定机器指令数量、执行速度、并行程度

后端优化的几种典型方法

  • 寄存器分配(只介绍这个)
  • 指令调度
  • 窥孔优化
  • ……

寄存器分配是现代编译器中最普遍,最重要的优化

4.1 寄存器分配

  • 前面在代码生成一章,假设目标机器有无限多个寄存器
    • 简化代码生成
  • 真实目标机器只有有限个寄存器,如:
    • RISC 机器(如MIPS指令集)通常有32个寄存器,CISC 机器(比如X86)通常有16个寄存器
  • 寄存器分配问题
    • 重写中间代码,使其使用的寄存器数量不超过目标机器的寄存器数量
      • 将使用到的多个变量分配到少量物理寄存器中
      • 不改变源程序的行为

寄存器分配的示例

有三个变量 a,b,c;假设目标机器上只有2个物理寄存器:r1,r2

1: a = 1;
2: b = a + 2;
3: c = b + 3;
4: c = a + 3;
5: return c + a;

和上一章类似,我们做活性分析

  • 变量b、c 活跃区间不相交,b、c 交替使用一个寄存器
  • 变量a 单独使用一个寄存器

寄存器分配后的代码:

1: r1 = 1;
2: r2 = r1 + 2;
3: r2 = r2 + 3;
4: r2 = r1 + 3;
5: return r2 + r1;

4.2 寄存器分配的思路

  • 变量t1 和 t2 可以同时使用同一个寄存器,当且仅当在程序的任何一个执行点上变量t1 和 t2 中至多有一个是活跃的
    • 即:如果t1 和 t2 同时活跃,则它们不能使用同一个寄存器
  • 目标
    • 程序执行时间最短
      • 减少内存load / store
    • 寄存器分配算法效率高
      • O(n),O(nlogn),或许O(n2),但是不能O(2n)

但是,很可惜的是,最优寄存器分配是一个NPC问题,在几乎任何假设下,都没有多项式时间,所以下面将介绍的是启发式算法

4.3 寄存器分配算法:历史

  • 寄存器分配的历史可以追溯到编译器起源时代
    • 1950年代Fortran编译器使用了寄存器分配
    • 较为粗暴的算法
  • 寄存器分配算法在1980年取得了重大突破
    • IBM研究员利用图着色算法进行寄存器分配
    • 更简单更快、结果更优
    • 最常用,应用在许多现代编译器中(e.g. gcc
    • 仍然是NPC(K >= 3),不得不用一些启发式策略

4.4 基于图着色的寄存器分配算法

4.4.1 第一步:计算程序每个执行点的活跃变量

还记得上一章的一般性后向数据流方程吗:

  • o u t [ s ] = ∪ p ∈ s u c c [ s ] i n [ p ] out[s] = \cup_{p \in succ[s]} in[p] out[s]=psucc[s]in[p]
  • i n [ s ] = u s e [ s ] ∪ ( o u t [ s ] − d e f [ s ] ) in[s] = use[s] \cup (out[s] - def[s]) in[s]=use[s](out[s]def[s])

自底向上轻松的算出outin

4.4.2 第二步:画出寄存器冲突图(RIG)
  • 寄存器冲突图(Register Interference Graph,RlG)
    • 一个无向图
      • 每个节点是程序的一个变量
      • 如果程序中两个变量同时活跃,添加一条边连接这表示这两个变量的节点
    • 如果两个变量间没有边连接,则它们可以使用同一个寄存器
  • 特点
    • 抽象出寄存器分配所需的所有信息
    • 构建RIG后,可以进行与目标机器无关的寄存器分配算法

我们根据上面活性分析的结果,画出下面的RIG

4.4.3 第三步:对RIG进行着色来分配寄存器
  • 图着色(Coloring)
    • 给图中每个节点涂上一种颜色,使得每条边两端的节点颜色不同
    • 如果一个图可以被着色成k种颜色,则称这个图为k可着色的
  • 对RIG进行着色来分配寄存器
    • 颜色==寄存器
      • 给每个节点涂颜色==给每个变量分配寄存器
    • 如果RIG是k可着色的,则该程序可以使用不超过k个寄存器
      • K==目标机器的寄存器数量

示例

  • 上述RIG用了四种颜色来着色,即可以用4个寄存器来存储程序中的6个变量
  • 实际上,这个RIG不存在小于4种颜色的着色方案

这样源程序可以改写为:

问题是:如何计算图着色?

  • 这个问题有点难

    • 其实非常难(NP-hard)
      • 解法:目前只能用启发式算法
    • 对于有限数量的寄存器,可能不存在一种着色方案**
      • 解法:变量溢出(第四步讨论)
  • 图着色的启发式算法

    • 对于个RIG进行k-着色
  • 观察

    • 选择一个邻居数量小于k的节点 t
    • 从RIG中删除 t 和它所有的边
    • 如果删除后的RIG是k可着色的,则原RIG也是k可着色的
      • 很容易理解,我们把 t 加回去,因为邻居数量小于t,总能找到它能用的颜色

算法步骤

  • 第一部分
    • 选择一个邻居数量小于k个的节点t,从RIG中删除t和它所有的边
    • t 放在栈顶
    • 重复上述步骤只到RIG中没有节点
  • 第二部分
    • 从栈顶弹出1个节点
    • 给该节点选择1种与其邻居都不同的颜色
    • 重复上述步骤只到栈为空

示例

思考:启发式算法如果失败了怎么办?

答案:直接将变量分配到内存

4.4.4 第四步:将无法分配寄存器的变量溢出到内存

如果所有节点都有大于等于k个邻居,怎么办?

例如 k = 3时,我们删除掉 a 之后就卡住了

  • 选择一个变量溢出,将其放入内存

仍以上图为例,我们假设选择 f 溢出

  • 删除 f 并继续着色法分配寄存器

然后,剩下的这个图是 3-可着色的

然后我们栈弹出元素着色的时候,如果遇到前面溢出的f

两种做法:

  • 乐观着色法:
    • 希望给溢出节点着色时其邻居用的
      颜色数量正好小于k
    • 如是,直接着色
  • 溢出到内存:
    • 如果乐观着色失败,将该节点溢出到内存
    • 在每次使用节点前插入load指令
    • 在每次定义节点后插入store指令

这样我们寄存器分配后,得到的代码:

我们重新进行下活性分析:

尝试画出 RIG

溢出缩减了f的活跃区间!!!,从而减少了RIG中的邻居数量(f仅在load后或store前活跃)

但其实又带来了新的问题:溢出哪些变量最好?

  • 找到可行的图着色方案,可能需要多次溢出
  • 溢出哪个变量很关键,也很难
    • 可能的启发式算法
      • 选择与其他变量冲突最多的变量
      • 选择使用较少的变量
      • 避免溢出循环内部的变量
      • ……

因篇幅问题不能全部显示,请点此查看更多更全内容

Top