深入理解计算机系统阅读笔记-第五章

第五章 优化程序性能

程序效率最重要的两点:
1、算法和数据结构
2、编译器可以高效优化的源代码
对于第二点,理解优化编译器的能力和局限性非常重要。程序的小变动都会引起编译器优化方式很大的变化。

通常,程序员要在程序源码简单易懂和实际运行速度之间做权衡。本章只考虑后者。

编译器分为与机器无关和与机器有关两类。

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

编译器优化首先因素:
1、决不能改变正确的行为
2、编译器对程序行为,对使用它们的环境了解有线
3、需要很快的完成编译工作

例如下面这2个程序看起来是一样的,f2的效率更高。但当xp和yp相等时,这两个程序的结果就不一样了,所以考虑到这种现象(这种现象称为存储器别名使用:memory aliasing),编译器不会将f1的代码优化成f2的形式。

void f1(int *xp, int *yp)
{
    *xp += *yp;
    *xp += *yp;
}

void f2(int *xp, int *yp)
{
    *xp += *yp * 2;
}

例如下面2个函数看起来也是一样的

int f(int);

int f1(x)
{
    return f(x) + f(x) + f(x) + f(x);
}

int f2(x)
{
    return 4 * f(x);
}

但当函数f的实现如下,改变全局变量,那上面两函数的结果就不同了。编译器需要考虑这种情况。

int counter = 0;

int f(int x)
{
    return counter++;
}

5.2 表示程序性能

每元素的周期数(cycles per element,CPE)可用来度量程序的性能。常用在理解循环性能上

时钟周期也可以度量程序性能。

void vsum1(int n)
{
    int i;

    for (i = 0; i < n; i++)
        c[i] = a[i] + b[i];
}

void vsum12(int n)
{
    int i;

    for (i = 0; i < n; i += 2) {
        c[i] = a[i] + b[i];
        c[i+1] = a[i+1] + b[i+1];
    }
}

上面程序的性能如下 

5.3 程序示例

阅读数据的代码结构和它的存储结构如下

typedef struct {
    int len;
    data_t *data;
} vec_rec, *vec_ptr;

   

向量有头信息加上指定长度的数组来表示。

这个声明用数据类型data_t作为基本元素的数据类型。为了度量数据类型int、float、double的性能,需要分别为不同的类型声明编译和运行程序,比如typedef int data_t;
除了头外,还会分配一个len个data_t类型对象的数组,来存放实际的向量元素。

下图是一些生成向量、访问向量元素以及获取向量长度的基本过程

vec_ptr new_vec(int len)
{
    vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
    if (!result)
        return NULL;
    result->len = len;
    if (len > 0) {
        data_t *data = (data_t *)calloc(len, sizeof(data_t));
        if (!data) {
            free((void*)result);
            return NULL;
        }
        result->data = data;
    }
    else
        result->data = NULL;

    return result;
}

int get_vec_element(vec_ptr v, int index, data_t *dest)
{
    if (index < 0 || index >= v->len)
        return 0;

    *dest = v->data[index];

    return 1;
}

int vec_length(vec_ptr v)
{
    return v->len;
}

在实际过程中国,data_t被声明为int、float或double。
get_vec_element会进行边界检查,这降低程序出错的几率,但也降低了性能。

下图是一个优化示例,它根据某种运算,将一个向量中所有的元素合并(combining)成一个值。

void combnine1(vec_ptr v, data_t *dest)
{
    int i;

    *dest = IDENT;
    for (i = 0; i < vec_length(v); i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OPER val;
    }
}

 通过对IDENT和OPER的不同定义,上面的代码可以重新编译成对数据执行不同的运算,比如如下定义分别用于求和和求乘积

#define IDENT 0
#define OPER +
#define IDENT 1
#define OPER *

 作为一个起点,下面是combine1的CPE度量值,它运行在Intel PentiumIII上,尝试了数据类型和合并运算的所有组合。因为单、双精度浮点数据的时间基本上是相等的,所以只列出了单精度浮点数据的度量值

 默认地编译命令,编译器几乎不做优化。将命令开关设置为-O2就可以开启优化。

