优化程序性能

一般来说,程序优化主要是以下三个步骤:

  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处理器有条件传送指令可以替换条件控制,对于表达式简单的逻辑或者算术运算,可以通过条件传送的来实现条件分支,来提高程序的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值