学会这些代码优化通用技巧,提升程序执行效率不止一个Level(c/c++)

扩展的K&R布局

目的是让较小的屏幕显示较多的信息

int extended(){
    
    
	int a=0,b=0;
	while(a!=10)
	{
    
    
		b++;
		a++;
	}
	return b;
}

函数内的左大括号不是紧跟上一行而是自己起一行,方便对应。

编写可读性强的代码

  1. 好的编程风格,布局,清晰的逻辑流,避免过多嵌套
  2. 好的命名
  3. 不要用过多复杂的函数,这里有一个经验,如果一个函数的代码总量超过了100行,那么它必可以本分解成更短的子函数,子函数长度以一屏的高度为宜
  4. 采用合适的数据类型,const,unsigned,重要数据用typedef
  5. 有意义的常量使用宏定义给出
  6. 加入适当的注释,拒绝ASC艺术(虽然我自己还蛮喜欢的),注释里不应当含有老的代码,这个任务应当由版本控制工具完成(svn等),好的注释常用于说明为什么这样编写代码和这段代码的功能功能,而不是描述其实现。

通用的编程技巧

如果软件性能要求得不到满足,有两种方法解决,第一就是换用算力更高的cpu,更快的I/O处理速度,采用更好的编译器来充分利用编译优化机制。第二就是从软件代码本身提高运行效率。需要注意的是:一般先用测性能的工具测出最影响性能的代码块,针对其进行优化,代码级的优化一般会采用不那么直观的技巧,所以需要写好注释,正常代码尽量还是要以可读性为主

一、函数级

1.经常使用的简单函数定义为内联函数(lnline函数)

可以减小开支,编译器会把一些比较小的函数(例如算术运算)自动变成内联函数,但是包含复杂控制结构的函数一般不会被转化(循环,switch等)

2.参数和返回值

  1. 参数传递的个数一般应小于四个
  2. 避免传入复杂的数据结构作为参数,可以声明一个指向该结构的指针作为参数
  3. 若函数不会修改复杂参数的内容,加入const可以让编译器优化
  4. 用全局变量代替传参(要慎重,会影响程序的模块化和重入)
  5. 不定义不使用的返回值,应使用void声明无返回
  6. 原型定义可以传递给编译器更多可用于优化的信息,所有函数都应当采用原型定义(及不省略参数的类型定义)

3.函数调用

  1. 如果一个函数只在实现它的文件被使用,可以声明为static类型以强制使用内部连接
  2. 否则默认会设为外部连接,可能会影响编译器优化
  3. 循环体中经常调用某个函数,并且该函数只在这个循环体出现,此时可以把循环体放进函数中,去掉多次函数调用开销
  4. 一些测试语句可以放在函数体前面(某些条件执行,某些条件终止),避免一些不必要的代码的执行
  5. 叶函数是指不调用任何函数的函数,不需要像一般的函数保存和恢复寄存器,所以经常被调用的函数应当成为叶函数

二、变量和内存访问

1.变量

  1. 局部变量比较少可以用寄存器保存而不需要堆栈,可以减小开销
  2. 局部变量应该定义再真正使用的变量域,可以减小个数
  3. 函数中多使用局部变量少使用全局变量(局部变量可以用寄存器保存,全局变量可能用到堆栈)
  4. 如果一个变量在函数体中被使用但是局限于一个源文件代码,可以声明为静态变量
  5. 避免把局部变量的地址传给另一个函数,因为这样无法判断其值是否被函数改变,也就无法用寄存器保存
  6. 局部变量尽量避免使用char和short型,因为这两个类型会转化为整型再运算,转化代价比较高
  7. flout和double用flout型
  8. 整型和浮点型转换开支巨大,应避免
  9. 注意:原来用于整型的优化可能不适用于浮点型例如3*(x/3)不等于x

2.内存

  1. 利用访问内存的临近特点使得缓存能够产生作用
    一个例子:c语言多维数组采用行优先的原则,即按照行把数据存到连续的内存,在处理下一行所以,从内存访问的角度,代码