5.4 消除循环的低效率

上一节的combine1函数的循环中调用vec_length作为for循环的测试条件,每次都要计算。实际上向量长度不会随着循环的进行而变化,所以可以增加一个临时变量存储它,作为for的测试条件。此时的性能得到大幅提升,每个向量元素节省了约10个时钟周期。

这类优化称为代码移动(code motion) 的优化实例。通常编译器无法判断这种改动是否有副作用,所以无法进行优化,这就需要程序员进行显式的优化。

5.5 减少过程调用

combine1中,每次循环还调用了get_vec_element,因为每次要做边界检查 ,开销特别大。可以改成如下代码

data_t *get_vec_start(vec_ptr v)
{
    return v->data;
}

void combine3(vec_ptr v, data_t *dest)
{
    int i;
    int length = vec_length(v);
    data_t * data = get_vec_start(v);

    *dest = IDENT;

    for (i = 0; i < length; i++) {
        *dest = *dest OPER data[i];
    }
}

它的性能如下,可以看到效率又提高了。

 

5.6 消除不必要的存储器引用

在combine3中将整数作为数据类型,乘法作为合并操作,其汇编代码如下:

1    .L18:                            loop:
2      movl (%edi),%eax                 Read *dest
3      imull (%ecx,%edx,4),%eax         Multiply by data[i]
4      movl %eax,(%edi)                 Write *dest
5      incl %edx                        i++
6      cmpl %esi,%edx                   Compare i: length
7      jl .L8                           If<,goto loop 

其中寄存器%ecx指向data,%edx包含i的值,而%edi指向dest 。

指令2读取存放在dest中的值,指令4写回这个位置。这看上去是种浪费,因为正常情况下,下一次迭代时指令2读取的值会是刚刚写回的那个值。可以通过如下代码进行优化

void combine4(vec_ptr v, data_t *dest)
{
    int i;
    int length = vec_length(v);
    data_t * data = get_vec_start(v);
    data_t x = IDENT;
    *dest = IDENT;

    for (i = 0; i < length; i++) {
        x = x OPER data[i];
    }

    *dest = x;
}

引入临时变量x,它在循环内部存储计算出来的值,循环结束后再存放到*dest中。其汇编如下

1    .L24:                        loop:
2      imull (%eax,%edx,4),%ecx     Multiply x by data[i]
3      incl %edx                    i++
4      cmpl %esi,%edx               Compare i:length
5      jl .L24                      If<,goto loop

可见编译器用寄存器%eax保存累计值。与combine3相比,每次迭代的存储器操作从两次读一次写减少到只需要一次读。寄存器%ecx和%edx的使用和前面一样,但是不在需要引用*dest。其性能如下:

 

 效率提升最大的是浮点数乘法。后面5.11.1中将说明原因。

5.7 理解现代处理器

前面都是代码级优化,不依赖与目标机器的任何特性,为了更高的效率,还需要根据目标处理器的特性进行调整。所以需要一个关于现代处理器是如何工作的简单操作模型。比如从汇编代码上看,程序时一条一条串行的,但在实际的处理器中,是多条指令并行的。

5.7.1 整体操作

下图是一个简化的现代微处理器示意图,主要有ICU(Instruction Control Unit,指令控制单元)和EU(Execution Unit,执行单元)组成。ICU负责从存储器读出指令序列,并根据这些指令序列生成一寸针对程序数据的基本操作;后者执行这些操作,以及指出分支预测是否正确。

ICU从指令高速缓存(instruction cache)中读取指令,通常是在当前正在执行的指令很早之前取址,所以它有足够的时间对指令解码,并把操作发送到EU。

