一、综述
取指是指处理器核将指令从存储器中读取出来的过程(按照其指令PC值对应的存储器地址)。
指令在存储器空间中所处的地址称为指令PC。
取指的最终目的是以最快的速度且连续不断地从存储器中取出指令供处理器核执行。
常规RISC架构汇编指令流特点:
- 对于非分支跳转指令,处理器需要按照顺序执行这些指令,指令PC值逐条指令连续增加。因此处理器在取指的过程中可以按顺序从存储器中读取指令。
- 对于分支跳转指令,处理器执行了这条指令后,如果该跳转指令的条件成立需要发生跳转,则会跳转至另外一个不连续的PC值处。因此处理器在取指过程中,理论上也需要从新的PC值对应的存储器地址读取指令。
- 指令的编码可以不相等,有的指令编码宽度是16位的,而有的指令编码宽度是32位的。对于32位的指令,其对应的PC地址可能与32位地址边界不对齐(无法被4整除)。
综上,处理器需要以快和连续不断的标准从存储器中取出指令,就需要能够做到如下性能。
- 对于非分支跳转指令,能够连续不断地按顺序将其从存储器中快速读取出来,即便是地址不对齐的32位指令,也最好能连续不断地每个周期读出一条完整指令。
- 对于分支跳转指令,能够快速地判断其是否需要跳转。如果需要跳转,则从新的PC地址处快速取出指令,即便是地址不对齐的32位指令,也最好能够一个周期读出一条完整指令。
二、如何快速取指
为了能够以更快的速度从存储器中取出指令,首先需要保证存储器的读延迟越小越好。不同的存储器类型有不同的延迟,片外的DDR存储器或者FLASH存储器可能需要几十个周期的延迟,片上的SRAM也可能需要几个周期的延迟。为了能够使得处理器核以最快的速度取指,通常使用ITCM和I-Cache的方法。
(1)ITCM
- 指令紧耦合存储器,是指配置一段较小容量的存储器(常用SRAM),用于存储指令,且在物理上离处理器核很近且专属于处理器核,因此能够取得很小的访问延迟(通常一个时钟周期)。
- ITCM的优点是实现非常简单,容易理解,且能保证实时性。
- ITCM的缺点是由于使用地址区间寻址,因此无法像缓存(Cache)那样映射无限大的存储器访问;同时为了保证足够小的访问延迟,无法将容量做到很大,否则无法一个时钟周期访问出来SRAM或芯片无法容纳过大的SRAM,因此ITCM只能用于存放容量大小有限的关键程序指令。
(2)I-Cache
- 指令缓存,是指利用软件程序的时间局部性和空间局部性,将容量巨大的外部指令存储器空间动态映射到容量有限的指令缓存中,可以将访问指令存储器的平均延迟降低到最小。
- 由于缓存的容量是有限的,因此访问缓存存在着相当大的不确定性。一旦缓存不命中,则需要从外部的存储器中存取数据,造成较长的延迟。在实时性要求高的场景中,处理器的反应速度必须有最可靠的实时性。如果使用了缓存,则无法保证这一点。
- 大多数极低功耗处理器应用的场景都应用于实时性较高的场景,因此更加倾向于使用延迟确定的ITCM。
- 缓存可以认为是处理器微架构中最复杂的部分。
三、如何处理非对齐指令
连续不断是处理器取指的另一个目标。如果每一个时钟周期都能够取出一条指令,就可以源源不断地为处理器后续执行提供指令流,而不会出现空闲的时钟周期。
但是,不管是从I-Cache,还是ITCM中取指令,当处理器取指遇到了一条地址非对齐的指令,则会为连续不断取指造成困难,因为ITCM和I-Cache的存储单元往往使用SRAM,而SRAM的读端口往往具有固定的宽度。假如一个32位的SRAM,每个时钟周期可以读出一个32位的数据。假设一条32位长的指令处于地址不对齐的位置,则意味着需要分两个时钟周期读出两个32位的数据,然后各取其一部分进行拼接成为真正需要的32位指令,这样就需要花费至少两个时钟周期才能够取出一条指令来。
(1)普通指令非对齐
对于普通指令的按顺序取指情形,可以使用剩余缓存(Leftover Buffer)保存上次取指令后没有用完的比特位,供下次使用。假设从ITCM中取出一个32位的指令字,但是只用到了它的低16位,这种情形可能是由于两种原因造成的。
- 只需要使用此次取出的32位中的低16位和上次取出的高16位组成一条32位指令。
- 这个指令长度本身就是16位宽,因此只需要取出低16位。
那么对于此次没有使用到的高16位,则可以暂存域剩余缓存中,待下个周期取出下一个32位的指令字之后,就可以马上拼接出新的完整32位指令字。
(2)分支跳转指令非对齐
对于分支跳转指令而言,如果跳转的目标地址与32位地址边界不对齐,且需要取出一个32位的指令字,上述的剩余缓存也无济于事(因为剩余缓存只有在按顺序取指时,才能提前预存上次没有用完的指令字)。对此,常见的实现方式是使用多体(Bank)化的SRAM进行指令存储。以常见的奇偶交错方式为例,使用两块32位宽的SRAM交错地进行存储。
两个连续的32位指令字将会被分别存储在两块不同的SRAM中。这样对于地址不与32位对齐的指令,则一个周期可以同时访问两块SRAM取出两个连续的32位指令字,然后各取其一部分进行拼接成为真正需要的32位指令。
四、如何处理分支指令
4.1 分支指令类型
无条件跳转/分支指令(其中又分为直接jal和间接jalr),带条件的跳转/分支指令(直接和间接)。直接和间接指跳转的目标地址需要立即数还是寄存器索引。
对于带条件的跳转/分支指令,流水线在取指阶段无法得知指令的条件是否成立,因此无法决定跳还是不跳,理论上指令只有在执行阶段完成之后,才能够解析出最终的跳转结果。假设处理器将取指暂停,一直等待执行阶段完成才继续取指,则会造成大量的流水线空泡期,从而影响性能。
为了提高性能,现代处理器的取指单元一般会采用分支预测技术。主要解决两个问题
- 预测分支指令是否真的需要跳转?简称为预测“方向”。
- 如果跳转,跳转的目标地址是什么?简称为预测“地址”。
4.2 预测方向
对于方向的预测,可以分为静态预测和动态预测两种。
(1)静态预测是最简单的方向预测方法,其不依赖于任何曾执行过的指令信息和历史信息,而是仅依靠这条分支指令本身的信息进行预测。
- 最简单的静态预测方法是总预测分支指令不会发生跳转,因此取指单元便总是顺序取分支指令的下一条指令。待执行阶段之后如果发现需要跳转,则会冲刷流水线重新进行取指。为了弥补冲刷流水线造成的性能损失,很多早期的RISC架构使用了“分支延迟槽(delay slot)”。分支延迟槽是指在每一条分支指令后面紧跟一条或者若干条指令不受分支跳转的影响,不管分支是否跳转,后面的几条指令所在的位置便称为分支延迟槽。由于分支延迟槽中的指令永远被执行而不用被丢弃重取,因此其不会受到冲刷流水线的影响。
- 另一种常见的静态预测方法是BTFN预测,即对于向后的跳转预测为跳,向前的跳转预测为不跳。向后的跳转是指跳转的目标地址(PC值)比当前分支指令的PC值要小。
(2)动态预测是指依赖已经执行过的指令的历史信息和分支指令本身的信息综合进行方向预测
- 最简单的分支方向动态预测器为,一比特饱和计数器,每次分支指令执行之后,便使用此计数器记录上次的方向。预测机制是:下一次分支指令永远采用上次记录的方向作为本次的预测。
- 两比特饱和计数器是最常见的分支方向动态预测器,对应的状态机转换如下图,每次预测出粗后便会向着相反的方向更改状态机状态。由于总共有4个状态,譬如从强需要跳转状态需要两次预测错误后,才能变到弱不需要跳转,因此具有一定的切换缓冲,其在复杂的程序流中预测精度比简单的一比特饱和计数器有更高的精度。
- 两比特饱和计数器对于预测一条分支指令很有效,但是处理器执行的指令流中存在着众多的不同分支指令。假设只有一个两比特饱和计数器,在任何分支指令执行时均进行更新,那么必然会互相冲击,预测的结果会很不理想。最理想的情况是为每一条分支指令都分配专有的两比特饱和计数器为其进行预测,但是指令数目太多,不可能提供巨量的两比特饱和计数器。所以只能够使用有限个两比特饱和计数器组织成一个表格,然后对于每条分支指令使用某种形式寻址方式索引表格中的某个表项的两比特饱和计数器。由于表格中的表项数目有限而指令数目众多,因此很多不同的分支指令都会不可避免地指向同样的表项,这种问题称为别名重合
目前一般使用不同的动态分支预测方法,就是采用不同的表格组织方式和索引方式,提供更高的预测精准率,常见方法如下:
一级预测器
- 一级是指其索引采用指令本身的PC值
- 方法简单易行,但是索引机制过于简单,很多不同的分支指令都会指向同样的表项。并且由于没有考虑到分支指令的上下文执行历史,分支预测的精度不如两级预测器。
两级预测器
- 两级预测器也成为相关预测器。对于每条分支指令,将有限个两比特饱和计数器组织成PHT。使用该分支跳转的历史作为PHT的索引。如图,假设用n个比特记录历史,则可以索引2的n次方个表项
- 分支历史可以分为局部历史和全局历史。局部历史是指每个分支指令自己的分支跳转历史,而全局历史是指所有的分支指令的分支跳转历史。
- 局部分支预测器会使用分立的局部缓存来保存不同指令的分支历史,每个局部历史缓存有自己对应的PHT。对于每条分支指令而言,先索引到其对应的局部历史缓存,然后使用局部历史缓存中的历史值索引其对应的PHT。
- 全局分支预测器则仅使用所有分支指令共享的全局历史缓存。全局分支预测器一个很明显的弊端是它无法区分单独每个分支指令的历史,不同的指令会相互冲击,但是它的优势是比较节省资源。PHT容量越大,优势越明显。
4.3 预测地址
只能在流水线执行阶段才能计算出分支目标地址。在现代高速的处理器中,这些都是不可能在一个周期内完成的,在高速的处理器中连续取下一条指令之前,甚至连译码判断当前取到的指令是否属于分支指令都无法及时在一个周期内完成。
为了能够连续不断地取指,需要分支预测分支的目标地址,常见技术如下:
(1)BTB
- 分支目标缓存,是指使用容量有限的缓存保存最近执行过的分支指令的PC值,以及他们跳转的目标地址。对于后续需要取指的每条PC值,将其与BTB中存储的各个PC值进行比较,如果出现匹配,则预测这是一条分支指令,并使用其对应存储的跳转目标地址作为预测的跳转地址。
- BTB是一种最简单快捷的预测地址方法,但是其缺点之一是不能将BTB容量做到太大,否则面积和时序都无法接受。
- BTB的另一个缺点是对于间接跳转/分支指令的预测效果并不理想。这主要是由于间接跳转/分支的目标地址是使用寄存器索引的操作数计算所得,而寄存器中的值随着程序执行可能每次都不一样,因此BTB中存储的上次跳转的目标地址并不一定等于本次跳转的目标值。
(2)RAS
- 返回地址堆栈,是指使用容量有限的硬件堆栈来存储函数调用的返回地址。
- 间接跳转/分支可以用于函数的调用和返回。而函数的调用和返回在程序中往往成对出现,因此可以在函数调用时将当前PC值加4,即其顺序执行的下一条指令的PC值压入RAS堆栈中,等到函数返回时将RAS中的值弹出,这样就可以快速地为该函数返回的分支跳转指令预测目标地址。
- 只要程序是在正常执行,其函数的调用和返回成对出现,那么RAS便能提供较高的预测准确率。当然RAS的深度有限,如果程序中出现很多次函数嵌套,需要不断地压入堆栈,造成堆栈溢出,则会影响到预测准确率,硬件需要特殊处理该情形。
(3)Indirect BTB
- 间接BTB是指专门为间接跳转/分支指令而设计的BTB,它与普通的BTB类似,存储较多的历史目标地址,但是通过高级的索引方法进行匹配,结合了BTB和动态两级预测器技术,能够提供较高跳转目标地址预测成功率。但其缺点是硬件开销非常大,只有在高级的处理器中才会使用。
五、RISC-V架构对于取指的简化
5.1 规整的指令编码格式
可以非常便捷的译码出指令的类型即其使用的操作数寄存器索引或者立即数,简化硬件设计。
5.2 指令长度指示码放于低位
RISC-V定义了一种可选的压缩指令子集,由字母C表示。如果支持此压缩子集,就会有32位和16位指令混合交织在一起的情形。
为了支持16位指令的取指,取指逻辑每取一条指令后需要以最快的速度译码判断出当前指令的宽度是16位或者32位。所有RISC-V指令编码的最低几位专门用于编码表示的指令的长度。将指令长度指示码放在指令的最低位,可以方便取指逻辑在顺序取指的过程中以最快的速度译码出指令的长度,极大的简化硬件设计。
另外,由于16位的压缩指令子集可选,假设处理器不支持此压缩指令子集而仅支持32位指令,甚至可以将指令字的低2位忽略不存储(固定为11),从而节省6.25%指令缓存的开销。
5.3 简单的分支跳转指令
RISC-V架构有两条无条件跳转指令,jal与jalr指令。
jal指令可以用于子程序调用,同时将子程序返回地址存在jal指令的目标接过寄存器(链接寄存器)中。
jalr指令可以用于子程序返回指令,通过将jal指令保存的链接寄存器用于jalr指令的基地址寄存器,则可以从子程序返回。
RISC-V架构有6条带条件分支指令,beq,bne,blt,blte,bge,bgeu与普通运算指令一样,使用两个整数操作数,对其进行比较。比较的条件满足则进行跳转,是将比较和跳转两个操作放到了一个指令中完成。这种带条件的分支指令使用12位的有符号数作为偏移量。该偏移量乘以2后与当前指令所在的地址相加,生成得到最终的跳转成功地址。
5.4 没有分支延迟槽指令
分支延迟槽使得处理器的硬件设计变得极为别扭,尤其是对于取指部分的硬件设计将会比较繁琐。
RISC-V架构放弃了分支延迟槽,认为放弃分支延迟槽的得大于失。因为现代的高性能处理器的分支预测算法精度已经非常高,可以有强大的分支预测电路,保证处理器能够准确地预测跳转执行达到高性能。
5.5 提供明确的静态分支预测依据
RISC-V架构中明确规定,编译器生成的代码应该尽量优化,使得向后跳转的分支指令比向前跳转的分支指令有更大的概率进行跳转。这样对于静态预测的低端处理器,可以保证其行为和软件行为匹配,最大化地提高静态预测的准确率。
5.6 提供明确的RAS依据
RISC-V架构中明确规定,如果使用jal指令且目标寄存器索引值rd等于x1或者x5,则属于需要进行RAS压栈。如果使用jalr指令,则按照使用的寄存器值(rs1和rd)的不同,明确规定了相应的RAS压栈或者出栈行为。