深入理解计算机系统读书笔记
真乃神书也,如果看完就能大大加深对计算机系统的理解。
深入理解计算机系统
第一章:计算机系统漫游
第一章将本书的内容大致进行了预览,相当于对每一章进行了一个简介。
第二章:信息的表达和处理
信息储存
- 大多数计算机使用8位的快(一字节)作为最小可寻址的内存单位,也就是说在寻址的过程中一字节就是机器能找到的最小的块。
- 处理器将内存看做是一个非常大的由一个个字节组成的数组,每个字节都有一个独一无二便于找寻的标志—地址。
- 这些可能的地址集合就是虚拟地址空间,这里的虚拟是指实际上并没有这样的地址,只是为了方便机器运行的概念,要和虚拟内存分开理解。
十六进制表示法
如果给出的二进制序列不是四的倍数,则在最左边添加0来凑到4的倍数。
字数据大小
- 字长(word size),就是指针的大小。又因为指针中的数据就是一个地址,指代着内存中的某一个块。所以字长决定了虚拟地址空间的最大大小。
- 所以所谓的64位机器指的是字长为64bit的机器,虚拟地址的范围是0~264-1,寻址范围扩大了。
寻址和字节顺序
- 寻址的对象可能会大于一个字节(一个地址所指代的内存位置),那么这个对象的地址应该由哪一个地址所指代呢?—所有地址中最小的那个。
- 大端法:在存储的时候先存对象的最高有效字节。
- 小端法:在存储的时候先存对象的最低有效字节。
表示字符串
- 在C语言中,字符串是一个以null结尾的字符数组。
- 当然,由于是字符所以存储的是ASCII码。
- Null表示为0x00。
表示代码
我们发现同样的代码在不同的机器、操作系统上生成的机器代码是不同的。因为编码规则的不同,所以二进制代码是不兼容的。
位级运算
- 位级运算的常见用法就是掩码运算。
- 结合练习题2.10/2.11就可以加深理解。
逻辑运算
- 要和位级运算区分开,2.14.
移位运算
- 左移:x<<k。向左移动k位,丢弃最高的k位,在右端补k个0。
- 右移:
- 逻辑右移:在左端补k个0
- 算数右移: 在左端补k个最高有效位的值
- 一般来说,有符号数使用的都是算数右移;无符号数使用的是逻辑右移。
整数表示
后面的内容用到了在单独看吧。
第三章:程序的机器级表示
数据格式
在英特尔的术语中,字(word)表示16位(2字节)数据类型。注意要和前面提到的字长(word size)概念区分开来,字长表示的是指针的大小,决定了虚拟地址空间的最大大小。

此处使用的是64位机器,char*表示的是指针,所以其size是机器的字长,字长为64位=8字节。
注意图片中的int和double的汇编代码后缀都是l,但是不会产生歧义因为浮点子采用的不同的指令和寄存器。
访问信息
这些寄存器中比较特殊的是栈指针%rsp,记录运行时栈的结束位置。

不同的汇编代码后缀会访问寄存器的不同部分,这就造成了一个问题:如果生成了一个小于8字节的数字结果需要存如寄存器中,那么寄存器中的剩余字节该如何变化呢?
答案是:生成1-2字节数字的指令会保持剩下的字节不变,如果生成四字节的数字则将高位的4个字节置为0。
操作数格式
需要注意的是直接寻址访问内存时,就是去除立即数的$号即可。

数据传送指令
对于数据传送指令,有一些可能会忽略的地方:
- 指令后缀要和源寄存器和目的寄存器对应。
- 两个操作数不能同时指向内存。
- movl指令以寄存器为目的时,会把寄存器的高4位设置为0。
其他一些易犯的错误可以在练习3.3中学习。
强制类型转换的内容在练习3.4中学习。
加深理解汇编语言和C语言练习3.5。
出入栈
当进行入栈操作push %rsp时会让%rsp减8,这是因为处理器的最小寻址单元是字节(一个地址代表一个字节),%rsp存储的数据为64位(八个字节),入栈后栈顶指针当然要减8.
算数逻辑操作

加载有效地址
就是leaq指令,leaq S,D的效果是D<-&S。举例来说leaq 7(%rdx,%rdx,4),%rax的效果就是将寄存器%rax设置为7+5%rdx(因为数据传输指令的操作数如果不加$号就是代指内存中的内容,其值就是地址)
加深理解看习题3.6
移位操作
移位操作有几个需要注意的地方:
- SHR是逻辑右移而SAR是算数右移。
- 移位的位数可以由立即数或者单字节寄存器表示
数据较大时的算数操作
见书3.5.5
控制
目前我们讨论的都是代码一条一条执行的情况,但是并不是所有的程序都是这样的:程序调用、循环、条件判断等。在汇编语言中多用jump来改变执行顺序。
条件码

- 在之前列出的指令中,只有leaq指令不改变条件码。
- 还有一些指令只改变条件码而不改变其他寄存器。如下图所示:
- 注意CMP指令的比较顺序(与SUB相反)。

对于条件码的访问
对条件码的访问有三种情况,1)根据条件码设置字节。2)条件跳转。3)条件传输。
本节只讨论情况1:

对于cmp、test、set的理解要看练习3,.13和3.14。
跳转指令(jump)
一张图简单说明jmp:

