计算机体系结构:量化设计与分析一书以RISC-V为例介绍计算机体系结构。本文为第一部分,介绍体系结构的基本知识和流水线原理。笔记内容为原书的第一章,附录A、B、C。
第一章主要介绍关于体系结构的基础知识,最为重要的是关于体系结构的相关定义(指令集体系结构)的内容(1.3节),主要位于附录A;其他内容(1.1-1.2&1…4-1.8节)包括计算机的分类,技术趋势,成本趋势,功耗等,只需要有大概的认知和了解;最后的1.9节介绍了计算机设计的量化原理,给出了三个设计计算机时的指导原则。其中相对不重要的部分省略了。
狭义的计算机体系结构仅包括指令集设计,但体系结构要解决的问题已经远超出指令集设计的范围了,因此,本书介绍的体系结构包括:指令集体系结构,微体系结构(存储器,内部处理器等组成),硬件实现。
其中的指令集体系结构,在附录A中详细介绍。
过去的计算机设计技术随着集成电路逻辑技术,半导体技术,磁盘技术等的快速发展而迅猛提高。然而现在随着摩尔定律的失效,技术提升的速度已经放缓了。仅关注性能方面的趋势,最大的特点是:带宽的改进远大于延迟的改进,经验表明带宽的增长速度至少是延迟改进速度的平方。
1.9还介绍了一个重要的定律,用于计算改进计算机的一部分而获得的性能增益,即Amdahl定律。Amdahl定义了加速比:
加速比 = 原执行时间 采用改进后的执行时间 加速比 = \frac{原执行时间}{采用改进后的执行时间} 加速比=采用改进后的执行时间原执行时间
假设改进的比例称为升级比例,改进的部分的升级加速比已知,则整体的执行时间为:
新执行时间 = 原执行时间 × [ ( 1 − 升级比例 ) + 升级比例 升级加速比 ] 新执行时间 = 原执行时间 \times [(1-升级比例)+\frac{升级比例}{升级加速比}] 新执行时间=原执行时间×[(1−升级比例)+升级加速比升级比例]
从而可计算总加速比为:
总加速比 = 原执行时间 新执行时间 = 1 ( 1 − 升级比例 ) + 升级比例 升级加速比 总加速比 = \frac{原执行时间}{新执行时间}=\frac{1}{(1-升级比例)+\frac{升级比例}{升级加速比}} 总加速比=新执行时间原执行时间=(1−升级比例)+升级加速比升级比例1
除此外还介绍了一些衡量性能的指标,例如CPI(每条指令时钟周期数),时钟频率,响应时间,带宽/延迟等:
指令集体系结构的区别在于处理器中内部存储类型的不同。共有三种体系结构:栈体系结构,累加器体系结构,通用寄存器体系结构。其中,栈和累加器式的体系结构都使用隐式的操作数,而通用寄存器使用显式的操作数,或者为寄存器,或者为存储器位置。
通用寄存器体系结构又可以分为两类,一类可使用任意指令来访问存储器,称为寄存器-存储器体系结构,另一类只能用载入和存储指令来访问存储器,称为载入-存储体系结构。第三类将所有操作数保存在存储器中,还没有出现在今天的计算机中,称为存储器-存储器体系结构。寄存器-存储器体系结构可以使用较少的指令,但指令的实现较为复杂;而载入-存储体系结构需要使用更多的指令,实现较为简单。
现有的计算机都使用通用寄存器体系结构,因为寄存器比存储器更快,且对于编译器来说,使用寄存器效率更高,这是因为寄存器可用于保存变量,可以降低存储器通信流量,加快程序速度。
解释存储器地址
通常在指令集中都是字节寻址的,存储器地址访问到的是一个字节,指令集提供对字节,半字,和字的访问方式,大多数计算机还提供对双字的访问。
一个大于一个字节的数据在存储器中的存放方式有两种,分别为:
现代计算机通常支持双端,可以配置为任意一种顺序。
在许多计算机中,对于大于一个字节的存储器寻址都必须是对齐的。因为存储器通常与一个字或双字的倍数对齐,读写更快,如果使用非对齐寻址则会增加硬件的复杂性,并且非对齐寻址可能需要多个对齐的存储器引用。
寻址方式
常用的寻址方式包括寄存器间接寻址,立即数寻址,偏移量寻址等。
一种体系结构至少支持以上提到的三种寻址方式,并且根据统计数据,偏移量寻址方式中的地址大小至少为12-16位,立即数寻址中立即数字段的大小至少为8-16位,就能满足大多数情况下的使用需求。
操作数的常见类型包括字符,半字,字,单精度浮点和双精度浮点。整数通常用二进制补码表示,字符通常使用ASCII码表示,浮点数都采用IEEE标准。
基本所有的指令集体系结构都至少支持以下几种操作:
关于改变控制流的指令,可以分为四类:
根据统计,在计算机中条件分支出现的频率是最高的。
控制流寻址
控制流寻址常见的一种方式是采用PC相对寻址,还有寄存器间接跳转动态寻址。分支常用PC相对寻址来指定目标,从下图可以看出,分支位移量至少为8位就可以满足大多数情况。
寄存器间接跳转寻址通常出现于以下四种情况:
条件分支选项
条件分支的实现技术主要有以下三种:
过程调用选项
在过程调用和返回时,需要进行一些状态保存,至少包括返回地址和ebp,esp等指针的保存,而保存寄存器则分为两种:由调用者进行保存;由被调用者保存。大多数实际系统都采用这两种机制的组合方式,根据一些确定的基本规则,将一些寄存器由调用者保存,而另一些则由被调用者保存。
指令的编码要说明指令的操作和操作数,此外,还必须能够得到操作数的寻址方式。对于简单的载入-存储计算机,寻址方式可以编码到操作码之中,而对于有多种寻址方式的计算机,通常为每个操作数添加一个地址标识符说明寻址方式。对指令集进行编码时,架构师必须平衡以下几个方面:
以下是三种常见的指令集编码方式:
变长编码适用于寻址方式和操作较多时,定长编码适用于寻址方式与操作数较少的情况,但牺牲了平均代码规模。两种编码方式之间的权衡是代码规模与处理器译码的难易程度(性能)。第三种选择就是提供多种指令长度,缩小代码尺寸,这对于嵌入式应用程序来说很重要。一些RISC指令集版本同时提供16位和32位的指令,从而压缩代码规模。
机器执行的指令都是由编译器生成的,因此在设计和实现指令集时,需要考虑编译器技术,尽可能降低编译器生成良好代码的难度。目前编译器的结构大致如下:
编译器的首要目标是正确性,其次是编译后的代码速度,这取决于对代码的优化程度。现代编译器的优化可以分类为:
不同优化的相对使用频率:
在设计指令集架构时,通过以下特性为编译器编写人员提供帮助:
综上,设计一个新的指令集体系结构总是希望满足以下三点:
RISC V的设计如下:
指令格式分为以下几种:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZT8zsXbq-1681740208430)(计算机体系结构-体系结构基础与流水线原理/image-20230415231042887.png)]
R型是寄存器-寄存器指令,主要是运算类的指令;I型是立即数指令,包括涉及立即数的运算和Load指令;S型指令(以及SB型)通常为存储指令和分支指令,而U型指令(以及UJ型)用于实现跳转。指令中包含了源寄存器(rs),目的寄存器(rd),这些字段在译码和写回时使用,读取或写入指定的寄存器。
术语表:
评价缓存性能
评价缓存性能的一种方法是扩展第一章给出的处理器执行公式:
其中CPU周期数为IC(指令数) x CPI
存储器停顿周期取决于缺失数和每次缺失的代价:
其中的存储器访问/指令表示单条指令的存储器访问次数,对于操作不访问存储器的指令,只有取指令本身访问一次存储器。如果直接使用缺失数测量缺失率,通常使用每千条指令缺失数。
缺失率与硬件速度无关,易于测量,但可能产生误导,更好的度量存储器层次结构性能的标准是存储器平均访问时间:
缓存
对于存储器层次结构的第一级:缓存,考虑以下四个问题:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UmtS3gGx-1681740208431)(计算机体系结构/image-20230318095933490.png)]
重点考虑读写策略。所有指令访问都是读取,大多数指令不会写入数据,针对读取操作的优化是:一旦得到了块地址,就开始读取块,如果命中,就立即交给处理器,否则只需忽略读取值,按照读取缺失处理。而对于写则不能这样优化,必须核对标志位是否相同,因此写入通常比读取慢。写入的策略有两种选项:
为了减少写回块的频率,通常采用一个脏位记录块是否被修改,如果块没有被修改,则不需要写回存储器。
对于写入缺失,也有两种策略:
Opteran微处理器中数据缓存的组织方式
Opteran采用两路组相联策略,LRU替换策略,写回策略,有一个牺牲块缓冲区,被替换且修改过的块会发送给该缓冲区,写入低一级的存储器。
根据存储器平均访问时间的公式,可以将缓存优化的方式分为3类:
所有的缺失可以分为三类:
其中的冒险不命中是最容易避免的,只需要采用全相联布置,但硬件实现成本高,可能会降低处理器时间频率;针对容量不命中,可采用的方法只有增大缓存;冷不命中可以通过增大块的大小来减少,但可能增加其他的缺失。许多降低缺失率的技术也会增加命中时间或缺失代价,因此必须综合考虑提高整体系统的速度的目标,优化缓存。下面介绍6种基本的缓存优化方法。
每个进程都有自己的地址空间,为了让同时运行的进程共享物理地址空间,计算机系统都采用虚拟存储器机制,让程序本身不需要考虑空间的具体分配,由操作系统及硬件负责具体空间的分配和虚拟地址到物理地址的转换。
虚拟存储器的划分由两种机制:段机制和页机制,常见这两种方式的混合使用,或是多页面大小的分页机制的使用。
存储器层次结构的四个问题
快速地址转换
分页表本身很大,有时候还会采用多级页表,这导致存储器访问甚至需要两次访存才能进行地址转换,因此计算机系统采用硬件缓存TLB(旁路地址转换缓冲)缓存地址转换。操作系统改变页表,必须使相应的TLB失效。
选择页大小
页大小是常见的体系结构参数,页表的大小和页大小成反比,增大页可以节省存储器,并且分页较大时,可以允许缓存命中较大缓存,并且大的分页还可以使TLB高效映射更多存储器。采用较小的页则可以减少空间碎片,加快进程启动时间。
流水线是一种将多条指令重叠执行的实现技术。一条指令需要多个操作,流水线技术利用了操作之间的并行性。流水线可以缩短每条指令的执行时间,可以记作CPI的下降或时钟周期的下降。如果开始时一条指令需要多个时钟周期,则看作是CPI的下降,否则可以看作是时钟周期的下降。
附录介绍RISC-V体系结构下的流水线实现原理。RISC体系结构已在附录A中有所介绍。
RISC-V指令集的简单实现
RISC指令集中的每条指令都可以在最多五个时钟周期内实现,这五个周期如下:
下图是MIPS的CPU结构,较RISC-V简单,可对照以上五个阶段,理解数据的流动过程。
RISC-V的流水线
在每个时钟周期开始执行一条新的指令,就可以实现流水线化,为了保证每个周期都能正确执行,需要解决流水线化带来的一些问题。本节首先确保在一个周期,不会同时对相同数据源执行两个不同操作。
下图是一个RISC数据路径的流水线表示:
主要的功能单元在不同周期使用,因此不会引入太多冒险,以下三点避免了可能的冒险:
流水化的基本性能问题
流水化提高了指令吞吐量,但不会缩短单条指令的执行时间,由于流水线控制会产生开销,通常还会稍微延长每条指令的执行时间。流水线开销包括流水线寄存器延迟(建立时间)和时钟偏差(两个寄存器之间的延迟),时钟的速度不可能快于最慢的流水级,因此时钟周期被限定了下限。
冒险阻止了指令流在下一个周期的执行,共有以下三类冒险:
冒险会使流水线停顿,为了避免冒险,经常要求一些指令延迟时,其他一些指令可以继续执行。附录中讨论的流水线,一条指令被停顿后,所有之后发射的指令也被停顿。
结构冒险
结构冒险是由于资源冲突,不允许某些指令重叠。例如写入存储器的同时从存储器取址。当遇到这种指令序列时,流水线会使其中一个指令停顿一个周期,这个周期被称为流水线气泡。结构冒险是可以避免的,例如访存的数据冒险,可以将缓存分为独立的指令缓存和数据缓存,也可以用一组缓冲区来保存指令。是否要避免结构冒险,要考虑单元的成本,对于罕见的结构冒险,不值得花代价避免其出现。
数据冒险
数据冒险是由数据的读写访问顺序产生的,重叠指令的执行改变了原有的读写顺序。下图的指令就是一个数据冒险,第五个周期ADD指令才写回,但SUB指令在第三个周期就要访问寄存器了。
or指令及之后的指令就不会导致冒险了,因为or读寄存器在第五周期的后半部分,而写入是在第五周期的前半部分。
典型的数据冒险是由以下三种相关产生的:
可以利用转发技术减少上述的数据冒险,这一技术也称为数据前推(Forwardig)。转发技术的关键是要把数据转移到需要的地方,对于上例,如果add计算后的结果转移到sub指令计算的位置,就可以避免出现停顿。工作方式如下:
上述方式可以减少数据冒险停顿,但有些数据冒险是无法通过转发处理的。考虑以下指令:
对于这段程序,ld指令第四周期才读出sub指令需要的值,而sub指令第二周期就要读取这个值了,这个冒险是无法避免的。这个时候就需要增加一种称为流水线互锁的执行方式,检测冒险,并在冒险结束之前使流水线停顿。
控制冒险
处理分支的最简单办法是,如果译码检测到了分支指令,就在下一个周期重新对下一条指令进行取址,这会产生一个周期的停顿。假设分支指令在ID计算分支地址并判断分支条件。如果分支没有被选中,这一个停顿周期就是不必要的,因此,下面讨论一些应对这一问题的技术。
降低分支代价有四种编译时机制,由软件利用硬件机制和分支特点降低分支代价。
延迟分支在早期RISC中应用广泛,在分支和分支目标之间的分支延迟时隙执行一条指令,这条指令由编译器进行调度。显然如果没有冒险,把一条分支指令之前的一定会执行的指令移动到分支指令之后是最合适的,不过这可能无法实现。这个技术也被称为延迟槽。现在的RISC-V使用动态分支预测,已经不再使用延迟分支了。
当流水线越来越深,分支的代价增加时,上述简单机制就不够了,需要更好的分支预测机制。这些机制分为两类:依赖编译时信息的静态分支预测机制,根据程序特性对分支动态预测的机制。
流水线化的方式已经在C.1中介绍过了。这里细致的说明每个周期指令完成的实际操作。
取址周期
IR <- Mem[PC]
NPC <- PC+4
其中IR存储取出的指令,NPC是下一条指令的地址。PC并不止这一种取值,还可能是分支值。
指令译码/寄存器提取周期
A <- Regs[rt]
B <- Regs[rt]
Imm <- 扩展后的立即数
执行周期
这一周期有几种可能的操作:
访存周期
这一周期完成了存储器引用:
LMD(载入存储器数据) <- MEM[ALUoutput] / MEM[ALUoutput] <- B
同时在这个周期把上个周期计算出的分支目标地址写入PC。(和上面的分支计算一样,这一操作没有必要等到这个周期)
写回周期
写回周期根据指令类型,写入寄存器组:
通过将执行划分为五个周期,最终的流水线如下图:
将一条指令从译码ID移入执行EX的过程通常为指令发射,已经执行这一步骤的指令称为已发射。对于整数的情况,所有的数据冒险都可以在ID阶段进行检查,如果存在数据冒险,就停顿当前指令。停顿的方式是IF/ID保持值而不更新,ID/EX输出空操作,这样流水线的IF和ID阶段停顿,后面的阶段继续运行,从而处理了数据冒险问题。以下是可能的数据冒险:
对于零检测分支,分支条件判断和目标地址计算工作在译码ID阶段就可以完成了,不需要等到执行,因此ID阶段可以判断分支条件,这样如果有分支,只浪费了分支之后IF的一条指令,相当于停顿一个周期,这条IF的指令还可以作为延迟槽,使用指令重排序将一条无论是否跳转都要执行的指令到这里,避免浪费。对于更深的流水线设计,分支延迟可能更大。MIPS采用了这样的设计。然而,如果在ID阶段就完成分支计算,会导致一些数据冒险产生更多停顿,因此RISC-V设计在EX阶段完成分支判断,这样如果没有分支预测,会浪费两条指令,相当于产生两个停顿。
RISC-V使用动态分支预测,不使用延迟槽,因为分支延迟并不总是可行,并且分支判断在EX阶段完成,在ID阶段根据预测的结果进行跳转,EX阶段进行验证。
对于RISC-V来说,假设采用动态分支预测,流水线是这样的(对于jal这样的指令,分支地址可以在ID计算),假设总是预测正确:
预测选中
CLK1 | CLK2 | CLK3 | CLK4 | CLK5 | CLK6 | CLK7 | |
---|---|---|---|---|---|---|---|
pipline i1 | IF(PC+4) | ID(分支预测,分支地址计算) | EX(计算分支条件) | MEM | WB | ||
pipline i2 | IF(根据预测结果更新PC=PC+8/branch target) | ID | flush | flush | flush | ||
pipline i3 | IF(PC=PC+4) | ID(branch target) | EX | MEM | WB |
预测不选中
CLK1 | CLK2 | CLK3 | CLK4 | CLK5 | CLK6 | CLK7 | |
---|---|---|---|---|---|---|---|
pipline i1 | IF(PC+4) | ID(分支预测,分支地址计算) | EX(计算分支条件) | MEM | WB | ||
pipline i2 | IF(根据预测结果更新PC=PC+8/branch target) | ID | EX | MEM | WB | ||
pipline i3 | IF(PC=PC+4) | ID(PC) | EX | MEM | WB |
如果没有分支预测,或者说默认未选中,那么流水线将是这样的:
CLK1 | CLK2 | CLK3 | CLK4 | CLK5 | CLK6 | CLK7 | CLK8 | |
---|---|---|---|---|---|---|---|---|
pipline i1 | IF(PC+4) | ID(分支地址计算) | EX(计算分支条件) | MEM | WB | |||
pipline i2 | IF(PC = PC+4) | ID(PC) | flush | flush | flush | |||
pipline i3 | IF(根据分支条件,PC=PC+4/branch target ) | flush | flush | flush | flush | |||
pipline i4 | IF(PC=PC+4) | ID(branch target) | EX | MEM | WB |
分支预测正确时减少了指令的浪费和停顿。
在理想情况下,流水线中没有停顿,一个流水线的时钟周期完成一条指令,假设流水线深度为d,没有流水线化的d个时钟周期可以完成一条指令,建立流水线后,可以完成d条指令,因此理想流水线加速比为d。考虑停顿,流水线加速比为:
S p e e d u p = d 1 + s t a l l s Speedup = \frac{d}{1+stalls} Speedup=1+stallsd
其中的停顿时间来自各种冒险,如果知道每个冒险发生的频率和代价,就可以计算出流水线加速比。
不同CPU会用不同的词描述改变指令正常执行顺序的情景,包括中断、错误、异常等词。在本书中,用异常来包含所有这些情况。异常的情况非常多,仅从流水线的角度来说,有以下这几种异常:
流水级 | 异常 |
---|---|
IF | 指令提取发生页错误、非对齐存储器访问、违反存储器保护规则 |
ID | 未定义或非法操作码 |
EX | 算数异常 |
MEM | 数据提取时发生页错误、非对齐存储器访问、违反存储器保护规则 |
WB | 无 |
在实际的流水线当中,错误很可能不按指令执行顺序发生。如果发生异常时,流水线可以停止,使紧急错误指令之前的指令可以完成,使其之后的指令可是从头重新启动,就说该流水线拥有精确异常。处理异常较为复杂,书中介绍了停止和恢复执行的方式,此处略过。
RISC这样的简单指令集有一个好处,对代码的调度是简单的。因为RISC完成一个完整的操作需要更多指令,增加了调度的灵活性。因此几乎所有复杂指令集的流水线都将复杂指令转换为类似RISC的简单操作,然后进行调度和流水化。
之前介绍的数据前推解决流水线中的一些冒险,但是总是存在一些不可避免的冒险,使流水线停顿。停顿时,不会提取或发射新指令,为了弥补这些性能损失,编译器可以采用调度指令来避免冒险,这种方法为静态调度。几种早期处理器使用了另外一种动态调度技术,为了更好的理解后续采用的更加复杂的机制,有必要介绍动态调度机制。
之前介绍的流水线,指令时按序执行的,如果两条指令之间存在冒险,即使后面的指令是不相关的,也会停顿下来。
将一条指令从译码ID移入执行EX的过程通常为指令发射,已经执行这一步骤的指令称为已发射。为使一条指令在操作数可用时就可以开始执行,而不受先前停顿的指令的影响,发射过程必须分为两部分:检查冒险,等待数据冒险结束。按序对指令进行译码和发射,但乱序执行。ID流水线将被划分为两级:
为了能让多条指令处于执行状态中,要改变功能单元设计,改变单元数,操作延迟和功能单元流水化。
采用记分牌的动态调度机制
动态调度流水线中,指令按序发射,乱序执行,实现方式是使用记分牌。记分牌全面负责指令发射与执行,包括所有冒险检测任务。乱序执行会导致原来顺序执行流水线中不存在的WAR和WAW冒险出现,这些冒险都由记分牌来检测和处理。为了保证多个指令同时执行,处理器拥有多个功能单元(存储器引用单元,整数运算单元,浮点运算单元等)。每条指令都会进入记分牌,有一条记录,记分牌会判断什么时候能读取操作数并执行,还会控制指令什么时候能写回目标寄存器。
现在先不考虑存储器访问,只考虑运算指令,指令在流水线中完成有四个步骤:
接下来以一个指令序列为例,给出记分牌中所有记录的信息:
LD F6,34R2
LD F2,45(R3)
MULD F0,F2,F4
SUBD F8,F6,F2
DIVD F10,F0,F6
ADDD F6,F8,F2
记分牌有三个部分:
有了上述信息,记分牌就可以控制指令的执行过程,一个指令的执行,要先检查需要使用的结构单元是否处于空闲,如果空闲,则可以发射,并准备读取操作数。读取操作数之前要检查RAW冒险,确保操作数就绪,得到操作数后可以开始执行。执行结束后,根据寄存器结果状态以及其他信息,检查WAR冒险和WAW冒险,无冒险时完成写入。