flout array[20][100];
int i,j;
for(i=0;i<20:i++)
	for(j=0;j<100;j++)
		array[i][j]=0.0

是优于下面代码的

flout array[20][100];
int i,j;
for(j=0;j<100;j++)
   for(i=0;i<20:i++)
   	array[i][j]=0.0

同时这也能说明如果我们同时访问相距较远的数组成员时,也会比较慢

  1. 经常一起使用的函数在指令空间也放在一起,指令的缓存策略能够更有效的工作,所以关键部分经常调用的函数放在一个源文件里,不常使用的放在另一个源文件
  2. 多数现代处理器不允许访问任意内存地址的数据,访问应对齐于CPU支持的某种内存边界(通常为4字节的边界),非对齐的访问会慢得多,因此定义复杂数据结构或者数组时,经常需要插入填充字节来保证所有变量都能对齐于某个合适的边界。所以,在定义结构类型时,把结构体成员按照类型长度排序,声明成员时把长的类型放在短的前面,最后再把结构体填充为最长类型数据长度的整数倍,所以例子
struct
{
    
    
  char a[5];
  long k;
  double x;
}fo

可以优化为

struct
{
    
    
  double x;
  long k;
  char a[5];
  char pad[7];	
}foo;

长的变量声明要放在短的前面,具体排序:double>long>flout>short

  1. 一个重要的例子
比如有以下的二维数组
char playfield[80][20];
它的索引可以表示为i*sizeofstruct)
最左边维数之外的其他维数大小可以补为大小为2的幂,所以优化之后,
数据的索引偏移量可以为i<<5+j
char playfield[80][32];

这样的话根据我们之前的结论(指针按行在连续的内存中存储),i<<5等价于i*=32,所以如果我们要访问playfield[60][15],在线性的内存里,它的偏移量等于i<<5+15,即它为第60*32+15=1935个成员。,用移位运算代替乘法运算,开销仅仅多了一些空元素,还是挺值的

3.算术表达式

  1. 操作符的运算复杂度排序:取模>除法>乘法>加减>移位
  2. 整数与一个2的幂乘除法可以用移位和加法运算代替,左移是乘法,右移是除法
i*=256可以优化为i<<8
i/256可以优化为i>>8

实际上整数与其他任何整数的乘法运算都可以进一步简化为移位和加减法例如:

i*=272可以优化为i=i<<8+i<<4 (256+16=-272);
i*=7可以优化为i=i<<3-i
  1. 除了乘除法,其他复杂运算的表达式也可以采用运算强度更低的表达式来代替,幂运算可以换为乘法运算,复杂的公式可以提取公共项,取余可以用比特运算。考虑如下代码
m=i/j/k     转化为  m=i/(j*k)
x=w%8       转化为  x=w&7
y=pow(z,2.0)转化为  y=x*x
x=y/w+z/w   转化为  x=(y+z)/w
if(a==b&&c==d&&e==f){
    
    ...}转化为 if((a-b)|(c-d)|(e-f))==0){
    
    ...}
x=x+1       转化为  x++
x=x+y       转化为  x+=y
  1. 针对(a/b)>c,如果b>0且b*c不会溢出,可以改写为a>(b*c)(除法运算比乘法运算需要更多的资源)
  2. 注意:我们前面提到的很多算术运算的强度削弱主要是针对无符号整数的,对于整数,可能需要考虑到符号的差异,因此,如果某个变量的值永远不是负数时,应采用无符号整数而不是有符号整数
    一个经常使用的布尔表达式时间差变量的取值是否在一定范围:(x>=min&&x<max),该表达式可以转化为(unsigned)(x-min)<(max-min)
  3. 乘法运算可以用加法运算来替代(在循环中),例如:
for(i=0;i<MAX;i++)
{
    
    
	h=14*i;
}

可以修改为:

for(i=0;i<MAX;i++)
{
    
    
	h+=14;
}
  1. 另外,如果一个表达式的某一部分会被频繁用到,如果该子式的值一直保持不变,则应在第一次计算将其保存起来,进行替换,一般的编译器都能对一些简单的公共表达式进行优化,但是对于复杂表达式,编译器可能无法抽取,所以还需要人工优化。考虑下面的代码:
