代码优化.docx

上传人:b****5 文档编号:7598376 上传时间:2023-01-25 格式:DOCX 页数:42 大小:444.91KB
下载 相关 举报
代码优化.docx_第1页
第1页 / 共42页
代码优化.docx_第2页
第2页 / 共42页
代码优化.docx_第3页
第3页 / 共42页
代码优化.docx_第4页
第4页 / 共42页
代码优化.docx_第5页
第5页 / 共42页
点击查看更多>>
下载资源
资源描述

代码优化.docx

《代码优化.docx》由会员分享,可在线阅读,更多相关《代码优化.docx(42页珍藏版)》请在冰豆网上搜索。

代码优化.docx

代码优化

第九章代码优化

由简单的编译算法产生的代码,经改进后是可以运行得更快,或空间占得更少,或两者兼有之。

这种改进是通过程序变换来获得的,这样的程序变换称做优化。

实施代码改进变换的编译器叫做优化编译器。

本章介绍独立于机器的优化,即不考虑任何目标机器性质的优化变换。

依赖于机器的优化,例如寄存器分配,我们已在第八章讨论过了。

一般而言,程序的内循环(特别是最内循环)是重点要改进的地方,因为它们往往是程序中经常执行的部分。

让这些部分尽可能地高效率,有可能使我们以最小的代价获得最大的利益。

当然,编译器只能靠自己来对程序的热点在哪儿作出最好的猜测,这种猜测并不一定完全符合实际情况。

程序流图中的循环由控制流分析过程来识别,这是本章讨论的重点之一。

要完成优化还需要收集程序中变量使用方式的信息,这由数据流分析来完成。

在程序的不同点收集的这种信息可以用一组简单的方程联系起来。

我们在本章给出一些用数据流分析收集信息和在优化中有效地使用这些信息的算法。

9.1优化的主要种类

在本节,我们介绍一些最有用的代码改进变换,实现这些变换的技术在下面几节给出。

考察一个基本块的语句就可以完成的变换叫做局部变换,否则叫做全局变换,通常先完成局部变换。

在介绍各种变换之前,我们先给出代码改进变换的标准,并给出一个C程序的源程序、中间代码和流图,以此为例来介绍各种优化。

我们不打算讨论过程间的优化,这里所说的程序是指单个过程。

9.1.1代码改进变换的标准

由优化编译器提供的变换应该有下列几点性质:

首先,代码变换必须保程序的含义,也就是优化不能改变程序对给定输入的输出,也不能引起在源程序版本中不会出现的错误,如除数为零。

对于优化,编译器采取稳妥的策略,即宁可失去某些优化的机会,也不能采取可能改变程序行为的冒险。

其次,变换减少程序的运行时间平均达到一个可度量的值。

即,并不是每种变换都能成功地改进每一个程序,偶而,优化可能稍稍增加了个别程序的运行时间,所以我们强调一种变换对各种程序的平均影响。

优化有可能使运行时间有可观的缩短,但是没有一个编译器能为程序找到最好的算法。

代码的空间已经不像以往那么重要了,但是有时我们的兴趣还在缩小目标代码所需空间。

第三,变换所作的努力是值得的。

编译器的编写者为实现代码改进变换所消耗的时间和精力,以及优化编译器优化阶段的开销,如果不能从目标程序的运行中得到补偿,那么这种改进变换是没有意义的。

有些变换,只有在对源程序进行详尽的、往往是费时的分析后才能使用,因此很少把它们用于只运行几次的程序。

例如,快速的、非优化的编译器可能对调试或者对运行几次就要扔掉的“学生作业”更有帮助。

我们在本章用一个快速排序程序quicksort来说明各种代码改进变换的作用,图9.1是这个程序的C代码。

我们不讨论该程序的算法方面,事实上,为了这个程序能正常工作,a[0]和a[max]应分别是被排序的最小元素和最大元素。

用第七章的技术为图9.1的两个注释之间的程序段产生的中间代码在图9.2。

