【CSAPP笔记】10. 代码优化

时间:2020-11-27 20:10:08

写程序的主要目标是使它在所有可能的情况下都能正确运行(bug free),一个运行得很快但有 bug 的程序是毫无用处的。在 bug free 的基础上,程序员必须写出清晰简洁的代码,这样做是为了今后检查代码或修改代码时,其他人能够读懂和理解代码。另一方面,让程序运行得更快也是一个很重要的考虑因素。不过,程序获得最大加速比的时候,是它第一次运行起来的时候。

在提到优化程序性能时(Code optimization),我们往往会想到算法与数据结构中的一个概念——复杂度。事实上,除了算法复杂度之外,仍然有许多的代码书写小细节可以改进性能表现。不过,编写高效的程序,第一个考虑的还是选择一组合适的算法与数据结构,因为算法复杂度影响还是相当大的,而且通常比其他常量因素更重要。第二点,我们必须写出编译器能够有效优化以转换成高效可执行代码的源代码。对于第二点,理解程序是如何被编译和执行、理解处理器和存储器系统是如何运作的、理解编译器优化的局限性是很重要的。在程序开发过程中,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡,也就是在尽量不破坏程序的模块化和通用性的前提下,做到对代码性能的优化。

即使是最好的编译器也受到妨碍优化的因素(optimization blocker)的阻碍,程序员必须编写容易优化的代码,来帮助编译器(很让人眼界一新的观点)。研究程序的汇编代码,是理解编译器,理解代码如何被运行的最有效的手段之一。

理解编译器优化能力的局限性

编译器必须很小心地对程序使用安全的优化。在C语言标准的保证之下,编译器要确保优化后得到的程序和未优化的版本有着一样的行为(知道这个也就知道编译器不是万能的)看看下面两个过程:

void func1(int *xp, int *yp)
{
*xp += *yp;
*xp += *yp;
}

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

乍一看这两个过程似乎有相同的行为,且过程 func2 的效率更高一点,它虽然用到了乘法,但只需要三次存储器引用(读 *xp,读 *yp,写 *xp),而 func1 要用到六次存储器引用。那么编译器能不能把代码 func1 优化成 func2 呢?答案是否定的。当 xp 等于 yp 的情况下,func1 会把指针所指向的值增加 4 倍,而 func2 只会增加 3 倍。这就是一个优化前后程序行为不同的典型例子——两个指针指向同一个存储器位置的情况,叫做存储器别名使用(memory aliasing),这就造成了一个妨碍优化的因素——编译器不能确定两个指针是否指向同一个位置,那么它就必须假设可能会存在这种情况,限制了优化能力。所以程序员要编写帮助编译器的代码,帮助编译器产生高效的可执行代码。

代码移动

如果一个表达式总是得到同样的结果,最好把它移动到循环外面,这样只需要计算一次。编译器有时候会试图尝试代码移动,不过编译器会十分小心,它们不能确定移动一个函数的代码是否会有副作用,因此往往会假设会有副作用。所以程序员要手动帮助编译器来优化。

void set_row(double *a, double *b, int i, int n){
int j;
for (j = 0; j < n; j++){
a[n*i + j] = b[j];
}
}
// 这里 n*i 是重复被计算的,可以放到循环外面
void set_row(double *a, double *b, int i, int n){
int j;
int ni = n * i;
for (j = 0; j < n; j++){
a[ni + j] = b[j];
}

冗余的过程调用

看一个循环低效率,但编译器没办法优化的极端例子。下面这个函数的作用是将一个字符串中所有大写字母转换成小写字母

void my_lower(char *s)
{
int i;
for(i = 0; i < strlen(s); i++)
if(s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}

这段代码的问题在于,每次循环都要调用一遍 strlen。而strlen的实现基本类似于下面这个样子:

int strlen(const char *s)
{
while(*s != '\0'){
s++;
length++;
}
return length;
}

在理想情况下,我们可能认为编译器能够认为循环中的 strlen 每次都会返回相同的结果,因此能够将其优化,移出循环。然而很可惜的是,这样的分析远远超出了编译器的能力。很多时候只能靠程序员自己进行代码优化。每次调用 strlen 就是一次 O(n),n是字符串长度。my_lower的时间复杂度高达 O(n^2)。所以,一个看上去无足轻重的代码片段可能隐藏的渐进低效率。冗余的过程调用在字符串长度较低时毫无危险,但当应用到一个有一百万个字符的串上,突然,这段无危险的代码就会成为主要的性能瓶颈。

优化的方法就是让其计算一次就好:

void my_lower(char *s)
{
int i;
int len = strlen(s);
for(i = 0; i < len; i++)
if(s[i] >= 'A' && s[i] <= 'Z')
s[i] -= ('A' - 'a');
}

消除不必要的存储器引用

// 把 nxn 的矩阵 a 的每一行加起来,存到向量 b 中
void sum_rows1(double *a, double *b, int n)
{
int i, j;
for (i = 0; i < n; i++)
{
b[i] = 0;
for (j = 0; j < n; j++)
b[i] += a[i*n + j];
}
}

对应的汇编代码为

# sum_rows1 的内部 for 循环

.L4:

movsd (%rsi, %rax, 8), %xmm0 # 从存储器位置 b[i] 载入浮点数,%rsi 保存数组 b 的起始地址, %rax 保存 i
# %rdi 是 a[i*n+j] 的位置
addsd (%rdi), %xmm0 # 计算结果,放到%xmm0 是存放浮点数的寄存器
movsd %xmm0, (%rsi, %rax, 8) # 再把计算结果写会存储器位置
addq $8, %rdi
cmpq %rcx, %rdi
jne .L4

可以看到,每次都会把 b[i] 读入,写。但每次读入的时候,都是上次最后写入的值,这样的无用读写显得很浪费。我们能够消除这样的无用读写,引入一个临时变量,用来在循环中累计计算出来的值。只有在循环完成之后,才将结果写入存储器。

void sum_rows2(double *a, double *b, int n)
{
int i, j;
for (i = 0; i < n; i++)
{
double val = 0;
for (j = 0; j < n; j++)
val += a[i*n + j];
b[i] = val;
}
}

处理条件分支

在汇编语言的跳转时有说到,对于以流水线模式工作的CPU,遇到分支的时候,CPU必须预测分支往哪个方向走。如果预测失误,会导致很严重的性能惩罚。对于本质上无法预测的情况,如果编译器能产生使用条件数据传送而不是条件控制转移的代码,能极大提高程序的性能。

void minmax1(int a[], int b[], int n){
int i;
for(i = 0; i < n; i++)
{
int t = a[i];
a[i] = b[i];
b[i] = t;
}
}

//优化版本如下:
void minmax2(int a[], int b[], int n){
int i;
for(i = 0; i < n; i++)
{
int min = a[i] < b[i] ? a[i] : b[i];
int max = a[i] < b[i] ? b[i] : a[i];
a[i] = min;
b[i] = max;
}
}

参考链接