文章中出现频率比较高的一个词是CPE(cycle per element 每元素周期数),在一开始你可以把它理解成每次循环所需要的时间大小。
开始正文前的废话:优化的副作用
由于这里是一个会产生程序bug的地方,所以我必须将其写下来以作提醒。实际上,优化的副作用是一些编译器在编译源文件时不进行代码优化的一个原因。比如下面的代码:
高手一看就知道,double2的运行效率肯定比duoble1强,因为其存储器引用的次数比double1少(double1为:两次读*xp,两次读*yp,两次写*xp,共六次;而double2为:读*xp,读*yp,写*xp,共三次)。很显然,两个函数的目的就是要将yp指向的值二倍后加到xp指向的地方。但是,如果指针xp和yp指向相同的存储单元,那么double1的运行结果正确(xp处的值X4),但是double2的运行结果就不正确(xp处的值X3)。这个副作用称为寄存器别名使用(memory aliasing),这是一个主要的妨碍优化的因素。
再比如函数调用,看下面的代码:
乍看上去func2比func1有效,因为func1调用f四次,而func2只调用f一次。但是如果调用函数f会影响全局变量的状态,那它就可能会有副作用了。例如:
明白人一看就知道了,对func1的调用,全局变量cnt会返回0 + 1 + 2 + 3 = 6,而调用func2,cnt会返回4*0 = 0。副作用很明显。
好了废话就写到这,以后再慢慢添加,下面开始正文。
1) 提高循环运行效率
计算机对人类的价值的一个重要体现就是能够帮助我们做重复性高的工作。所以,提高程序中循环体的运行效率,可以极大地改善程序的整体运行效率(例如降低运行时间)。例如下面这个例子:
两个函数的功能都是将一个字符串中的大写字母转换为小写字母,但是lower2的效率比lower1好。因为lower1在每次循环的终止条件测试部分都调用strlen函数计算字符串的长度,而lower2函数将其预先计算好并放入局部变量中,这样在循环测试部分直接访问局部变量就可以了。明白人都知道访问局部变量的速度比调用函数后获得结果的速度快的多。根据测试数据,字符串的长度每增加一倍,lower1的运行时间会变为原来的四倍,而lower2的运行时间加倍。很明显lower1的复杂形是二次的,而lower2的复杂性是线性的。对于一个长度为262144的字符串,lower1需要3.1分钟运行时间,而lower2仅需要0.006秒,差距很明显。这是一个“代码移动(code motion)”优化的例子。所以,应该避免引入这样的“渐进低效率”。
2) 尽量少在循环中调用过程
因为过程调用会带来很大开销,而且妨碍大多数形式程序的优化,所以有时候应该将其替换。例如上例中的两个函数lower1和lower2,其循环部分可以这样替换:
由于是直接引用的字符串数组并对其进行操作,这比起调用函数来对字符串数组进行操作的开销要小的多,但是这样做会损害程序的模块性和抽象性。不过对于性能至关重要的程序,这也是很无奈的。所以最好将这样的修改写进文档,已备日后需要。
3) 消除不必要的存储器引用
看下面的代码:
这段代码的循环部分产生如下汇编代码。其中,%ecx指向data,%edx包含i的值,而%edi指向dest。
1 .L18: loop:
2 movl (%edi), %eax Read *dest
3 imull (%ecx, %edx, 4), %eax Multiply by data[i]
4 movl %eax, (%edi) Write *dest
5 incl %edx i++
6 cmpl %esi, %edx Compare i:length
7 jl .L18 if <, goto loop
可以看出,指令2读取dest中的值,指令4写回这个位置。这太浪费了,因为下一次迭代时指令2读取的值是刚刚写回的那个值,所以我们对其进行优化。我们引入一个临时变量x,用它来存放循环中计算出来的值。等循环完成后才将x的值放回dest处。代码如下:
combine_opt的循环体的汇编代码如下:
1 .L20 loop:
2 imull (%eax, %edx, 4), %ecx Multiply x by data[i]
3 incl %edx i++
4 cmpl %esi, %edx Compare i:length
5 jl .L20 if <, goto loop
改善很明显哈。使用这种方法,可以大幅度提高浮点数运算的效率,但是要注意副作用memory aliase。
4) 循环展开技术(loop unrolling)
循环展开是在每次迭代中执行更多的数据操作来减小循环开销的影响,其思想是在一次迭代中访问数组元素并做乘法,这样就降低了迭代次数,从而减小了循环开销。
例如对于上例的combine_opt,我们使用三次循环展开来优化。第一个循环一次处理数组的三个元素,也就是,循环索引i每次迭代会加3,而一次迭代中会对数组元素i,i+1,i+2进行合并操作。
通常,如果循环展开k次(即循环是stride-k的),我们就把上限设为 n - k + 1。要注意不要忘了第二个循环,它是处理剩下的那些元素的。计算k次循环展开的CPE有些复杂,需要分析其汇编源代码,我就不费那个脑子了,直接抄数据给你们看吧。
k 1 2 3 4 8 16
CPE 2.00 1.50 1.33 1.50 1.25 1.06
可以看到k为2时,主循环的每次迭代需要3个时钟周期,所以CPE为 3/2 = 1.5。
5) 用指针代替数组
实际上很早以前就有前辈用这种方法了。但是这种方法是以程序的可读性为代价的,并且其对机器、编译器的依赖很大,所以这不是个通用的方法。
我们修改上例中的代码combine_unrolled,将其写为指针版本。
在有些机器上,指针版本的代码确实变快了一点,但在另一些机器上却变慢了一点(每次迭代的周期数在0到2的差值范围内浮动),这确实很让人困惑。
6) 提高并行性
因为现在的处理器都是超标量多流水线的,所以尽量提高程序的并行性也是提高程序运行效率的一个非常好的手段。我们前面的例子的并行性其实并不好,因为我们将累积值放在一个单独的变量中,直到前面的计算完成之前,我们都不能计算x的新值(用循环展开也不能)。这时处理器会暂停,等待开始新的操作。
我们可以用循环分割(loop splitting)来实现我们的“梦想”。循环分割技术的思想是:对于一个可结合和可交换的合并操作,比如说整数加法或乘法,我们可以通过将一组合并操作分割成两个或多个部分,并在最后合并结果来提高性能。例如,Pn表示元素a(0),a(1),a(2),…,a(n-1)的乘积。假设n为偶数,我们可以把它写成
Pn = PEn * POn,这里PEn是索引值为偶数的元素的乘积,而POn是索引值为奇数的元素的乘积。于是我们得到两路并行的代码:
效率的提高不言而喻,但是很明显循环展开和分割在效率的提高上是有一个临界点的,超过这个极限,就会产生寄存器溢出的问题,影响效率的提升。循环分割的限制是由机器的硬件水平决定的,这个实在是没辙。
7) 充分利用处理器的cache
在计算机科学中,有一个很有名的不成文的规律叫 80/20 规律。例如在一个数据库中,80%的数据是常用数据,会被频繁访问;而剩下的20%就不是常用数据。于是乎,这个规律诞生了cache(缓存)这个东西,并且获得了巨大的成功。
利用cache需要对虚拟内存有很深的了解,例如一个cache的数据块是64个字节,那么下面两个循环的效率将会是天壤之别。
我们来分析一下。一个cache数据块是64个字节,也就是说刚好可以放下数组中一行数据。在第一个版本的内循环中,cache的命中率是 15/16 = 93.75%,而第二个版本的内循环就很让人郁闷,cache命中率为0,不慢才怪。至于版本1为什么快,而版本2为什么慢的跟乌龟似的,这实在不是一两句话就能说清楚的,建议你去看看相关的资料。还有,上例中数组和cache的大小是我假定的,为的是讲起来方便。实际情况还要看用户使用的机器的硬件情况,以写出通用性更好的代码。
本文要点总结:
1. 高级设计。为文体选择适当的算法和数据结构。要特别警觉,避免使用会渐进地产生糟糕性能的算法或编码技术。
2. 基本编码原则。避免限制优化的因素,这样编译器就能产生高效的代码。
·消除连续的函数调用。在可能是,将计算移到循环外(code motion)。考虑有选择的妥协程序的模块性以获得更大的效率。
·消除不必要的存储器引用。引入临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中。
3. 低级优化
·尝试各种与数组代码相对的指针形式。
·通过展开循环降低循环开销。
·通过诸如迭代分割之类的技术,找到使用流水线化的功能单元的方法。
4. 充分利用cache,使程序的运行效率得到保证。
最后,我再补充一句,小心优化副作用,也不要在优化上面浪费太多时间,实在不行就让编译器搞定(慎用编译器的优化选项)。