深入理解计算机系统系列文章目录
第一章 计算机的基本组成
1. 内容概述
2. 计算机基本组成
第二章 计算机的指令和运算
3. 计算机指令
4. 程序的机器级表示
5. 计算机运算
6. 信息表示与处理
第三章 处理器设计
7. CPU
8. 处理器体系结构
9. 优化程序性能
10. 其他处理器
第四章 存储器和IO系统
11. 存储器的层次结构
12. 存储器和I/O系统
文章目录
前言
写程序最主要的目标:在所有可能的情况下都正确工作+让程序运行得快
编写高效程序需要做到以下几点:
- 选择一组适当的算法和数据结构。
- 编写出编译器能够有效优化以转换成高效可执行代码的源代码。
- 针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和多处理器的某种组合上并行地计算。
在程序开发和优化的过程中,我们必须考虑代码使用的方式,以及影响它的关键因素。
通常,程序员必须在实现和维护程序的简单性与它的运行速度之间做出权衡。
对于在性能重要的环境中反复执行的代码,进行大量的优化会比较合适。
一个挑战就是尽管做了大量的变化,但还是要维护代码一定程度的简洁和可读性。
理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的、具有指定行为的机器级程序。
妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。
程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务。
这包括消除不必要的函数调用、条件测试和内存引用。
这些优化不依赖千目标机器的任何具体属性。
第二步,利用处理器提供的指令级并行(instruction-levelparallelism)能力,同时执行多条指令。
在本章的描述中,我们使代码优化看起来像按照某种特殊顺序,对代码进行一系列转换的简单线性过程。
要确切解释为什么某段代码序列具有特定的执行时间,是很困难的。
性能可能依赖于处理器设计的许多细节特性,而对此我们所知甚少。
这也是为什么要尝试各种技术的变形和组合的另一个原因。
参考资料
《深入理解计算机系统》
《深入浅出计算机组成原理》
一、优化编译器的能力和局限性
现代编译器运用复杂精细的算法来确定一个程序中计算的是什么值,以及它们是被如何使用的。
然后会利用一些机会来简化表达式,在几个不同的地方使用同一个计算,以及降低一个给定的计算必须被执行的次数。
大多数编译器,包括GCC,向用户提供了一些对它们所使用的优化的控制。
最简单的控制就是指定优化级别。
可能增加程序的规模,也可能使标准的调试工具更难对程序进行调试。
二、表示程序性能
我们引入度量标准每元素的周期数(CyclesPerElement,CPE),作为一种表示程序性能并指导我们改进代码的方法。
处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒来表示。
例如,当表明一个系统有“4GHz"处理器,这表示处理器时钟运行频率为每秒4 x 109个周期。
每个时钟周期的时间是时钟频率的倒数。
通常是以纳秒(nanosecond,1纳秒等于10-9秒)或皮秒(picosecond,1皮秒等于10-12秒)为单位的。
用时钟周期来表示,度量值表示的是执行了多少条指令,而不是时钟运行得有多快
对千较大的n的值(比如说大千200),运行时间就会主要由线性因子来决定。
这些项中的系数称为每元素的周期数(简称CPE)的有效值。
注意,我们更愿意用每个元素的周期数而不是每次循环的周期数来度量,
这是因为像循环展开这样的技术使得我们能够用较少的循环完成计算,
而我们最终关心的是,对于给定的向量长度,程序运行的速度如何。
三、优化方式
1. 消除循环的低效率
将含有循环的代码翻译成机器级程序
- 每次循环迭代时都必须对测试条件求值。
- 向量的长度并不会随着循环的进行而改变。
因此,只需计算一次向量的长度,然后在我们的测试条件中都使用这个值
这个优化是一类常见的优化的一个例子,称为代码移动(codemotion)。
这类优化包括识别要执行多次(例如在循环里)但是计算结果不会改变的计算。
因而可以将计算移动到代码前面不会被多次求值的部分。
优化编译器会试着进行代码移动。
不幸的是,就像前面讨论过的那样,对于会改变在哪里调用函数或调用多少次的变换,编译器通常会非常小心。
它们不能可靠地发现一个函数是否会有副作用,因而假设函数会有副作用。
为了改进代码,程序员必须经常帮助编译器显式地完成代码的移动
C语言中的字符串是以null结尾的字符序列,strlen必须一步一步地检查这个序列,直到遇到null字符。
对于一个长度为n的字符串,strlen所用的时间与n成正比。
因为对lowerl的n次迭代的每一次都会调用strlen,所以lowerl的整体运行时间是字符串长度的二次项,正比千n2
一个看上去无足轻重的代码片断有隐藏的渐近低效率(asymptoticinefficiency)。
2. 减少过程调用
过程调用会带来开销,而且妨碍大多数形式的程序优化。
在处理任意的数组访问时,边界检查可能是个很有用的特性,但是对combine2代码的简单分析表明所有的引用都是合法的。
原则上来说,向量抽象数据类型的使也用者甚至不应该需要知道向量的内容是作为数组来存储的,
而不是作为诸如链表之类的某种其他数据结构来存储的。
比较实际的程序员会争论说这种变换是获得高性能结果的必要步骤
3. 消除不必要的内存引用
每次迭代时,累积变量的数值都要从内存读出再写入到内存。
这样的读写很浪费,因为每次迭代开始时从dest读出的值就是上次迭代最后写入的值。
引入一个临时变量ace,它在循环中用来累积计算出来的值。
只有在循环完成之后结果才存放在dest中。
四、理解现代处理器
到目前为止,我们运用的优化都不依赖于目标机器的任何特性。
这些优化只是简单地降低了过程调用的开销,以及消除了一些重大的"妨碍优化的因素”,这些因素会给优化编译器造成困难。
随着试图进一步提高性能,必须考虑利用处理器微体系结构的优化,也就是处理器用来执行指令的底层系统设计。
为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。
由千大量的晶体管可以被集成到一块芯片上,现代微处理器采用了复杂的硬件,试图使程序性能最大化。
带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径庭。
在代码级上,看上去似乎是一次执行一条指令,每条指令都包括从寄存器或内存取值,执行一个操作,并把结果存回到一个寄存器或内存位置。
在实际的处理器中,是同时对多条指令求值的,这个现象称为指令级并行。
现代微处理器取得的了不起的功绩之一是:
它们采用复杂而奇异的微处理器结构,其中,多条指令可以并行地执行,同时又呈现出一种简单的顺序执行指令的表象。
我们会发现两种下界描述了程序的最大性能。
当一系列操作必须按照严格顺序执行时,就会遇到延迟界限(latency bound),因为在下一条指令开始之前,这条指令必须结束。
当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限能够限制程序性能。
吞吐量界限(throughput bound)刻画了处理器功能单元的原始计算能力。
这个界限是程序性能的终极限制。
1. 整体操作
图5-11是现代微处理器的一个非常简单化的示意图。
我们假想的处理器设计是不太严格地基千近期的Intel处理器的结构。
这些处理器在工业界称为超标量(superscalar),意思是它可以在每个时钟周期执行多个操作,
而且是乱序的(out-of-order),意思就是指令执行的顺序不一定要与它们在机器级程序中的顺序一致。
整个设计有两个主要部分:指令控制单元(InstructionControlUnit,ICU)和执行单元(ExecutionUnit,EU)。
前者负责从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作;
而后者执行这些操作。
和第4章中研究过的按序(in-order)流水线相比,乱序处理器需要更大、更复杂的硬件,但是它们能更好地达到更高的指令级并行度。
ICU从指令高速缓存(instruction cache)中读取指令,指令高速缓存是一个特殊的高速存储器,它包含最近访问的指令。
通常,ICU会在当前正在执行的指令很早之前取指,这样它才有足够的时间对指令译码,并把操作发送到EU。
不过,一个问题是当程序遇到分支,程序有两个可能的前进方向。
一种可能会选择分支,控制被传递到分支目标。
另一种可能是,不选择分支,控制被传递到指令序列的下一条指令。
现代处理器采用了一种称为分支预测(branch prediction)的技术,处理器会猜测是否会选择分支,同时还预测分支的目标地址。
使用投机执行(speculative execution)的技术,处理器会开始取出位千它预测的分支会跳到的地方的指令,并对指令译码,甚至在它确定分支预测是否正确之前就开始执行这些操作。
如果过后确定分支预测错误,会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。
标记为取指控制的块包括分支预测,以完成确定取哪些指令的任务。
指令译码逻辑接收实际的程序指令,并将它们转换成一组基本操作(有时称为微操作)。
每个这样的操作都完成某个简单的计算任务,例如两个数相加,从内存中读数据,或是向内存写数据。
对千具有复杂指令的机器,比如x86处理器,一条指令可以被译码成多个操作。
关千指令如何被译码成操作序列的细节,不同的机器都会不同,这个信息可谓是高度机密。
幸运的是,不需要知道某台机器实现的底层细节,我们也能优化自己的程序。
EU接收来自取指单元的操作。
通常,每个时钟周期会接收多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。
这些功能单元专门用来处理不同类型的操作。
读写内存是由加载和存储单元实现的。
加载单元处理从内存读数据到处理器的操作。
这个单元有一个加法器来完成地址计算。
类似,存储单元处理从处理器写数据到内存的操作。
它也有一个加法器来完成地址计算。
如图中所示,加载和存储单元通过数据高速缓存(data cache)来访问内存。
数据高速缓存是一个高速存储器,存放着最近访问的数据值。
使用投机执行技术对操作求值,但是最终结果不会存放在程序寄存器或数据内存中,直到处理器能确定应该实际执行这些指令。
分支操作被送到EU,不是确定分支该往哪里去,而是确定分支预测是否正确。
如果预测错误,EU会丢弃分支点之后计算出来的结果。
它还会发信号给分支单元,说预测是错误的,并指出正确的分支目的。
在这种情况中,分支单元开始在新的位置取指。
在可以取出新指令、译码和发送到执行单元之前,要花费一点时间。
图5-11说明不同的功能单元被设计来执行不同的操作。
那些标记为执行“算术运算”的单元通常是专门用来执行整数和浮点数操作的不同组合。
随着时间的推移,在单个微处理器芯片上能够集成的晶体管数最越来越多,后续的微处理器型号都增加了功能单元的数量以及每个单元能执行的操作组合,还提升了每个单元的性能。
由千不同程序间所要求的操作变化很大,因此,算术运算单元被特意设计成能够执行各种不同的操作。
比如,有些程序也许会涉及整数操作,而其他则要求许多浮点操作。
如果一个功能单元专门执行整数操作,而另一个只能执行浮点操作,那么,这些程序就没有一个能够完全得到多个功能单元带来的好处了。
存储操作要两个功能单元一个计算存储地址,一个实际保存数据。
在ICU中,退役单元(retirementunit)记录正在进行的处理,并确保它遵守机器级程序的顺序语义。
我们的图中展示了一个寄存器文件,它包含整数、浮点数和最近的SSE和AVX寄存器,是退役单元的一部分,因为退役单元控制这些寄存器的更新。
指令译码时,关千指令的信息被放置在一个先进先出的队列中。
这个信息会一直保持在队列中,直到发生以下两个结果中的一个。
首先,一旦一条指令的操作完成了,而且所有引起这条指令的分支点也都被确认为预测正确,那么这条指令就可以退役(retired)了,所有对程序寄存器的更新都可以被实际执行了。
另一方面,如果引起该指令的某个分支点预测错误,这条指令会被清空Cflushed),丢弃所有计算出来的结果。
通过这种方法,预测错误就不会改变程序的状态了。
正如我们已经描述的那样,任何对程序寄存器的更新都只会在指令退役时才会发生,只有在处理器能够确信导致这条指令的所有分支都预测正确了,才会这样做。
为了加速一条指令到另一条指令的结果的传送,许多此类信息是在执行单元之间交换的,即图中的“操作结果”。
如图中的箭头所示,执行单元可以直接将结果发送给彼此。这是4.5.5节中简单处理器设计中采用的数据转发技术的更复杂精细版本。
控制操作数在执行单元间传送的最常见的机制称为寄存器重命名(registerrenaming)。
当一条更新寄存器r的指令译码时,产生标记t,得到一个指向该操作结果的唯一的标识符。
条目(r,t)被加入到一张表中,该表维护着每个程序寄存器r与会更新该寄存器的操作的标记t之间的关联。
当随后以寄存器r作为操作数的指令译码时,发送到执行单元的操作会包含t作为操作数源的值。
当某个执行单元完成第一个操作时,会生成一个结果(v,t),指明标记为t的操作产生值v。
所有等待t作为源的操作都能使用v作为源值,这就是一种形式的数据转发。
通过这种机制,值可以从一个操作直接转发到另一个操作,而不是写到寄存器文件再读出来,使得第二个操作能够在第一个操作完成后尽快开始。
重命名表只包含关于有未进行写操作的寄存器条目。
当一条被译码的指令需要寄存器r,而又没有标记与这个寄存器相关联,那么可以直接从寄存器文件中获取这个操作数。
有了寄存器重命名,即使只有在处理器确定了分支结果之后才能更新寄存器,也可以预测着执行操作的整个序列。
2. 功能单元的性能
图5-12提供了IntelCorei7Haswell参考机的一些算术运算的性能,有的是测量出来的,有的是引用Intel的文献。
这些时间对于其他处理器来说也是具有代表性的。
每个运算都是由以下这些数值来刻画的:
一个是延迟(latency),它表示完成运算所需要的总时间;
另一个是发射时间(issuetime),它表示两个连续的同类型的运算之间需要的最小时钟周期数;
还有一个是容量(capacity),它表示能够执行该运算的功能单元的数量。
表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数。
一个完全流水线化的功能单元有最大的吞吐量,每个时钟周期一个运算,而发射时间较大的功能单元的最大吞吐量比较小。
具有多个功能单元可以进一步提高吞吐鼠。
对一个容量为C,发射时间为I的操作来说,处理器可能获得的吞吐量为每时钟周期 C / I 个操作。
3. 处理器操作的抽象模型
作为分析在现代处理器上执行的机器级程序性能的一个工具,我们会使用程序的数据流(data-flow)表示,
这是一种图形化的表示方法,展现了不同操作之间的数据相关是如何限制它们的执行顺序的。
这些限制形成了图中的关键路径(criticalpath),这是执行一组机器指令所需时钟周期数的一个下界。
五、 循环展开
循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,减少循环的迭代次数。
psum2函数(见图5-1)就是这样一个例子,其中每次迭代计算前置和的两个元素,因而将需要的迭代次数减半。
循环展开能够从两个方面改进程序的性能。
首先,它减少了不直接有助于程序结果的操作的数量,例如循环索引计算和条件分支。
第二,它提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量。
六、提高并行性
七、一些限制因素
我们已经看到在一个程序的数据流图表示中,关键路径指明了执行该程序所需时间的一个基本的下界。
也就是说,如果程序中有某条数据相关链,这条链上的所有延迟之和等于T,那么这个程序至少需要T个周期才能执行完。
我们还看到功能单元的吞吐量界限也是程序执行时间的一个下界。
也就是说,假设一个程序一共需要N个某种运算的计算,
而微处理器只有C个能执行这个操作的功能单元,并且这些单元的发射时间为I。
那么,这个程序的执行至少需要N·1/C个周期。
1. 寄存器溢出
循环并行性的好处受汇编代码描述计算的能力限制。
如果我们的并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出(spilling),将某些临时值存放到内存中,
通常是在运行时堆栈上分配空间。
一旦循环变量的数量超过了可用寄存器的数量,程序就必须在栈上分配一些变撮。
程序必须从内存中读取两个数值:累积变量的值和data[i]的值,将两者相乘后,将结果保存回内存。
一旦编译器必须要诉诸寄存器溢出,那么维护多个累积变量的优势就很可能消失。
幸运的是,x86-64有足够多的寄存器,大多数循环在出现寄存器溢出之前就将达到吞吐量限制。
2. 分支预测和预测错误处罚
在一个使用投机执行(speculative execution)的处理器中,处理器会开始执行预测的分支目标处的指令。
它会避免修改任何实际的寄存器或内存位置,直到确定了实际的结果。
如果预测正确,那么处理器就会”提交“投机执行的指令的结果,把它们存储到寄存器或内存。
如果预测错误,处理器必须丢弃掉所有投机执行的结果,在正确的位置,重新开始取指令的过程。
这样做会引起预测错误处罚,因为在产生有用的结果之前,必须重新填充指令流水线。
在编译条件语句和表达式的时候,GCC能产生使用这些指令的代码,而不是更传统的基于控制的条件转移的实现。
翻译成条件传送的基本思想是计算出一个条件表达式或语句两个方向上的值,然后用条件传送选择期望的值。
在4.5.7节中我们看到,条件传送指令可以被实现为普通指令流水线化处理的一部分。
没有必要猜测条件是否满足,因此猜测错误也没有处罚。
怎么能够保证分支预测处罚不会阻碍程序的效率呢?
-
不要过分关心可预测的分支
现代处理器中的分支预测逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势。
例如,在合并函数中结束循环的分支通常会被预测为选择分支,因此只在最后一次会导致预测错误处罚。
处理器能够预测这些分支的结果,所以这些求值都不会对形成程序执行中关键路径的指令的取指和处理产生太大的影响。 -
书写适合用条件传送实现的代码
分支预测只对有规律的模式可行。
对千本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件 控制转移的代码,可以极大地提高程序的性能。
八、内存性能
总结
虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,
但是应用程序员有很多方法来协助编译器完成这项任务。
没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,
因此程序设计的这些方面仍然应该是程序员主要关心的。
我们还看到妨碍优化的因素,例如内存别名使用和过程调用,严重限制了编译器执行大掀优化的能力。
同样,程序员必须对消除这些妨碍优化的因素负主要的责任。
这些应该被看作好的编程习惯的一部分,因为它们可以用来消除不必要的工作。
基本级别之外调整性能需要一些对处理器微体系结构的理解,描述处理器用来实现它的指令集体系结构的底层机制。
对于乱序处理器的情况,只需要知道一些关于操作、容噩、延迟和功能单元发射时间的信息,就能够基本地预测程序的性能了。
我们研究了一系列技术.包括循环展开、创建多个累积变址和重新结合,它们可以利用现代处理器提供的指令级并行。
随着对优化的深入,研究产生的汇编代码以及试着理解机器如何执行计算变得重要起来。
确认由程序中的数据相关决定的关键路径,尤其是循环的不同迭代之间的数据相关,会收获良多。
我们还可以根据必须要计算的操作数猛以及执行这些操作的功能单元的数量和发射时间,计算一个计算的吞吐量界限。
包含条件分支或与内存系统复杂交互的程序,比我们最开始考虑的简单循环程序,更难以分析和优化。
基本策略是使分支更容易预测,或者使它们很容易用条件数据传送来实现。
我们还必须注意存储和加载操作。
将数值保存在局部变量中,使得它们可以存放在寄存器中,这会很有帮助。