从更深层次角度优化程序性能

优化程序性能的必要性

作为软件行业的重要组成部分,程序本身是为了解决现实问题而为人所创造出来的,其要求在大部分的工作环境下均可以正常达到自身设计的目的——在一定可等待可接受时间内给出预期解决问题后的正常结果。在大部分情况下,程序仍需要达到以下的要求

  1. 主要代码高度简洁凝练,逻辑清晰,代码功能作用明确,具有较强的可读性
  2. 代码复杂度并非来源于大量重复赘余的代码
  3. 能够使用合理高效的算法与数据结构
  4. 能够在面对多种等类型大数量数据输入的情况下仍正常运作并得到正确输出
  5. 程序具有很好的处理输入数据能力,处理数据的时长以及资源占用尽可能低

作为程序优良评判的重要属性,整个执行的时间和执行过程中所需要占用的内存等有限资源的量的多少直接关乎到程序对解决相应问题的能力上限。例如很常见的资源传输速率的快慢直接影响到程序本身是否能在预期的极短时间内处理好对应的数据,这直接关乎到程序的功能实现;其次在很小的数据处理中可能体现不出来的性能高低,当面对很庞大的结构处理时,提升很小百分比的处理性能将会被极大地放大,尤其体现在时间和资源的占有量上,所以优化程序本身代码,提升程序的性能是极为关键重要的。

程序性能的量化表示

在程序中,一般的性能影响因素例如算法数据结构的应用,亦或在主要的常用的运行中被多次重复使用以至占据大部分时间和内存资源的循环,迭代等具体代码处,针对第二点,我们可以单方面从更改代码优化代码的使用进行具体的性能提升。
依据标准 :引入度量标准每元素的周期数(Cycles Per Element—CPE)用以作为一种表示程序的性能的统一度量标准,将程序的性能量化以数学的角度呈现出来,该标准的好处:有助于我们在更细节的级别上理解迭代程序循环性能。
相关原理:处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒来表示。例如,当表明-一个系统有“4GHz” 处理器,这表示处理器时钟运行频率为每秒4X10’个周期。每个时钟周期的时间是时钟频率的倒数。通常是以纳秒( nanosecond, 1纳秒等于10~9秒)或皮秒( picosecond, 1 皮秒等于10~1秒)为单位的。例如,一个4GHz的时钟其周期为0.25纳秒,或者250皮秒。从程序员的角度来看,用时钟周期来表示度量标准要比用纳秒或皮秒来表示有帮助得多。用时钟周期来表示,度量值表示的是执行了多少条指令,而不是时钟运行得有多快。
示例:对于同一个函数,不同的具体执行过程在面对同等数据处理量时的每周期,同时斜率描绘出每元素的周期数CPE的值在这里插入图片描述
通过最小二乘拟合得到两个的近似函数,以二者各自的时钟周期为单位分别近似等于两个函数368+9.0n和368+6.0n,即均为常量准备周期时间368加上一个每元素周期因子6和9构成总共时钟周期合,显然从函数图像上来看,9.0的函数因元素增量变化的趋势大于后者6.0的函数,即同样的数据元素量,前者的变化将会更大,程序的性能不如后者。所以我们评判程序的运行速度以及程序的整体性能,可根据其CPE进行考察,将关注点放在降低其CPE上,提升程序的性能。

从编译角度深入理解提升程序性能的空白之处

程序从编写到执行,期间最为接近计算机底层的即为编译后所产生的机器代码,对于我们而言,用高级语言所写的代码可能已经非常清楚明了如何执行相关职责等等,但是转化为更接近计算机的语言,事实往往有些区别,可供我们读取的汇编代码提供了一个很好的接口——允许我们从更直观的角度了解我们所编写的程序是如何真正被计算机执行的,以及其所作出的优化和有待改进的地方,这便是我们所探寻的。
编译器在编译我们的代码时,我们可以显式地要求编译器编译时进行优化编译,如“—Og —O1”等优化级别可供选择。虽然编译器的确进行了相关的优化,但并非如我们所想——编译器可接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。目前编译器采用了复杂的分析和优化方式,而且变得越来越好。然而,即使是最好的编译器也会受到妨碍优化的因素的阻碍。这些妨碍优化的因素主要是程序行为中那些严重依赖于执行环境的地方。
为了具体地将编译器始终存在的局限性以及我们能获得的些许启发,此处以案例分析进行举例说明

案例分析

案例一:

void twiddle1(long *xp,long *yp){
	*xp += *yp;
	*xp += *yp;
}
void twiddle2(long *xp,long *yp){
	*xp +=2*(*yp);
}

单从这两个函数所要实现的功能看,两者均实现了将* yp位置的值两遍添加至* xp处,且从程序的性能来看,后者由于对内存的读取仅为三次较前者六次的内存读取,可使程序的性能更加优良,执行更加贴合计算机的底层取向,所以当我们使用编译器的优化进行编译时,理所应当认为编译器会做出像后者一样的实现优化,然而,事实果真如此吗?编译器会做出这种看似机智的优化吗?
答案是——NO
原因——内存别名使用的可能
分析:编译器其实很聪明,以至于聪明到其会预测出所有与预期结果可能不一致的情况,而那些情况对于结果的正确性将会产生致命的影响,如本案例中经典的“内存别名使用”情况——编译器在编译的时候有充分的理由去认为一种可能存在的情况:xp和yp二者指向的内存位置为同一位置。而这种情况是我们刚才从未考虑到的,这种情况同时也是极为致命而又少见的,即当xp和yp指向同一内存位置,前者的结果是四倍的原值,而后者结果却为三倍的原值!编译器自然不会存在侥幸心理冒着任何一丝风险去进行这种层次的优化!
案例二:

long f();
long f1(){
	return f()+f()+f()+f();
}
long f2(){
	return 4*f();
}

从f1和f2两个函数的返回值可直接看出前者调用四次f函数,并相加返回,后者调用一次,直接乘于4返回,看似两者行为结果完全一致,并且因为函数调用的次数多少,我们可以轻易地判断出f2由于有着更少的过程调用,其性能更加优良,当编译器优化编译时,按理也应进行如f2一样的优化以简化代码且减少过程调用提升性能。然鹅,编译器会做出如此优化吗?
答案是——NO
原因——过程调用的可变性
分析:我们说,编译器会考虑到我们从未或者很少考虑到的情况,并且编译器愿意为原代码结果的正确性去牺牲部分看似可提升的程序执行性能,本案例中看似均为四次的过程调用结果相同,实则我们已经默认了该过程调用具有不变性——有着始终不变的执行方案和产生完全一致结果,但是,编译器会考虑到该过程调用可能会因为各种原因,如某个变量是会为其他改变的,会对后续的再次调用产生影响,那么执行的结果就完全有可能不一致,则依次调用该函数返回累加和一次调用返回四倍是完全不一样的!那么编译器就不会在这种不可预测的情况下去做这种优化!
这便是编译器提升性能的典型局限性所在以及其完全不可能涉及到的优化盲区

典型程序性能提升示例刨析

从刚才的两个分析中,我们可以明白为什么编译器在某些我们认为完全可进行显式优化的地方未进行优化,以及从其中我们能以编译器更底层的视角去分析出程序需要面对可能的存在的特殊情况,同样也可知:编译器并非那么的灵活和智能,其能做的提升程序性能的优化方面仅仅局限在所有对原程序结果百分百安全的圈子内,这也注定我们需要人为地进行代码层次上的优化更改,以提供给编译器等底端安全优化一个更大更安全的圈子供其进行代码优化。
针对我们常用的易懂的优化点以及对应优化方案,此处列举了三种典型供参考。

循环展开

示例:长度为n向量的元素前置和

void psum(float a[],float p[],long a){
	lont i;
	p[0] = a[0];
	for(i=1;i<n,i++){
		p[i] = p[i-1]+a[i];
	}
}

分析:本示例中,程序目的是算出向量a前x位向量和并存至另一个数组p中去,for循环为主体,步长为1,顺次累和并赋值,直至完成n项运算。看似无法再优化的for循环,可从其循环步长入手,这是因为循环步长的大小关系到整个循环的次数进而影响到整体性能,对于循环的操作次数,减少其循环索引的计算亦或条件分支判断等,适当进行循环展开,可减少循环次数,并且能够提供一个更足的空间供我们去设计更好的每次计算方法和思路。针对此例,可做一个简单的循环展开。
优化后:

