一般来说,程序优化主要是以下三个步骤:
1. 高级设计 —— 算法和数据结构选择
2. 基本编码原则 —— 编码优化
3. 低级优化 —— 代码结构化
高级设计
算法的选择是必须首要考虑的,也是最重要的一步。一般我们需要分析算法的时间复杂度,即处理时间与输入数据规模的一个量级关系,一个优秀的算法可以将算法复杂度降低若干量级,那么同样的实现,其平均耗时一般会比其他复杂度高的算法少(这里不代表任意输入都更快)。
比如说排序算法,快速排序的时间复杂度为O(nlogn),而插入排序的时间复杂度为O(n^2),那么在统计意义下,快速排序会比插入排序快,而且随着输入序列长度n的增加,两者耗时相差会越来越大。但是,假如输入数据本身就已经是升序(或降序),那么实际运行下来,快速排序会更慢。
而往往算法的选择必然和数据结构联系在一起,即时间和空间的选择。同样是排序算法,快速排序和堆排序的时间复杂都是O(nlogn),但是快速排序使用线性表,而堆排序使用小根堆(或大根堆),很明显大根堆的空间复杂度要大得多,维持这样一个数据结构代价也更大。
基本编码原则
选择了合适的算法和数据结构之后,在对源代码的修改中,首先要去除一些不必要的工作,如不必要的函数调用和内存引用,让代码尽可能有效地执行所期望的任务。
消除连续的函数调用
函数的调用会引起参数的入栈出栈,返回地址的入栈出栈,对调用函数的数据的保存,这些都是一次函数调用的开销。复杂参数的拷贝可能造成更大代价。
定义一个结构data:
struct data {
int length;
int* array;
};
//获取数据的函数
int get_length(data* a) const { return a->length; }
int* get_array(data* a) const { return a; }
int get_element(data* a, int index, int length) {
if(index < 0 || index >= length)
retun -1;
return a->array[i];
}
考虑以下程序:将线性表的元素的累积在dst内存处。
void multiply_1(int* a int length, int* dst) {
for(int i = 0; i < get_length(a); ++i) {
*dst = *dst * get_element(a, i, get_length(a));
}
}
很明显multiply_1的代码中,有两处的调用太碍眼了的:循环中的get_length函数调用和get_element函数调用。中对边界的检查也是多余的,所以可以消除这种连续的函数调用。
void multiply_2(data* a, int* dst) {
int length = get_length(a);
int *array = get_array(a);
for(int i = 0; i < length; ++i) {
*dst = *dst * array[i];
}
}
消除不必要的内存引用
观察multiply_2的代码,每次循环要先从dst内存中读取值,再将和array[i]的积写到dst内存处。因此,每次循环都包括一次读和一次写内存,而内存的读写也是有开销的。因此,我们可以消除这种不必要的内存引用。
void multiply_3(data* a, int* dst) {
int length = get_length(a);
int *array = get_array(a);
int product = 1;
for(int i = 0; i < length; ++i) {
product = product * array[i];
}
*dst = product;
}
程序员必须自己消除这种影响,因为编译器只能对程序小心地进行安全的优化。内存别名引用和函数调用一样,妨碍着编译器的优化。
比如,dst和array指向同一个内存地址,即使内存名字不一样,也会造成完全不同的结果。
低级优化
要理解低级优化,必须要熟悉处理器的流水线体系结构。处理器执行指令并不是一条一条完成,而是将每一条指令划分成若干个阶段(以简单的处理器为例):
PC:确定指令的地址,包括顺序语句的下一条,跳转指令,return指令等,有可能错误,但后续能修正。
取指:读取指令内容。
译码:翻译指令码确定要做的行动。
执行:执行行动,如算术运算。
访存:从内存中读取数据,或者将数据写入内存。
写回:将数据写到寄存器文件。
每一个阶段都有不同的硬件单元来完成,而这些单元之间有着复杂的互动和反馈机制。所以,一条指令没有完成,下一条指令已经开始,这样处理器同一个周期可以处理多个指令,从而提高了处理器的效率。
关于流水线结构和顺序结构执行循环的区别如图所示,ii值是每个循环指令的执行时间。
关于这个知识点的内容可参考CSAPP第四章·处理器体系结构。
循环展开
循环展开有两个好处:
1、减少不直接有助于程序结果的操作的数量,例如循环索引的计算和条件分支;
2、它提供了通过指令并行进一步优化代码的方法,减少关键路径上的操作数量。
void multiply_4(data* a, int* dst) {
int length = get_length(a);
int *array = get_array(a);
int product = 1;
int limit = length - 1;
int i;
for(i = 0; i < limit; i += 2) {
product = (product * array[i]) * array[i + 1];
}
for(; i < length; ++i) {
product = product * array[i];
}
*dst = product;
}
并行计算
观察程序multiply_3,每一次计算product,必须要等到前一次计算完成,处理器的流水线势必要暂停等待,这就造成性能的损失。循环展开中提到的关键路径指的就是循环计算product,因为product存在着依存的关系,便形成了一条路径,而这条路径是限制程序性能的关键,故称为关键路径。
累积变量
对于一个可交换和结合的合并运算来说,可以通过将一组合并分解成多组进行,这样形成多条关键路径,处理器可以同时处理这些关键路径,从而提高程序的性能。
下面的程序是对循环进行两路并行计算:
void multiply_5(data* a, int* dst) {
int length = get_length(a);
int *array = get_array(a);
int product0 = 1;
int product1 = 1;
int limit = length - 1;
int i;
for(int i = 0; i < limit; i += 2) {
product0 = product0 * array[i];
product1 = product1 * array[i + 1];
}
for(; i < length; ++i) {
product0 = product0 * array[i];
}
*dst = product0 * pruduct1;
}
可以增大并行路数,使得所有功能单元的流水线都是满的,这样能得到最好的程序性能。但是有一点需要指出的是,对于不能结合或者交换的数据类型或运算,比如浮点数的乘法和加法,由于四舍五入和溢出的问题,使得如上代码的优化不一定能得到相同的结果,需要格外小心。大部分编译器不会对浮点数代码自动进行这种优化。
重新结合
和累积变量的思想一样,都是通过指令级并行,提高程序性能。
void multiply_6(data* a, int* dst) {
int length = get_length(a);
int *array = get_array(a);
int product = 1;
int limit = length - 1;
int i;
for(i = 0; i < limit; ++i) {
product = product * (array[i] * array[i + 1]);
}
for(; i < length; ++i) {
product = product * array[i];
}
*dst = product;
}
和multiply_4类似,通过循环展开,但是区别是结合不一样,使得整个循环中依赖前一个计算结果的次数减半,从而程序性能得到提高。同样,可以将循环展开幅度变大,总的依赖次数也将进一步减少,程序性能进一步提高,最后当满流水线运行时达到最大值。
使用向量指令
Inter在1999年引入了SSE指令(Streaming SIMD Extensions,流SIMD拓展),即单指令多数据的拓展指令集。SSE经过几代的发展,最新的版本为高级向量拓展(advanced vector extension),通过向量寄存器对向量数据进行操作。目前的AVX向量寄存器长为32个字节,可以并行处理8组或4组数值。程序代码可以被编译成AVX的向量指令,对性能的提升可以达到4倍或8倍的提升。
条件数据传送
对于条件分支,处理器采用分之预测的办法预测下一条指令的位置,通常通过良好地设计处理器分之预测逻辑,可以使得预测成功率达到50%以上,但是如果错误,将招致严重的惩罚:程序性能大大降低。因为,处理器流水线技术会通过插入气泡的形式纠正这种错误,但是对于周期的浪费也是不可避免的。
作为替代方法,最近的X86处理器有条件传送指令可以替换条件控制,对于表达式简单的逻辑或者算术运算,可以通过条件传送的来实现条件分支,来提高程序的性能。