跳转分为两种:
- 直接跳转(.):jmp .L1表示跳转到L1所在的地方。
- 间接跳转(*):
- jmp *%rax表示以%rax中的值作为跳转目标。
- jmp *(%rax)表示以%rax中的值作为地址的内存中的值作为跳转目标。
跳转指令一览:

跳转指令编码
一般来说不关心跳转指令的机器编码,但是了解一下对于理解链接器的知识有很大帮助。
跳转指令有两种不同的编码方式:
- PC相对寻址
- 绝对地址寻址
以下展示的是PC相对寻址的例子:

上图中有几个需要注意的点:
- 每个字节都有一个对应的地址,上图中每一小块(“48”)正好是一个字节,对应一个地址。
- 所谓相对是相对于PC程序计数器来说的。
- 机器代码中的第5行的0xf8就是-8。
- PC相对寻址的优势是就算程序被链接之后仍然可以执行不需要修改。
- 在笔记本中有对上图的详细解析。
加深对于PC相对寻址的理解可以看练习3.15
条件分支
使用条件控制实现
这是最常见的用法,可以理解为先对条件进行判断再执行后面的语句。对于该概念的理解可以参考习题3.18(根据汇编代码不全C语言代码)
使用条件传送实现
使用上一节的条件控制是传统方法,在现代处理器上可能会相当低效。所谓条件传送实现,该方法将不同情况的语句均执行一遍,然后根据条件是否满足从两者中选择一个,通过下图可以加深理解:将y-x和x-y都计算出来,根据ntest的值选择输出。

那么为何在某些时候条件传送实现有更高的效率呢?
- 因为指令的执行依靠流水线系统,采用分支预测逻辑来猜测跳转。但是类似if(x<y)这种非常难以判断,如果发生错误则在判断后流水线中已执行的工作都需要丢弃,一个错误的预测会导致大量时间的浪费。
- 而条件传送不管测试的数据是什么,处理的时间都差不多,使得流水线总是满的。
但是如果两条指令中有一个产生了错误行为就会导致非法行为。
例如:

若xp为null,则返回0,否则返回*xp。

若xp为null,则第二行对xp的间接引用造成了空指针错误。
所以,条件数据传送只能使用在非常受限制的情况下。
习题3.21 对条件数据传送加深理解。
循环
do-while循环
以下代码有助于理解do-while循环:

while循环
while循环有两种翻译方法:
-
Jump to middle:先跳至test,再决定是否跳到中间执行循环,执行完循环又会进入test中。

-
guraded-do:
先使用条件分支,如果条件不成立就不执行循环。进入循环后每执行一次都会再次进行判断。

for循环
可以将for循环改写成while。再考虑汇编语言的事。
参见习题3.29
switch
使用跳转表进行跳转,跳转表就是一个数组。使用跳转表的情况需要满足两个条件:
- 开关数量较多。
- 值的范围跨度较小。
下图中==&&==表示指向代码位置的指针,表中出现了一些特殊情况,有些index没有表示,有些重叠了。在汇编代码中可以看出处理方式。

跳转表的声明如下:

这样一来第五行的内容就比较好理解了,相当于是L4+8*index,这个8应该就是因为一个一个数组项占用8个地址的空间。

过程
%rsp:栈指针、%rip:程序计数器
- P调用了过程Q,Q执行完了以后返回P。
- 传递控制:进入过程Q时,程序计数器(%rip)需要设为Q开始的地址;执行结束时,程序计数器要设置为Q后面的那条指令的地址。
- 传递数据:P可以将参数传递给Q,Q也能返回给P一个值。
- 分配和释放内存:进入Q前需要给Q分配内存,返回时需要释放内存。
运行时栈
注意图中P帧和Q帧的位置
- 当前正在执行的过程的帧都在栈顶。
- Q的代码会拓展当前栈的边界,分配栈帧需要的空间。
- 许多函数都不需要栈帧—所有的局部变量都可以保存在寄存器中,且该函数不会调用其他任何函数。
- 当过程需要的存储空间超过了寄存器能放得下的大小时,就会在栈上分配空间:比如说函数P的前六个参数都存储在寄存器中。

上图表示的是函数P调用函数Q时的栈图。其中Q的帧中的三部分具体是什么意思在后面有讲解。 - 被保存的寄存器:我的理解是也许程序P并未执行完,从Q返回P时被改变的寄存器需要被修改回来继续使用。此处就是存储在程序P中需要保留的寄存器数据。
- 局部变量:我觉得是指那些不属于传递的被传递参数的那部分局部变量。
- 参数构造区:为Q调用的函数传递超过六个的那部分参数。函数自身的栈帧中存储的参数是将要传递给调用函数的参数。
转移控制
用指令call Q调用过程Q来记录,该指令会把地址A压入栈中,并将PC设置为Q的起始地址。
当执行到过程Q的ret指令时,将栈中的地址A pop出并把A放入程序计数器PC。
举一个例子来理解一下:


习题3.32和上面的内容差不多。
数据传送
大部分过程间的数据传送都是通过寄存器实现的(%rdi、%rsi传参数,%rax传递返回值)寄存器传参顺序表如下:

如果一个函数有超过六个参数,超出的部分就要用栈来传递了,参考运行时栈的图。栈传递参数的时候,所有的数据大小都要向8的倍数对齐。
具体的数据传输中的参数存储如下所示:

