一、优化编译器的能力和局限性
1 void twiddle1(long *xp , long *yp) {
2 *xp += *yp;
3 *xp += *yp;
4 }
5
6 void twiddle2(long *xp , long *yp) {
7 *xp += 2 * *yp;
8 }
上面的两段代码中,函数twiddle2 的效率更高,因为它只要求3 次内存引用(读xp,读yp,写xp),而twiddle1 需要6 次(2 次读xp,2 次读yp,2 次写xp)。
不过,如果xp = yp,那么函数twiddle1 实际的操作是将xp 的值增加4 倍。而函数twiddle2 则是将xp 的值增加了3 倍。
由于编译器不知道xp 与yp 是否可能相等,因此twiddle2 不能作为twiddle1 的优化版本。
1 long f();
2
3 long func1 () {
4 return f() + f() + f() + f();
5 }
6
7 long func2 () {
8 return 4 * f();
9 }
先不考虑函数f 的具体内容,可以看到,func2 只调用f 一次,而func1 调用f 四次。但如果考虑函数f 如下:
1 long counter = 0;
2
3 long f() {
4 return counter ++;
5 }
显然,对于这样的f,func1 会返回6,而func2 会返回0。这种情况编译器也是无法判断的,因此编译器也无法做出这种优化。
二、程序性能的表示
对于一个程序,如果我们记录该程序的数据规模以及对应的运行所需的时钟周期,并通过最小二乘法来拟合这些点,我们将得到形如y = a + bx 的表达式,其中y 是时钟周期,x 是数据规模。当数据规模较大时,运行时间就主要由线性因子b 来决定。这时候,我们将b 作为度量程序性能的标准,称为每元素的周期数(Cycles Per Element, CPE)。
三、代码移动
1 typedef struct {
2 long len;
3 data_t *data;
4 } vec_rec , *vec_ptr
这个声明用data_t 来表示基本元素的数据类型。
1 void combine1(vec_ptr v, data_t *dest) {
2 long i;
3
4 *dest = IDENT;
5 for (i = 0; i < vec_length(v); i++) {
6 data_t val;
7 get_vec_element(v, i, &val);
8 *dest = *dest OP val;
9 }
10 }
上面的代码中,循环体每执行一次,都会调用一次函数vec_length,但数组的长度是不变的,那么可以考虑将vec_length 移出循环体来提升效率:
1 void combine2(vec_ptr v, data_t *dest) {
2 long i;
3 long length = vec_length(v);
4
5 *dest = IDENT;
6 for (i = 0; i < length; i++) {
7 data_t val;
8 get_vec_element(v, i, &val);
9 *dest = *dest OP val;
10 }
11 }
四、减少过程调用
1 data_t *get_vec_start(vec_ptr v) {
2 return v -> data;
3 }
4
5 void combine3(vec_ptr v, data_t *dest) {
6 long i;
7 long length = vec_length(v);
8 data_t *data = get_vec_start(v);
9
10 *dest = IDENT;
11 for (i = 0; i < length; i++)
12 *dest = *dest OP data[i];
13 }
在上一页的代码中,我们消除了循环体中的所有调用。但实际上,这样的改变不会带来性能的提升,在整数求和的情况下还会造成性能下降。这是因为内循环中还有其他的操作形成了瓶颈。
五、消除不必要的内存引用
x86-64 汇编代码
1 .L17:
2 vmovsd (%rbx), %xmm0
3 vmulsd (%rdx), %xmm0 , %xmm0
4 vmovsd %xmm0 , (%rbx)
5 addq $8 , %rdx
6 cmpq %rax , %rdx
7 jne .L17
通过上面的汇编代码可以看到,每次迭代时,累积变量的数值都要从内存中读出再写入到内存,这样的读写是很浪费的,而且是可以消除的:
1 void combine4(vec_ptr v, data_t *dest) {
2 long i;
3 long length = vec_length(v);
4 data_t *data = get_vec_start(v);
5 data_t acc = IDENT;
6
7 for (i = 0; i < length; i++)
8 acc = acc OP data[i];
9 *dest = acc;
10 }
六、现代处理器的优化
- 近期的Intel 处理器是超标量(superscalar)的,意思是它可以在每个时钟周期执行多个操作。此外还是乱序的(out-of-order),意思是指令执行的顺序不一定与机器级程序中的顺序一致。
- 这样的设计使得处理器能够达到更高的并行度。例如,在执行分支结构的程序时,处理器会采用分支预测(branch prediction)技术,来预测是否需要选择分支,同时还预测分支的目标地址。
- 此外还有一种投机执行(speculative execution)技术,意思是处理器会在分支之前就执行分支之后的操作。如果预测错误,那么处理器就会将状态重置到分支点的状态。
七、循环展开
所谓循环展开,指的是通过增加每次迭代计算的元素数量来减少循环的迭代次数。
考虑如下的程序:
1 void psum1(float a[], float p[], long n) {
2 long i;
3 p[0] = a[0];
4 for (i = 1; i < n; i++)
5 p[i] = p[i-1] + a[i];
6 }
通过对psum1 函数进行循环展开,能够使迭代次数减半:
1 void psum2(float a[], float p[], long n) {
2 long i;
3 p[0] = a[0];
4 for (i = 1; i < n - 1; i += 2) {
5 float mid_val = p[i-1] + a[i];
6 p[i] = mid_val;
7 p[i+1] = mid_val + a[i+1];
8 }
9 if (i < n)
10 p[i] = p[i-1] + a[i];
11 }
八、寄存器溢出
对于循环展开,很自然地考虑如下问题:是否展开的次数越多,性能提升越大?实际上,循环展开需要维护多个变量,一旦展开的次数过多,没有足够的寄存器保存变量,那么就需要将变量保存到内存中,这就会导致访存时间消耗增加。即便是在x86-64 这样拥有足够多寄存器的架构中,循环也很可能在寄存器溢出之前就达到吞吐量限制,从而无法持续提升性能。