您的位置:首页 > 理论基础

《深入理解计算机系统 第3版》学习笔记——第5章 优化程序性能(程序优化方法总结)

2018-01-11 12:05 746 查看
        程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。这包括消除不必要的函数调用、条件测试和内存引用。这些优化不依赖目标机器的任何具体属性。了解了处理器的运作,就可以进行程序优化的第二步,利用处理器提供的指令级并行能力,同时执行多条指令。

        为了清晰地说明如何去优化一个程序,这里使用一个如下向量数据结构的运行示例。



        该数据结构定义如下:
        
/* 创建一个vector抽象数据类型 */
typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
        接着定义一些该数据类型支持的方法

        
/* 创建一个指定长度的vector */
vec_ptr new_vec(long len)
{
/* 为头部结构体分配内存 */
vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
data_t *data = NULL;
if (!result) return NULL;/* 头部创建失败 */
result->len = len;
if (len > 0)
{
data = (data_t*) calloc(len, sizeof(data_t));
if(!data)
{
free((void*) result);
return NULL;/* 存储空间分配失败 */
}
}
result->data = data;
return result;
}
        
/* 取vector元素并存在目标位置 */
/* 越界返回0,成功返回1 */
int get_vec_element(vec_ptr v, long index, data_t *dest)
{
if(index < 0 || index >= v->len) return 0;
*dest = v->data[index];
return 1;
}
        
/* 返回vector的长度 */
long vec_length(vector v)
{
return v->len;
}


1.消除循环的低效率
        考虑一段向量元素求和的函数
        
/* 求向量元素和 */
void combine_add1(vec_ptr v, data_t *dest)
{
long i;

*dest = 0;
for (i = 0; i < vec_length(v); i++)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}
        这个函数的功能非常简单,无非就是将传入的向量指针v所指向的vector头对应的各个元素相加,每加上一个元素,将其结果保存在*dest中。
        可以观察到,在求向量元素和的过程中combine_add1函数频繁调用了函数vec_length作为for循环的测试条件。向量的长度并不会随着循环的进行而改变,但是在这一段代码中每一次循环都需要对测试条件求值,这就做了很多的重复的无意义的工作。所以这里修改了另外一个版本combine_add2,只计算一次向量长度,保存在length中,以提升程序运行的效率。
        
/* 求向量元素和 */
void combine_add2(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);

*dest = 0;
for (i = 0; i < length; i++)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}
        这个优化叫做代码移动。这类优化主要解决那些需要执行多次但每次计算结果不会改变的计算。编译器会试着进行代码移动以优化程序,但是对于有些函数来说,调用次数不同或者调用位置不同会导致调用结果也有不同,如一个计算函数自身被调用次数的函数。编译器不能可靠的发现对某些函数进行代码移动是否会带来负面影响,那么它就不会对这段代码进行优化。为了改进代码,程序员必须经常帮助编译器显示地完成代码移动。
2.减少过程调用
        过程调用会带来时间开销。在combine_add2的代码中,每次
4000
循环迭代都会调用get_vec_element来获取下一个元素。每次调用这个函数都会把索引i与边界做比较,这就会造成程序运行效率低。这里增加一个函数get_vec_element,用于返回向量的首个元素的地址:
        
data_t *get_vec_start(vec_ptr v)
{
return v->data;
}
        如此一来可以把get_vec_element函数的调用替换为对数组元素的引用,具体实现代码如下:
        
/* 直接通过数组访问vector元素 */
void combine_add3(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);

*dest = 0;
for (i = 0; i < length; i++)
{
*dest = *dest + data[i];
}
}
        书上在这里给出的结论是,性能并没有明显的提升,甚至求和的性能还略有下降。看来在循环调用中,还存在着其他因素限制了程序的运行效率。那么是什么原因导致的呢?
        分析:现代处理器以指令流水线化的方式工作,当遇到分支的时候,处理器猜测分支该往哪个方向走。处理器会继续执行其预测的分支指令,分支结果确定之前,处理器会避免修改寄存器或内存值。如果预测正确,那么处理器就会提交执行的指令的结果,将其存储到寄存器或者内存中。如果预测错误,处理器必须丢掉这次投机执行的结果,并且跳转到正确位置重新开始取指令的过程,重新填充指令流水线。但是,这并不意味着所有的程序分支都会减缓程序的执行。在现代处理器中,分支预测的逻辑非常善于辨别不同的分支指令的长期趋势。例如在循环中,结束循环的分支通常被预测为选择分支,因此只有最后一次会导致预测错误。而在本例中,检测总是确定索引是在界内的,只有最后一次i越界了才会预测失败,是高度可预测的。所以代码避免边界比较并没有带来明显的效率提升。
        不是所有的检测都是高度可以测的,有时候甚至需要程序员编写好的代码来引导程序被翻译成尽可能可预测的汇编代码。这需要一些试验,写出函数的不同版本,然后检查产生的汇编代码,并测试性能。故而这依然是代码优化的重要步骤,这些步骤将最终产生显著的性能提升。
