写程序最主要的目标是使它在任何情况下都能正确工作,但在很多情况下,让程序运行的快也是一个重要的因素。编写高效程序要做到一下几点:
必须选择一组适当的数据结合和算法;
编写出编译器能有效优化的源代码;
编写适合不同处理器架构和性能的代码。
当然,有人可能会说,优化代码可以依靠编译工具(如GCC)的优化选项,为什么还需要我们自己亲自干呢?有这种疑问的请继续阅读,没有疑问的可以跳过第0节。
. 为什么不能完全依靠编译器的自动优化?
编译器必须很小心的对程序只使用安全的优化,即对于程序可能遇到的所有情况,在优化前后必须得到相同的结果。为了解释一种程序转换是否安全,我们先看一个例子:
这两个程序似乎表达同一个意思,而且第2个会更高效一点。因为第一个程序总共要进行6次内存引用,而第二个只需要3次内存引用。
由于在优化之前,编译器会考虑所有可能的情况,其中一种情况就是“内存别名使用”。此时,第一个函数会执行以下的计算:
结果是xp的值增加4倍。另一方面,第二个函数twiddle_2会执行以下的计算:
结果是xp的值增加3倍。但编译器并不知道你会如何使用第一个函数twiddle_1,保险起见它不能将函数1优化成函数2的样子。所以我们不能完全依靠编译器的自动优化工具来对自己的代码进行优化,而是要结合代码实际使用情况,有针对性的结合几种优化原则,来帮助编译器对代码进行无差错优化。
1. 减少函数调用次数
循环语句中的代码往往要执行多次,它的高效与否往往决定了整个程序的性能瓶颈。所以在循环语句中,我们要尽可能的降低其运行消耗(时间消耗内存消耗)。先看一段代码:
第一个函数中,每次循环都要调用函数strlen一次,而函数调用所花费的性能代价是很大的。第二个函数在循环展开之前就把字符串的长度存在了一个标量中,以后循环中就使用该标量值来代替调用函数strlen,而从寄存器中读取一个值相比于调用一个函数来获得值无论在时间上还是存储开销上都大大降低。实验可知,当字符串长度为100万字符时,函数lower_1需要花费1000秒的时间,而函数lower_2仅需要2毫秒,性能提升近50万倍!
2. 减少内存引用次数
加载或读取内存地址是非常耗时耗力的,虽然现在绝大多数处理器都使用了缓冲技术来减少程序运行中的内存引用的时间开销,但相比于直接读取寄存器它的速度还是相当相当慢的。看下面一个例子:
第一个函数中,每次循环都要对内存地址dest进行一次读写;而第二个函数巧妙的设定了一个局部变量用于循环内的计算,当循环结束在将结果放到指定的内存地址dest处。
3. 循环展开
3.1 减少循环次数
下面一段程序将上节中的函数func2中的for语句进行了“2x1”循环展开:
当然,我们也可以进行“kx1”循环展开,
3.2 提高并行性
随着集成度的提高,现代处理器一般都具有多核结构,或者支持超标量技术(SIMD),这就给我们程序员编码提供了另一种选择,利用编写并行性较高的程序 来达到提高运行速度的目的。
我们将func3函数重新编写成“2x2”循环展开形式:
相应的“k x k”模式,相比大家应该知道怎么变了吧?此处k的最大取值取决于你程序运行所在处理器中寄存器的个数和流水线级数。一般情况下k取10以内问题都不大。
3.3 重新结合变换
这是一种利用改变结合变换达到提高并行性目的的方法。
正如以上代码所示,func5仅比func3在循环语句中添加了一对括号,从而改变了该语句执行时的顺序,产生了我们称为“2x1a”的循环展开形式。
对于刚接触C的来说,这两个函数看上去本质是一样的,但是当我们运用性能分析工具gprof来比较这两个函数的时候,会得到令人吃惊的结果:2x1a的性能几乎等同于2x2的性能。
考虑v = v * x * y * z;如何添加括号,以达到最优性能呢?
答案:v = v * ((x * y) * z)或v = v * (x * (y * z))
4. 用条件送传指令代码代替分支语句
总所周知,现代处理器普遍具有分支预测功能,它为我们程序的快速运行提供了不小的功劳。例如在预测循环测试条件时,我们处理器总是判定条件成立(当然,程序运行的绝大部分时间也确实如此),只有在最后一次时,我们才会预测错误。而这时付出的代价和前面所带来的收益可以忽略不记。
因为绝大部分循环条件测试的结果都是显而易见的,我们可以忽略分支预测错误的惩罚。但在一些场合(比较大小),他的测试结果是随机的,此时我们就不得不考虑分支预测错误时所带来的惩罚了。为此有没有一种方法来避免使用条件分支呢?答案看码!
5.总结
上述总总描述了许多优化程序性能的基本策略:1. 高层设计
为遇到的问题选择适当的算法和数据结构。
2.基本编码原则
消除连续的函数调用,有可能时将计算或访存移到循环外。
消除不必要的内存引用,引入临时变量来保存中间结果,在最后的值计算出来时,才存放到数组或全局变量中。
3.底层优化
展开循环,降低开销,并且使得进一步的优化成为可能。
通过多个累计变量和重新结合等技术,找到提高指令级并行。
用条件传送指令代替条件语句,使得减少分支预测错误的惩罚。