CSAPP:优化程序性能(三)

来源:互联网 发布:制作动画片软件是什么 编辑:程序博客网 时间:2024/06/04 18:12

理解现代处理器

之前所讲述的优化策略都不依赖于目标机器的任何特性,这些优化只是简单的降低了过程调用开销、消除妨碍编译器优化的因素,随着师徒进一步提升性能,必须考虑利用处理器的微体系结构的优化,也就是处理器用来执行指令的底层系统设计。

由于现代微处理器的复杂性,处理器的实际操作与通过观察机器级程序所察觉到的程序行为是大相径庭的,在代码级上似乎是一次执行一条指令。每条指令包括从内存或寄存器取值,执行操作,把结果写会内存或者寄存器,但是在实际的处理中是同时对多条指令求值的,这个现象称为指令级并行

现代微处理器的了不起之处就在于——多条指令并行执行,同时又呈现出一种顺序执行的表象。

两种下界描述了程序的最大性能:

1.延迟界限

当一系列操作必须严格按照顺序执行的时候就会遇到延迟界限,因为在下一条指令开始执行之前,这条指令必须结束。

2.吞吐量界限

吞吐量界限体现了处理器功能单元的原始计算能力,这个界限是程序性能的终极限制。


整体操作

一个现代微处理器的简化示意图


这些处理器在工业界成为超标量,每个时钟周期执行多个操作,而且是乱序的——指令的执行顺序不一定要与它们在机器级程序中的顺序一致。

整体设计有两个主要部分——指令控制单元(ICU Instruction Control Unit)和执行单元(EU Execution unit),前者负责读出指令序列,并根据指令序列生成一组针对程序数据的基本操作后者执行这些操作。

ICU从指令高速缓存读取指令,指令高速缓存保存着最近访问的指令,通常在正在执行的指令很早之前取指(Fetch),这样才有足够的时间进行译码(Decode),并把操作发送至EU,当程序遇到分支(特指条件转移指令)时,有两种可能,选择分支,控制传递到分支目标和不选择分支。控制被传递到指令序列的下一条指令,现代处理器采用了分支预测技术,猜测是否选择分支以及分支的目标地址。

标记为取指控制的块承担着分支预测以及确定取哪些指令的任务。

标记为指令译码的块接收实际程序指令,并将它们转化为一组基本操作,例如:

addq %rax, 8(%rdx)  会被分解为3个操作,从内存加载一个值到处理器,将加载进来的值与寄存器%rax的值相加,将结果存回内存。

EU接受来自ICU的操作,这些操作被分配到功能单元中。

数据高速缓存存储着最近访问的数据。

使用推理执行的技术对操作之求值,最终结果不会存放在寄存器或者内存中,知道处理器能够确定应该实际执行这些指令。

分支操作送到EU中不是确定分支该往哪里执行(CPU总是预测分支会跳转),而是确定分支预测是否正确,如果预测错误,EU会丢弃分支点之后计算出的结果并发信号给分支单元告诉它预测错误并指出正确的分支目的,分支单元开始在新的位置取指。

退役单元记录正在进行的处理,确保遵守机器级程序的顺序语义,指令译码时,关于指令的信息存储在一个先进先出的队列中,信息会一直在队列中保存,知道发生以下两个结果之一,首先,一旦一条指令操作完成了,而且所有引起这条指令的分支点都被确认预测正确,那么这条指令就可以退役了,所有对程序寄存器的更新都可以执行了;另一方面如果一起该指令的某个分支点预测错误,这条指令会被清空,丢弃所有计算结果,通过这种方法来保证预测错误不会改变程序的状态。

控制操作数在执行单元间传送的最常见机制成为寄存器重命名当一条更新寄存器r的指令译码时,产生标记t,得到指向该操作结果的唯一标识符,条目(r,t)被加入一个表中,表中维护着每个会更新程序寄存器r与会更新寄存器操作的标记t之间的关联,当随后以寄存器r作为操作数的指令译码时,发送到执行单元的操作会包含t作为源操作数的值,当某个执行单元完成第一个操作生成一个结果(v,t)指明标记t产生的值v,所有等待t作为源的操作都可以使用v作为源值。


循环展开

通过增加每次迭代计算的元素数量,减少循环迭代的次数

使用循环展开修改combine4得到程序combine5

void combine5(vec_ptr v, data_t *dest){    long i;    long length = vec_length(v);    long limit = length - 1;    data_t *data = get_vec_start(v);    data_t acc = IDENT;        for(i = 0; i<limit, i+=2){        acc = (acc OP data[i]) OP data[i+1];    }        for(; i<length; i++){        acc = acc OP data[i];    }    *dest = acc;}
查看程序CPE度量表



对于整数加法,CPE有所改进,得益于减少了循环开销操作,降低了开销操作的数量,此时,整数加法的一个周期的延迟成了限制性能的因素,另一方面,其他情况并没有性能提高——它们已经达到了其延迟界限。


