C语言循环优化二三事(二)
在前一篇博客里,我们提出了三种常见的循环优化的方法,主要包括:减少不必要的计算,减少不必要的函数调用,减少不必要的内存访问。这三种方法跟特定的机器的特性无关,具有很强的通用性。今天,我们本着榨干机器最后一滴性能的原则,再进一步提出几种跟指令执行有关系的优化方法。
一.循环展开
循环展开可以减少循环的次数,对程序的性能带了两方面的提高。一是减少了对循环没有直接贡献的计算,比如循环计数变量的计算,分支跳转指令的执行等。二是提供了进一步利用机器特性进行的优化的机会。
例子:
优化前的代码见前一篇博客里的sum3.
优化后:
void sum4(vec_ptr v,data_t *dest){ int i; int len=vec_length(v); int limit=len-3; data_t *data=get_vec_start(v); data_t acc=0; for(i=0;i<limit;i+=4){ acc=acc+data[i]+data[i+1]; acc=acc+data[i+2]+data[i+3]; } for(;i<len;++i) acc+=data[i]; *dest=acc; }
通过循环展开,每次迭代将累加4个元素,减少了循环次数,从而减少了总的执行时间(单独使用这种优化方法,对浮点数累乘几乎没有提高,但是整数累乘得益于编译器的重关联代码变化会有大幅度提高)。
这种优化可以直接利用编译器完成,将优化level设定到较高,编译器会自动进行循环展开。使用gcc,可以显式使用-funroll-loops选项。
二.提高并行性
现代处理器大多采用了流水线、超标量等技术,可以实现指令级并行。我们可以利用这个特性对代码做进一步的优化。
2.1使用多个累积变量
优化代码示例
void sum5(vec_ptr v,data_t *dest){ int i; int len=vec_length(v); int limit=len-1; data_t *data=get_vec_start(v); data_t acc0=0; data_t acc1=0; for(i=0;i<limit;i+=2){ acc0+=data[i]; acc1+=data[i+1]; } for(;i<len;++i) acc0+=data[i]; *dest=acc0+acc1; }
这里同时使用了循环展开和使用多个累加变量,一方面减少了循环次数,另一方面指令级并行的特性使得每次迭代的两次加法可以并行执行。基于这两点可以显著减少程序执行的时间。通过增加展开的次数和累加变量的个数,可以进一步提高程序的性能,直到机器指令执行的吞吐量的极限。
2.2重结合变换
除了使用多个累积变量显式利用机器的指令级并行特性外,还可以对运算重新结合变换,打破顺序相关性来享受指令级并行带来的好处。
在sum4中,acc=acc+data[i]+data[i+1]的结合顺序是acc=(acc+data[i])+data[i+1];
我们将之变成acc=acc+(data[i]+data[i+1]);
代码如下:
void sum6(vec_ptr v,data_t *dest){ int i; int len=vec_length(v); int limit=len-3; data_t *data=get_vec_start(v); data_t acc=0; for(i=0;i<limit;i+=4){ acc=acc+(data[i]+data[i+1]); acc=acc+(data[i+2]+data[i+3]); } for(;i<len;++i) acc+=data[i]; *dest=acc; }
进一步增加循环展开的次数,可以进一步提高程序性能,最终也可以达到机器指令执行的吞吐量的极限。(在循环展示提到的整数乘法的性能提高就在于编译器隐式采取了这种变换,但是由于浮点数不具备结合性,所以编译器没有采用,但是程序员在保证程序结果正确性的情况下,可以显式使用这一点)。
根据相关资料的数据,sum5进行5路展开,5路并行可以达到sum1近10倍的性能。
除了这两篇博客提到的方法,还可以使用SIMD指令对程序加速。
参考文献:CSAPP 2nd edition