voidquicksort(m,n)

intm,n;

{

inti,j;

intv,x;

if(n<=m)return;

/*程序段开始*/

i=m1;j=n;v=a[n];

while

(1){

doi=i+1;while(a[i]

doj=j1;while(a[j]>v);

if(i>=j)break;

x=a[i];a[i]=a[j];a[j]=x;

}

x=a[i];a[i]=a[n];a[n]=x;

/*程序段结束*/

quicksort(m,j);quicksort(i+1,n);

}

图9.1快速排序的C代码

(1)i:

=m1(16)t7:

=4*i

(2)j:

=n(17)t8:

=4*j

(3)t1:

=4*n(18)t9:

=a[t8]

(4)v:

=a[t1](19)a[t7]:

=t9

(5)i:

=i+1(20)t10:

=4*j

(6)t2:

=4*i(21)a[t10]:

=x

(7)t3:

=a[t2](22)goto(5)

(8)ift3>vgoto(5)(23)t11:

=4*i

(9)j:

=j1(24)x:

=a[t11]

(10)t4:

=4*j(25)t12:

=4*i

(11)t5:

=a[t4](26)t13:

=4*n

(12)ift5>vgoto(9)(27)t14:

=a[t13]

(13)ifi>=jgoto(23)(28)a[t12]:

=t14

(14)t6:

=4*i(29)t15:

=4*n

(15)x:

=a[t6](30)a[t15]:

=x

图9.2图9.1部分程序的三地址代码

在代码优化器中,程序用流图表示,边表示控制流,结点表示基本块,就像8.3节讨论的那样。

例9.1图9.3是图9.2程序的流图,程序所有的条件转移和无条件转移在图9.3中都改成了转移到相应的基本块。

在图9.3中,块B1是初始结点。

该图有三个循环,块B2和B3都单独构成循环;块B2,B3,B4和B5一起形成一个循环,该循环的首结点是块B2。

循环的识别在9.2节介绍。

9.1.2公共子表达式删除

如果表达式E先前已计算,并且从先前的计算到E的再次出现,E中变量的值没有改变,那么E的这个再次出现称为公共子表达式。

如果我们能够利用先前的计算结果,就可以避免表达式的重复计算。

例如,在图9.3的基本块B5中,对t7和t10赋值的语句分别有公共子表达式4*i和4*j出现在它们的右部。

我们用t6代替t7,用t8代替t10,这些公共子表达式得以删除,删除后该基本块的代码如图9.4所示。

以上我们是仅局限于基本块进行的公共子表达式的删除

例9.2图9.3流图中,B5和B6块中全局公共子表达式和局部公共子表达式删除后的结果在图9.5给出。

我们重点讨论B5的变换。

删除了局部公共子表达式后,B5仍然计算4*i和4*j,从全局看,它们仍然是公共子表达式。

B5中三个语句

t8:

=4*j;t9:

=a[t8];a[t8]:

=x

可以由

t9:

=a[t4];a[t4]:

=x

代替。

这是因为t4在B3中计算,在图9.5中可以看到,当控制从B3中4*j的计算传到B5时,中间没有改变j的值,所以在B5中需要4*j时可以引用t4。

t4代替t8后,B5的另一个公共子表达式变得清楚了。

它是表达式a[t4],对应于源代码中a[j]的值。

不仅控制离开B3和进入B5时j的值没有变,而且a[j]的值也不变(a[j]的值计算在临时变量t5中),因为在这段区间中没有对a的元素赋值。

这样,B5的语句

t9:

=a[t4];a[t6]:

=t9

可以由a[t6]=t5代替。

类似地,图9.4的B5中对x赋的值和B2中对t3赋的值一样。

删掉图9.4中对应到源代码表达式a[i]和a[j]的公共子表达式后,其结果是图9.5的B5。

图9.5的B6也是完成了一串类似变换后的结果。

图9.5的B1和B6中表达式a[t1]不能看作公共子表达式,虽然t1在两个地方都使用,但是因为控制离开B1进入B6之前,它可以通过B5,B5有对a的赋值,因此在到达B6时,a[t1]的值可能和离开B1时的值不一样,把a[t1]作为公共子表达式是不稳妥的。

9.1.3复写传播

图9.5的B5可以通过使用两种新的变换来删除x而进一步化简。

一种变换是下一小节介绍的删除死代码;另一种变换和形成为f:

=g的赋值有关,这种赋值叫做复写语句,简称为复写。

在例9.2中,若我们更深入地讨论的话,复写概念会较早一些提出,因为删除公共子表达式的算法会引进复写。

其它一些算法也会引进复写。

例如,当删除图9.6的公共子表达式c:

=d+e时,该算法使用新的变量t来保存d+e的值。

因为控制到达c:

=d+e可能会在对a的赋值之后,也可能会在对b的赋值之后,因此用c:

=a或c:

=b来代替c:

=d+e都是不妥的。

复写传播变换的做法是在复写语句f:

=g后,尽可能用g代表f。

例如,图9.5B5的赋值x:

=t3是一个复写。

把复写传播运用于B5产生

x:

=t3

a[t2]:

=t5

a[t4]:

=t3(9.1)

gotoB2

这看起来似乎没有改进,但我们将会看到,它增加了删除对x赋值的机会。

9.1.4死代码删除

如果变量的值以后还要引用,则称它在程序的该点是活跃的,否则它在该点是死亡的,死代码或无用代码就是指计算的结果决不被引用的语句。

虽然程序员不会故意引入死代码,但是前面的变换可能会引起死代码。

例如我们可能在程序中增加一些

if(debug)print…(9.2)

语句来帮助测试或调试程序。

当调试结束时,我们不是将它们从程序中删除,而是在程序的一开始将

debug:

=true

改成

debug:

=false

这样,从数据流分析可以推断出,程序每次到达这个语句时debug的值总是假。

而且我们可以断定,不论程序实际取什么分支序列,该语句总是先于测试(9.2)的、对debug的最后一个赋值语句。

当复写传播用false代替debug时,打印语句就成了死代码,可以从目标代码中删掉测试和打印。

更一般地,若在编译时能推断出一个表达式的所有运算对象都是常量,因而在编译时能完成这个计算,那么可以用该计算的结果代替这个表达式,这种变换叫做常量合并。

复写传播可能会引入一些常量合并的机会。

复写传播的另一个优点是它常常使得复写语句成为死代码。

例如,复写传播后再删除死代码,可以删掉(9.1)中对x的赋值,把它变成

a[t2]:

=t5

a[t4]:

=t3

gotoB2

这段代码是图9.5中B5的进一步改进。

9.1.5代码外提

现在我们开始简短介绍一个非常重要而值得优化的地方,即循环,尤其是消耗程序运行大部分时间的内循环。

如果我们减少了内循环的指令数,这时即使增加了外循环的指令数,程序的运行时间也可能缩短。

循环优化的三种重要技术是:

代码外提,它把代码移出循环;归纳变量删除,我们将用它从图9.5的内循环B2和B3中删掉i和j;还有强度削弱,它用较快的操作代替较慢的操作,如用加代替乘。

我们先介绍代码外提。

减少循环中代码总数的一种重要办法是代码外提。

这种变换把循环不变计算,即运算结果独立于循环执行次数的表达式,放到循环的前面。

例如,下面的while语句

while(i<=limit2)…

如果while的体不改变limit的值,那么limit2是循环不变计算。

代码外提的结果是

t=limit2;

while(i<=t)…

当然while的体也不能改变t的值。

在我们所给出的快速排序程序中,没有可以代码外提的地方。

9.1.6强度削弱和归纳变量删除

考虑图9.7中由B3构成的循环。

j和t4的值步伐一致地变化,每次j的值减1,t4的值就减4,因为4*j赋给t4。

这样的变量都叫做归纳变量。

如果在循环中有两个或更多的归纳变量,也许只需要留下一个,而摆脱其余的。

这个操作由归纳变量删除过程来完成。

对于图9.7(a)B3构成的循环,j和t4都不能摆脱,因为t4在B3中引用,j在B4中引用。

然而可以用它们来说明强度削弱,而这个强度削弱又为删除归纳变量创造了机会。

例9.3在图9.7(a)中,对内循环B3,若不考虑第一次进入,关系t4=4*j在B3的入口一定保持,在j:

=j1后,关系t4=4*j+4(即4*j=t4-4)也保持,那么t4=4*j可以用t4=t44代替。

现在要进行这个变换的唯一问题是第一次进B3时t4没有初值,所以在给j置初值的那个基本块的末尾给t4置初值4*j。

在图9.7(b)中,这个语句放在块B1的最后。

如果乘运算比加或减需要更多时间的话(许多机器都是这样),那么这种变换会加快目标代码的速度。

9.4节将讨论怎样寻找归纳变量以及可以施加什么变换。

下面我们再举一个删除归纳变量的例子来作为本节的结束,该例处理外循环B2,B3,B4和B5上下中文的i和j。

例9.4把强度削减用于B2和B3的内循环后,i和j的作用仅在于决定B4的测试结果。

我们已知道j和t4满足关系t4=4*j,i和t2也满足关系t2=4*i,那么测试t2>=t4等价于i>=j。

一旦作出这种替换,B2的i和B3的j就成了死变量,在这些块中对它们的赋值也就成了死代码,可以删除,这个结果在图9.8中给出。

前面的代码改进变换的效果是明显的。

在图9.8中,B2和B3的指令数都从图9.3最初流图的4条减为3条,B5从9条减到3条,B6从8条减到3条。

虽然B1从4条增加到6条,但是B1在这段程序中仅执行一次,所以总的运行时间几乎不受B1大小的影响。

9.1.7优化编译器的组织

从上面的优化实例可以看到,要进行一项优化,需要掌握程序控制流和数据流方面很多信息,因此对中间代码进行控制流分析和数据流分析是代码优化阶段不可缺少的环节。

本章的代码优化使用图9.9的组织形式,它由控制流分析,数据流分析和代码变换三部分组成。

第八章讨论的代码生成器是从变换后的中间代码产生目标程序。

图9.9的组织形式有下列优点:

(1)实现高级结构所需的操作在中间代码中是显式的,这就有可能优化它们。

例如,a[i]的三地址计算在图9.2中是明显的,这样,像表达式4*i的重复计算才可以删除。

有些代码改进变换要想在源语言级完成是不大可能的。

例如,像Pascal和Fortran这样的语言,程序员只能按常规的方式引用数组元素a[i],即使程序员知道多次引用a[i]意味着它的地址会重复计算,他也没有办法改进它。

当然,像C这样的语言,这种变换可以由程序员在源程序级完成,因为数组元素的访问可以系统地重写成使用指针,以提高效率。

这种重写类似于传统的Fortran优化器所做的工作。

(2)中间代码基本上独立于目标机器,所以,由一种机器的代码生成器改为另一种机器的代码生成器时,优化器不必作很多修改。

9.2流图中的循环

要想优化产生较好的结果,必须考虑循环优化,因此我们需要定义流图中的循环由哪些构成。

我们使用一个结点是另一个结点的必经结点的概念来定义自然循环和一类重要的一类图——可归约流图。

9.2.1必经结点

我们说流图中结点d是结点n的必经结点,如果从初始结点起,每条到达n的路径都要经过d。

写成ddomn。

根据这个定义,每个结点是它本身的必经结点,循环的入口是循环中所有结点的必经结点。

例9.5考虑图9.10的流图,它的初始结点是1。

初始结点是所有结点的必经结点。

结点2仅是它本身的必经结点,因为控制可沿着13开始的路径到达任何其它结点。

结点3是除1和2以外的所有结点的必经结点。

结点4是除了1,2和3以外的所有结点的必经结点,因为从1出发的所有路径必须由1234或134开始。

结点5和6仅是它们自己的必经结点,因为控制流可以走另一个结点而跳过这个结点。

最后,7是7,8,9和10的必经结点:

8是8,9和10的必经结点,9和10仅是本身的必经结点。

9.2.2自然循环

必经结点信息的一重要运用是确定流图中适合于改进的循环。

这样的循环有两个基本性质。

(1)循环必须有唯一的入口点,叫做首结点,首结点是循环中所有结点的必经结点。

(2)至少有一种办法重复循环,也就是至少有一条路径回到首结点。

寻找流图中所有循环的一个办法是找出流图所有的回边(如果有adomb,那么边ba叫做回边)。

例9.6在图9.10中,4dom7,则74是回边。

类似地,7dom10,107是回边。

其它的回边有43,83和91。

给出一个回边nd,我们定义这个边的自然循环是d加上所有不经过d能到达n的结点。

d是这个循环的首结点。

例9.7回边107的自然循环由结点7,8和10组成,因为8和10是所有能够不经过7而到达10的结点。

回边91的自然循环是整个流图(不要忘记路径10789)。

算法9.1构造回边的自然循环。

输入流图G和回边nd。

输出由回边nd确定的自然循环中的所有结点的集合loop。

方法由结点n开始,考虑已置入loop的每个结点m,md,以保证m的前驱也能置入loop,这个算法在图9.11中给出。

loop中的每个结点,除了d以外,一旦加入stack,它的前驱就要被检查。

注意,因为d是初始时置入循环,我们决不会考察它的前驱,因此仅找出那些不经过d可以到达n的结点。

如果把自然循环作为“循环”,那么我们有一个实用的性质:

两个循环要么不相交,要么一个完全包含(嵌入)在另一个里面,除非它们有相同的首结点。

于是,暂时忽略有相同首结点的情况,若一个循环的结点集合是另一个循环的结点集合的子集,那么相对后一个循环而言,前一个循环是内循环。

不再包含其它循环的循环则是最内循环。

当两个循环有相同的首结点,但并非一个循环的结点集合是另一个的子集,例如像图9.12那样,我们很难说哪个是内循环。

例如,若B1结尾的测试是

ifa=10gotoB2

则循环{B0,B1,B3}可能是内循环。

但是,如果不仔细检查代码,我们不能保证这一点。

可能a大多数时候是10,那么在进入B3之前会环绕循环{B0,B1,B2}很多次。

所以,我们认为,当两个自然循环有相同的首结点,并且不是一个嵌在另一个里面时,把它们合并,看成一个循环。

procedureinsert(m);

ifmloopthenbegin

loop:

=loop{m}

把m压入stack

end;

/*下面是主程序*/

stack:

=空;

loop:

={d};

insert(n);

whilestack非空dobegin

弹出stack的顶元m;

form的每个前驱pdoinsert(p)

end

图9.11构造自然循环的算法

9.2.3前置结点

某些变换要求我们移动语句到首结点的前面。

于是,开始处理一个循环L时,我们创建一个新基本块,叫做前置结点。

前置结点的唯一后继是L的首结点,并且原来从L外到达L首结点的边都改成进入该前置结点。

从循环L里面到达首结点的边不改变。

这种整理在图9.13给出。

起初,前置结点为空,然后L的变换可能会放置一些语句到该结点中。

9.2.4可归纳流图

实际出现的流图常常落入下面定义的可归约流图类。

结构化的控制流语句,如if-then-else,while-do,continue和break语句,它们的使用产生的程序流图总是可归约的。

甚至事先没有结构化程序设计概念的程序员用goto语句编的程序,几乎也都是可归约的。

有好几种关于可归约流图的定义,我们采用的定义能显示出可归约流图的一个非常重要性质:

不存在从循环外向循环内的转移,进入循环只通过它的首结点。

一个流图G是可归约的,当且仅当可以把它的边分成两个不相交的子集,其中的边分别叫做正向边和回边,并且有下列性质:

(1)正向边子集形成有向无环图,在这个图中,每个结点可以从G的初始结点到达。

(2)回边子集仅由前面所讲的回边组成。

例9.8图9.10的流图是可归约的。

通常,如果知道了流图的dom关系,就可以找出和去掉所有的回边。

如果流图可归约,那么剩下的边必定都是正向边,所以检查流图是否可归约,只要检查所有正向边是否构成有向无环图便可以了。

对于图9.10,如果拿开5条回边43,74,83,91和107,很容易看出剩下的图是无环的。

例9.9我们看图9.14的流图,它的初始结点是1。

该流图没有回边,因为23和32都不是回边。

由于该图不是无环的,因此它不是可归约的。

直观上,这个流图不可归约的原因是,可以从结点2和3两处进入由它们构成的环。

这相当于该“循环”有两个首结点,它使得许多代码优化技术,如9.1节介绍的代码外提和归纳变量删除,都不能直接运用。

幸好,象图9.14这样的不可归约控制流结构在大多数程序里面几乎不出现,以致多于一个首结点的循环的研究没有多大价值。

有些语言只允许程序有可归约流图,其它一些语言,只要不使用goto语句,也只会产生可归约流图。

对循环分析来说,可归约流图的关键性质是,我们非形式地称为循环的结点集合一定含一条回边。

因此只要通过回边找出所有的自然循环,也就是找出了所有的循环。

例9.10回到图9.10,我们可看出,最内循环是{7,8,10},它是回边107的自然循环。

集合{4,5,6,7,8,10}是回边74的自然循环,注意,8和10可经107到达7。

直观上看去,{4,5,6,7}是一个循环,这是错的,因为4和7都是入口点,违反了我们有关一个入口的限制。

从另一角度说,没有理由认为控制会环绕结点集合{4,5,6,7}消耗较多的时间,完全有可能控制从7到8的次数多于从7到4的次数。

把8和10包含在这个循环里,我们可确信已分离出程序频繁执行的一个区域。

应该认识到,对各分支作出执行频度的假设是危险的。

例如,若把循环{7,8,10}的不变语句移出8或10,而事实上,控制沿边74比沿78更经常。

那么,我们实际上增加了被移动语句的执行次数。

在9.4节我们将讨论避免这个问题的方法。

下一个较大的循环是{3,4,5,6,7,8,10},它是回边43和83的自然循环。

和前面一样,如果把{3,4}看成循环则违反了关于一个入口点的要求。

最后一个循环是回边91的自然循环,它是整个流图。

9.3全局数据流分析介绍

为了优化代码,编译器需要把程序流图作为一个整体来收集信息,并把这些信息分配给流图的各个基本块。

例如,从9.1节可以看到,使用全局公共子表达式的信息可以删除更多的冗余计算。

数据流信息可以通过建立和解方程来收集,这些方程联系程序不同点的信息。

典型的方程形式为

out[B]=gen[B](in[B]kill[B])(9.3)

这个方程的意思是,当控制流通过基本块B时,在B末尾得到的信息是在B中产生的信息,或者是进入B开始点并且没有被B注销的信息。

这样的方程序叫做数据流方程。

怎样建立和解数据流方程依赖三个因素:

(1)产生和注销的概念依赖于所需要的信息,即根据数据流方程所要解决的问题。

而且,对某些问题,不是沿着控制流前进并且由in[B]来定义out[B],而是反向前进并由out[B]来定义in[B]。

(2)因为数据沿控制路径流动,所以数据流分析受程序控制结构影响。

(3)

展开阅读全文
相关资源
猜你喜欢
相关搜索
资源标签

当前位置:首页 > 农林牧渔 > 林学

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1