3.消除不必要的内存引用
        combine_add3的代码将累加值存放在dest这个指针所指向的位置,下图是编译器编译出来的汇编代码:
             


        可以看到在这个循环中,每次都要从dest指向的内存读出对应的数值,然后把新值又写入内存。考虑到每次读取的值都是上一次循环写入的值,所以这样的读写非常浪费时间。为了消除这种无意的内存读写,将代码重写,引入临时变量acc,用于累计计算结果,在循环执行完毕后再讲acc的值写入内存。
        
/* 累加结果存放在局部变量当中 */
void combine_add4(vec_ptr v, data_t, dest)
{
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = 0;

for (i = 0; i < length; i++)
{
acc = acc + data[i];
}
*dest = acc;
}
        减少了大量的内存读写,程序性能得到了极大的提高。
4.循环展开
        循环展开是一种程序变换,通过增加每次迭代计算的元素数量,减少循环的迭代次数。循环按照因子k展开,即是说每次循环索引增加k。这里展示一个k=2时的循环展开版本:
        
/* 按照因子k = 2展开的版本 */
void combine_add5(vec_ptr v, data_t *data)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc = 0;

/* 一次循环累加两个数 */
for (i = 0; i < limit; i+=2)
{
acc = (acc + data[i]) + data[i + 1];
}

/* 完成剩下的数的累加 */
for (; i < length; i++)
{
acc = acc + data[i];
}
*dest = acc;
}
        在本例中,由于减少了循环开销操作,所以性能有所提升。不过将求累和改为求累积的话,性能并不能有所提升。其原因在于求乘积的运算占用的时钟数比求和要长,导致在循环执行的过程中性能瓶颈主要在于求乘积而非循环开销。这说明具体问题还得具体分析,并不是说循环扩展就一定能够带来性能提升。书中给出了k取不同值以及采用其他combine方法下性能的结果对比,具体结论和分析见书P366-P369。
5.提高并行性
        在前面的四种优化方法中,程序都存在一个问题,即把累加值放在一个单独的变量acc中。在计算完成之前都不能计算acc的新值。为什么不设置多个acc变量,同时计算多个acc值呢?这里给出展开次数k = 2,即采用两个acc变量的程序版本:
        
void combine_add6(vec_ptr v, data_t *dest)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc0 = 0;
data_t acc1 = 0;

/* 一次处理两个元素 */
for (i = 0; i < limit; i++)
{
acc0 = acc0 + data[i];
acc1 = acc1 + data[i + 1];
}

/* 完成所有的数累加 */
for (; i < length; i++)
{
acc0 = acc0 + data[i];
}
*dest = acc0 + acc1;
}
        因为程序的性能受运算单元延迟限制,执行加法的功能单元是完全流水线化的,这意味着每个时钟周期可以开始一个新的操作,并且有些操作可以由多个运算单元执行。硬件具有更高的加法和乘法效率,所以程序员在编写代码的时候也应该尽可能使得代码能够向硬件的效率靠拢。充分利用各个运算单元,以提高性能。书中给出了k取不同值以及采用其他combine方法下性能的结果对比,具体结论和分析见书P370-P373。
        书中还给出了重新结合变换来极大提升性能。这里给出其源代码。详情可见书P373-P376。
        
/* 按照因子k = 2展开重新结合的改进版本 */
void combine_add7(vec_ptr v, data_t *data)
{
long i;
long length = vec_length(v);
long limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc = 0;

/* 一次循环累加两个数 */
for (i = 0; i < limit; i+=2)
{
acc = acc + (data[i] + data[i + 1]);
}

/* 完成剩下的数的累加 */
for (; i < length; i++)
{
acc = acc + data[i];
}
*dest = acc;
}
        可以看出,这里的combine_add7其实是combine_add5的一个改进版本。在累加的时候先求两个读取到的元素和,在把求和结果与acc变量求和。对于整数加法来说,这样的改进对性能影响并不大,因为整数加法的运算延迟比较小。但是对于整数乘法和浮点数加乘法来说性能确实质的改变。为什么有这样的现象呢,主要是因为虽然每次循环要做两次加/乘运算,但是只有其中一次加/乘法会依赖acc,这就导致从内存读取出来的两个数在相乘的时候并不会对数据数据相关链产生影响,可以并行执行,从而使效率提升一倍。本质上依然是一种提高并行性的优化方法。
总结
        这里主要是利用一个小程序总结了程序优化的一些方法,在实际的工程当中程序代码异常复杂,在优化的过程中一定不能影响程序的正确性。应该注意一些函数是否有副作用,有没有改变一些全局程序状态等。此外,对于复杂的工程,还需要一些测时间的工具和技术来确定应该主要优化哪些地方的代码。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: