深入理解操作系统(14)第五章:优化程序性能(2)优化程序性能(包括:消除不必要的内存引用/ICU,EU/分支预测/投机执行/降低循环开销:循环展开/转换到指针/寄存器溢出/性能提高技术/gprof)
1. 消除不必要的内存引用
建议在临时变量中存放结果,使得循环迭代中不再需要读和写中间值。
如图 5.10
上图中用临时变量x来存放循环的结果,然后循环结束后再赋值给dest。
2. 理解现代处理器
2.1 整体操作
下图给出了现代微处理器的一个非常简单化的示意图。
我们假设的处理器设计是基于Intel "P6"微体系结构的,这种微体系结构是 Intel Pentium系列处理器的基础。P6微体系结构是自20世纪90年代后期以来许多厂商生产的高端处理器的典型。
在工业界称为超标量( superscalar),意思是它可以在每个时钟周期执行多个操作,而且是乱序的(out- of-order),意思就是指令执行的顺序不一定要与它们在汇编程序中的顺序一致。
乱序(out- of-order),意思就是指令执行的顺序不一定要与它们在汇编程序中的顺序一致。
图5.11
说明:
整数/分支:执行简单的整数操作(加法、测试、比较、逻辑)。
还处理分支,就像下面会讨论的那样
通用整数:可以处理所有的整数操作,包括乘法和除法。
浮点加法:处理简单的浮点操作(加法、格式转换)
浮点乘法/除法:处理浮点乘法和除法。更复杂的浮点指令,
例如超越函数( transcendentalfunction),会被转换成操作的序列。
加载:处理从存储器读数据到处理器的操作。这个功能单元有一个加法器来执行地址计算
存储:处理从处理器到存储器的写操作。这个功能单元有一个加法器来执行地址计算
2.1.1 ICU 和 EU
上图中整个设计有两个主要部分:
ICU(Instruction Control unit,指令控制单元),
负责从存储器中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作。
EU(Execution Unit,执行单元)。
执行这些操作。
2.1.2 分支预测
现代处理器采用了一种称为分支预测( branchprediction)的技术,在这种技术中处理器会预测是否选择分支,同时还预测分支的目标地址。
一种可能会选择分支,控制被传递到分支目标;
另一种可能是,不选择分支,控制被传递到指令序列的下一条指令。
2.1.3 投机执行
使用一种称为投机执行(speculative execution)的技术,
处理器会开始取出它预测的分支处的指令并对指令解码,甚至于在它确定分支预测是否正确之前就开始执行这些操作。
如果过后它确定分支预测错误,它会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。
这种更加异乎寻常的技术是开始取出和执行两个可能方向上的指令,随后再抛弃掉不正确方向上的结果。时至今日,都不认为这种方法的成本效率是值得的。
2.2 功能单元的性能
图5.12提供了Intel Pentium III的一些基本操作的性能,其他处理器也具有这样的计时特征。
图5.12
每个操作都是由两个周期计数值来刻画的:
1. 一个是执行时间(latency),它指明功能单元完成操作所需要的总周期数;
2. 另一个是发射时间(Issue time),它指明连续的、独立操作之间的周期数。
在一个流水线化的单元中,发射时间比执行时间短。
例如,一个典型的浮点加法器包含三个阶段:一个阶段处理指数值,一个将小数相加,而一个四舍五入计算最后的结果。
2.3 更近的观察处理器操作
2.3.1 将指令翻译成操作
通过图5.10来说明下图中的表示法。
图5.10-1
如这个示例翻译表明的那样,我们的操作在许多方面模仿了汇编语言指令的结构,除了它们是用标识寄存器不同实例的标号来引用它们的源和目的操作的。在实际的硬件中,寄存器重命名动态地给标记赋值,使之指向这些不同的值。标记是位模式而不是像“%edx.1”这样的符号名字,但是它们提供的用途是一样的。
2.3.2 执行单元的操作处理
图5.13以两种形式展示了操作:
1. 一种是指令解码器生成的形式,
2. 另一种是用计算图( complgraph)来表示的,
在这种图中,操作是用圆角方框表示的,而箭头表明操作之间的数据传递。
我只为一次迭代与下一次迭代之间改变了的操作数而显示箭头,
因为只有这些值才在功能单元之间进行传递
图5.13
说明:
1. 每个操作符方框的高度表明这个操作需要多少个周期,也就是这一种功能的执行时间。
在此整数乘法 mull需要四个周期,加载需要三个周期,而其他操作需要一个周期。
2. 在展示一个循环的计时中,我们将块竖直地放置,来表示操作执行的时间,向下的方向表示时间的增长。
我们可以看到,循环的五个操作形成了两个并行的链,表明两个计算序列必须顺序地执行。左边的链处理数据首先从存储器中读一个数组元素,然后用它乘以累积的乘积。右边的链处理循环索首先对它加1,然后拿它与 length做比较。跳转操作检査这个比较的结果,以确定分支预测是正确的。注意从跳转操作方框中没有向外的箭头。如果分支预测正确,不需要任何处理。如果分支预测错误,那么分支功能单元会发信号给指令取出控制单元,而这个单元会采取改正的行动。无论是两种情况中的哪一种,其他的操作都不依赖于跳转操作的结果。
图5.14给出了同样的到操作的翻译,只不过合并操作是整数加法。
如图形描述所示,所有的操作,除了加载以外,现在都只需要一个周期。
图5.14
2.3.3 有无限资源的操作调度
为了看看处理器将如何执行一系列的反复,首先设想一个处理器,它有无限多个功能单元和完美的分支预测。只要一个操作的数据操作数可用,该操作就能够开始执行了。这样一个处理器的性能只受下列因素的限制:功能单元的执行时间和吞吐量,以及程序中的数据相关性。
图5.15给出的是在这样一个机器上整数乘法的 combine4中循环头三次迭代的计算图。
图5.15
图5.16展示了在一个有无限多个功能单元的机器上,整数加法的 combine4的头四次迭代。
图5.16
2.3.4 资源约束下的操作调度
当然,一个真实的处理器只有固定数目的功能单元。和我们前面的例子不同,在那些例子中性能只受数据相关性和功能单元的执行时间的限制,现在性能还受资源约束的限制。
图5.17展在一个有资源约束的处理器上,整数乘法的 combine4的操作调度。
图5.17
对于这个例子来说,功能单元数量有限并没有使我们的程序变慢。性能仍然是被整数乘法的四周期执行时间限制的。
对于整数加法的情况,资源约束明显地限制了程序性能。
每次迭代要有四个整数或分支操作而只有两个功能单元能完成这些操作。因此,我们不能期望能保持比每次迭代两个周期更好的处理频率了。
3. 降低循环开销-循环展开
个人感觉循环展开在开发中不太实用,影响代码可读性。
略
4. 转换到指针代码
有时候我们能通过使用指针而非数组提供程序性能。
但是,根据经验,指针和数组代码的想对性依赖于机器,编译器等因素。我们知道现代编译器对数组代码的优化已经非常高级,而指针代码只进行了很小限度的优化。
综上,为了可读性,使用数组代码更好。
5. 提供并行性
5.1 循环分割
我们通过将一个合并操作分割成两个或更多的部分,并在最后合并结果来提高性能。
如下图5.24所示:
5.2 寄存器溢出
有效的减少局部变量的数量,来避免寄存器溢出。
因为系统只有8个常用程序寄存器。
6. 优化合并代码的结果小结
现在,我们已经考虑了合并( Combing)代码的六个版本,其中有的还有多个变种。
让我们暂停来看看这种努力的整体效果,以及我们的代码是如何在一台不同的机器上执行的。图5.27给出的是对我们所有函数以及几个其他变种的度量性能。正如看到的那样,我们只要简单地展开循环多次,就能达到整数求和的最大性能,但是对于其他操作,我们引入一些(但不是很多)并行性整体性能达到了27.6倍,比我们原始的代码好了很多。
图5.27
6.1 浮点性能异常
略
6.2 变换平台
虽然我们是在一个特殊的机器和编译器环境中讲述我们的优化策略的,但是通用的原则也适用于其他机器和编译器。
当然,最优的策略可能是与机器相关的。作为一个示例,给了Compaq alpha21164处理器在与图527中所示的 Pentium3相当的条件下的性能结果。
略
7. 分支预测和预测错误处罚
7.1 什么是分支预测?
分支预测:
当遇到分支的时候,处理器必须猜测分支该往哪个方向走。对于条件转移的情况,这意味着要预测是否会选择分支。
7.2 投机执行
处理器使用一种称为投机执行(speculative execution)的技术,
处理器会开始取出它预测的分支处的指令并对指令解码,甚至于在它确定分支预测是否正确之前就开始执行这些操作。
如果过后它确定分支预测错误,它会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。
在一个使用投机执行的处理器中,处理器会开始执行预测的分支目标处的指令。它这样做的方式是,避免修改任何实际的寄存器或存储器位置,直到确定了实际的结果如果预测是正确的,处理器就简单地“提交”投机执行的指令的结果,把它们存储到寄存器或存储器中。如果预测是错误的,处理器必须丢弃掉所有投机执行的结果,在正确的位置,重新开始取指令的过程。这样做会引起很大的分支处罚因为在产生有用的结果之前,必须重新填充指令流水线。
7.3 程序员:减少条件分支的使用
不幸的是,C程序员对改进一个程序的分支性能是无能为力的,除了意识到数据相关的分支会引起性能上很高的花费。除此之外,程序员对编译器产生的详细的分支结构几乎没有什么控制,很难使分支更容易预测一些。
最终,我们必须依靠两种因素的结合:
一是编译器生成好的代码,尽量减少条件分支的使用;
另一个是处理器有效地分支预测,降低分支预测错误的数量。
8. 理解存储器性能
8.1 加载的执行时间
作为一个性能受加载操作执行时间限制的代码示例,考虑函数 list len,
如图5.30所示。这个函数计算的是一个链表的长度。在该函数的循环中,变量ls的每个连续的值都依赖于指针引用ls->next读出的值。我们的测试表明函数 list len的CPE为3.0,我们认为这是加载操作执行时间的直接反映。
图5.30
8.2 存储的执行时间
在迄今为止我们所有的示例中,我们只通过使用加载操作从一个存储器位置读数据到一个寄存器中来与存储器交互。与之对应的,
存储( store)操作将一个寄存器值写到存储器。
正如图5.12表明的那样,这个操作名义上的执行时间也是三个周期,发射时间为一个周期。
存储器操作的实现包含很多细微的问题。
1. 对于寄存器操作,在指令解码成操作时,处理器就可以确定哪些指令会影响另外哪些指令。
2. 另一方面,对于存储器(或内存)操作在加载和存储地址被计算出来之前,
处理器都不能预测哪些指令会影响另外哪些指令。
由于存储器操作占到了程序很大的一部分,存储器子系统被优化成以独立的存储器操作来提供更大的并行性
9. 现实生活:性能提高技术
9.1 优化程序性能的基本策略:
1. 高级设计。为手边的问题选择适当的算法和数据结构。
要特别警觉,避免使用会渐进地产生糟糕性能的算法或编码技术。
2. 基木编码原則。避免限制优化的因素,这样编译器就能产生高效的代码
1. 消除连续的函数调用。在可能时,将计算移到循环外。
考虑有选择的妥协程序的模块性以获得更大的效率
2. 消除不必要的存储器引用。引入临时变量来保存中间结果。
只有在最后的值计算出来时才将结果存放到数组或全局变量中。
3. 低级优化。
1. 尝试各种与数组代码相对的指针形式。(为了代码可读性,可使用数组即可)
2. 通过展开循环降低循环开销
3. 通过诸如迭代分割之类的技术,找到使用流水线化的功能单元的方法。
9.2 优化代码后通过"检查代码"避免引入错误
要给读者最后的忠告是,要小心避免花费精力在令人误解的结果上。
一项有用的技术是,在优化代码时使用检查代码( checking code)来测试代码的每个版本,以确保在这一过程中没有引入错误。
10. 确认和消除性能瓶颈
到此刻为止,我们只考虑了优化小的程序,在这样的小程序中,需要优化的地方很清楚。
在处理大程序时,甚至于很难知道应该优化什么。
在本节中,我们会描述如何使用代码剖析程序(codeprofilers),这是在程序执行时收集性能数据的分析工具。
我们还展示了一个系统优化的通用原称为 Amdahl定律( Amah'slaw)。
10.1 程序剖析( gprof 工具)
Unix系统提供了一个剖析程序 GPROF
这个程序产生两种形式的信息。
首先,它确定程序中每个函数花费了多少CPU时间。
其次,它计算每个函数被调用的次数,以调用函数来分类。
这两种形式的信息都非常有用。这些计时给出了不同函数在确定整体运行时间中的相对重要性。调用信息使得我们能理解程序的动态行为。
例子:
[root@localhost XXX_]# gcc -pg client.c -o c
[root@localhost XXX_]#
[root@localhost XXX_]# ./c
HANI_XXX_Get_Proc send success(filename=/etc/XXX_/1.txt).
_hani_XXX__GetRecvProc recv sever data success.
XXX_client over.
[root@localhost XXX_]# ls -l
total 76
-rw-r--r--. 1 root root 2891 Dec 10 12:03 1.txt
-rwxr-xr-x. 1 root root 13712 Dec 10 12:03 c
-rw-r--r--. 1 root root 7081 Dec 9 05:03 client.c
-rw-r--r--. 1 root root 1012 Dec 10 12:03 gmon.out
-rwxr-xr-x. 1 root root 26264 Dec 9 05:04 s
-rw-r--r--. 1 root root 11716 Dec 9 05:04 server.c
-rw-r--r--. 1 root root 2426 Dec 9 05:02 XXX_.h
[root@localhost XXX_]# gprof c
Flat profile:
Each sample counts as 0.01 seconds.
no time accumulated
% cumulative self self total
time seconds seconds calls Ts/call Ts/call name
0.00 0.00 0.00 1 0.00 0.00 HANI_XXX_Get_Proc
0.00 0.00 0.00 1 0.00 0.00 HANI_XXX_SearchOrMakeDir
0.00 0.00 0.00 1 0.00 0.00 _hani_XXX_GetRecvProc
% the percentage of the total running time of the
time program used by this function.
……
/* 下面是调用历史 */
ranularity: each sample hit covers 2 byte(s) no time propagated
index % time self children called name
0.00 0.00 1/1 main [8]
[1] 0.0 0.00 0.00 1 HANI_XXX_Get_Proc [1]
0.00 0.00 1/1 _hani_XXX_GetRecvProc [10]
-----------------------------------------------
0.00 0.00 1/1 main [8]
[2] 0.0 0.00 0.00 1 HANI_XXX_SearchOrMakeDir [2]
-----------------------------------------------
0.00 0.00 1/1 HANI_XXX_Get_Proc [1]
[10] 0.0 0.00 0.00 1 _hani_XXX_GetRecvProc [10]
-----------------------------------------------
……
10.2 使用剖析程序指导优化-vip
作为一个用剖析程序来指导程序优化的示例,我们创建了一个包括几个不同任务和数据结构的程序。
这个应用程序读一个文本文件,创建一张互不相同的单词和每个单词出现次数的表,然后按照出现次数的降序对单词排序。
作为基准程序,我们在一个由莎士比亚全集组成的文件上运行这个程序。据此,我们确定莎士比亚一共写了946596个单词,其中26946是互不相同的。最常见的单词是“the”,出现了29801次。单词“love”出现了2249次,而“deab”出现了933次。
我们的程序是由下列部分组成的。我们创建了一系列的版本,从各部分简单的算法开始,然后再换成更成熟完善的算法:
1. 从文件中读出每个单词,并转换成小写字母。
我们最初的版本使用的是函数 lower(图5.7)我们知道它的复杂度是二次的
2. 对字符串应用一个哈希函数,为一个有s个表元( buckets)的哈希表产生一个0~s-1之间的数字。
我们最初的函数只是简单地对字符的ASCI代码求和,再对s求模
3. 每个哈希表元都组织成一个链表。程序沿着这个链表扫描,寻找一个匹配的条日。
如果找到了,该单词的频度就加1。否则,就创建一个新的链表元素。
我们最初的版本递归地完成这个操作将新元素插在链表尾部。
4. 已经生成了这张表,我们就根据频度对所有的元素排序。我们最初的版本使用插入排序
如图中(a)部分所示,我们最初的版本需要9秒多钟,大多数时间花在了排序上。这并不奇怪因为插入排序有二次复杂度,而程序对27000个值进行排序。
在我们下一个版本中,我们用库函数 gsort进行排序,这个函数是基于快速排序算法的。
在图中这个版本称为“ Quicksort”。更有效的排序算法使花在排序上的时间降低到可以忽略不计,而整个运行时间降低到大约1.2秒。图的(b)部分给出的是剩下各个版本的时间,所用的比例能使我们看的更清楚。
图 5.37
10.3 Amdahl 定律
其主要思想是当我们加快系统-个部分的速度时,对系统整体性能的影响依赖于这个部分有多重要和速度提高了多少。
11. 第五章总结
虽然关于代码优化的大多数论述都描述了编译器是如何能生成高效代码的,但是应用程序员有很多方法来协助编译器完成这项任务。没有任何编译器能用一个好的算法或数据结构代替低效率的算法或数据结构,因此程序设计的这些方面仍然应该是程序员主要关心的。我们还看到妨碍优化的因素,例如存储器别名和过程调用,严重限制了编译器执行大量优化的能力。同样,程序员必须对消除这些妨碍优化的因素负主要的责任。
除此之外,我们还研究了一系列技术,包括循环展开、迭代分割以及指针运算。随着我们对优化的深入,研究汇编代码以及试着理解机器是如何执行计算的变得重要起来。对于现代、乱序处理器上的执行,分析程序是如何在有无限处理资源但是功能单元的执行时间和发射时间与目标处理器相符的机器上执行的,收获良多。为了精练这个分析,我们还应该考虑诸如功能单元数量和类型这样的资源约束。
包含条件分支或与存储器系统复杂交互的程序,比我们首先考虑的简单循环程序,更加难以分析和优化。基本策略是使循环更容易预测,并试着减少存储和加载操作之间的相互影响。
当处理人型程序时,将我们的注意力集中在最耗时的部分变得很重要。代码剖析程序和相关的具能帮助我们系统地评价和改进程序性能。我们描述了 GPROF,一个标准的Unⅸx剖析工具。也还有更加复杂完善的剖析程序可用,例如 Intel的 VTUNE程序开发系统。这些工具可以在过程级分解执行时间,测量程序每个基本块( basic block)的性能。基本块是没有条件操作的指令序列。
Amdahl定律提供了对通过只改进系统一部分所获得的性能收益的一个简单但是很有力的看法。收益既依赖于我们对这个部分的提高程度,也依赖于这个部分原来在整个时间中所占的比例