优化程序性能 --《深入理解计算机系统》


这里优化程序性能的目的是通过手工改变代码结构,而不是算法效率和数据结构优化,有些编译器在某些优化选项下可能会做出类似的改动。

1. 优化编译器的能力和局限性

现代编译器大多数都向用户提供了对优化的控制,最简单的就是指定优化级别,如-O1(基本的优化),-O2,-O3(更全面的优化),这里主要考虑限制优化级别1编译出的代码,其实优化级别1不一定性能就不如2和3。编译器必须很小心地对程序进行优化,因为要保证程序的正确性,为了理解决定一种程序转换是否安全的难度,看一下下面的例子:

void twiddle1(int *xp,int *yp)
{
	*xp+=*yp;
	*xp+=*yp;
}
void twiddle2(int *xp,int *yp)
{
	*xp+=2* *yp;
}

可以看出,这两个过程行为相同,twiddle2效率更高一些,因为它只要求3次存储器引用(读xp,读yp,写*xp),而twiddle1需要6次,如果我是编译器我肯定会选择把twiddle1转换为twiddle2的过程,但真的是这样吗?
如果xp等于yp,两个过程的计算结果就会不一样,twiddle1:

	*xp+=*xp;//*xp变为2*xp
	*xp+=*xp;//*xp变为4*xp

而twiddle2:

	*xp+=2* *xp;//*xp变为3*xp

由于编译器不知道twiddle1会如何被调用,因此它必须考虑xp=yp的情况,所以不能优化为twiddle2的代码。
这种两个指针可能指向同一个存储器位置的情况叫存储器别名使用,在只执行安全的优化中,编译器必须考虑这种情况。
还有一个妨碍优化的因素是函数调用。看下面的例子:

int f();
int fun1()
{ return f1()+f1()+f1()+f1(); }
int fun2()
{ return 4*f1(); }

从数学的角度,fun1和fun2看上去会产生相同的结果,但fun2只调用了f一次,执行fun1时会很想产生fun2这样的代码。但是如果f操作了全局变量呢?

int count=0;
int f()
{ return count++; }

这样fun1会返回0+1+2+3=6,而fun2返回0 。编译器会假设最糟的情况,并保持所有的函数调用不变。
包含函数调用的代码可以用内联函数替换的过程进行优化,比如刚刚的fun1,可以替换成:

int fun1()
{
	int t=count++;
	t+=count++;
	t+=count++;
	t+=count++;
	t+=count++;
	return t;
}

这样就减少了函数调用的开销。

2. 表示程序性能

引入每元素周期数 CPE来度量程序性能
处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒来表示。例如,当表明一个系统有“4GHz”处理器,这表示处理器时钟运行频率为 4*109千兆赫兹。每个时钟周期的时间是时钟频率的倒数。通常用纳秒(nanosecond,1 纳秒等于10-9秒),或者皮秒(picosecond,1 皮秒等于10-12秒)来表示,一个 4GHz 的十周周期为0.25纳秒,或者说250皮秒。从程序员的角度来看,用时钟周期来表示度量标准要比用纳秒或者皮秒来表示有用的多。
用时钟周期来表示,度量值表示的是执行了多少条指令,而不是时钟运行的有多快。

3. 优化方法

下面用一个向量的例子说明程序如何被系统转换为更有效的代码。

typedef struct {
    long len;
    data_t *data;//data_t分别声明为不同类型
} vec_rec, *vec_ptr;
//生成向量
vec_ptr new_vec(long len)
{
    /* Allocate header structure */
    vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
    if (!result)
        return NULL; /* Couldn’t allocate storage */
    result->len = len;
    /* Allocate array */
    if (len > 0) {
        data_t *data = (data_t *)calloc(len, sizeof(data_t));
        if (!data) {
            free((void *) result);
            return NULL; /* Couldn’t allocate storage */
        }
        result->data = data;
    }
    else
        result->data = NULL;
    return result;
}
//根据所以访问向量元素
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;
}
//获取向量长度
long vec_length(vec_ptr v)
{
    return v->len;
}

下面的代码使用某种运算,将一个向量中所有的元素合并成一个值,运算可以是#define IDENT 0 #define OP *#define IDENT 1 #define OP +

