汇编级程序性能优化

编译器的能力和局限性

有些人会说,明明编译器就会有优化(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;

显然 twiddle1x = 4 * x,而 twiddle2x = 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;
}

咋眼一看,f1f2 的意图一样,都是返回四倍的 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.cf 函数实现,在 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
O0O1O2O3
0.804s0.539s0.338s0.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 = 0i = 1i = 2i = 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;
}
comine4comine5_2comine5_3
0.131s0.128s0.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_2comine6comine4 - O3
加法0.141s0.124s0.127s
乘法0.161s0.135s0.132s

加法几乎没提升,但是乘法还是比较明显的。k * k 循环展开能够突破延迟界限,最高可达到吞吐量界限。某些编译器会先按 K * 1 展开,然后满足条件的情况下,再引入并行性转换成 comine6 版本。也就是说循环展开与提高并行性都可交由编译器完成。

但是浮点数加、乘法不满足结合律与交换律,所以编译器并不会对浮点数加、乘进行优化。这时就需要程序员权衡利弊,到底是否需要这么优化。

浮点型comine4 - O3comine6
加法0.311s0.245s
乘法0.329s0.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 展开时的性能。同样编译器不会改变对浮点型的结合,因为浮点型不满足结合律。

comine6comine7comine5_2
整型加法0.124s0.129s0.141s
整型乘法0.135s0.146s0.161s
浮点型加法0.245s0.253s0.318s
浮点型乘法0.273s0.267s0.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 要小。

优化前后性能对比

comine6comine7comine1-Ogcomine1-O3
整型加法0.124s0.129s0.485s0.111s
整型乘法0.135s0.146s0.497s0.167s
浮点型加法0.245s0.253s0.575s0.330s
浮点型乘法0.273s0.267s0.447s0.316s

限制因素

寄存器溢出

受到 2 * 2 展开启发,我们分别进行 5 * 510 * 10 测试,我们把数据量扩大到 5e 用以展示区别。

2 * 25 * 510 * 10
0.597s0.551s0.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 会衡量是否有必要做这个替换。

  1. 使用条件传送需要提前计算所有情况,如果此处开销很大时,GCC 也不会做替换。
  2. 是否存在副作用。

对于第一种情况,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;
    }
}
minmax1minmax2
0.498s0.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;
}
数据量sum1sum2
10000.481s0.560s
100000.694s2.394s
200002.466s21.098s

数据量在 1000 时差距不明显,因为 1000 * 1000 * 4 < 4 一般 cpu 配置 L3 为 8 MB,cache 能容纳下所有的数据,而当矩阵总体数据量超过了 cache 所能存储的范围时,差距就会显现出来。

指令的局部性

程序指令是存放在内存中的,CPU 必须读出指令才能执行,所以循环中的指令具有良好的空间局部性。而 goto 和非本地跳转随意乱跳,就不具备局部性,这就是为什么要避免使用的原因之一。

代码最重要的是在运行时是只读的不可被修改的,所以 CPU 只会读,并不会写。

参考资料

  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
汇编语言和高语言是两种不同的编程语言,它们各自有着不同的优缺点。 汇编语言是一种低语言,与计算机硬件直接交互。它使用机器指令来编写程序,可以对计算机的底层进行精细控制。汇编语言的优点包括: 1. 效率高:由于直接操作底层硬件,汇编语言可以实现高效的程序执行。 2. 灵活性强:汇编语言可以直接访问和操作计算机的寄存器、内存等资源,具有很高的灵活性。 3. 可以优化性能:通过手动优化代码,可以使程序在特定硬件上运行更快。 然而,汇编语言也存在一些缺点: 1. 学习曲线陡峭:相对于高语言而言,汇编语言更加复杂和难以理解,需要对计算机硬件有深入的了解。 2. 开发效率低:由于需要手动管理底层资源,编写汇编语言程序的开发效率较低。 3. 可移植性差:由于不同计算机体系结构的差异,汇编语言程序通常不具备良好的可移植性。 高语言是相对于汇编语言而言的一种抽象层次更高的编程语言。它使用更接近自然语言的语法和结构,更加易于理解和使用。高语言的优点包括: 1. 开发效率高:高语言提供了丰富的库和工具,可以大大提高开发效率。 2. 可读性强:高语言的语法更接近自然语言,代码更易于理解和维护。 3. 可移植性好:高语言通常具有良好的可移植性,可以在不同的平台上运行。 然而,高语言也存在一些缺点: 1. 执行效率低:相对于汇编语言而言,高语言的执行效率较低,因为它需要通过编译或解释器转换为机器码。 2. 对底层资源控制有限:高语言的抽象层次较高,对底层硬件资源的直接控制能力有限。 3. 存在一定的学习成本:虽然相对于汇编语言而言,高语言的学习曲线较为平缓,但仍然需要一定的学习成本。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值