void psum(float a[],float p[],long a){
	lont i;
	p[0] = a[0];
	for(i=1;i<n-1,i+=2){
		float mid_val = p[i-1] +a[i];
		p[i]=mid_val;
		p[i+1] = mid_val +a[i+1];
	}
}

优化方案:在某些循环中,可通过循环展开,增加循环的步长以缩短循环的总次数,提升程序的性能。

过程调用

示例:向量的元素运算

void combine1(vec_ptr v,data_t *dest){
	lont i;
	*dest = IDENT;
	for(i=0;i<vec_length(v),i++){//每次调用
		data_t val;
		get_vec_element(v,i,&val);
		*dest = *dest OP val;
	}
}

分析:本示例中,通过for循环依次从向量中取出每个数,进行相关运算,分析这段实现代码,直接从整体代码看,主要的实现方式以及性能消耗在for循环,其中,在for循环的步长判断中,由于每次进行判断时是与一个获取向量长度函数的返回值进行比较,所以每次循环均会进行过程调用,根据编译器的角度可以分析,由于过程调用的可变性,其不会进行此处的优化,而是按照每次循环判断进行过程调用的方式,这无疑增加了内存的开销以及执行时间,而针对本示例,显然对于整个向量的长度是恒不变的,即vec_length(v)的返回值为一个恒值,不会存在编译器所设想的情况,所以我们可以通过将本过程调用单独从循环内部拿出,并用以变量保存其值,供永久使用。每次循环时,通过该变量进行判断,这样编译器自然就会达成所预期的优化,不会做n次过程调用,很大程度上优化了代码,提升了程序的性能。
优化后:

void combine1(vec_ptr v,data_t *dest){
	lont i;
	*dest = IDENT;
	int size = vec_length(v);//一次获取永久使用
	for(i=0;i<size;i++){
		data_t val;
		get_vec_element(v,i,&val);
		*dest = *dest OP val;
	}
}

优化方案:在循环中的步长判断中,若存在某个过程调用返回值恒为不变值或者可通过避开过程调用的方式,以此避免每次循环的过程调用,消除因此每次过程调用带来的低效率,提升程序的性能。

内存引用

示例:向量的元素运算

void combine2(vec_ptr v,data_t *dest){
	lont i;
	long length = vec_length(v);
	data_t *data = get_vec_start(v);
	*dest = IDENT;
	for(i=0;i<length,i++){
		*dest = *dest OP val;
	}
}

分析:本示例中,进行了循环判断处过程调用的外置优化,但是从该代码对应的汇编文本可以看出,每次循环产生的结果均由dest处内存读出,结果再写至dest处内存,即每次的循环均会有两次的内存读取操作,理解内存的读取过程以及高速缓存的同学,会明白这意味着什么,我们应该尽量避免内存的多次读取,尤其是当这些操作可以省略或者用简单的变量去代替,那便是对程序的很大程度上的优化,本示例中,可通过将每次的读取存放在一个普通的变量中去,直至结束循环后再进行一次内存读取即可,优化如下:
优化后:

void combine3(vec_ptr v,data_t *dest){
	lont i;
	long length = vec_length(v);
	data_t *data = get_vec_start(v);
	acc= IDENT;//普通变量暂时保存
	for(i=0;i<length,i++){
		acc = acc OP val;
	}
	*dest = acc;//最终存值
}

优化方案:对于循环中每次固定的内存读取,尽可能地避免多次读取内存的操作,如可通过简单的变量进行数据的暂存,直到最后再将值存放在内存中去,以此提升程序的性能。

总结

在编写程序中,除了从算法数据结构的角度上进行优化外,从更接近计算机底层的角度以编译器更易接受的方式进行代码编写,对于提升程序的性能,减短所使用的时间,减少堆内存等资源的占用具有着很大的影响,可能简单的更改会带来极大的性能提升。

参考文献

机械工业出版社——深入理解计算机系统(原书第三版)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值