Overview
这一章的主题是如何让程序运行的更快,这里的程序的算法已经确定,并且程序能够正确运行。
Generally Useful Optimizations
可以将重复计算的东西移到循环外面,避免重复的计算
Reduction in Strength
- 用简单的操作降低花费大的操作
- shift add代替乘除法
Share Common Subexpressions
相同的表达式可以降低重复使用
Optimization Blocker #1: Procedure Calls 过程调用
上面代码实际上很难看出问题,但是实际上这样的代码时间复杂度为O(n^2) 因为每个loop中我们都调用了strlen(s)函数!该函数是线性的函数。
提升效率的方法如下:
即将strnlen(s)函数移出循环
Memory Matters
下面再看一个糟糕的代码例子,我们要在内存和寄存器中来回移动数据
代码每次迭代都会更新b[i]的值,这样我们就把一个值从内存中拿出来,再放到另一个内存中去。
Memory Aliasing
当程序中的不同部分指向内存中的同一位置,称为别名。并且C编译器无法知道是否存在别名。
因此局部变量可以告诉编译器不要重复调用函数,不用重复在内存和寄存器中来回移动数据,只需要暂时存储在某个地方。
这样编译器会自动分配一个寄存器,并且把东西放在寄存器中
Removing Aliasing
如上面的代码,我们将a[i*n+j]的代码暂时保存在val中,然后循环外直接赋予b[i]。
这里,对内存的读写实际上会限制程序的性能,因此改写之后不会明显加快。
因此要习惯例子中引入局部变量的写法,这样可以告诉编译器不要一遍又一遍的调用相同的函数,不要一遍又一遍的读取和写入相同的内存位置,只需要将其保存在临时位置就好,然后编译器会自动分配一个寄存器并将结果放在寄存器中,一切都会很棒。
and后面讲了很多处理器级别的优化,听的有点懵。。
Benchmark Example: Data Type for Vectors
定义一个数据结构,这看起来像pascal实现的方式,在一种语言中实现数组的典型方式是,使用一个数据结构不仅保存数组中的值,还保存与之相关的其他信息,例如他的大小,所以这是一种很好的抽象方式,你编写的代码可以确保,如果你对数组的访问越界了,那么你会得到一个错误的信息。
下面看到的函数的功能是,从数组中取出索引值对应的元素,传递一个指针,然后该指针被赋值为数组中索引对应的元素。
此函数的返回值为0或10表示失败,1表示成功。将元素的数据类型定义为 data_t,这样可以修改data_t的定义,然后重新编译,
data_t可以定义为 int, long,float,和double,我们将会看到性能特征如何随着不同的数据类型而变化。
Benchmark Computation
使用的基准测试非常简单,对于这个数组,计算数组中所有元素的总和,上面使用了两个宏定义IDENT和OP,OP定义为加法,且IDENT定义为0。或者OP定义为乘法, 且IDENT定义为1。所以我们有八种不同的组合
Cycles Per Element (CPE)
cpe代表了处理一个元素所花的时间周期,用cpe这个指标的原因是通常当你编写代码遍历处理一个数组的时候,你并不关心对一个元素的处理需要多少秒或者多少微秒或者多少纳秒,你想知道它的整体的性能特征是什么。进行低级代码优化的时候也是这样,使用处理器内部时钟的时钟周期作为时间单位更有用,而不是以纳秒这样的单位。因为一个处理器是以2Ghz还是以2.3Ghz运行,程序员是无法控制的,但是程序员可以控制程序中不同的计算部分使用了多少个时钟周期。
只改变编译器的优化选项,可以将每个元素降低到10个时钟周期。然后进行一些优化,减少程序中的冗余
前面看到,每次调用get_vec_element函数,该函数都会检查数组索引是否越界,一遍又一遍的检查数组越界是一种很愚蠢的行为,
因此当遍历的时候,先得到它的长度来确定要访问的元素数量,放弃函数的边界检查,可以引入一个函数,返回数组的长度,数组长度以外的东西直接忽略,所以重新写一个循环,引入局部变量,先把数据累加到临时变量,然后再赋值给dest,这样程序就实际上变得更快了,优化如下。
Basic Optimizations
Effect of Basic Optimizations
它把整数的加法降低到了一个多时间周期,整数乘法降低到3个时钟周期,双精度乘法降低到了5个时钟周期。
Modern CPU Design
实际上现代的CPU设计是非常困难的。CPU提供庞大的硬件基础设施,使程序运行速度比一次执行一条指令快,采用了一种被称为超标量乱序执行的技术,这个想法粗略的讲,可以认为你的程序是一个顺序执行的指令序列,CPU尽可能读取多的指令序列,然后CPU把读入的指令拆开,发现有的指令之间不是相互依赖的,所以我可以开始执行程序后面的代码而不是当前的代码,因为他们彼此独立,这也被称为指令级并行性(instruction level parallelism),也就是即使你的程序是一个顺序的指令序列,但实际上,这些代码可以拆分成不同的部分,某些部分相互依赖,某些部分独立。
上图的上半部分显示了获取指令的方法。
Pipelined Functional Units
乱序执行比较复杂,但是功能单元比你想的可能更加复杂,功能单元使用了流水线技术(woc这就是我现在上computer architecture迷糊的地方)
流水线的基本思想是将计算分解为一系列不同的阶段。
一个简单的例子是你要计算 a*b+c的值,你先做乘法,然后做加法,但是乘法器做乘法比人要麻烦,乘法器会将乘法分解为一个接一个完成的步骤,可以认为每个阶段都有单独的专用硬件,然后可以做流水线操作。也就是,当一个操作从一个阶段移动到下一个阶段的时候,前一个阶段空出来了,你可以填入新的数据。
PPT上的例子,乘法器分为三个阶段,计算a*b和a*c,然后把两个积相乘,要注意的是a*b和a*c不以任何方式产生依赖,所以我们可以同时计算他们两个的乘积,但是我们没有两个乘法器,所以可以分段做。
由于流水线的操作,上面的操作只需要7个时钟周期,即使每个乘法操作都需要3个cycle。
Haswell CPU
指令有两个参数,延迟(latency)是一个指令从头到尾需要多长的时间,但由于有流水线操作,还有一个参数(cycles/issue)表示两个小步骤之间的距离。
注意除法是非常慢的,并且没有流水线技术,相对而言在大部分的机器上,除法都是一项比较昂贵的操作。
所以有人在弹幕里提到:在开源库里,除数不变的批量除法运算都会换成乘除数的倒数
Combine4 = Serial Computation (OP = *)
如果我们把程序的计算顺序画成上图中的样子,程序进行一系列的乘法运算,并在开始下一个之前,需要上一个的计算结果,这样我们即便有一个流水线乘法器,程序本身限制了所有的乘法必须按照顺序执行。
所以让我们看看我们能否超越那个延迟界限(latency bound)
Loop Unrolling (2x1)
这里采用了一种技术,叫做循环展开,基本思想是:在循环中计算一个多值,而不是一个值。上图中展示了2*1的循环展开,也就是说每次循环处理数组的两个元素。
这样的处理我们会发现速度没有很快的提升,只有加法稍微快了一点点。因为这样的改动我们实际上还是需要顺序依赖。
Loop Unrolling with Reassociation (2x1a)
但是下图中 将括号的位置改变了,速度快了很多
Effect of Reassociation
Reassociated Computation
改动后的代码的关键路径变为了原来的一半,因此所用的时间减少了一半。
对于整形数据的加法和乘法进行这种转换没有问题,但是我们知道不能用于浮点数,不满足结合律,可能会出现舍入、溢出的情况。
现在有一套新的界限,这个界限是你程序能够达到了最好的性能,延迟界限是指在一系列操作必须严格顺序执行时,执行一条指令所要花费的全部时间,但是其实还有一个更基本的界限,称为吞吐量限制,这个限制是基于硬件的数量和性能,基于功能单元的基本计算能力。