您的位置:首页 > 移动开发

CSAPP:优化程序性能(四)

2017-06-21 22:00 176 查看
了解一些限制程序性能的因素

一. 寄存器溢出

如果我们的并行度P超过了可用寄存器的数量,那么编译器就会通知溢出,将某些临时值存放在内存中,通常是运行时堆栈上分配空间,聚个例子,当把combine6的多累积变量模式扩展到k=10或者k=20的时候,我们会发现这种循环展开程度没有改善CPE,有些甚至变差了,现代x86-64处理器有16个寄存器,并可以使用16个ymm寄存器保存浮点数,一点循环变量的数量超过了可用寄存器数量,程序就必须在栈上分配一些变量,从而是操作步骤中增加了从内存读取数据的操作,适得其反。

二. 分值预测错误的惩罚

在一些使用投机执行的处理器中,处理器会预测分支目标处的指令,它会避免修改任何实际的寄存器或者内存位置,知道确定了实际的结果。如果预测正确,那么处理器就会提交投机执行的指令结果,把结果存储到寄存器或者内存,如果预测错误,处理器必须丢掉所有投机执行的结果,在正确的位置,重新开始取值过程,这样做会引起预测错误惩罚,因为在产生有用的结果之前,必须重新填充指令流水线。

那么如何保证分值预测处罚不会阻碍程序效率呢?下面是一些通用的原则:

1. 不要过分关心可预测的分支

我们看到错误的分支预测对程序的性能影响很大,但这并不意味着每一个分支都会拖慢程序的性能,实际上,现代处理器的分支预测逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势,例如,在合并函数中,结束循环的分支通常会被预测为选择分支,一次只有在最后一次会导致预测错误的惩罚。

前面的文章中讲到从combine2 变化到 combine3的时候,我们把函数get_vec_element从函数的内循环中拿出来,CPE基本上没有改变,因为对这个函数来说,这些检测总是预测索引是在界内的,所以是高度可预测的。

而且执行边界检查的预测可以与合并操作并行执行,处理器能够预测这些分支的结果,所以不会对程序执行中关键路径的指令的取指和处理产生太大影响。

2. 书写适合用条件传送实现的代码

分支预测只对有规律的模式可行,程序中的许多测试时不可预测的,对于本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大的提高程序的性能,这不是程序员可以直接控制的,但是有些表达条件行为的方法能够更直接地被翻译成条件传送,而不是其他操作。

GCC能够为 以一种“”功能性“”的风格书写的代码产生条件传送,这种风格对立与一种“”命令式“的风格,我们用一个例子来感受一下

给定两个整数数组a和b,对于每个位置i,设置a[i]为a[i]和b[i]中较小的那个,设置b[i]为较大的那个,首先用命令式的代码风格实现

void minmax1(long a[], long b[], long 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;
}
}
}


随机数据测试该函数,CPE大约为13.50,而对于可预测数据,CPE为2.5~3.5,预测错误惩罚周期约为20个时钟周期

用功能式的风格实现这个函数

void minmax2(long a[], long b[], long 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;
}
}
无论数据是任意的还是可预测的,CPE大约为4.0(检查汇编代码确实使用了条件传送)

理解内存性能

加载的性能

一个包含加载操作的程序的性能既依赖于流水线的能力,也依赖于加载单元的延迟。到目前为止我们还没在示例中看到过加载操作的延迟产生的影响,加载操作地址只依赖于索引i,所以加载操作不会成为限制性能的关键路径的一部分。

要确定一台机器上加载操作的延迟,我们可以建立一系列操作组成一个计算,一条加载操作的结果决定下一条操作的地址

typedef struct ELE{
struct ELE *next;
long data;
}list_ele, *list_ptr;

long list_len(list_ptr ls){
long len = 0;
while(ls){
len++;
ls = ls->next;
}
return len;
}

链表函数,其性能受限于加载操作的延迟
其汇编代码为

// ls in %rdi , len in %rax

.L3:
addq $1, %rax
movq (%rdi), %rdi
testq %rdi,%rdi
jne .L3

第三行的movq指令是这个循环中的关键瓶颈

存储的性能

存储操作并不影响任何寄存器,一系列存储操作都不会产生数据相关,下面所示函数说明了加载和存储操作之间可能相互影响

void weite_read(long *src, long *dst, long n)
{
long cnt = n;
long val = 0;

while(cnt){
*dst = val;
val = (*src) + 1;
cnt--;
}
}

示例A:write_read(&a[0], &a[1], 3)
示例A从src读出的结果不受对dst写的影响,在较大次数的迭代上测试这个示例得到CPE等于1.3

示例B:write_read(&a[0], &a[0], 3)

这种情况下,*src的每次加载都需要用到 *dst的前次执行的存储值,我们称之为写/读相关——一个内存读的结果依赖于一个最近的写,示例B的CPE为7.3,写/读相关导致处理速度下降了6个时钟周期。

为了研究为什么一种情况比另一种情况慢,我们仔细看看加载和存储执行单元



存储单元包含一个存储缓冲区,它包含已经发射到存储单元而又还没有完成的存储操作的地址和数据,这里的完成包括更新数据高速缓存。提供这样的缓冲区使得一系列存储操作不必等待每个操作都更新高速缓存就能执行,当一个加载操作发生时,他必须检查存储缓冲区的条目,看看有没有地址相匹配,如果有地址相匹配,它就取出相应的数据条目作为加载操作的结果。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: