本文参考《深入理解计算机系统》中的第五章,本文中有不详细的地方请查看原书。
本文会出现部分汇编代码,以下为可能出现的优化。
1. 两个指针指向同一个位置
void twiddle1(int *xp, int *yp) //*xp进行加两次*yp
{
*xp += *yp;
*xp += *yp;
}
void twiddle2(int *xp, int *yp)
{
*xp += 2* *yp;
}
这两个程序似乎有相同的行为,他们将yp位置的值两次添加到xp位置。此时twiddle2效率更高,进行了三次存储器引用(读 *xp,读 *yp, 写 *xp),而twiddle1执行了两次twiddle2的操作,编译器会优化吗?
在gcc 下并没有进行优化(gcc -S 生成汇编,cat读取汇编代码)下面为汇编代码
twiddle1汇编
movl 8(%ebp), %eax ;取x
movl (%eax), %edx
movl 12(%ebp), %eax ; 取y
movl (%eax), %eax
addl %eax, %edx ; x+y
movl 8(%ebp), %eax
movl %edx, (%eax) ;放进x
movl 8(%ebp), %eax ; 再次进行上面的操作
movl (%eax), %edx
movl 12(%ebp), %eax
movl (%eax), %eax
addl %eax, %edx
movl 8(%ebp), %eax
movl %edx, (%eax)
movl 8(%ebp), %eax ;twiddle2汇编
movl (%eax), %edx
movl 12(%ebp), %eax
movl (%eax), %eax
addl %eax, %eax
addl %eax, %edx
movl 8(%ebp), %eax
movl %edx, (%eax)
上面的汇编代码省略了栈帧结构,可以看出twiddle1进行了多次存取,可见编译器并不会对其优化,因为编译器无法判断x和y的地址是否相同,如果是相同,那就有意思了,twddle1中*xp会翻四倍。因为编译器出于对程序员的不信任,优化总是小心而安全的。
- 程序示例
下面通过实例说明一个抽象的程序如何被转化成有效代码的下面为构造实例。
typedef int data_t;
#define IDENT 1
#define OP *
typedef struct
{
long int len;
data_t *data;
}vec_rec,*vec_ptr;
vec_ptr new_vec(long int len)
{
vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
if (!result)
return NULL;
result->len = len;
if (len > 0)
{
data_t *data = (data_t *)calloc(len, sizeof(data_t));
if(!data)
{
free((void *) result);
return NULL;
}
result->data = data;
}
else
result->data = NULL;
return result;
}
int get_vec_element(vec_ptr v, long int index, data_t *dest)
{
if (index < 0 || index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
long int vec_length(vec_ptr v)
{
return v->len;
}
void combine1(vec_ptr v, data_t *dest)
{
long int i;
*dest = IDENT;
for (i = 0; i<vec_length(v); i++)
{
data_t val = 0;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
此时combine1的汇编代码为
combine1:
pushl %ebp
movl %esp, %ebp
subl $28, %esp
movl 12(%ebp), %eax
movl $1, (%eax)
movl $0, -4(%ebp)
jmp .L16
.L17:
leal -8(%ebp), %eax
movl %eax, 8(%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
movl 8(%ebp), %eax
movl %eax, (%esp)
call get_vec_element ;外调用
movl 12(%ebp), %eax
movl (%eax), %edx
movl -8(%ebp), %eax
imull %eax, %edx
movl 12(%ebp), %eax
movl %edx, (%eax)
addl $1, -4(%ebp)
.L16:
movl 8(%ebp), %eax
movl %eax, (%esp)
call vec_length ;外调用
cmpl -4(%ebp), %eax
jg .L17
leave
ret
这段代码未经优化,编译器会为其做从C语言到机器代码的直接翻译,通常有明显的低效率,我们可以通过“-O1”命令进行优化(不放汇编了),接下来用更高级别的优化测试上面的代码。
combine1它每次循环都会对length求值,但是长度不变,所以可以进行以下优化:
void combine2(vec_ptr v, data_t *dest)
{
long int i;
long int 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;
}
}
此时汇编减少了一个跳转过程
.L16:
movl -8(%ebp), %eax
cmpl -4(%ebp), %eax
jl .L17
leave
ret
过程调用会带来相当大的开销,而且妨碍大多数形式的程序优化,从combine2代码看出,每次都会调用get_vec_element来获取下一个元素,我们可以做一个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];
}
}
此时的部分汇编代码
.L17:
movl 12(%ebp), %eax
movl (%eax), %edx
movl -20(%ebp), %eax
sall $2, %eax
addl -12(%ebp), %eax
movl (%eax), %eax
imull %eax, %edx
movl 12(%ebp), %eax
movl %edx, (%eax)
addl $1, -20(%ebp)
对比combine1可以看出又减少了一层循环调用,现在再次看combine3发现指针dest可能存在反复写入,浪费时间,(书上存在的,我并没有找到,有可能编译器对其进行了优化),将*dest放到外面用acc变量进行维护即可。
循环展开改善代码,提高并行性展开代码。
void combine5(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
data_t *data = get_vec_start(v);
long int limit = length - 1;
data_t acc = IDENT;
for(i = 0; i < limit; i += 2 ) //循环展开
{
//乘法后结合可以提高代码并行性。
acc = acc OP (data[i] OP data[i+1]);
}
for(; i<length;i++)
{
acc = acc OP data[i];
}
*dest = acc;
}
循环展开:它是一种程序变换,通过增加每次迭代计算的元素数量减少循环的次数,不过他也牺牲了一部分空间。
代码并行性:程序是受单元延迟限制的,但是加法和乘法完全是是流水化的,它们可以在每个周期开始一个新的操作。所以我们可以添加两个acc临时变量(没贴代码),这样可以使得运行速度增快。并且加法和乘法满足结合律,我们可以让本前两个结合转化为后两个结合,重新结合变换能够减少计算中关键操作的数量,通过更好的利用功能单元的流水线能力得到更好的性能。