深入理解计算机系统05:优化程序性能

一、优化编译器的能力和局限性

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 这样拥有足够多寄存器的架构中,循环也很可能在寄存器溢出之前就达到吞吐量限制,从而无法持续提升性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值