关于程序的性能优化-part2


实例

为了说明一个抽象程序是如何被系统转换为更有效地代码的,之后的例子我们将使用如下的向量数据结构的运行示例,向量由两个内存块表示:头部和数据数组。
头部是一个声明如下的结构:

/* Create abstract data type for vector */
typedef struct {
    long len;
    data_t *data;
} vec_rec, *vec_ptr;

这个声明用data_t来表示基本元素的数据类型。在测试中,我们度量代码对于整数和浮点数数据的性能。为此,会分别为不同的类型声明编译和运行程序,像下面这个例子对数据类型long一样:

typedef long data_t;

我们还会分配一个len个data_t类型对象的数组,来存放实际的向量元素。

如下给出的是一些生成向量,访问向量元素以及确定向量长度的基本过程。值得注意的是向量访问函数get_vec_element,它会对每个向量引用进行辩解检查。类似于很多其它语言所使用的数组表示法。边界检查降低了程序出错的机会,但会减缓程序的执行。

向量抽象数据类型的实现,在实际的程序中,数据类型data_t会被声明为明确的数据类型如int或float:


/* Create vector of specified length */
vec_ptr new_vec(long len)
{
    /* Allocate header structure */
    vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
    data_t *data = NULL;
    if (!result)
        return NULL;  /* Couldn't allocate storage */
    result->len = len;
/* $end vec */
    /* We don't show this in the book */
    result->allocated_len = len;
/* $begin vec */
    /* Allocate array */
    if (len > 0) {
        data = (data_t *)calloc(len, sizeof(data_t));
	if (!data) {
	    free((void *) result);
 	    return NULL; /* Couldn't allocate storage */
	}
    }
    /* data will either be NULL or allocated array */
    result->data = data;
    return result;
}

/* Free storage used by vector */
void free_vec(vec_ptr v) {
    if (v->data)
	free(v->data);
    free(v);
}

/*
 * Retrieve vector element and store at dest.
 * Return 0 (out of bounds) or 1 (successful)
 */
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;
}

/* Return length of vector */
long vec_length(vec_ptr v)
{
    return v->len;
}

作为一个优化的示例,如下的代码会使用某种运算,将一个向量中所有的元素合并为一个值,通过使用编译时常数IDENT和OP的不同定义,这段代码可以重编译为对数据进行不同的运算。
这是一个合并运算的初始实现,通过定义IDENT和OP,可以用来测试该函数对不同运算的性能:

/* Implementation with maximum use of data abstraction */
void combine1(vec_ptr v, data_t *dest)
{
    long i;

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

在之后我们会对这段代码进行一系列的优化,为评估性能优化,会在一个参考机上测量函数的CPE性能。
以下是combine1的CPE度量值

函数

方法

整数

浮点数

+             *

+             *

combine1

combine1

抽象的未优化的

抽象的-O1

22.68        20.02

10.12        10.12

19.98       20.18

10.17       11.14

未经优化的代码是从C语言代码到机器代码的直接翻译,通常效率较低,简单的使用命令行选项“-O1”,就会进行一些基本的优化,这样就可以显著提升性能。

消除循环的低效率

可以观察到,过程combine1调用函数vec_length作为for循环的测试条件。在循环中,每次循环迭代时都必须对测试条件求值。另一方面,向量的长度并不会随着循环的进行而改变。因此,我们只需要计算一次向量的长度,然后在我们的测试条件中都会使用这个值。

以下是一个修改了的版本,称为combine2,它在开始时就调用了vec_length,并且将结果赋值给局部变量length。对于某些数据类型和操作,这个变换明显地影响了某些数据类型和操作的整体性能,对于其他的则很小甚至没有影响。无论哪种情况,都需要这种变化来消除这个低效率,这有可能称为尝试进一步优化时的瓶颈。

改进循环测试的效率,通过把vec_length的调用移出循环,不需要每次迭代都执行该函数。

void combine2(vec_ptr v, data_t *dest)
{
    long 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;
    }
}

函数

方法

整数

浮点数

+             *

+             *

combine1

combine2

抽象的-O1

移出vec_length

10.12        10.12

7.02          9.03 

10.17       11.14

9.02        11.03

这是一类常见的优化,称为代码移动(code motion)。这类优化需要识别执行多次但是计算结果不会改变的计算。因而可以将计算移动到代码前面不会被多次求值的部分。

优化编译器会试着进行代码移动。不过,对于会改变在哪里调用函数或调用多少次的变换,编译器会非常小心。他们不能够可靠的发现一个函数是否会有副作用,因而会假设函数有副作用。例如,vec_length函数由某周副作用,那combine1和combine2可能就会有不同的行为。为了改进代码,程序员一般要显式的完成代码移动。

减少过程调用

如我们所知,过程调用会带来开销,而且妨碍大多数形式的程序优化。从combine2的代码可以看出,每次循环都要调用get_vec_element来获取下一个向量元素。对于每个向量调用,这个函数要把向量索引i与循环边界做比较,很明显会造成低效率。在处理任意的数组访问时,边界检查是很有用的特性,但是对combine2代码的简单分析表明所有的引用都是合法的。

作为替代,假设为我们的抽象数据类型增加一个函数get_vec_start。这个函数返回数组的起始地址。之后我们可以写出图三的函数,其内循环里没有函数调用。它没有用函数调用来获取每个向量元素,而是直接访问数组。虽说这种变换损害了程序的模块性,但是在获取高性能结果的路上,这是必要步骤。

data_t *get_vec_start(vec_ptr v)
{
    return v->data;
}
void combine3(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);


    *dest = IDENT;
    for (i = 0; i < vec_length(v); i++) {
        *dest = *dest OP data[i];
    }
}

函数

方法

整数

浮点数

+             *

+             *

Combine2

Combine3

移出vec_length

直接数据访问

7.02          9.03 

7.17          9.02 

9.02        11.03

9.02        11.03

ok,我们可以看到,性能提升和理论配合的并不是很好,显然内循环中有其他的操作形成了瓶颈,限制性能超过了调用get_vec_element。我们继续做优化。

消除不必要的内存引用

combine3的代码将合并运算计算的值累积在dest指定的位置。在dest指针地址存放在寄存器中时,考虑到底层汇编,累计变量的数值都需要从内存读出再写入到内存。这样的读写是很浪费的。为了消除这种不必要的读写,我们按如下生成了combine4的代码。
把结果累积在临时变量中,将累积值存放在局部变量acc中,消除了每次循环迭代中从内存中读出并将更新值写回的需要:

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
}

函数

方法

整数

浮点数

+             *

+             *

Combine3

Combine4

直接数据访问

直接数据访问

7.17          9.02  

1.27          3.01 

9.02        11.03

3.01         5.01

在这操作之后,我们可以看到程序的性能有了显著的提升。


文章作者: RickDamon
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 RickDamon !
  目录