up=val[(i-1)*n+j];
down=val[(i+1)*n+j];
right=val[i*n+j-1];
left=val[i*n+j+1];
sum=up+down+right+left;

注意到需要执行三次乘法,但是注意下标内部,有一项是一个常数,i*n+j所以可以简化为一次乘法。如下面的代码所示:

int inj=i*j+j;
up=val[inj-n];
down=val[inj+n];
right=val[inj-1];
left=val[inj+1];
sum=up+down+right+left;
  1. 对于经常会涉及到的指针操作,考虑下面代码:
int i;
for(i=0;i<numPixels;i++)
{
    
    
	Rendering_context->back_buffer->surface->bites[i]=some_value;
}

每次循环都要进行引用,我们把指针引用抽取出来,优化为:

Unsigned char *back_surface_bites= Rendering_context->back_buffer->surface->bites;
int i;
for(i=0;i<numPixels;i++, back_surface_bites++)
{
    
    
	*back_surface_bites =some_value;
}
  1. c语言一般不保证各个子表达式的计算顺序,所以对于一些有歧义的式子可能会导致副作用(side effect):x=–i+j++。编译器在对待可能有副作用的式子的时候会非常保守地优化,前面说的函数内才用全局变量,指针传参都可能导致副作用,所以推荐函数内部使用局部变量并且避免把局部变量的地址传递给另一个函数。

4.switch/if等控制结构

  1. 多处理器对于顺序执行的代码处理效率最高(可以采用流水线等技术)所以尽量编写顺序执行的程序,但分支结构又是程序中不可避免的,所以这部分的优化主要是提升分支的可预知性,因此,最简单的方法就是使得主要的流程紧跟if而不是else分支,因为else分支会跳转,导致流水线中断。
  2. 除了常见的if/else语句外,多分支情况下还经常见到else if这样的语句,后面的分支要经过前面分支的判断,所以应该把最常见的情况放在前面的分支李,避免无谓的条件判断例如下面:
