首页 > 代码库 > 软件优化理论基础以及方法论小结.

软件优化理论基础以及方法论小结.

就像我其他博文中说的,对于软件的优化除开算法,全部都是为流水线服务的.所以优化的时候要时刻记住一这点.由于优化的东西比较杂,我写的不是很好,所以在文章的最后,我会试着提炼出一些通用性的原则.

由于之前在WPS上写的,所以代码没有用模版来排版,各位看官就将就着看吧..我也只是为了提炼一次知识,真正的优化还是认真看一遍书来的好.

 

有哪些方法优化软件?

通常有两种方式,一种是通过编译器,另一种则是自己写.因为编译器会考虑到特殊情况,所以能做的优化很多时候并不是特别多.这时候就需要自己写优化来帮助

 

一.结构相关的优化:

这里其实没什么太多好说的.就三点:

1.对于赋值语句,最后更新被赋值的变量

2.对于除法,尽量在其中插入一些其他的独立运算.

3.对于相同的运算,减少它们连在一起运算的情况.(这一点实际上不必太在意,毕竟除了除法速度都很快)

 

二.控制相关的优化:

这个主要集中在循环上以及条件分支预测上.具体的这里可以提一点,就是循环中判断多用!=,少用<.小于符号的判断的成功率对于CPU一直都很低.

 

如何优化循环?

其实书上说的能优化的循环都属于循环中比较特殊的一类状况.主要分为展开和重组.

1.展开多用于线性代码.其实就是减少分支判断.本来执行一次一个判断的,变成多次对一个判断.减少CPU对分支预测的判断次数.从而加速.并且还有就是,假如你访问连续的4个存储器地址,那么可以只更新一次计数器.有人说编译器应该会展开,但是我觉得很多时候编译器是不能展开的,除非你告诉它具体的循环次数,不然很可能编译器展开之后,会让结果运行不同.

 

循环展开的好处还受到三个因素的制约:
1) 循环开销的减少程度

这个主要是因为展开之后循环的次数也减少了,

2) 代码量的大小

这个由于展开之后访问的跨度变大了.如果cache比较小的话(嵌入式领域),那么很可能会造成缺失率的提高.而且代码量也增大了,需要的存储空间也增加了.(以空间换时间真是不变的真理啊..)

3) 编译器的限制

这个是因为编译器会规定寄存器的用途.所以你展开多了之后,很可能会造成寄存器不够用,从而增加了数据相关的可能.对于64位处理器来说寄存器可能比较多,但是对于32位处理器来说就很可能导致不够的情况发生了.并且对于多发射的处理器上更为突出.因为它需要并行执行更多的独立不相关指令.

 

对于编译器而言,还有一个要注意的地方,就是体间相关.也就是你的这一次循环修改的变量要给下一次循环来用.那么就不能展开了.这个对于编译器来说,算法比较复杂,所以还是尽量自己来优化比较好.顺带一提,C语言中的restrict关键就是为这个算法寻找路径服务的.

 

针对循环还有一个变态的方法,叫做软件流水.用重组循环的技术,让不同循环体间的部分指令可以一起并行执行.但对于单个循环体来说,这个政令的执行仍然是串行的.这样就可以减少一般的循环展开所导致的代码量过大的问题了.可以说直接就把部分分支指令消除了.当然这种情况实际应用的适用范围应该比较小.所以就还是靠编译器来实现就好了.

这个感觉效果比较好,我还是给一个例子吧.

LOOP:

LD    F0,0(R1)

ADD  F4,F0,F2

SA    F4,0(R1)

DADDUI  R1,R1,#-8

BNE   R1,R2,LOOP

展开后

LD    F0,0(R1)

ADD  F4,F0,F2

SA    F4,0(R1)

LD    F0,0(R1)

ADD  F4,F0,F2

SA    F4,0(R1)

LD    F0,0(R1)

ADD  F4,F0,F2

SA   F4,0(R1)

.....

使用软件流水后

LOOP:

SA    F4,16(R1)

ADD  F4,F0,F2

LD    F0,0(R1)

ADD  R1,R1,#-8

BNE   R1,R2,LOOP

