回到首页

输入关键词,多个关键词用空格分隔。

← CSAPP 原始课程笔记 · 系列目录

深入理解计算机系统 第 9 课 Program Optimization

约 5 分钟
本页目录

这一讲可谓是全书最实用的部分,介绍如何基于代码执行的机制,去优化我们的代码。


通用代码优化技巧

即使是常数项系数的操作,同样可能影响性能。性能的优化是一个多层级的过程:算法、数据表示、过程和循环,都是需要考虑的层次。于是,这就要求我们需要对系统有一定的了解,例如:

  • 程序是如何编译和执行的
  • 现代处理器和内存是如何工作的
  • 如何衡量程序的性能以及找出瓶颈
  • 如何保持代码模块化的前提下,提高程序性能

最根源的优化是对编译器的优化,比方说再寄存器分配、代码排序和选择、死代码消除、效率提升等方面,都可以由编译器做一定的辅助工作。

但是因为这毕竟是一个自动的过程,而代码本身可以非常多样,在不能改变程序行为的前提下,很多时候编译器的优化策略是趋于保守的。并且大部分用来优化的信息来自于过程和静态信息,很难充分进行动态优化。

接下来会介绍一些我们自己需要注意的地方,而不是依赖处理器或者编译器来解决

代码移动

如果一个表达式总是得到同样的结果,最好把它移动到循环外面,这样只需要计算一次。编译器有时候可以自动完成,比如说使用 -O1 优化。一个例子:

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

这里 n*i 是重复被计算的,可以放到循环外面

long j;
int ni = n * i;
for (j = 0; j < n; j++){
    a[ni + j] = b[j];
}

减少计算强度

用更简单的表达式来完成用时较久的操作,例如 16*x 就可以用 x << 4 代替,一个比较明显的例子是,可以把乘积转化位一系列的加法,如下:

for (i = 0; i < n; i++){
    int ni = n * i;
    for (j = 0; j < n; j++)
        a[ni + j] = b[j];
}

可以把 n*i 用加法代替,比如:

int ni = 0;
for (i = 0; i < n; i++){
    for (j = 0; j < n; j++)
        a[ni + j] = b[j];
    ni += n;
}

公共子表达式

可以重用部分表达式的计算结果,例如:

/* Sum neighbors of i, j */
up =    val[(i-1)*n + j  ];
down =  val[(i+1)*n + j  ];
left =  val[i*n     + j-1];
right = val[i*n     + j+1];
sum = up + down + left + right;

可以优化为

long inj = i*n + j;
up =    val[inj - n];
down =  val[inj + n];
left =  val[inj - 1];
right = val[inj + 1];
sum = up + down + left + right;

虽然说,现代处理器对乘法也有很好的优化,但是从 3 次乘法优化到 1 次,总是不错的主意

阻碍代码优化的因素

过程调用

我们先来看一段代码,找找有什么问题:

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

问题在于,在字符串长度增加的时候,时间复杂度是二次方的!

问题就在于,每次循环中都会调用一次 strlen(s),而这个函数本身需要通过遍历字符串来取得长度,因此时间复杂度就成了二次方。

可以怎么优化呢?简单,那么只计算一次就好了:

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

于是曲线图就成了这样,变成线性时间:

有的同学可能就问了,为什么编译器不能自动把这个过程调用给移到外面去呢?

前面说过,编译器的策略必须是保守的,因为过程调用之后所发生的事情是不可控的,所以不能直接改变代码逻辑,比方说,假如 strlen 这个函数改变了字符串 s 的长度,那么每次都需要重新计算。如果移出去的话,就会导致问题。

所以很多时候只能靠程序员自己进行代码优化。

内存问题

接下来我们看另一段代码和由编译器生成的对应汇编:

可以看到在汇编中,每次都会把 b[i] 存进去再读出来,为什么编译器会有这么奇怪的做法呢?

因为有可能这里的 a 和 b 指向的是同一块内存地址,那么每次更新,都会使得值发生变化:

但是中间过程是什么,实际上是没有必要存储起来的,所以我们引入一个临时变量,这样就可以消除 Memory Aliasing 的问题

处理条件分支

这个问题,如果不是对处理器执行指令的机制有一定了解的话,可能会难以理解。

现代处理器普遍采用超标量设计,也就是基于流水线来进行指令的处理,也就是说,当执行当前指令时,接下来要执行的几条指令已经进入流水线的处理流程了。

这个很重要,对于顺序执行来说,不会有任何问题,但是对于条件分支来说,在跳转指令时可能会改变程序的走向,也就是说,之前载入的指令可能是无效的。这个时候就只能清空流水线,然后重新进行载入。处理器内部会采用称为『分支预测』的技术:

比方说在一个循环中,根据预测,可能除了最后一次跳出循环的时候会判断错误之外,其他都是没有问题的。这就可以接受,但是如果处理器不停判断错误的话(比方说代码逻辑写得很奇怪),性能就会得到极大的拖累:

分支问题有些时候会成为最主要的影响性能的因素,但有的时候其实很难避免。

总结

  • 用好编译器及其不同的参数设定
  • 不要做蠢事
    • 注意算法可能隐藏的低效
    • 写对编译器友好的代码,尤其是过程调用和内存引用
    • 注意内层循环
  • 根据机器来优化代码
    • 利用指令级并行
    • 避免不可以预测的分支
    • 有效利用缓存

相关文章