教材参考计算机组织与结构——性能设计(第九版)第14章
这一章我们来讲一讲一种很著名的计算机架构——RISC(另一种与其相对的叫CISC)。那么问题来了,什么是RISC?
RISC是Reduced Instruction Set Computer,即精简指令集计算机的简称。而与其相对的则是复杂指令集计算机,这里我们不作讨论。
RISC之所以叫精简指令集计算机,就是因为其指令的数目相对较少,我们先来看看它有哪些特征:
-
数量众多的通用寄存器,可能有数十个至上百个,使用特殊的编译技术来优化寄存器的使用。
-
指令数量少(从名字也可以看出来吧),格式固定,长度也是固定的。
-
专注于优化指令流水线。
RISC和CISC的对比图如下:
13.1 指令执行特点
在讲RISC的具体特点之前,我们来看一看为什么会出现RISC。
其实,在RISC出现之前,设计者们偏向于CISC,随着硬件工艺的逐渐成熟,硬件的价格逐渐下降,相对而言提高的则是软件的价格,同时,高级语言(High Level Language)也在不断发展,不断变得复杂。
那么人们就想着,不如让指令集变得更加多、更加复杂,用更加复杂的硬件实现来满足高级语言的需要。于是,CISC就旨在简化编译器的实现,用更加复杂的指令集来支持高级语言。
但是,之后的人做了一些实验,让高级语言的程序跑起来,观察高级语言的语句在程序中执行的次数,并乘以对应的机器指令的条数,得到了如下的结果:
可以看到出现次数最多的是赋值,即变量的分配问题,而最耗时的是过程调用,同时也要注意分支和循环。总结而言,最应该受到关注的是关于操作、操作数和过程调用的问题。
对于操作数而言,研究发现,多数的变量引用是对局部的标量;而关于过程调用的研究中发现,过程调用需要的参数并不多,98%的过程调用都只需要6个以内的参数,而且过程调用的深度并不深。
这些有关的研究便给了设计者一些启发:
-
由于操作数大多都是局部变量,于是考虑用更多的寄存器。
-
由于分支、循环、过程调用较多,要更加小心地设计流水线。
于是便产生了RISC这一架构。
13.2 大寄存器组
为什么要用寄存器组呢,这是因为寄存器是最快的一种内部存储,比cache和内存都要快,而且由于寄存器组中的寄存器数量不多,因此寻址非常快。
但是由于存储空间不大,那么就要用一些策略来决定哪些最常用的操作数放在寄存器中了。有硬件和软件两种方案来解决存储空间不足的问题。
-
软件的解决方式:让编译器进行分配,需要更加精密地分析程序
-
硬件的解决方式:当然就是多搞几个寄存器,当然,我们下面介绍另一种策略——寄存器窗口。
什么是寄存器窗口?
寄存器窗口可以理解为由数量有限的几个寄存器组成的一个小寄存器组。一个寄存器窗口由3个部分组成:
-
参数寄存器
-
局部变量寄存器
-
临时寄存器
对于过程调用而言,寄存器组总是从一个局部移动到另一个局部,比如A调用了B,那么A的局部变量要被保存起来,然后寄存器给B的局部变量使用。
参数的传递是靠重叠的部分来实现的,前一个的寄存器窗口的临时寄存器和下一个寄存器窗口的参数寄存器是重叠的,这样就可以不进行数据的移动而进行参数的传递。
这样,过程调用就可以被看作是从一个窗口移动到下一个窗口,而返回就是相反的过程。当然了,一般寄存器窗口不会做成上面那种样子,而是圆环的形式。
当过程调用发生,当前窗口指针CWP(Current Window Pointer)会被指向当前活跃的寄存器窗口。如果没有可用的窗口,那么会产生一个中断,然后最晚返回的窗口会被保存到内存,一个保存窗口指针SWP(Saved Window Pointer)会指向被保存的窗口。如果有N个窗口,那么就可以有N-1个过程调用,这很好理解,比如有2个过程A和B,对应2个寄存器窗口,那么就可以有过程A调用B这一个过程调用。
这时候有人能就会问:局部变量这么处理,那全局变量呢?
典型的处理方式是由编译器分配到内存。或者也可以用一些全局变量寄存器来存储,不过这样会加重寄存器的负担。
13.3 大寄存器组和缓存对比
大寄存器组和缓存Cache看上去似乎十分相似,貌似大寄存器组也就是起到了缓存的作用,存储那些经常要用的变量(虽然寄存器要快许多),两者有什么异同呢?
-
大寄存器组存储的是所有局部变量,而cache只是最近使用的变量
-
两者空间的使用都不够充分,大寄存器组由于给每个窗口分配的容量都是这么多,可能会用不完;而对cache,由于每次都是读一块数据,所以一块中的某些数据实际上都是用不到的。
-
大寄存器组是由编译器进行分配,而cache是分配给最近使用的局部变量。
-
在和内存交互的时候,大寄存器组是由过程调用的深度来决定的,如果过程调用多了就需要放入内存了,而cache是由替换策略决定的。
-
寻址方式不同
-
大寄存器组一个周期内能够寻址并取到多个操作数,而cache只能取到一个
下图展示了大寄存器组得寻址方式,首先指令中的低位会存储一个虚拟的寄存器号,用来标志寻址的是哪个寄存器,同时,还需要一个窗口号,用来表示是哪一个寄存器窗口。
13.4 基于编译器的寄存器优化
说完了大寄存器组,我们来讲讲如何用编译器对寄存器的分配进行优化,编译器优化的目的是使得需要的操作数能够尽量存放在寄存器中,而非在内存中,因为读内存是十分耗时的。
高级语言是没有显式引用寄存器的,比如C语言,虽然C语言有register
来声明寄存器变量。事实上,编译器是通过映射来分配寄存器的,程序中的每一个变量都会被分配给一个虚拟的寄存器,然后编译器会将这无限的虚拟寄存器映射到有限的真实寄存器中。多个虚拟寄存器可以共享一个真实的寄存器,如果没有真实寄存器可用,则会将变量放入内存。
一种编译器中经常使用的优化方法就是图染色,图染色的目的就是找到那些可以共享一个寄存器的变量,然后把他们分配到一个寄存器中,比如下面的A和D都是红色,则可以被分配给同一个寄存器:
使用图染色时,首先给每一个可用的真实寄存器分配一个颜色(颜色相同表示分配到同一个寄存器),在这个图中有结点和边,结点的含义是每一个要分配的变量(或者说虚拟寄存器),边则表示组成边的两个变量的生命周期是有重叠的,比如A和B的生命周期是有部分重叠的。
过程为:可以采用回溯法的算法思想,比如从A开始着色,给A可以涂上红蓝绿,然后再下一层到B,如果A涂了红色,那么B只能够是蓝色或者绿色,就分了2个叉(红色的被剪枝了,因为不满足约束条件),然后再到C,依次往下。最后可以看到,F是不能够被涂色的,于是只能分配到内存。
13.5 RISC和CISC对比
讲完RISC的寄存器优化之后,我们总结一下RISC的特点:
-
大部分指令在单周期内执行完成
-
操作大多都是寄存器到寄存器的
-
少且简单的寻址模式
-
少且固定的指令格式
-
硬连线设计(不是微程序)
-
更加有效的流水线
-
对中断的反应更加迅速
-
更长的编译时间以及花费在编译器上的精力
RISC和CISC到底哪个更好,还没有定论,事实上现在很多都是混合使用的。
13.6 RISC流水线
大部分指令是两阶段的,只包含了取指和执行,对于存取(Load and Store)则有三阶段——取指(Instruction Fetch)、执行(Execute)(计算内存地址)和存储(寄存器到内存或者内存到寄存器)。
图(a)展示了没有流水线的情况,图(b)展示了两阶段的流水线,只包含了取指和执行这两个阶段,如果是load指令,则将E和D都看成是执行,可以看到这样是存在流水线气泡的,因为执行阶段的时间太长,所以第二条的执行要延迟一拍。而图(c)则是三阶段的,就没有了上面的问题,但是产生了新的问题,就是将rA的值和rB的值相加时,会存在依赖关系,因为需要等到第二条指令从内存中读到操作数,也就是D结束,才能够执行该命令,因此这里加了一条NOOP空过一个时拍。图(d)也是同样的道理,只不过需要加2条NOOP。
那么像上面这种还含有跳转指令的该如何处理呢?
可以采用延迟转移进行优化,我们来看一个例子。
延迟转移:在转移指令之后插入一条或几条有效的指令。当程序执行时,要等这些插入的指令执行完成之后,才执行转移指令,因此,转移指令好像被延迟执行了,这种技术称为延迟转移技术,这一条或几条指令被称为延迟插槽。
Address | Normal | Delayed | Optimized |
---|---|---|---|
100 | LOAD x,rA | LOAD X,rA | LOAD X,rA |
101 | ADD 1,rA | ADD 1,rA | JUMP 105 |
102 | JUMP 105 | JUMP 106 | ADD 1,rA |
103 | ADD rA,rB | NOOP | ADD rA,rB |
104 | SUB rC,rB | ADD rA,rB | SUB rC,rB |
105 | STORE A,Z | SUB rC,rB | STORE rA,Z |
106 | STORE rA,Z |
上表中,Normal为正常顺序执行的流水线,而Delayed是用了一条NOOP空过一个时拍,上面已经讲过了,而Optimized是使用了一些技巧进行优化的,我们来看一下流水线的情况:
可以看到,空过一个时拍可以解决问题,但还是效率低了些,如果我们把转移指令上面的那一条指令插入到转移指令之后作为一个延迟的插槽,于是这条插入的指令还是能够正常执行的,且没有依赖关系会导致流水线出错,也就是说,这条指令充当了本来的NOOP指令,但是又真正执行了,于是效率就提高了。
今天的RISC就讲到这里啦~