这个意思我还是解释一下...源代码的意思是在一个数组中取出数来,然后每个数加上一个数,再存回去.展开的代码我是按照书上来的,但感觉0(R1)中的0应当在展开后会改变其值..姑且就留在那了.最后再来说说使用了软件流水后的代码.这段代码可读性就很差了,也是我为什么建议用编译器来实现这个.简单来说就是先存a[i]的值,然后加a[i+1],再读取a[i+2].也就是说把三个循环中的一部分拿出来,整合到一个循环中.由于存储操作后都跟着一个加法,所以并行度不错.存值的时候结果F4早就计算好了,计算值的时候F0早就准备好了,读值的时候F0也准备好了..真是非常巧妙啊!!!屌炸天的优化啊!!!!

 

2.重组,就是通过不同的指令延时不同,在不改变执行结果的情况下,把独立指令插入到其他指令的延时中去.这个其实主要交给编译器来做,并且编译的之后要指定其CPU的类型.对于不同CPU是不一样的.并且还包括下面的静态分支和静态多发射.

 

利用静态分支预测

这个就是通过编译器来预测.如果你的编译器支持这个选项,那么还是有必要了解一下其原理的.这个方法有三种实现方式:
1.总是选中

2.根据分支的方向

3.根据以前运行时得到的配置文件

显然这三种实现方式命中率是依次递增的.

主要好处有:对于支持分支延迟时,有利于进行指令调度;有利于协助动态预测进行分支预测;有利于确定路径的执行频率.

其实往简单来说,就是重新安排分支相关的指令,减少延时.

这里看上去像是CPU内部的预测,但其实是通过编译器来的.编译器来假设哪个分支会执行的比较多,然后根据其预测来调整指令的位置,方便CPU去调度.

 

利用静态多发射

这个是在发射阶段就得决定的.就是尽量让保证同时发射的指令是不相关的.好比你有一条指令产生r1的值,后面有一条指令用到了r1.编译器为了利用静态多发射就会优化,尽量让这两个指令不是同时发射,从而减少了在乱序引擎中的等待延时.虽说硬件也会检测发射的指令是否相关,但我们总是尽量让CPU的负担少一点.这里的编译器使用的方法叫做VLIW方法.想细看的自己搜一下.因为现在的CPU我想更多的用的动态’优化,所以就不说了.

 

利用条件传送指令来优化判断

这个是我猜测的用法.可能具体编译器不同,编译出来的东西也不一样.但是尽量对于if...x=..的这种条件判断下之后一个赋值语句的式子,尽量不要打花括号.如果是汇编的话打了花括号很可能编译器会设置成

test 

beq  JUMP

mov

...

JUMP

这样的汇编语句格式.但是其实利用cmov,可以减少一个指令,从而优化时间.

test

cmov

....

 

另外,条件传送指令还有一点就是对于if--then--else这种语句有优化作用.可以增加并行性.

说明如下:

优化前:                     优化后:

LD  1        ADD        LD  1    ADD

                 ADD        CLD 2    ADD

BEQZ                       BEQZ

LD  2                        LD  3

LD  3

同一排的表示并行执行.

这里假设可以同时并发的执行访存指令和定点运算指令.

所以你看到了.这样优化后,如果条件满足的话,可以提前一条指令执行访存操作.

 

对于条件判断尽量用不等号

由于对于分支预测’<’一直命中率不高,所以尽量用!=来判断更好.

 

全局指令调度

这个比较神奇,以我的理解,通过编译器来寻找一系列相关计算,然后根据其计算路径优化.

这个优化也比较复杂,我认为可能会导致一些数据不正确之类的问题.所以还是不说了.不过可以给个例子.

A----r = ((r*x) *y)*z;

B----r = (r *(x*y))*z

C----r = r* ((x*y)*z)

D----r = r*(x*(y*z))

E----r = (r*x)*(y*z)

这些功能一样,但是运算顺序不一样的表达式,哪个性能比较好呢?

答案是CD.我的理解是只有CD对R的更新放在了最后.所以可以在LDR的值时,先进行计算,在其并行性相对会好一些.当然这个优化并不绝对有用,只是相对来说CD的下限要好一些.

 

三.数据相关的优化:

可以把数据相关优化分为三类:

1.存储

2.运算路径

3.浮点运算

 

对于存储,主要的目的在于减少cache的缺失率.这里有一条原则就是,尽量让每个访问之间的间隔少一些.