注意b图中第6、7行的内容,在栈中的存储情况如下图所示:

栈上的局部存储
很多时候,局部变量存储在寄存器中,但是有些时候局部变量必须存在内存中。
- 寄存器不够存了
- 对某个局部变量使用地址运算符==&==时,如果存在寄存器中貌似不合理。
- 如果某个局部变量是一个数组,必须存在内存中。
下面的程序是参数为地址的函数调用实例:

4. 由于传递的参数都是&地址,所以参数需要存储在栈中。
5. 可能会有疑问为什么栈中没有返回地址之类的其他内容,因为这些东西都是在call指令执行的时候才开始存入栈的。
之前的实例是为了说明局部变量的地址作为参数在函数调用过程中的传递过程,更复杂的情况如下所示:


注意在栈中返回地址和参数8之间的部分虽然都是需要传递的数据,但是实际上只是call_proc方法的局部变量而已,因为涉及到传递&地址,所以必须存在栈中,实际上前六个参数还是要存在寄存器中(调用顺序在前面),超过六个的部分存在栈中。

注意此栈帧的结构,参数的地址均与八的倍数对齐。而局部变量可能没这个规矩。
寄存器中的局部存储空间
大致可以理解为程序P执行到一半时调用函数Q,为了防止函数Q修改了未来P将要使用的寄存器的值,提出了一种寄存器使用惯例:
- 被调用者保存寄存器:是指P调用Q时,Q需要为P保存的寄存器。%rbx、%rbp和%r12–%r15
- 调用者保存寄存器:是指P调用Q时,由程序P自己保护的寄存器。其他程序都可以修改。
- 所谓的保护是指将寄存器的值存入栈中当需要的时候存回去,或者根本不去修改。
具体的例子如下所示:

注意第5行,因为参数x存储在%rdi之中,但是对函数Q的调用传递参数依旧要使用%rdi,将被修改,我们不希望在Q(y)中将未来还要使用的x被修改,所以将x的值存在被调用者保存寄存器%rbp中,返回时还会保持原值。
当然,记得在栈中存储局部变量的知识点吗?存储在栈中也是一种保护,当然是在这些寄存器用完之后才使用栈。
在这里的例子中由于是使用的被调用者保存寄存器,将需要保护的局部变量存入相应寄存器之后就不需要过多关注了(不需要考虑函数P对于这些寄存器的处理),因为如何保护寄存器就是Q的问题了。
递归过程
书中给出了一个计算阶乘的汇编代码,注意每层都保护着传入的参数,保存在被调用者保护寄存器%rbx中。值得仔细理解。笔记本上进行了一个简单的模拟。
这样想来,平时写程序时使用的递归最终造成的是栈的扩大(耗内存)。

习题3.35还提供了一个小例子。
本章后面的内容:数组、结构、溢出等先不看。
第四章:处理器体系结构
本章的目的是设计一个高效的、流水线话的处理器。
Y86-64指令集体系结构
这是本书自定义的一个指令集,是为了方便我们学习而创造的。
Y86-64指令
只包含8字节整数操作,称为“字”,不会造成歧义。Y86-64指令集如下图所示:

可以看到质量编码长度为1-10字节不等。
-
第一个字节为指令的类型,其中高四位是代码部分,低四位是功能部分。

-
寄存器对应的数字如下所示:

-
所有整数采用小端法编码,还要确定字节编码需要有唯一解释性。
-
对于指令的字节编码可以参考练习题4.2
-
x86-64将常数值编码成1,2,4,8字节,而Y86-64常数都编码为8字节
Y86-64程序
看一个使用Y86-64指令集的具体程序:

- .pos:从地址0开始产生代码
- .align 8:对齐
- .pos 0x200:栈从这个地址开始
stack:
为了更好的理解Y86-64指令的使用,练习题4.4-4.6都是具体的指令编写,可以参考。
Y86-64指令的push操作
有一个疑问是,pushq %rsp执行时,是先修改%rsp的值再将其压入栈;还是先将%rsp的值压入栈中,再修改%rsp?
根据习题的结果我们发现会先存旧值,再改变%rsp。
逻辑设计和硬件控制语言HCL
说的是一些与硬件有关的自定义语言知识,是为了更好的理解后面的内容所提出的。但是过深的理解并无太多好处,我决定等到后面遇到在回过头看看吧。
HCL整数表达式
- 多路复用的HCl表达

这是指从s开始顺序下行,遇到第一个1开始执行。 - 下图的三路求最小值的逻辑电路由HCL描述如下:

- 算数/逻辑单元(ALU):

存储器和时钟
- 时钟寄存器(寄存器):
- 随机访问存储器(存储器):
- 虚拟内存系统
- 寄存器文件:理解为所有的寄存器都存在里面

可以看出来有两个读端口和一个写端口,可以同时进行多个读写操作。
Y86-64的顺序实现
设计这个SEQ处理器是为了实现本章目标的第一步,在此之前已经获得了设计处理器所需要的部件。
执行阶段
- 取指:
从内存中取出icode(指令代码)、ifun(指令功能)、寄存器操作数指示符rA,rB、还可能取出一个四字节常数valC或者valP。 - 译码:
从寄存器文件中读入两个操作数,可以理解为:valA<–R[rA] - 执行
根据之前获得的valA、valB、valC计算指令指明的操作,得到valE - 访存
将数据写入内存或从内存中读出数据 - 写回
将结果写回寄存器文件中 - 更新PC
将PC更新为下一条指令的地址
来看看Y86-64指令的顺序实现的各个阶段