现代处理器采用分支预测(branch prediction)技术,预测是否选择分支,并预测分支的目标地址。还有投机执行(speculative execution)技术,处理器会开始取出它预测的分支处的指令并对指令解码,甚至于在它确定分支预测是否正确之前就开始执行这些操作。如果过后它确定分支预测错误,会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。另一种异乎寻常的技术是两个分支方向的指令都执行,随后在抛弃不正确的结果。分支预测是在取址控制的块实现的。

指令解码逻辑接收实际的程序指令,并将它们转换成一组基本操作。每个操作都完成某个简单的计算任务,例如两个数相加,从存储器读数据,或向存储器写数据。对于具有复杂指令的机器,可能将一条指令解码成可变数量的操作。每个处理器设计的详细情况都有所不同,但我们试着描述一种典型的实现。在这种机器上,有如下解码方式

下面这个指令解码产生1个加法操作

addl %eax, %edx                            

下面这个指令解码产生3个操作:一个操作从存储器加载一个值到处理器中,一个操作将加载尽量的值加上寄存器%eax中的值,一个操作将结果存回到存储器。 这种解码逻辑分解指令的操作,实现了在一组专门的硬件单元之间的任务分割。然后,这些单元可以并行地执行乘法指令的各个部分。对于具有简单指令的机器,操作更济民地应对于原始的指令。

addl %eax, 4(%edx)

 EU接收来自指令读取单元的操作。通常,它回每个时钟周期接收若干个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元是专门用来处理特定类型的操作。前面现代微处理器示意图中的单元如下:

整数/分支:执行简单的整数操作(加法、测试、比较、逻辑)。还处理分支,下面会讨论。

通用整数:可以处理所有的整数操作,包括乘法和除法。

浮点加法:处理简单的浮点操作(加法、格式转换)。

浮点乘法/除法:处理浮点乘法和除法。更复杂的浮点指令,如超越函数(transcendental function),会被转换成操作的序列。

加载:处理从存储器读数据到处理器的操作。这个功能单元有一个加法器来执行地址计算。

存储:处理从处理器到存储器的写操作。这个功能单元有一个加法器来执行地址计算。

数据高速缓存:是一个高速存储器,包含最近访问的数据值,加载和存储单元通过它来访问存储器。

退役单元(Retirement Unit):记录正在进行的处理,并确保它遵守机器级程序的顺序语义。图中国的寄存器文件:包含整数和浮点数寄存器,是退役单元的一部分,因为退役单元控制这些寄存器的更新。指令解码时,关于指令的信息被放置在一个先进先出的队列中,这个信息会一直保持在队列中,直到两个结果中的一个发生。首先,一旦指令的操作完成了,而所有导致这条指令的分支点也都被确认预测正确,那么这条指令就可以退役了,所有对程序寄存器的更新都可以被实际执行了。另一方面,如果导致该指令的某个分支点预测错误,这条指令就会被清空,对齐所有计算出来的值。通过这种方法,错误的预测就不会改变程序状态了。

如前所述,任何对程序状态的更新都只会在指令退役时才会发生,只有在处理器能够确信导致这条指令的所有分支都预测正确了,才能这样做。为了加速一条指令到另一条指令的结果的传送,许多此类信息是在执行单元之间交换的,即图中的“操作结果”。如图中箭头所示,执行单元可以直接将结果发送给彼此。

最常见的控制操作数在执行单元间传送的机制称为寄存器重命名(register renaming)。当一条更新寄存器r的指令解码时,产生标记t(tag t),得到一个指向该操作结果的唯一的标识符。条目(r,t)被加入到一张表中,该表维护着每个程序寄存器与会更新该寄存器的操作的标记之间的关联。当随后以寄存器r作为操作数的指令解码时,发送到执行单元的操作会包含t作为操作数源的值。当某个执行单元完成第一个操作时,会生成一个结果(v,t),指明标记为t的操作产生值v。此时,所有等待t作为源的操作都能使用v作为源值了。通过这种机制,值可以直接从一个 操作传递到另一个操作,而不是写到寄存器文件在读出来。重命名表只包含关于有未进行写操作的寄存器条目。当一条已解码的指令需要寄存器r,而又没有标记与这个寄存器相关联,这个操作数可以直接从寄存器文件中获得。有了寄存器重命名,即使只有在处理器确定了分支结果之后才能跟新寄存器,也可以预测着执行操作的整个序列。

