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