SEQ硬件结构


SEQ的时序

注意周期3开始时,状态单元(程序计数器、条件码寄存器、寄存器文件、数据内存)是根据周期二的状态设置的。在周期三结束时状态单元并未被修改,当周期四开始时状态单元才被设置。
SEQ阶段的实现
本节会设计实现SEQ所需的控制逻辑块的HCL描述 ,补充一下HCl语言,这是用来描述不同处理器设计的控制逻辑的。
取指阶段

注意上图的内容,指令的第一个字节由Split来分解;后九个字节包含寄存器指示符字节和常数字由Align处理,Align的处理方式由need_regids和need_valC决定。
我们还注意到PC的增加也是由need_regids和need_valC决定的。
三个信号的意义如下所示:
- instr_valid:此指令是否合法?
- need_regids:此指令包括寄存器吗?
- need_valC:此指令包括常数字吗?
对于当前指令是否包含寄存器,有如下HCl描述:

译码和写回阶段
将这两个都涉及寄存器的阶段放在一起讨论。

图中valA、valB为从寄存器文件中读出的值;valM和valE为写入寄存器文件的值;dstE、dstM为两个写端口的地址输入;srcA、srcB为两个读端口的地址输入。注意:此处的地址是指寄存器的“地址”。
当然,使用哪一个地址还是由指令的类型决定的。
地址srcA的值的选择由如下的HCl代码决定:

srcB的值如何表示呢?见习题4.20。
dstE、dstM;srcA、srcB就算将要作为地址使用,也都是按照rA、rB顺序
执行阶段

通过上图可以看到,ALUA使用valC还是valA是由icode决定的;具体的描述如下:

访存阶段


啊,自己看图吧。为了更好的理解,把这些练习题中的HCl代码编写都看一遍会有帮助。
更新PC阶段



SEQ小结
太慢了,在一个时钟周期之内要完成一条指令所需要的所有步骤,时钟必须非常慢才能使信号在一个周期之内传播所有的阶段。想要解决这个问题需要引入流水线的概念。


假设一条指令的执行需要120秒,不使用流水线每120秒完成一条指令;使用流水线40秒完成一条指令。
流水线的通用原理
流水线的作用就是提高吞吐量,但是略微的增加延迟。
- 吞吐量:单位时间内服务的顾客数
- 延迟:服务一个用户所需的总时间
计算流水线
我们把不同的执行阶段分为不同的组合逻辑,在进入新的组合逻辑之前需要将一些状态存入流水线寄存器之中。

上图并未实现流水线,所以指令的执行都放入一个组合逻辑之中,实现了;流水线的图如下:

流水线实现的详细说明
总的来说,在时钟信号由0上升至1时。组合逻辑A的结果均未存入流水线寄存器中。只有当时钟信号上升的一瞬间,流水线寄存器才会改变旧有的状态。

流水线的局限性
不一致的划分
运行时钟的速率是由最慢的阶段的延迟限制的,这样会导致需要时间较少的阶段空闲。

流水线过深
将计算分为过多的阶段,将组合逻辑存入流水线寄存器的时间对整体的时间造成了影响。

带反馈的流水线系统
可能会出现第二条指令需要的值是第一条指令的执行结果这样的情况:

或者上一条指令的执行结果影响接下来程序的行为的:

直观上来说,我们希望在指令执行结束时将状态传递给下一条指令,虽然在非流水线系统上没什么大问题,但是在流水线系统上会出现问题:

可以看出来,这样基于直觉的修改,改变了程序的行为。
Y86-64的流水线实现
现在要开始本章的主要任务—设计一个流水线化的Y86-64处理器。
SEQ+:重新安排计算阶段
将更新PC阶段放到一个时钟周期开始时执行

图中的plcode、pCnd等都是流水线寄存器中的一些状态字段。
更具体的硬件结构图如下所示:(注意,下图不是SEQ+,而是PIPE-)

其中的F、D、E、M、W都是流水线寄存器


举一个具体的指令执行的例子:

注意从周期五从下往上看,各条指令的执行阶段和我们画的硬件结构一样,都是从下往上的。
对信号进行重新排列和标号

我们主要到在流水线的硬件结构图中有许多同名的状态码和控制逻辑块,要小心使用防止错误。
我们使用大写前缀来唯一的标识不同流水线寄存器中的字段。比如D_stat、E_stat等
D、E、M、W都是指流水线寄存器
用小写前缀f、d、e、m、w来标识流水线阶段
注意到对于数据的选择使用的是标号为“Select”的块
预测下一个PC
流水线的目标就是增大吞吐量,最好一个时钟周期执行完一条指令。但是问题就在于如果遇到条件指令则无法填满流水线。
对于Call指令和jmp(无条件转移),PC的下一条指令的地址就是ValC。但是条件转移指令还是要等到指令执行后面的阶段才能获得下一条PC的地址。
条件转移指令可以在二者之间随便选一个,对于预测错误的处理放到后面再说。
ret指令通过弹出栈顶的数据确定下一条PC的地址。