还有就是对于函数参数问题.虽说一般最多也就只有6.但是还是能少则少.特别是对于调用很多次的函数,可能会进栈出栈很多次.这就需要指针了.

 

由于很多CPU的运算延迟是不同的,所以对于重新组合指令

 

实际上浮点运算在最近的CPU中都是与其他运算分开的,所以不必担心一个整数除法和一个浮点除法会占用同一元件的情况.

C++prime里面好像提到过++xx++的效率会不一样.不过我感觉编译器应该会根据上下文来优化的.至少我觉得for中的更新变量就完全让编译器统一起来.

 

减少相关性计算

这个方法是编译器来优化的.可以看下面的指令

add  r1,r2,#4

add  r1,r1,#4

其实完全可以改成一条指令

add  r1,r2,#8

当然,这一点对于高级语言来说主要是编译器来做.自己多注意一下还是比较好的.毕竟编译器要做这一点比人来做要难得多.

 

对于运算来说,CPU中只有除法是应当尽量避免的.乘法对于CPU来说效率已经几乎和加减法相近了,所以如果你准备做一些比较极致的优化,尽量从除法入手吧.

 

四.一些妨碍编译器优化的因素

1.安全性.

int f1( int &xp,int &yp){

xp+=yp

xp+=yp

}

int f1( int &xp,int &yp){

xp+=2*yp

}

这两种看似是一样的,但是对于编译器来说,如果XPYP是同一个变量的话,那么执行结果就不一样.f1执行结果为4xp,f2结果为3xp

2.函数调用

int f();

int f1(){

return f()+f()+f()+f();
}

int f2(){
return 4*f();

}

这个看上去也没什么差别.毕竟不是递归.但是假如分f()中使用了全局变量或静态变量呢?

那么差别就大了.比如f(){static int  x; return x++;}这样这两个函数的执行结果就不一样了.

 

好了,最后我来总结一下原则:

1.永远不要过度拟合,至少你修改后要保证注释能简单明了的解释清楚.

2.循环内是线性代码时,尽量的展开循环.减少分支预测.

具体的层数根据CPU不同,数据也不同.我个人倾向于4.当然也得注意循环体之间的影响.

3.尽量减少除法的使用.

整数除法用位移和减法代替最好.

4.减少循环的体间相关.增加循环展开的并行度

5.减少计算量.尽量使用变量存储计算过程.

6.利用空间局部性.时间局部性可能更多得靠编译器来分配优化.

 

附上CSAPP上的建议:

1.消除循环中的低效率语句

比如对字符串操作,每次循环都调用一次strlen.这样效率就很差了.直接用一个变量代替比较好.编译器很难识别这一点,所以还是手工优化吧.

2.减少函数调用

简单来说就是多用宏和内联函数.

3.消除不必要的存储器引用.

4.循环展开

5.提高并行性.

即关键路径上执行的语句要尽量少.这里的关键路径指的是和结果运算直接相关的路径.就好比我上面写的ABCD的那四个表达式.当其他语句执行完了,才开始执行r的更新.r的路径上只有一次更新.

6.不要过份关心分支预测

只要求值不会对形成程序执行中关键路径的指令的取指和处理产生太大的影响.

7.利用条件传送指令,多写功能式的代码

什么是功能式的代码呢?

普通式:

void minmax(int a[],int b[],int n)

{

int i;

for(i = 0; i < n;i++)

{

if( a[i] > b[i])

{

int  t=a[i];

a[i] = b[i];

b[i] = t;
}
}

}

功能式:

void minmax2(int a[],int b[], int n){
int i;

for(i = 0; i < n;i++){
int max = a[i] < b[i] ? a[i]: b[i];

int min = a[i] < b[i] ? b[i]: a[i];

a[i]=min;

b[i]=max;

}

}

这个方法主要在GCC上适用.GCC会识别这种方式的代码.其他平台书上没说.

功能式的解释为:用条件操作来计算值,然后用这些值来更新程序状态.

类似于一种命令式风格.

8.根据Amdahl定理,想要大幅提高整个系统的速度,必须对整个系统很大一部分进行优化.

但一般情况下只需把重点函数优化得更彻底一些即可.如果想大幅提升,还是重构或者全面优化吧.

 

 如有错误,欢迎批评.