void combine1(vec_ptr v, data_t *dest)
{
    long int i;
    *dest = IDENT;
    for (i = 0; i < vec_length(v); i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OP val;
    }
}

3.1 消除循环的低效率

下面来看看combine1存在什么性能上的问题。在combine1中,调用函数vec_length作为for循环的测试条件,每次循环都必须对测试条件求值,而且向量的长度并不会变,因此我们只需要计算一次向量的长度,然后在测试条件中都使用这个值。如combine2所示,这种例子叫做代码移动,即将要执行多次,但计算结果不会改变的计算移到前面不会被多次求值的部分。编译器未必能做出这样的优化,因此需要程序员自己手动进行这样的变换。

void combine2(vec_ptr v, data_t *dest)
{
    long int i;
    long length = vec_length(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OP val;
    }
}

3.2 减少过程调用

combine2中,每次循环都要调用get_vec_element获取下一个元素,这个函数要把i和循环边界做比较。作为替代,如果增加一个get_vec_start函数,它返回数组的起始地址,然后就能用下面combine3这样的过程,这样循环里就没有函数调用,而是直接访问数组。

data_t *get_vec_start(vec_ptr v)
{
	return v->data;
}
void combine3(vec_ptr v, data_t *dest)
{
    long int i;
    long int length = vec_length(v);
    data_t *data = get_vec_start(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        *dest = *dest OP data[i];
    }
}

3.3 消除不必要的存储器引用

在循环代码中,指针dest的地址存放在寄存器中,在第i次迭代中,程序读出这个位置处的值,乘以data[i],再将结果存回到dest,这样的读写很浪费,因为每次迭代开始从dest读出的值就是上次迭代最后写入的值。我们可以通过引入一个临时变量acc累积计算出来的值,在循环完成后再将acc的值写入dest。

void combine4(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    for (i = 0; i < length; i++) {
        acc = acc OP data[i];
    }
    *dest = acc;
}

可能有人会以为编译器会做这样的优化,但想想之前讲过的存储器别名使用的问题。举个例子,IDENT=1,OP为*,v=[2,3,5],比较combine3和combine4的执行情况。

combine3(v,get_vec_start(v)+2);
combine4(v,get_vec_start(v)+2);

结果如下表所示。为什么呢?因为combine3的*dest,也就是v的最后一个元素,是一直在变化的,导致中间计算时的数也是变化的,并不是初始的那三个元素了;而combine4则是最后计算结束才改变*dest的值。
在这里插入图片描述

4. 循环展开

循环展开就是增加每次迭代计算的元素的数量,减少循环的迭代次数。

void combine5(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 acc = IDENT;
    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = (acc OP data[i]) OP data[i+1];
    }

    /* Finish any remaining elements */
    for (; i < length; i++) {
      acc = acc OP data[i];
    }
    *dest = acc;
}

这个优化措施利用了对CPU数据流的知识,比汇编代码更接近机器底层。简单地说是利用了CPU的并行性,将数据分成不相关的部分并行地处理。

5. 提高并行性

和版本5的思想类似,但由于并行化更高,性能更好一些,充分利用了向量中各个元素的不相关性。
版本6使用多个累积变量方法,将一运算分割成两个或更多的部分,在最后合并结果。

/* Unroll loop by 2, 2-way parallelism */
void combine6(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 = IDENT;
    data_t acc1 = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
    acc0 = acc0 OP data[i];
    acc1 = acc1 OP data[i+1];
    }
    /* Finish any remaining elements */
    for (; i < length; i++) {
    acc0 = acc0 OP data[i];
    }
*dest = acc0 OP acc1;
}

版本7是在版本5的基础上重新结合变换,打破顺序相关,改变了并行执行的操作数量。差别只在于两个括号是如何放置的

void combine7(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 acc = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = acc OP (data[i] OP data[i+1]);
    //in combine5:
    //acc = ( acc OP data[i]) OP data[i+1];
    }

    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc OP data[i];
    }
    *dest = acc;
}

整理自书《深入理解计算机系统》

猜你喜欢

转载自blog.csdn.net/weixin_43927408/article/details/88065644