代码优化一般需要与算法优化同步进行,代码优化主要是涉及到具体的编码技巧。同样的算法与功能,不同的写法也可能让程序效率差异巨大。一般而言,代码优化主要是针对循环结构进行分析处理,目前想到的几条原则是:
a.避免循环内部的乘(除)法以及冗余计算
这一原则是能把运算放在循环外的尽量提出去放在外部,循环内部不必要的乘除法可使用加法来替代等。如下面的例子,灰度图像数据存在BYTE Img[MxN]的一个数组中,对其子块 (R1至R2行,C1到C2列)像素灰度求和,简单粗暴的写法是:
int sum = 0; for(int i = R1; i < R2; i++) { for(int j = C1; j < C2; j++) { sum += Image[i * N + j]; } }但另一种写法:
int sum = 0; BYTE *pTemp = Image + R1 * N; for(int i = R1; i < R2; i++, pTemp += N) { for(int j = C1; j < C2; j++) { sum += pTemp[j]; } }可以分析一下两种写法的运算次数,假设R=R2-R1,C=C2-C1,前面一种写法i++执行了R次,j++和sum+=…这句执行了RC次,则总执行次数为3RC+R次加法,RC次乘法;同 样地可以分析后面一种写法执行了2RC+2R+1次加法,1次乘法。性能孰好孰坏显然可知。
b.避免循环内部有过多依赖和跳转,使cpu能流水起来
关于CPU流水线技术可google/baidu,循环结构内部计算或逻辑过于复杂,将导致cpu不能流水,那这个循环就相当于拆成了n段重复代码的效率。
另外ii值是衡量循环结构的一个重要指标,ii值是指执行完1次循环所需的指令数,ii值越小,程序执行耗时越短。下图是关于cpu流水的简单示意图:
简单而不严谨地说,cpu流水技术可以使得循环在一定程度上并行,即上次循环未完成时即可处理本次循环,这样总耗时自然也会降低。
先看下面一段代码:
for(int i = 0; i < N; i++) { if(i < 100) a[i] += 5; else if(i < 200) a[i] += 10; else a[i] += 20; }这段代码实现的功能很简单,对数组a的不同元素累加一个不同的值,但是在循环内部有3个分支需要每次判断,效率太低,有可能不能流水;可以改写为3个循环,这样循环内部就不 用进行判断,这样虽然代码量增多了,但当数组规模很大(N很大)时,其效率能有相当的优势。改写的代码为:
for(int i = 0; i < 100; i++) { a[i] += 5; } for(int i = 100; i < 200; i++) { a[i] += 10; } for(int i = 200; i < N; i++) { a[i] += 20; }关于循环内部的依赖,见如下一段程序:
for(int i = 0; i < N; i++) { int x = f(a[i]); int y = g(x); int z = h(x,y); }其中f,g,h都是一个函数,可以看到这段代码中x依赖于a[i],y依赖于x,z依赖于xy,每一步计算都需要等前面的都计算完成才能进行,这样对cpu的流水结构也是相当不利的,尽量避免此类写法。 c.定点化
定点化的思想是将浮点运算转换为整型运算,目前在PC上我个人感觉差别还不算大,但在很多性能一般的DSP上,其作用也不可小觑。定点化的做法是将数据乘上一个很大的数后,将 所有运算转换为整数计算。例如某个乘法我只关心小数点后3位,那把数据都乘上10000后,进行整型运算的结果也就满足所需的精度了。
d.以空间换时间
空间换时间最经典的就是查表法了,某些计算相当耗时,但其自变量的值域是比较有限的,这样的情况可以预先计算好每个自变量对应的函数值,存在一个表格中,每次根据自变量的 值去索引对应的函数值即可。如下例:
//直接计算 for(int i = 0 ; i < N; i++) { double z = sin(a[i]); } //查表计算 double aSinTable[360] = {0, ..., 1,...,0,...,-1,...,0}; for(int i = 0 ; i < N; i++) { double z = aSinTable[a[i]]; }后面的查表法需要额外耗一个数组double aSinTable[360]的空间,但其运行效率却快了很多很多。
e.预分配内存
预分配内存主要是针对需要循环处理数据的情况的。比如视频处理,每帧图像的处理都需要一定的缓存,如果每帧申请释放,则势必会降低算法效率,如下所示:
//处理一帧 void Process(BYTE *pimg) { malloc ... free } //循环处理一个视频 for(int i = 0; i < N; i++) { BYTE *pimg = readimage(); Process(pimg); } //处理一帧 void Process(BYTE *pimg, BYTE *pBuffer) { ... } //循环处理一个视频 malloc pBuffer for(int i = 0; i < N; i++) { BYTE *pimg = readimage(); Process(pimg, pBuffer); } free前一段代码在每帧处理都malloc和free,而后一段代码则是有上层传入缓存,这样内部就不需每次申请和释放了。当然上面只是一个简单说明,实际情况会比这复杂得多,但整体思想是一致的。
算法上的优化是必须首要考虑的,也是最重要的一步。一般我们需要分析算法的时间复杂度,即处理时间与输入数据规模的一个量级关系,一个优秀的算法可以将算法复杂度降低若干量级,那么同样的实现,其平均耗时一般会比其他复杂度高的算法少(这里不代表任意输入都更快)。
比如说排序算法,快速排序的时间复杂度为O(nlogn),而插入排序的时间复杂度为O(n*n),那么在统计意义下,快速排序会比插入排序快,而且随着输入序列长度n的增加,两者耗时相差会越来越大。但是,假如输入数据本身就已经是升序(或降序),那么实际运行下来,快速排序会更慢。
对于经过前面算法和代码优化的程序,一般其效率已经比较不错了。对于某些特殊要求,还需要进一步降低程序耗时,那么指令优化就该上场了。指令优化一般是使用特定的指令集,可快速实现某些运算,同时指令优化的另一个核心思想是打包运算。目前PC上intel指令集有MMX,SSE和SSE2/3/4等,DSP则需要跟具体的型号相关,不同型号支持不同的指令集。intel指令集需要intel编译器才能编译,安装icc后,其中有帮助文档,有所有指令的详细说明。
例如MMX里的指令 __m64 _mm_add_pi8(__m64 m1, __m64 m2),是将m1和m2中8个8bit的数对应相加,结果就存在返回值对应的比特段中。假设2个N数组相加,一般需要执行N个加法指令,但使用上述指令只需执行N/8个指令,因为其1个指令能处理8个数据。
实现求2个BYTE数组的均值,即z[i]=(x[i]+y[i])/2,直接求均值和使用MMX指令实现2种方法如下程序所示:
#define N 800 BYTE x[N],Y[N], Z[N]; inital x,y;... //直接求均值 for(int i = 0; i < N; i++) { z[i] = (x[i] + y[i]) >> 1; } //使用MMX指令求均值,这里N为8的整数倍,不考虑剩余数据处理 __m64 m64X, m64Y, m64Z; for(int i = 0; i < N; i+=8) { m64X = *(__m64 *)(x + i); m64Y = *(__m64 *)(y + i); m64Z = _mm_avg_pu8(m64X, m64Y); *(__m64 *)(x + i) = m64Z; }使用指令优化需要注意的问题有:
关于值域,比如2个8bit数相加,其值可能会溢出;若能保证其不溢出,则可使用一次处理8个数据,否则,必须降低性能,使用其他指令一次处理4个数据了;剩余数据,使用打包处理的数据一般都是4、8或16的整数倍,若待处理数据长度不是其单次处理数据个数的整数倍,剩余数据需单独处理