流水线冒险
现在回到之前引入反馈的流水线系统,简单的反馈会导致程序的行为发生错误。
相邻的指令之间可能会存在一些相关:
- 数据相关:后面的指令需要前面指令的结果
- 控制相关:一条指令需要确定下一条指令的位置
这些相关所造成的错误称为冒险:当然也存在数据冒险、控制冒险
下面是progl的执行过程,在下图可以看出第二条指令的写回(W)阶段完成以后才开始add指令的译码阶段,其中三条nop指令延迟了流水线的行为,使得前两条指令和add指令的相关没有发生,但是若没有这三个nop就会出现问题。

如果上图程序中的三个nop变两个,会怎么样呢?

我们可以看到上图中周期六中valB的赋值出现了问题(因为对%rax的赋值需要在下一个时钟信号上升时才会真正写入%rax)
如果将nop指令的个数减少至1、0个时同样会发生错误。
所以我们必须要改进流水线来让它正确的处理这样的冒险。当然有许多冒险的类型:
- 程序寄存器冒险
- 程序计数器冒险
- 内存冒险
- 条件码寄存器冒险
- 状态寄存器冒险
用暂停来避免数据冒险(加载互锁)
执行到add指令的译码阶段时,发现可能会出现数据冒险的情况,插入一个bubble。在下一个阶段时继续对add指令进行译码,以此类推下去直到不存在冒险为止。注意到halt指令也被暂停在取指阶段。

这样的处理方式是很常见的,但是缺点也很明显:严重降低了整体的吞吐量。
用转发来避免数据冒险
转发的思想就是不需要等到上一条指令写回寄存器之后再存入本条指令译码阶段的valA或valB中。而是在上一条指令译码之后就将这些可能会用到的值存在流水线寄存器中。
从下图可以看出,当add指令执行到译码阶段之后需要从寄存器%rdx和%rax之中取指,但是之前的两条指令还没有完成写回(W)阶段;但是经过执行阶段,我们想要的值其实已经存在了valE之中(10、3)。其中M_valE表示在流水线寄存器M中的字段,e_valE表示在执行阶段的字段(ALU的输出)。
具体的过程中还包含了一个判断是否需要转发的过程,X_dstE和X_dstM表示译码之后获取的写操作寄存器ID。在执行add指令的译码阶段时,将获取的读操作寄存器ID:X_srcA、X_srcB与irmovq指令的写操作寄存器ID:X_dstE和X_dstM进行比较,若相同则可以转发。
但是还是可能会出现多个读操作寄存器ID对应一个写操作寄存器的情况,需要规定优先顺序,后面再说。
这里有一个关于时序的问题:译码阶段需要产生valA和valB的值,只要在译码阶段结束之前产生就可以,ALU在执行阶段结束之前肯定会算出结果valE,所以不会有时序问题。

加入了转发机制流水线化最终实现的硬件结构称为PIPE,FwdA与FwdB就是处理转发的。结构图如下所示:

加载/使用数据冒险
但是有一类数据冒险不能使用单纯的转发来解决,就是涉及内存读取的那部分。原因就是对于内存的读取发生在较晚的时间,比如下图所示的prog5:

在位置0x028的指令mrmovq将内存中的值写入寄存器%rax,下一条指令addq需要用到寄存器%rax中的值,但是当addq指令译码阶段时,mrmovq指令还没有执行到访存阶段。所以取寄存器%rax值的会出错。(对寄存器%rbx的取值是正确的)
所以我们需要提出一种应对这种冒险的解决方案:将暂停和转发结合起来。

避免控制冒险
无法确定下一条指令的地址时,即面临控制冒险:ret以及条件跳转预测错误时;本小节就是要讨论一下如何处理控制冒险。

上图中程序的流水线图如下所示,注意到流水线中的指令顺序和上图中不一样。

可以发现ret指令后跟着三个bubble,这是要等到ret指令执行到写回(W)阶段,PC就会被设置为返回的地址,然后取值阶段就会取出irmovq指令。
这里讨论一下分支预测出错的情况:

xorq:异或(两元素不同则结果为1)

大致说一下整个过程:
- 第二条指令执行到E(执行)阶段时(条件码发生改变),后面两条指令发现自己不应该执行了。
- 而后面两条指令都还没有执行到E阶段,所以不会改变条件码。
- 发现后两条指令不应该执行之后,插入两个bubble并取出不该执行的那两条指令。
异常处理
我们的指令集体系可能会出现三种内部异常:
- halt指令
- 非法指令和功能码组合的指令
- 取指或数据读写试图访问非法地址
对于异常的处理并未做过多具体的讲述,先过。
PIPE各阶段的实现
添加了转发流水线、流水线寄存器、流水线控制逻辑的Y86-64处理器,本节将介绍各个逻辑块的设计。举个例子来说:SEQ和PIPE产生srcA信号的逻辑HCL代码如下:

PC选择和取指阶段

PC选择逻辑从三个程序计数器源中选择(M_valA、W_valM、predPC)
- M_valA:当流水线进入错误分支时,跳转语句走到访存阶段时取出M_valA作为下一条PC的值
- W_valM:当ret指令进入到写回阶段时,W_valM就是程序返回的值。
- predPC:其他情况
f_pc以及f_predPC的HCL码如下:


译码和写回阶段
此阶段的时间主要花在转发逻辑中

注意到在译码阶段,有一个valA和valP合并的过程(valP:当前PC自增后的值:也就是当前指令后面一条指令的地址)
- 只有call,jxx指令需要在后面用到valP
- 且这两个指令不需要寄存器端口A中读出的值
可以看到E_valA值可能是:D_valP、d_rvalA(寄存器文件读端口的值)、W_valE、W_valM、M_valE、m_valM、e_valE;

具体的HCL描述如下:需要对dstE、dstM这些值具有更多的了解,这些值一直存在流水线寄存器中,以便后来转发;其实srcA、srcB的作用在译码阶段就已经使用过了,valA和valB的值就可能是以其作为地址从寄存器文件中获得的(不考虑转发的话)
可以从图中看出:
- dstE多指rB的ID:通过运算器计算出来的值需要存入的寄存器ID
- dstM多指rA的ID:通过取内存获得的值需要存入的寄存器ID
就比如说:
- mrmovq D(rB),rA指令,dstE=null(没有通过计算的值需要存入寄存器),dstM=rA(通过读取内存获取的值D(rB)需要存到寄存器rA中)
- irmovq %10,rB指令,dstE=rB(通过运算器计算的值%10需要存入寄存器rB中),dstM=null(没有通过取内存获得的值需要存入寄存器)
- 如果上一条指令是rmmovq rA,D(rB)指令,那么本条指令就不存在数据冒险且dstE=dstM=null(因为没有需要写回的寄存器)
也许从第三行到第六行的顺序不应改变的原因就是因为pop指令:因为不是所有指令都有dstE和dstM两个目标ID的。

上述代码的逻辑比较容易理解,但是需要注意的是优先级的问题,五个转发源的优先级如果乱了的话会有问题。比如下图的prog8:如果不选择e_valE而选择M_valE就会出现错误,取得的%rdx值为10

执行阶段

注意对于E_dstE到M_dstE的过程中进行了一个判断或修改。
访存阶段

流水线控制逻辑
我们要设计流水线控制逻辑,这套逻辑必须要能处理下面四种控制情况:

特殊控制情况所期望的处理
我们前面说了希望流水线控制逻辑能够处理几种特殊的情况,那么遇到这些特殊情况的时候具体希望怎么处理呢?
加载/使用冒险 + ret指令
- 对于冒险的情况之前都有说过了,暂停一个周期插入气泡;
- 对于ret指令,流水线要停三个周期,直到访存阶段读出返回地址;


分支预测错误
当跳转指令到达执行阶段(E)就能够发现预测错误,将已经执行的两条指令插入气泡取消不正确的指令,将正确的指令读取到取指阶段。

导致异常的指令
第五章:优化程序性能
优化编译器的能力和局限性
我们想要展示我们写出的C代码,即使用-O1编译得到的性能,也比用可能的最高的优化等级编译一个更原始的版本得到的性能好。
其次,编译器的优化需要保证在安全的情况下优化:未优化的版本和优化的版本行为相同。
举个例子来说:

上图就是不安全的优化,虽然确实进行了优化:
- 第一个函数进行了六次访存(2次读xp,2次读yp,2次写*xp)
- 第二个函数只有三次访存(读xp,读yp,写*xp)
但是若xp=yp,则执行程序的结果会表现出不同的行为。所以不能产生第二个函数不能作为函数1的优化版本。这就是第一个妨碍优化的因素—内存别名使用
第二个妨碍优化的因素是函数的调用。

看上去好像func1()和func2()是等同的,而且func2只调用了f()一次,func1()却调用了四次,貌似是很好的优化方法;但是如果在函数f()中涉及到全局变量的修改(执行次数会影响全局变量的值)就会出现问题—副作用,大多数编译器不会去判断一个函数有没有副作用,它会假设最糟的情况,保持函数调用不变。

其外,可以可以使用内联来对函数调用进行优化,将函数调用替换为函数体:


这样一来就允许对于展开的代码进行进一步优化了:

表示程序性能
引入每元素的的周期数:CPE作为表示程序性能的方法,帮助我们在更细节的级别上理解迭代程序的循环性能。
- 1GHz:处理器时钟频率是每秒 1 ∗ 1 0 9 \ 1*10^{9}\, 1∗109个周期(每秒执行了多少指令),其周期为1纳秒或1000皮秒。
- 长度为n的向量a的前置和p定义为:

为了展示一下我们如何表示程序性能,下面的前置和函数可以作为参考:

第一个函数每次迭代计算结果向量的一个元素,第二个函数每次迭代计算两个元素(循环展开技术)。
使用最小二乘拟合来对于函数psum1和psum2需要的周期数关于n的取值范围,分别近似于368+9.0n和368+6.0n;表示初始化过程、准备循环、完成过程的开销为368个周期加上每个元素6或9周期的线性因子。
这里,函数psum1和psum2的CPE分别为6.0和9.0。斜率!!!

我们更愿意用每个元素的周期数来度量,而不是每次循环的周期数。
程序示例
向量数据结构的表示:


对于data_t,这是用来表示向量的数据类型,使用typedef来定义数据类型:

下面的new_vec()方法的作用就是创造一个长度为len的向量:

下面的get_vec_element()方法的作用是对每个向量引用进行边界检测,边界检测使得出现错误的概率降低,但是会减缓程序的执行。

下面介绍一个优化示例,将一个向量中所有的元素合并为一个值;其中IDENT、OP需要使用声明来定义。


我们会对上面的代码进行一系列的变化来评估性能的变化。对于combine1()函数我们进行了一系列性能的评估:

本题中对于32位整数和64位整数操作有相同的性能。可以看到直接翻译的combine1效率较低,而简单的使用-O1就会进行一些基本的优化。
消除循环的低效率
我们注意到combine1函数在for循环中每次都要调用vec_length(v)函数,而向量的长度是不变的不需要每次循环都计算一遍,所以做出了如下修改:将对于数组长度的取值提到循环外面(代码移动)


优化的编译器会非常小心的处理会改变在哪里调用函数或调用多少次的变换。一般来说编译器无法有效的判断副作用,所以往往会假设函数有副作用。为了改进代码,程序员往往会帮编译器显式的完成代码移动。
下面据举一个英文字母大小写转换函数的例子:

可以看到:
- lower1函数的n此迭代都会调用一次strlen函数
- strlen函数的运行时间正比于n
- lower1函数的整体运行时间正比于 n 2 \ n^{2}\, n2
- lower2和lower1的区别就在于lower2将strlen函数的调用移出for循环
性能比较结果如下:

我们当然不希望一个大小写转化的函数成为项目的性能瓶颈。
从理想的角度来说,编译器应该可以判断出向量的长度是不会变的,所以对于strlen函数的调用可以移出for循环而不会对函数的行为造成改变。
但是这对于编译器来说是很困难的,因为随着程序的进行字符串的内容会发生改变,编译器还需要判断是否字符串是否会由非0变为0。
所以程序员必须自己做这个工作。
减少过程调用
回顾combine2函数,每次循环都会调用get_vec_element()函数,该函数判断此时的index是否合法,并获取下一向量元素。

但是该方法中的其实不需要判断,都是合法的。于是我们增加了一个函数get_vec_start()来返回数组的起始地址,从而出现了替代函数combine3:


我们发现运行的性能反而变慢了,为什么反复的边界检查会不会使性能更差呢?原因在后面会再提到。
消除不必要的内存引用
函数combine3和其对应的汇编代码如下(double、*):

%rbx存放的是指针dest指向的地址、第i个元素的指针保存在%rdx(每次迭代都加8)、每次迭代都与%rax进行比较

可以看到在上述代码中,每次迭代时累积变量的数值都会从内存读出再写回到内存;这是非常浪费的,因为每次从dest读出的值就是上次存入的值。
于是我们重写了一下combine3,生成了combine4函数:

可以看到,使用了一个临时变量acc来存储累积计算出来的值来减少内存的读写操作。(也许dest就存在%xmm()中)

这样优化以后程序的性能有了明显的提高:

那么又有一个问题,为什么编译器不能自动将combine3的代码优化成combine4那样呢?因为在某些特定的情况下两个函数会表现出不同的行为,比如说:(v=[2,3,5])


虽然这样的差异是人为制造的,但是编译器并不能判断程序员的本意是什么,保守的方法就是不断的读写内存,效率低。
现在我们好奇的就是还有没有制约代码性能的因素。
习题5.4
对于函数combine3来说,使用–O2进行编译时比使用–O1进行编译的CPE性能要好得多,但还是略低于combine4:


来对比一下combine3函数使用–O2和–O1产生的汇编代码的区别:
- –O2:

- –O1:

我们发现这两者之间的区别就是–O2没有使用vmovsd指令,而且使用–O2优化的汇编代码与combine4的汇编代码有所不同,就是多了一行代码:

–O2优化的汇编代码转化为C语言更像是这样:

这样一来,就不会改变程序的行为,应该和如下的行为一致:

理解现代处理器
到目前为止我们的优化都无关于处理器的特性,只是减少了过程调用以及内存访问。
如果想要进一步提高性能,需要利用处理器微体系结构的优化,也就是处理器用来执行指令的底层系统设计。
处理器的实际操作与观察机器级程序的大相径庭—看上去像是一次执行一条指令,从寄存器或者内存中取值,处理完以后存入寄存器或内存。但是在实际的处理器中,其实是同时对多条指令求值的—指令集并行。
现代微处理器牛批的地方就在于:采用复杂的微处理器结构使多条指令可以并行执行但是又呈现出一种简单的顺序执行的表象。
程序的最大性能有两种下界描述:
- 延迟界限:一系列操作需要严格的顺序执行
- 吞吐量界限:处理器功能单元的原始计算能力
整体操作
处理器设计主要分为两个部分:
- 指令控制单元(ICU):负责从内存中读出指令序列并根据这些指令序列生成一组针对程序数据的基本操作
- 执行单元(EU):执行指令控制单元生成的操作

从图中可以看出一些重要的点: - ICU从指令高速缓存中读取指令
- ICU会在指令执行前很早就取值,使其有足够的时间译码并将操作发送给EU
- 指令译码逻辑:接收程序指令并转化成一组基本操作(微操作)
一条指令可以被转化成多个操作,就比如:

会被转化为三个操作:
- 从内存中加载一个值到处理器中
- 将刚刚加载进来的值加上寄存器%rax的值成为结果
- 将结果存回内存中
- 每个时钟周期都会接收多个操作,这些操作会被分配到一组功能单元中
- 加载单元:该单元处理从内存读数据到处理器的操作
- 存储单元:该单元处理从处理器写数据到内存的操作
- 举一个存储单元的例子(intel i7):

我们可以发现:
- 多个功能单元可以执行同类的操作
- 这个特性将会对程序获得最大性能所带来很大影响
在ICU中的退役单元包含一个寄存器文件,它有什么用呢?
- 退役单元记录正在进行的处理,控制其内部寄存器的更新
- 当指令译码时(指令转化为微操作),关于指令的信息被放置在一个先进先出的队列中;当指令执行完成且所有分支均预测正确,该指令退役,对寄存器的修改可以执行;当分支预测错误,则该指令被清空。
- 只有指令退役时才会发生对程序寄存器的更新
功能单元的性能
接下来引出几个有关处理器算数运算的性能:
- 延迟:完成运算所需要的总时间
- 发射时间:两个连续的同类型运算之间需要的最小时钟周期数
- 容量:能够执行该运算的功能单元数
我们可以通过上图看到: - 从整数到浮点数延迟是增加的
- 发射时间是1时,表明每个时钟周期都开始一个新的运算,是通过流水线实现的(完全流水线化的)。
- 除法器的发射时间和延迟时间一样,说明除法器在开始一个运算之前需要完成整个除法。
到这里需要再引出一个吞吐量的概念:每秒进行运算的数量
- 吞吐量定义为发射时间的倒数
- 若容量大于1则吞吐量为每周期C(容量)/I(发射时间)
合并函数的性能有两个界限:
- 延迟界限
- 吞吐量界限

为什么整数加法的吞吐量是0.5呢?
- 首先这个0.5是指CPE,指的是每个数据需要多少周期(越少越好)
- 按照之前对吞吐量的定义:发射时间的倒数(每周期计算的数量),想要对应到CPE还需要倒过来
- 所以原先每个数据仅用1/4周期,但是由于载入单元数量的限制,CPE只有1/2,即0.5
处理器操作的抽象模型
到目前为止combine4是最快的代码,因为它消除了循环的低效率、减少了过程调用、消除了不必要的内存引用


我们可以看到:
- 除了整数加法之外,其他的运算都与处理器的延迟界限是一样的
- 计算n个元素所需要的时钟周期数:L*n + K(L为延迟,K为调用函数以及终止循环的开销)
所以我们在这里引入一个数据流图的概念:


函数combine4的关键路径决定了整体的运行效率,加法与读取内存与乘法器并行的执行。
所以,延迟界限是此处基本的限制。
关键路径是累积变量上的操作

我们希望吞吐量界限成为唯一的限制。
循环展开
循环展开指的是增加每次处理的元素数量,从而减少循环的迭代次数。
对于k*1循坏,每次i自增k,循环次数减少为n/k次

上图中i每次自增2,这样一来循环就减少了一半

上图可以看出循环展开之后对于整数加法来说有一些提高,其他的计算都还无法突破延迟界限,为什么呢?
我们通过关键路径来判断为什么无法突破延迟界限:

可以看到虽然循环的次数减半,但是每次迭代中都有两个顺序的乘法操作,所以关键路径上还是有n个乘法操作,所以无法突破延迟界限。
提高并行性
我们知道执行加法乘法的功能单元是完全流水线化的,但是我们目前写的代码不能利用这种能力,我们将累计值放在一个单独的变量acc中,每次前面的计算完成之前,都不能计算acc的新值。
下图的代码

首先计算的是括号内(acc OP data[i]),再将其结果与data[i+1]进行OP,再将结果存入acc中。

之所以无法突破延迟界限,是因为对于acc的修改需要()OP data[i+1]完成以后,而括号内的操作也无法并行操作,因为括号内的操作也需要acc的值。所以这代码只能串行的执行。
接下来开始说明提高并行性的方法:
多个累积变量
可以看到在for循环中出现了两个累积变量,acc0和acc1,称为2 x 2循环展开。(分别表示每次处理的元素个数、累积变量个数)

acc0 = acc0 OP data[i] 和 acc1 = acc1 OP data[i+1]这两步操作可以并行执行,突破了延迟界限。

重新结合变换
之前的导致串行的代码如下:
acc = (acc OP data[i]) OP data[i+1]
稍作修改一下,可以达到先前两个累积变量的效果:
acc = acc OP (data[i] OP data[i+1])
两次循环间的acc OP *、data[i] OP data[i+1]得以并行执行
关键路径上的mul是在累积变量上的mul操作


一些限制因素
寄存器溢出
当我们进行20 x 20循环展开时,CPE的值却要差于10 x 10循环展开:

这是因为这两种循环展开都需要用到许多累加寄存器,而20 x 20循环展开处理器不具有那么多寄存器,所以就在内存中分配空间进行存储,导致效率降低。
本文深入探讨计算机系统的各个方面,包括信息的表达与处理、程序的机器级表示、处理器体系结构及优化程序性能等内容。通过详细解析计算机系统的工作原理,旨在帮助读者全面理解计算机系统的组成与运作。
2110

被折叠的 条评论
为什么被折叠?