5.7.2 功能单元的性能

下图是常见处理器一些基本操作的性能,每个操作都是由两个周期计数值来表示:
执行时间(latency):它指明功能单元完成操作所需要的总周期;
发射时间(issue time):它指明连续的、独立操作之间的周期数。

在一个流水线化的单元中,发射时间比执行时间短。流水线化的功能单元是作为一系列阶段来实现的,每个阶段完成操作的一部分。例如一个典型的浮点加法器包含三个阶段:
1、处理指数值
2、将小数相加
3、四舍五入计算最后的结果
操作可以 连续地通过各个阶段,而不是等待一个操作完成后再开始下一个。只要当要执行的操作是连续的、逻辑上独立地,才能运用这种功能。大多数单元能够每个时钟周期开始一个新的操作。仅有浮点乘法器要求连续的操作之间至少要有两个周期,而两根除法器根本就没有流水线化。

5.7.3 更近地观察处理器操作

下面介绍一种文本表示法来描述指令解码器产生的操作,还有一种图形化的表示法来显示功能单元对操作的处理。当然他们都无法准确的表示具体的、现实的处理器的实现,它们简单的帮助理解处理器在执行程序时能够如何利用并行性和分支预测。

将指令翻译成操作

我们关注combine4的循环部分,整数数据的乘法汇编代码如下:

combine4:type=INT,OPER=*
data in %eax, x in %ecx, i in %edx, length in %esi
1    .L24:                                loop:
2      imull (%eax,%edx,4),%ecx             Multiply x by data[i]
3      incl %edx                            i++
4      cmpl %esi,%edx                       Compare i:length
5      jl .L24                              If<,goto loop

 每次处理器执行这个循环时,指令解码器将这4条指令翻译成执行单元的一个操作序列。第一次迭代时,i=0,我们假定的机器会发射下面的操作序列:

将乘法指令的存储器引用转换成一条显式的load指令,它将数据从存储器读到处理器。给每个迭代都变化的值分配操作数标号(operand label) 。这些标号是寄存器重命名生成的标记的风格化版本。因此,循环开始处,寄存器%ecx中的值由标号%ecx.0标识,更新后由%ecx.1标识。这次迭代与下次迭代不变化的寄存器值可以在解码时直接从寄存器文件中获得。我们还引入了标号t,1来表示load操作读取的、传送到imull操作的值,而我们显式地给出了操作的目的地。因此,一对操作

load(%eax, %edx.0, 4) -->   t.1
imull t.1, %ecx.0     -->   %ecx.1

表明,处理器首先执行一条load操作,用%eax的值(这个值在循环中不会改变) 和循环开始时存放在%edx中的值来计算地址。这会产生一个临时值,标号为t.1。然后,乘法操作获取这个值和循环开始时%ecx的值,产生一个%ecx的新值。正如这个例子说明的那样,标记可以与并不会写到寄存器文件中的中间值相关联。

操作:cmpl %esi, %edx.1  --> cc.1
指明,比较操作(由两个整数单元中的一个执行)比较%esi中的值(这个值在循环中不会改变)和新计算出来的%edx的值。然后,它会设置标号cc.1标识的条件码。正如这个例子说明的那样,处理器可以用重命名来记录对条件码寄存器的改变。