if(i==1{
    
    
	do1thing();
}else if(i==2){
    
    
	do2thing();
}else if(i>5){
    
    
	doRealthing();
}

可以优化为

if(i>5{
    
    
	doRealthing();
}else if(i==1){
    
    
	do1thing();
}else if(i==2){
    
    
	do2thing();
}
  1. 如果要判断的并列条件比较多,并且每个分支跳转的可能性都差不多,一种方法是采用switch/case结构,另一种是采用if/else的嵌套。注意,c语言的布尔表达式采用短路计算的策略,即有多个条件时,前面的子表达式计算结果如果将导致表达式一定为FALSE,则后面的表达式不再计算,根据这个特性,我们应当把计算更快的,更简单的,为FALSE概率更大的表达式放在前面。
  2. switch/case语句,由于编译器一般是采用分支跳转的方式来实现switch,为了让其表现性能更好,case语句的标号应该尽量接近,例如下面的代码:
switch(i){
    
    
case 1:...
case 10:...
case 100:...
case 1000:...
default:...
}

上面的代码很难通过跳转表的方式实现,编译器一般会将其转化为if/else实现,实际上,编译器一般会针对case标号的具体分布情况,采用跳转和条件语句结合的方式,所以一般把最常用的标号放在最前面,这样的话即便采用多个条件语句,由于测试条件最少而使得执行对应分支最快。

5.循环优化

循环在使程序更加紧凑的同时,其重复性也可能导致自己成为程序的“热点”,一个低效的循环可能对程序的效率有较大的影响,因此循环也是性能优化的重点之一。具体的优化方法可以参考以下几个原则:

  1. 利用算术表达式中提到的强度削弱策略可以通过运算强度比较低的加减法代替乘除法
  2. 有些循环内部额运算和循环变量是无关的,可以把它们放到循环外部,从而不需要每次循环都计算,这些循环不变量包括表达式,函数的调用,指针运算,数组访问等,大多数编译器都支持循环不变量的优化,但是并不能检测和移出所有的,所以程序员还是需要自己优化。例如,在循环中调用一个函数得到返回值对数组进行赋值,由于编译器不确定把该函数放到外面会不会导致副作用,所以不会对其优化。
  3. 尽量把条件和分支判断放在循环外面,避免流水线中断而导致的开销。例如:
int flags;
for(i=0;i<n;i++)
{
    
    
	if(flags&0x01){
    
    
	doonething();
	}
	else if(flags&0x10){
    
    
	doanotherthing();
	}
	else{
    
    
	dosomething();
	}
}

假设在这些循环中,flags的值不会改变,那么每次都进行条件分支,就会多很多开销,所以可以优化为:

int flags;

	if(flags&0x01){
    
    
		for(i=0;i<n;i++){
    
    
			doonething();
			}
		}
	 if(flags&0x10){
    
    
	 	for(i=0;i<n;i++){
    
    
			doanotherthing();
			}
		}
	else{
    
    
		for(i=0;i<n;i++){
    
    
			dosomething();
			}
		}
  1. 如果一个函数经常在一个循环中被调用,可以考虑把循环放在函数体内部,减少重复调用带来的开销。
  2. 循环语句中经常会出现在全部循环结束之前就可以退出的情形,这个时候可以考虑提前退出循环。如:适当使用break,满足条件之后则跳出。
  3. 有时候,可以考虑把具有相同循环控制的相邻循环合并为一个循环,这种方法称为循环融合优化,注意:如果合并后的循环体中的代码完成的任务过多,可能没有办法容纳在指令缓存中,此时不进行循环优化可能会更好。循环优化的例子:
for(i=0;i<100;i++){
    
    
	stuff();
}
for(i=0;i<100;i++){
    
    
	morestuff();
}

可以优化为

for(i=0;i<100;i++){
    
    
	stuff();
	morestuff();
}
  1. 如果循环变量的改变值的方向对循环结果没什么影响,可以采用从大到小循环直到0,考虑如下计算n!的函数:
int fact1_func(int n)
{
    
    
	int i,fact=1;
	for(i=1;i<=n;i++)
	{
    
    
		fact*=i;
		return(fact);
	}

该循环需要执行:计算i-n,判断是否小于等于0?如果是,i+1后继续,但是如果采用下面的循环,只需要判断是否为0?如果是,i-1后继续,优化后的循环要更快。

int fact2_func(int n)
{
    
    
	int i,fact=1;
	for(i=n;i!=0;i--)
	{
    
    
		fact*=i;
		return(fact);
	}
  1. 当循环次数很小时,可以考虑把小循环展开,从而消除循环变量的维护和分支指令的开销,当然可能也会增加代码量,所以完全的循环展开只有在小循环且循环体非常简单的情况下使用,例如:
for(i=0;i<3;i++){
    
    
	spmething(i);
}

可以修改为:

something(0);
something(1);
something(2);

当然循环比较大的时候依然可以采用循环展开技术,以下代码:

sum=0for(i=0;i<1000;i++){
    
    
	sum+=array[i];
}

可以展开为:

sum=0for(i=0;i<1000;i+=4){
    
    
	sum+=array[i];
	sum+=array[i+1];
	sum+=array[i+2];
	sum+=array[i+3];
}

这样原来的循环需要执行1000次,新的条件下只需要250次,循环和分支带来的开销大幅度减少。

  1. 如果一个多重循环中,最内层的循环要远远小于最外层的循环,则可以通过交换循环的次序来进行优化。因为内层循环要小得多,所以经常会导致在内外层循环来回跨越1,因此可以通过交换循环次序的方式进行优化。

三、小结

以上就是程序优化的一般方法了,上面介绍的方法有的可以提升执行速度,有的可以提高内存的访问,至于在实际编程中选用哪些技术进行优化,需要根据软件的实际情况和代码的性能要求来确定。下面介绍一个通俗的理论来进行判断:

80/20原则

软件优化的一个重要原则就是不要试图对代码的每个部分进行优化,而应该只对那些“热点”进行优化,如果花费大量的时间优化“冷点”部分,则这些开销对于整体性能的提升不是特别的大,这个原理常被称为80/20原则或者pareto原理
80/20原则要求我们再进行软件优化之前首先找到应用的热点,再进行优化,效率会高的多,同事,如果针对各部分执行时间相对比较均匀的情况,无法通过性能检测工具时间采样来得到热点,但并不意味它被完全优化了,均匀执行的应用通常都包含了相似的代码或者同样的宏,如果能对宏进行优化,则能对程序的性能产生影响。这种情况下,编译器的优化可能会更加容易发挥作用,可以帮助提升每一段代码的性能。

四、浮点数

因为最近涉及到的项目主要是针对浮点数的优化,所以在这里做一个记录,同时,浮点运算在科学运算中也有很重要的地位,所以浮点数的优化对于性能的提升非常有帮助。内存中的浮点数有三种格式,单精度(32bit),双精度(64bit)double,双精度扩展(80bit)或者long double。浮点数有两个部分,整数部分与小数部分,单精度小数部分占24bit,双精度小数部分为53bit,扩展精度小数部分为64bit。

  1. intel的浮点运算只针对某种浮点类型,也就是说如果两个不同的浮点类型计算需要先转化成相同的类型,所以编程时,应当尽量采用同一种浮点数,且精度越低,计算越快,所以如果不是有特殊的要求,一般声明为flout
  2. 需要注意的是:从精度的角度考虑,原来整数运算的结合律,交换律等可能不再适用,例如:flout t0,t1,t2;
    t0=4.0f+1.0f+t1+t2;
    如果t1t2都为整型,则可以常数传播优化,把4+1代为5,另外可以自由结合,但是要求浮点数进度下,上述的操作都不可实现
  3. 当浮点数的数值非常小,而标准的归一化浮点数无法表示这个数的时候,这个数被称为微小数,如果直接用0来代替,显然在精度方面会牺牲不少,因此,intel的处理器或者fpu支持微小数的表示和运算但耗时可能达到上百个时钟周期。要处理微小数带来的性能下降有以下三种方法,一是子u该原有代码。在原来的表达式上加一个放大因子,使得计算结果可以用标准的归一化浮点格式表示;另外一种方法使用一个精度更高的浮点类型;最后一种方法是牺牲精度,用0代替微小数。
  4. 另外,程序员也可以手工修改代码来提升浮点数运算性能,抽取公共子表达式,这样用户可以改变浮点运算的执行顺序,比如下面:
double a,b,c,d,e,f;
e=b*c/d;
f=b/d*a;

可以通过引入一个临时变量来抽取公共表达式,代码改变为:

double a,b,c,d,e,f,t;
t=b/d;
e=c*t;
f=a*t;
  1. 另外在浮点运算中,除法的开销要比乘法打的多,因此,如果有可能,可以考虑把浮点除法以乘法运算来代替,考虑下面代码:
double a,b,c,e,f;
e=a/c;
f=b/c;

引入一个临时变量保存1/c,代码改编为:

double a,b,c,e,f,t;
t=1/c;
e=a*t;
f=b*t;

五、快速实用总结(针对懒癌犯了的观众老爷)

毕竟你都跳过那么多直接看这里,也不能保证看到的有多全是吧

  1. 1维数组比2维数组好
  2. 可以把小数转换为整数的乘除,乘法比除法快
  3. 乘除可以使用移位运算,但前提是2的N次方。同样,如果不是,可以进行通分转换为2的N次方,再进行近似计算
  4. 数组查表更加快
  5. 32位可是使用2给ALU,for循环中跨度为2,循环中做2次计算代替逐个计算
  6. 缩短数据类型
  7. 将函数声明为inline,可以加快系统运行,但会增加内存空间,以空间换时间
    数组放在高数缓存区(如果有的话)
  8. 可以用汇编来写

六、结语

以上就是一般的提升代码执行效率的方法与例子了,主要参考的书目是英特尔平台编程,如果有不对或者没说清楚的地方,欢迎各位指正讨论

猜你喜欢

转载自blog.csdn.net/KingsMan666/article/details/108835110