提高并行性

在此,程序的性能受运算单元的延迟的限制,执行加法和乘法的功能单元是完全流水线化的,这意味着每个时钟周期可以开始一个新的操作,并且有些操作可以被多个功能单元执行。硬件具有以更高速率执行乘法和加法的潜力,但是代码不能利用这种能力,即使使用循环展开也不行,这是因为——我们将累计值放在一个单独的变量acc中,在前面的计算完成之前都不能计算acc的新值。虽然计算acc新值的功能单元能够每个时钟周期开始一个新的操作,但是它只会每L个周期开始一个新的操作,这里的L是合并操作的延迟,现在我们来打破这种顺序相关,得到比延迟界限更好的性能的方法。


多个累计变量

对于一个可结合和可交换的合并运算来说,比如整数加法和乘法,我们可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。

假如P表示元素a0, a1, a2...,an-1的乘积,假设n为偶数,我们可以把它写成P = PE * PO

PE = (0<= i <= n/2-1)a2i

PO = (0<= i <= n/2-1)a2i+1

我们使用这种方法改写combine5

void combine6(vec_ptr v, data_t *dest){    long i;    long length = vec_length(v);    long limit = length-1;    data_t *data = get_vec_start(v);    data_t acc0 = IDENT;    data_t acc1 = IDENT;    for(i=0; i<limit; i++){        acc0 = acc0 OP data[i];        acc1 = acc1 OP data[i+1];    }    for(; i<length; i++){        acc0 = acc0 OP data[i];    }    *dest = acc0 OP acc1;}

这段代码既使用了两次循环展开,以使每次迭代合并更多元素,也使用了两路并行,将索引值为偶数的元素合并到acc0,索引值为奇数的元素合并到acc1,我们称其为“2*2循环展开”。

查看combine6的CPE度量表



我们打破了由延迟界限设下的限制,处理器不再需要延迟一个加法或者乘法以待前一个操作完成。


重新结合变换

现在来讨论另外一种打破顺序相关,是性能提高到延迟界限之外的方法。

void combine7(vec_ptr v, data_t *dest){    long i;    long length;    long limit = length - 1;    data_t *data = get_vec_start(v);    data_t acc = IDENT;    for( i=0; i<limit; i+=2){        acc = acc OP (data[i] OP data[i+1]);    }    for(; i<length; i++){        acc = acc OP data[i];    }    *dest = acc;}

combine7 与 combine5的唯一区别在于

combine5在第一个循环体中是

acc = (acc OP data[i]) OP data[i+1];

combine7在第一个循环体中是

acc = acc OP (data[i] OP data[i+1]);
差别仅在于两个括号是如何放置的,我们称之为重新结合变换,括号改变了向量元素与累积值acc的结合顺序,产生了“2*1a”的循环展开模式。


结果令人吃惊,整数加的性能与combine5(K * 1展开版本)相同,其他三种情况则与使用了并行累计变量的combine6版本相同,是K*1扩展性能的2倍,这些情况已经突破了延迟界限造成的限制。

因为每次迭代内的第一个乘法(data[i] OP data[i+1])都不需要等待前一次迭代的累计值就可以执行。

对于整数加法和乘法,这些运算可结合,这表示对于重新变换顺序对结果没有影响,对于浮点数情况,必须再次评估这种重新结合是否有可能严重影响结果。


终上所述就是利用循环展开,降低循环次数(K1),添加多个累积变量(K2)来使用“K1 * K2”循环展开。


还可以使用向量指令大道更高的并行度

首先了解一下SIMD(Single-Instruction, Multiple-Data)单指令多数据,顾名思义,一条指令可以操作多条数据

Intel在1999年引入了SSE(Strenming SIMD Extention)作为对SIMD的扩展

SSE历经几代,最新的版本为AVX(Advanced vector extention)高级向量扩展

SIMD执行模型是使用单条指令对整个向量数据进行操作,这些向量保存在一组特殊的向量寄存器中,名字为%ymm0~%ymm15,目前的AVX寄存器长为32字节,也就是256bit.

256/32 = 8; 256/64 = 4  可以保存8个32位数或者4个64位数,这些数据既可以是整数也可以是浮点数,AVX指令可以对这些寄存器执行向量操作,比如执行8组数值或者4组数值的加法或乘法。

例如YMM寄存器%ymm0包含8个float,用a0, a1...,a7来表示,%rcx寄存器包含8个float数内存地址b0,b1,.., b7

vmulps (%rcx), %ymm0, %ymm1
会从内存中读出8个值,并行执行8个乘法,计算ai * bi并保存在%ymm1中。

AVX的指令集不包括对64位整数的并行乘法指令,因此GCC无法为此种情况生成向量代码。

原创粉丝点击