最后,预测跳转指令会选择分支。跳转指令
jl-taken cc.1
检查新计算出来的条件码的值(cc.1)是否表明这是个正确的选择。如果不是,那么它会发信号给ICU,告诉它在jl后面的指令处开始取指令。为了简化表示法,我们省略了所有关于可能的跳转目的地的信息。实际上,处理器必须记录未被预测方向的目的地,这样,在预测错误时,它可以从那里开始取址。
 如这个示例翻译表明的那样,我们的操作在许多方面模仿了汇编语言指令的结果,除了它们是用标识寄存器不同实例的标号来引用它们的源和目的操作的。在实际硬件中,寄存器重命名动态地给标记赋值,使之指向这些不同的值。标记是位模式而不是像%edx.1这样的符号名字,但是它们提供的用途是一样的。

执行单元的操作处理

通过计算图(computation graph)表示,圆角框表示操作,框的高度代表周期(load需要3个周期,imull需要4个周期,其他只需要一个周期);箭头代表操作之间的数据传递

下图是加法操作

有无限资源的操作调度

下图是combine4在乘法上的3次迭代计算图。每次迭代都有一组5个操作,只是操作数标号有递增。

可以看到,只有前一次迭代incl操作完成,下一次循环就可以开始了,所以它们是并行的。
还可以看到流水线化的效果,每个迭代从头到尾需要七个周期,但是随后的迭代4个周期就能完成。所以有效的处理频率是每4周期一次迭代,CPE为4.0

下图是加法的,可以看到它的CPE达到了1.0

资源约束下的操作调度

真实的处理器只有固定数目的功能单元。下图是有资源约束的处理器上,combine4乘法的操作调度。

受资源约束,处理器必须要有调度策略,在有多个选择时,它要确定应该执行哪个操作。比如在周期3中,相比于无限资源的图5.15,我们延时了第三次循环的incl 。我们通过记录操作的程序顺序(program order)来做到这一点。程序顺序也就是如果我们按照严格的顺序来执行机器级程序,操作执行的顺序。那么我们会根据操作的程序顺序赋给它们优先级。此例中,迭代3在最后,所以延迟迭代3的incl操作。

在乘法操作中,资源并没有影响的程序性能,但加法操作就会了,如下图,每次迭代要有四个整数或分支操作,而只有两个功能单元。因此八个周期完成4次迭代,CPE2.0

通常,处理器性能受3类约束限制:
1、程序中的数据相关性迫使一些操作延迟直到它们的操作数被计算出来。因为功能单元有一个或多个周期的执行时间,这就设置了一个给定的操作序列执行周期数的下界。
2、资源约束限制了在任意给定时刻能够执行多少个操作。 如上面例子中功能单元的有限数量,其他的包括功能单元流水线化的程度,以及ICU和EU中其他资源的宪政。
3、分支预测逻辑的成功限制了处理器能够在指令流中超前工作以保持执行单元的使用效率,但发生预测错误时,处理器从正确的位置重新开始都会引起很大的延迟。

5.8 降低循环开销

循环展开(loop unrolling)技术可以减少迭代,降低开销。

void combine5(vec_ptr v, data_t *dest)
{
    int i;
    int length = vec_length(v);
    int limit = length -2;
    data_t * data = get_vec_start(v);
    data_t x = IDENT;

    for (i = 0; i < limit; i += 3) {
        x = x OPER data[i] OPER data[i+1] OPER data[i+2];
    }

    for (; i < length; i++) {
        x = x OPER data[i];
    }
    *dest = x;
}

上面c程序对应的汇编代码和操作的翻译如下

对应的图如下

 循环运行如下

测试的各种展开性能如下

 

5.9 转换到指针代码

大多数情况,数组和指针的性能完全一样,有时,可以使用指针代替数组改进性能。

5.10 提高并行性

5.10.1 循环分割(loop splitting)

5.10.2 寄存器溢出(register spilling)

5.10.3 对并行的限制

5.11 综合:优化合并(Combing)代码的效果小结

5.12 分支预测和预测错误处罚

5.13 理解存储器性能

5.14 现实生活:性能提高技术

5.15 确认和消除性能瓶颈

5.16 小结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值