编译器的能力和局限性
有些人会说,明明编译器就会有优化(gcc -Og -O1 -O2 -O3
),为何还需要人为刻意优化?
因为编译器很蠢,他只有在确定优化后的代码与优化前的代码的行为完全一致,才会进行优化。并不会猜测程序员的编写这段代码的意图。
优化限制1:内存别名使用
// 程序意图:x += 2 * y
void twiddle1(long *xp, long *yp) {
*xp += *yp;
*xp += *yp;
}
void twiddle2(long *xp, long *yp) {
*xp += 2* *yp;
}
twiddle1
3 读 2 写,twiddle2
1 读 1 写,显然后者开销更低。
看以下汇编:
# 函数调用都会执行以下两个指令
# push %rbp
# mov %rsp, %rbp
# 函数调用结束都会执行以下两个指令
# popq %rbp
# retq
# 对比函数汇编代码可忽略
# 函数第一个参数默认在 %rdi 第二个参数默认在 %rsi
# xp 在 %rdi,yp 在 %rsi
# Og
0000000000000000 _twiddle1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 8b 07 movq (%rdi), %rax # %rax = *xp
7: 48 03 06 addq (%rsi), %rax # %rax += *yp
a: 48 89 07 movq %rax, (%rdi) # *xp = %rax
d: 48 03 06 addq (%rsi), %rax # %rax += *yp
10: 48 89 07 movq %rax, (%rdi) # *xp = %rax
13: 5d popq %rbp
14: c3 retq
0000000000000000 _twiddle2:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 8b 06 movq (%rsi), %rax # %rax = *yp
7: 48 01 c0 addq %rax, %rax # %rax += %rax
a: 48 01 07 addq %rax, (%rdi) # *x += %rax
d: 5d popq %rbp
e: c3 retq
# O1
0000000000000000 _twiddle1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 8b 07 movq (%rdi), %rax
7: 48 03 06 addq (%rsi), %rax
a: 48 89 07 movq %rax, (%rdi)
d: 48 03 06 addq (%rsi), %rax
10: 48 89 07 movq %rax, (%rdi)
13: 5d popq %rbp
14: c3 retq
# O2
0000000000000000 _twiddle1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 8b 07 movq (%rdi), %rax
7: 48 03 06 addq (%rsi), %rax
a: 48 89 07 movq %rax, (%rdi)
d: 48 03 06 addq (%rsi), %rax
10: 48 89 07 movq %rax, (%rdi)
13: 5d popq %rbp
14: c3 retq
# O3
0000000000000000 _twiddle1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 8b 07 movq (%rdi), %rax
7: 48 03 06 addq (%rsi), %rax
a: 48 89 07 movq %rax, (%rdi)
d: 48 03 06 addq (%rsi), %rax
10: 48 89 07 movq %rax, (%rdi)
13: 5d popq %rbp
14: c3 retq
你会发现汇编代码完完全全是 C 代码一字不落的翻译。最尴尬的是不管是开启什么级别的优化,它的汇编代码是完全一样的!上面两段代码的行为不是一样的吗?编译就应该给我优化呀?
一顿抱怨之后,返回来思考一下,这两段代码真的一样吗?的确不一样,如果传入的是同一个地址呢?
// twiddle1
*xp += *xp;
*xp += *xp;
// twiddle2
*xp += 2* *xp;
显然 twiddle1
是 x = 4 * x
,而 twiddle2
是 x = 2 * x
。要知道编译器并不会猜测你的意图,就算猜测了,他也不知道你的意图是前者还是后者呀?
类似于这个例子,两个指针可能指向同一个内存位置的情况称之为内存别名使用。编译器在编译时并不知道这两个指针是否会指向同一个内存位置,所以为了程序的准确性,必须认为可能发生内存别名使用。所以这就限制了编译器的优化策略
优化限制2:程序副作用
// f.c 文件
long f();
long f1() {
return f() + f() + f() + f();
}
long f2() {
return 4 * f();
}
// ff.c 文件
long f() {
return 1;
}
咋眼一看,f1
和 f2
的意图一样,都是返回四倍的 f
。这回编译器应该给我优化了吧!看了看下面的汇编。可见 GCC
真是不够 “聪明”,完全没做优化。
# nop 指令是一个空指令,目的是让指令对齐,可以无视
0000000000000000 _f1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 41 56 pushq %r14
6: 53 pushq %rbx
7: 31 c0 xorl %eax, %eax
9: e8 00 00 00 00 callq 0 <_f1+0xe> # 第一次调用
e: 49 89 c6 movq %rax, %r14
11: 31 c0 xorl %eax, %eax
13: e8 00 00 00 00 callq 0 <_f1+0x18> # 第二次调用
18: 48 89 c3 movq %rax, %rbx
1b: 4c 01 f3 addq %r14, %rbx
1e: 31 c0 xorl %eax, %eax
20: e8 00 00 00 00 callq 0 <_f1+0x25> # 第三次调用
25: 49 89 c6 movq %rax, %r14
28: 49 01 de addq %rbx, %r14
2b: 31 c0 xorl %eax, %eax
2d: e8 00 00 00 00 callq 0 <_f1+0x32> # 第四次调用
32: 4c 01 f0 addq %r14, %rax
35: 5b popq %rbx
36: 41 5e popq %r14
38: 5d popq %rbp
39: c3 retq
3a: 66 0f 1f 44 00 00 nopw (%rax,%rax)
0000000000000040 _f2:
40: 55 pushq %rbp
41: 48 89 e5 movq %rsp, %rbp
44: 31 c0 xorl %eax, %eax
46: e8 00 00 00 00 callq 0 <_f2+0xb>
4b: 48 c1 e0 02 shlq $2, %rax
4f: 5d popq %rbp
50: c3 retq
# O3,篇幅所限,这里只给出 O3,Og O0 O1 O2 请读者自行验证
0000000000000000 _f1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 41 56 pushq %r14
6: 53 pushq %rbx
7: 31 c0 xorl %eax, %eax
9: e8 00 00 00 00 callq 0 <_f1+0xe>
e: 49 89 c6 movq %rax, %r14
11: 31 c0 xorl %eax, %eax
13: e8 00 00 00 00 callq 0 <_f1+0x18>
18: 48 89 c3 movq %rax, %rbx
1b: 4c 01 f3 addq %r14, %rbx
1e: 31 c0 xorl %eax, %eax
20: e8 00 00 00 00 callq 0 <_f1+0x25>
25: 49 89 c6 movq %rax, %r14
28: 49 01 de addq %rbx, %r14
2b: 31 c0 xorl %eax, %eax
2d: e8 00 00 00 00 callq 0 <_f1+0x32>
32: 4c 01 f0 addq %r14, %rax
35: 5b popq %rbx
36: 41 5e popq %r14
38: 5d popq %rbp
39: c3 retq
3a: 66 0f 1f 44 00 00 nopw (%rax,%rax)
回头一想!不对,C 中的只是个函数声明,编译器并不知道 f
具体实现。如果别的文件的函数实现是下面这样的。
int count = 1;
long f() {
count++;
return 1;
}
即使返回结果是一样的,但是 count
值改变了呀。这种情况称之为程序的副作用。
在编译时,编译器不知道声明的该函数是否产生副作用时,只能默认这个函数会产生副作用,所以老老实实调用四次。所以这也会限制编译器优化。
聪明你的会想到!哦,我把 ff.c
的 f
函数实现,在 f.c
中实现不就好了?看以下汇编,的确如你所愿,编译器给你优化了。
# Og
0000000000000000 _f:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: b8 01 00 00 00 movl $1, %eax
9: 5d popq %rbp
a: c3 retq
b: 0f 1f 44 00 00 nopl (%rax,%rax)
0000000000000010 _f1:
10: 55 pushq %rbp
11: 48 89 e5 movq %rsp, %rbp
14: b8 04 00 00 00 movl $4, %eax
19: 5d popq %rbp
1a: c3 retq
1b: 0f 1f 44 00 00 nopl (%rax,%rax)
0000000000000020 _f2:
20: 55 pushq %rbp
21: 48 89 e5 movq %rsp, %rbp
24: b8 04 00 00 00 movl $4, %eax
29: 5d popq %rbp
2a: c3 retq
内联函数优化性能?
除了将函数写在同一个文件,我还有别的优化方式吗?你会想到 inline
内联函数。
// ff.c 文件
inline long f() {
return 1;
}
实际编译后,f
根本没有展开。为什么?因为 GCC 只能在同一个文件内对其内联展开。
啊?这?那 inlline
还有啥用。。我没有标 inline
也会在同文件下展开。
# O3 -finline
0000000000000000 _f1:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 41 56 pushq %r14
6: 53 pushq %rbx
7: 31 c0 xorl %eax, %eax
9: e8 00 00 00 00 callq 0 <_f1+0xe>
e: 49 89 c6 movq %rax, %r14
11: 31 c0 xorl %eax, %eax
13: e8 00 00 00 00 callq 0 <_f1+0x18>
18: 48 89 c3 movq %rax, %rbx
1b: 4c 01 f3 addq %r14, %rbx
1e: 31 c0 xorl %eax, %eax
20: e8 00 00 00 00 callq 0 <_f1+0x25>
25: 49 89 c6 movq %rax, %r14
28: 49 01 de addq %rbx, %r14
2b: 31 c0 xorl %eax, %eax
2d: e8 00 00 00 00 callq 0 <_f1+0x32>
32: 4c 01 f0 addq %r14, %rax
35: 5b popq %rbx
36: 41 5e popq %r14
38: 5d popq %rbp
39: c3 retq
3a: 66 0f 1f 44 00 00 nopw (%rax,%rax)
程序示例
#include <stdio.h>
#include <stdlib.h>
#define data_t int // 数据类型
typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
vec_ptr new_vec(long len) {
vec_ptr res = (vec_ptr) malloc (sizeof(vec_rec));
data_t *data = NULL;
if (!res) return NULL;
res->len = len;
if (len > 0) {
data = (data_t*) malloc (sizeof(data_t) * len);
if (!data) {
free((void*) res);
return NULL;
}
}
res->data = data;
return res;
}
int get_vec_element(vec_ptr v, long idx, data_t *dest) {
if (idx < 0 || idx >= v->len) return 0;
*dest = v->data[idx];
return 1;
}
long vec_lenth(vec_ptr v) {
return v->len;
}
#define OP + // 操作类型
#define IDENT 0 // * 是 1,+ 是 0
#define N 100000000 // 大小 1亿
void comine1(vec_ptr v, data_t *dest) {
long i;
*dest = IDENT;
for (i = 0; i < vec_lenth(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
int main() {
vec_ptr v = new_vec(N);
data_t dest;
comine1(v, &dest);
return 0;
}
使用的是 gprof 统计程序运行时间、函数调用次数等。 gprof 无法跟踪被内联展开的函数,所以当存在大量内联函数时就会失效。所以用 time
来统计时间
cumulative 到这个函数为止的所使用时间
self 这个函数所使用的时间
--O0
% cumulative self self total
time seconds seconds calls ms/call ms/call name
47.05 0.33 0.33 100000000 0.00 0.00 get_vec_element
36.91 0.59 0.26 1 258.40 668.80 comine1
11.58 0.67 0.08 100000001 0.00 0.00 vec_lenth
2.17 0.68 0.02 1 15.20 15.20 new_vec
-O1
% cumulative self self total
time seconds seconds calls ms/call ms/call name
56.22 0.16 0.16 100000000 0.00 0.00 get_vec_element
39.90 0.27 0.11 1 111.71 269.12 comine1
5.44 0.28 0.02 1 15.23 15.23 new_vec
O0 | O1 | O2 | O3 |
---|---|---|---|
0.804s | 0.539s | 0.338s | 0.241s |
消除循环低效率
认识一下 for(exp1; exp2; exp3)
exp1 只会执行一次,exp2 会执行 N + 1
次,exp3 会执行 N
次
而编译器并不知道这个函数是否带有副作用,所以这就是为什么 vec_lenth
执行了 100000001
次的原因。开启 O1
优化后,vec_lenth
没有调用,说明被展开了。所以从 0.804
优化到了 0.539
。
所以我们想到了第一个优化,将 vec_lenth
移动到循环外部
void comine2(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
for (i = 0; i < len; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
测试后,发现这比开了 O1 优化还要快只用了 0.32
% cumulative self self total
time seconds seconds calls ms/call ms/call name
65.04 0.21 0.21 100000000 0.00 0.00 get_vec_element
31.73 0.31 0.10 1 101.52 309.64 comine2
4.76 0.32 0.02 1 15.23 15.23 new_vec
0.00 0.32 0.00 1 0.00 0.00 vec_lenth
这类优化称之为代码移动
减少函数调用
若要再进一步优化程序,你会说像 vec_lenth
一样将 get_vec_element
也移出循环,搞一个函数让其返回内部数组就好了。毕竟直接从索引拿值肯定会比通过函数调用快吧,而且 get_vec_element
还要进行边界检查呢。
data_t *get_vec_start(vec_ptr v) {
return v->data;
}
void comine3(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
*dest = IDENT;
data_t *data = get_vec_start(v);
for (i = 0; i < len; i++) {
*dest = *dest OP data[i];
}
}
用 time
测试发现 0.312
快了一丢丢,几乎没变。
因为处理器总是预测数组的索引是合法的。事实上大部分也是合法的,所以边界检查的开销几乎可以忽略不计
消除不必要的内存引用
查看 comine3
汇编代码发现,循环内部每次加的时候都要写入内存。
0000000000000086 <comine3>:
86: 41 54 push %r12
88: 55 push %rbp
89: 53 push %rbx
8a: 49 89 fc mov %rdi,%r12
8d: 48 89 f3 mov %rsi,%rbx
90: e8 00 00 00 00 callq 95 <comine3+0xf>
95: 48 89 c5 mov %rax,%rbp
98: 4c 89 e7 mov %r12,%rdi
9b: e8 00 00 00 00 callq a0 <comine3+0x1a>
a0: ba 00 00 00 00 mov $0x0,%edx
a5: 48 39 ea cmp %rbp,%rdx
a8: 7d 0b jge b5 <comine3+0x2f>
aa: 8b 0c 90 mov (%rax,%rdx,4),%ecx
ad: 01 0b add %ecx,(%rbx) # 每次加都得写入内存
af: 48 83 c2 01 add $0x1,%rdx
b3: eb f0 jmp a5 <comine3+0x1f>
b5: 5b pop %rbx
b6: 5d pop %rbp
b7: 41 5c pop %r12
b9: c3 retq
对内存的读写开销可是很大的,干脆用个临时变量,当加完后再一次性写入,这不就快了?然后很快的改出一版代码。
void comine4(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < len; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
用 time
测试 0.131
快到起飞比 O3 还要快。你可能会说!这编译器要优化呀,为什么不优化?不是一样的吗?编译器不会猜测你的意图,他从大局来看就是不一样的。
comine3(v, get_vec_start(v) + 2)
comine4(v, get_vec_start(v) + 2)
假设 v = [1, 2, 3]
,因为 *dest
是元素地址,每次 *dest
值改变的时候,数组元素也会改变,自然会影响后续的结果。而如果你事先将元素存到寄存器中运算,并不会改变元素的值。
函数 | 初始值 | i = 0 | i = 1 | i = 2 | i = 3 | 最后 |
---|---|---|---|---|---|---|
comine3 | [1, 2, 3, 4] | [1, 2, 3, 4] | [1, 2, 4, 4] | [1, 2, 6, 4] | [1, 2, 12, 4] | [1, 2, 16, 4] |
comine4 | [1, 2, 3, 4] | [1, 2, 3, 4] | [1, 2, 3, 4] | [1, 2, 3, 4] | [1, 2, 3, 4] | [1, 2, 10, 4] |
循环展开
试问还想再进一步优化,又该如何优化呢?很快就注意到这个循环。通过减少循环次数,首先可以减少不必要的操作(索引计算、条件分支等),其次可以减少必要的操作数量。
// 2 * 1 循环展开
void comine5_2(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
long lim = len - 1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < lim; i += 2) {
acc = (acc OP data[i]) OP data[i + 1];
}
for (; i < len; i++) {
acc = acc OP data[i]; // 剩余操作
}
*dest = acc;
}
// 3 * 1 循环展开
void comine5_3(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
long lim = len - 1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < lim; i += 3) {
acc = ((acc OP data[i]) OP data[i + 1]) op data[i + 2];
}
for (; i < len; i++) {
acc = acc OP data[i]; // 剩余操作
}
*dest = acc;
}
comine4 | comine5_2 | comine5_3 |
---|---|---|
0.131s | 0.128s | 0.129s |
循环展开后时间几乎没有变化。因为k * 1
循环展开不能将性能改进超过延迟界限。O3
及其以上优化 GCC 自动循环展开。
提高并行性
执行加法和乘法的功能单元是完全流水化的,每个周期都能开始一个新的操作,但是我们将累计值放在了 acc 中,如果前面的一个操作没有完成,后面的操作也不能开始,这种顺序相关限制了程序的性能,所以我们需要打破这种相关,从而提升性能。
多个累积变量
受到 k * 1
循环展开的启发,整数加法和乘法满足交换律和结合律,不难想出 k * 2
循环展开。
// 2 * 2 循环展开
void comine6(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
long lim = len - 1;
data_t *data = get_vec_start(v);
data_t acc1 = IDENT;
data_t acc2 = IDENT;
for (i = 0; i < lim; i += 2) {
acc1 = acc1 OP data[i];
acc2 = acc2 OP data[i + 1];
}
for (; i < len; i++) {
acc1 = acc1 OP data[i]; // 剩余操作
}
*dest = acc1 OP acc2;
}
整型 | comine5_2 | comine6 | comine4 - O3 |
---|---|---|---|
加法 | 0.141s | 0.124s | 0.127s |
乘法 | 0.161s | 0.135s | 0.132s |
加法几乎没提升,但是乘法还是比较明显的。k * k
循环展开能够突破延迟界限,最高可达到吞吐量界限。某些编译器会先按 K * 1
展开,然后满足条件的情况下,再引入并行性转换成 comine6
版本。也就是说循环展开与提高并行性都可交由编译器完成。
但是浮点数加、乘法不满足结合律与交换律,所以编译器并不会对浮点数加、乘进行优化。这时就需要程序员权衡利弊,到底是否需要这么优化。
浮点型 | comine4 - O3 | comine6 |
---|---|---|
加法 | 0.311s | 0.245s |
乘法 | 0.329s | 0.273s |
重新结合变换
// 2 * 1a
void comine7(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
long lim = len - 1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < lim; i += 2) {
acc = acc OP (data[i] OP data[i + 1]); // 结合方式改变
}
for (; i < len; i++) {
acc = acc OP data[i]; // 剩余操作
}
*dest = acc;
}
测试发现,改变括号位置,会使性能接近 k * 2
展开时的性能。同样编译器不会改变对浮点型的结合,因为浮点型不满足结合律。
comine6 | comine7 | comine5_2 | |
---|---|---|---|
整型加法 | 0.124s | 0.129s | 0.141s |
整型乘法 | 0.135s | 0.146s | 0.161s |
浮点型加法 | 0.245s | 0.253s | 0.318s |
浮点型乘法 | 0.273s | 0.267s | 0.361s |
只是改变了符号位置,为什么会使浮点数加减法执行效率差距那么大呢?
acc = acc OP (data[i] OP data[i + 1]); // comine7
acc = (acc OP data[i]) OP data[i + 1]; // comine5_2
其实对比后不难发现。
comine5_2
循环先算 acc OP data[i]
,所以每个 acc
都依赖于上次循环acc
,所以就存在了顺序相关,导致后面计算不能继续。
而 comine7
循环先算 data[i] OP data[i + 1]
,data[i]
与 data[i + 1]
并不依赖上次循环的 data[i]
与 data[i + 1]
,这一部分是可以先算的,虽然 acc
也会存在是顺序相关,但是导致不能计算的部分比 comine5_2
要小。
优化前后性能对比
comine6 | comine7 | comine1-Og | comine1-O3 | |
---|---|---|---|---|
整型加法 | 0.124s | 0.129s | 0.485s | 0.111s |
整型乘法 | 0.135s | 0.146s | 0.497s | 0.167s |
浮点型加法 | 0.245s | 0.253s | 0.575s | 0.330s |
浮点型乘法 | 0.273s | 0.267s | 0.447s | 0.316s |
限制因素
寄存器溢出
受到 2 * 2
展开启发,我们分别进行 5 * 5
、 10 * 10
测试,我们把数据量扩大到 5e
用以展示区别。
2 * 2 | 5 * 5 | 10 * 10 |
---|---|---|
0.597s | 0.551s | 0.523s |
虽然差距很小,但是也是有差距的。k * k
展开一定会随着 k
越大速度执行效率越高吗?接着测试20 * 20
时达到了 0.557s
还不如 5 * 5
。为什么会造成这种原因?
# 10*10 循环内部操作
ed: 03 0c 90 add (%rax,%rdx,4),%ecx
f0: 44 03 7c 90 04 add 0x4(%rax,%rdx,4),%r15d
f5: 44 03 74 90 08 add 0x8(%rax,%rdx,4),%r14d
fa: 44 03 6c 90 0c add 0xc(%rax,%rdx,4),%r13d
ff: 44 03 5c 90 10 add 0x10(%rax,%rdx,4),%r11d
104: 44 03 54 90 14 add 0x14(%rax,%rdx,4),%r10d
109: 44 03 4c 90 18 add 0x18(%rax,%rdx,4),%r9d
10e: 44 03 44 90 1c add 0x1c(%rax,%rdx,4),%r8d
113: 03 7c 90 20 add 0x20(%rax,%rdx,4),%edi
117: 03 74 90 24 add 0x24(%rax,%rdx,4),%esi
11b: 48 83 c2 0a add $0xa,%rdx
# 20*20 循环内部操作
50: 8b 34 90 mov (%rax,%rdx,4),%esi
153: 01 74 24 28 add %esi,0x28(%rsp)
157: 44 03 7c 90 04 add 0x4(%rax,%rdx,4),%r15d
15c: 44 03 74 90 08 add 0x8(%rax,%rdx,4),%r14d
161: 44 03 6c 90 0c add 0xc(%rax,%rdx,4),%r13d
166: 8b 4c 90 10 mov 0x10(%rax,%rdx,4),%ecx
16a: 01 4c 24 04 add %ecx,0x4(%rsp)
16e: 8b 4c 90 14 mov 0x14(%rax,%rdx,4),%ecx
172: 01 4c 24 08 add %ecx,0x8(%rsp)
176: 8b 4c 90 18 mov 0x18(%rax,%rdx,4),%ecx
17a: 01 4c 24 0c add %ecx,0xc(%rsp)
17e: 8b 4c 90 1c mov 0x1c(%rax,%rdx,4),%ecx
182: 01 4c 24 10 add %ecx,0x10(%rsp)
186: 8b 4c 90 20 mov 0x20(%rax,%rdx,4),%ecx
18a: 01 4c 24 14 add %ecx,0x14(%rsp)
18e: 8b 4c 90 24 mov 0x24(%rax,%rdx,4),%ecx
192: 01 4c 24 18 add %ecx,0x18(%rsp)
196: 8b 4c 90 28 mov 0x28(%rax,%rdx,4),%ecx
19a: 01 4c 24 1c add %ecx,0x1c(%rsp)
19e: 44 03 64 90 2c add 0x2c(%rax,%rdx,4),%r12d
1a3: 03 6c 90 30 add 0x30(%rax,%rdx,4),%ebp
1a7: 03 5c 90 34 add 0x34(%rax,%rdx,4),%ebx
1ab: 44 03 5c 90 38 add 0x38(%rax,%rdx,4),%r11d
1b0: 44 03 54 90 3c add 0x3c(%rax,%rdx,4),%r10d
1b5: 44 03 4c 90 40 add 0x40(%rax,%rdx,4),%r9d
1ba: 44 03 44 90 44 add 0x44(%rax,%rdx,4),%r8d
1bf: 03 7c 90 48 add 0x48(%rax,%rdx,4),%edi
1c3: 8b 74 90 4c mov 0x4c(%rax,%rdx,4),%esi
1c7: 01 74 24 2c add %esi,0x2c(%rsp)
1cb: 48 83 c2 14 add $0x14,%rdx
可以看到 20 * 20
展开的汇编代码内部有大量的对内存操作。
因为寄存器数量有限,如果临时变量太多,导致寄存器不够时,编译器会诉诸溢出,会使用内存存放一些暂时用不到的变量。要知道对内存的读写开销是非常大的,所以这也就导致了性能下降。
分支预测
当遇到分支时,处理器必须预测该执行那条分支。处理器执行预测分支时并不会实际修改寄存器或内存的值,而是当确定分支结果时才会改变。预测错误时,就会丢弃之前的执行结果重新执行。此时就会造成严重的错误处罚。
条件控制与条件传送
// 传统的基于条件控制的条件转移
long absdiff(long x, long y) {
long res;
if (x < y) // 这里会进行分支预测,可能会带来惩罚
res = y - x;
else
res = x - y;
return res;
}
// 条件传送
long comvdiff(long x, long y) {
long rval = y - x;
long lval = x - y;
long ntest = x >= y;
if (ntest) rval = eval; // 条件传送,避免惩罚
return rval;
}
// 两段程序如果开启优化,都会采用条件传送的方式
GCC
开启优化在编译条件语句和表达式时,一般情况下,如果能用条件传送代替传统基于条件控制的实现,将会采用条件传送的方式来避免分支预测可能带来的惩罚。
替换不是绝对的,GCC
会衡量是否有必要做这个替换。
- 使用条件传送需要提前计算所有情况,如果此处开销很大时,
GCC
也不会做替换。 - 是否存在副作用。
对于第一种情况,GCC
的评估很迷,有时候明明替换后明显比替换前性能好,GCC
也不会替换。所以这种情况,都需要自己测试是否手动改为条件传送方式。
主要针对于第二种情况,是否存在副作用,这是一个老生常谈的问题。代码如下:
xp ? *xp : 0;
对于上述表达式,就不会改用条件传送,因为如果该指针为空的话,就不能拿到该指针指向的值。
不要过分关心可预测分支
// 带边界检查
void comine2(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
for (i = 0; i < len; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
// 不带边界检查
void comine3(vec_ptr v, data_t *dest) {
long i;
long len = vec_lenth(v);
*dest = IDENT;
data_t *data = get_vec_start(v);
for (i = 0; i < len; i++) {
*dest = *dest OP data[i];
}
}
因为处理器总是预测数组的索引是合法的。事实上大部分也是合法的,所以边界检查的开销几乎可以忽略不计。
适合条件传送实现的代码
分支预测依赖数据的规律。如果数据没有规律,分支预测的性能会很低。如果能用条件传送代替条件控制,就能避免分支预测失败的处罚。
void minmax1(long a[], long b[], int n) {
long i;
for (i = 0; i < n; i++) {
if (a[i] > b[i]) {
long t = a[i];
a[i] = b[i];
b[i] = t;
}
}
}
void minmax2(long a[], long b[], int n) {
long i;
for (i = 0; i < n; i++) {
long min = a[i] < b[i] ? a[i] : b[i];
long max = a[i] < b[i] ? b[i] : a[i];
a[i] = min;
b[i] = max;
}
}
minmax1 | minmax2 |
---|---|
0.498s | 0.177s |
不是所有代码编译器都会自动转换为条件传送的方式,这需要程序员的试验。
0000000000400536 <minmax1>:
400536: b9 00 00 00 00 mov $0x0,%ecx
40053b: eb 04 jmp 400541 <minmax1+0xb>
40053d: 48 83 c1 01 add $0x1,%rcx
400541: 48 63 c2 movslq %edx,%rax
400544: 48 39 c8 cmp %rcx,%rax
400547: 7e 22 jle 40056b <minmax1+0x35>
400549: 48 8d 04 cd 00 00 00 lea 0x0(,%rcx,8),%rax
400550: 00
400551: 4c 8d 0c 07 lea (%rdi,%rax,1),%r9
400555: 4d 8b 01 mov (%r9),%r8
400558: 48 01 f0 add %rsi,%rax
40055b: 4c 8b 10 mov (%rax),%r10
40055e: 4d 39 d0 cmp %r10,%r8
400561: 7e da jle 40053d <minmax1+0x7>
400563: 4d 89 11 mov %r10,(%r9)
400566: 4c 89 00 mov %r8,(%rax)
400569: eb d2 jmp 40053d <minmax1+0x7>
40056b: c3 retq
0000000000400536 <minmax2>:
400536: 41 b9 00 00 00 00 mov $0x0,%r9d
40053c: 48 63 c2 movslq %edx,%rax
40053f: 4c 39 c8 cmp %r9,%rax
400542: 7e 2f jle 400573 <minmax2+0x3d>
400544: 4a 8d 0c cd 00 00 00 lea 0x0(,%r9,8),%rcx
40054b: 00
40054c: 4c 8d 14 0e lea (%rsi,%rcx,1),%r10
400550: 49 8b 02 mov (%r10),%rax
400553: 48 01 f9 add %rdi,%rcx
400556: 4c 8b 01 mov (%rcx),%r8
400559: 4c 39 c0 cmp %r8,%rax
40055c: 4d 89 c3 mov %r8,%r11
40055f: 4c 0f 4e d8 cmovle %rax,%r11
400563: 49 0f 4c c0 cmovl %r8,%rax
400567: 4c 89 19 mov %r11,(%rcx)
40056a: 49 89 02 mov %rax,(%r10)
40056d: 49 83 c1 01 add $0x1,%r9
400571: eb c9 jmp 40053c <minmax2+0x6>
400573: c3 retq
局部性原理
时间局部性:被引用过内存的位置,可能在不久的将来多次引用。
空间局部性:被引用过的内存,可能在不久的将来引用附近的位置。
局部性原理是计算机最重要的优化,计算机中无处不在。如:服务器上的 web 网页会缓存在本地电脑。又如磁盘中会加入缓存区。本地内存又是磁盘的缓存。cache 又是内存的缓存,寄存器又是 cache 的缓存。
数据引用的局部性
void set(int a[N][N], int r, int c) {
int i, j;
for (i = 0; i < r; i++) {
for (j = 0; j < c; j++) {
a[i][j] = i + j; // 预热 cache
}
}
}
int sum1(int a[N][N], int r, int c) {
int i, j;
int res = 0;
for (i = 0; i < r; i++) {
for (j = 0; j < c; j++) {
res += a[i][j];
}
}
return res;
}
int sum2(int a[N][N], int r, int c) {
int i, j;
int res = 0;
for (i = 0; i < c; i++) {
for (j = 0; j < r; j++) {
res += a[j][i];
}
}
return res;
}
数据量 | sum1 | sum2 |
---|---|---|
1000 | 0.481s | 0.560s |
10000 | 0.694s | 2.394s |
20000 | 2.466s | 21.098s |
数据量在 1000
时差距不明显,因为 1000 * 1000 * 4 < 4
一般 cpu 配置 L3 为 8 MB
,cache 能容纳下所有的数据,而当矩阵总体数据量超过了 cache 所能存储的范围时,差距就会显现出来。
指令的局部性
程序指令是存放在内存中的,CPU 必须读出指令才能执行,所以循环中的指令具有良好的空间局部性。而 goto
和非本地跳转随意乱跳,就不具备局部性,这就是为什么要避免使用的原因之一。
代码最重要的是在运行时是只读的不可被修改的,所以 CPU 只会读,并不会写。