背景
本学期学校开设了计算机组成原理课程与实验。授课老师一言难尽就不提了,实验的内容是单周期CPU的设计,也没有提及流水线技术。
流水线是一种能使多条指令重叠执行的实现技术。目前,流水线技术被广泛采用。
(没想到写这个学习笔记需要这么久。。。。最后实在是写不下来了。。。)
明确
- 为什么重视处理器设计:计算机的性能体现在三个方面:指令数、时钟周期长度和每条指令的时钟周期数。指令数由编译器和指令系统体系结构共同决定。处理器的实现方式决定了时钟周期长度和CPI。这三者又是紧密联系的,如RISC-V指令系统就是面向流水线设计的。
2.** 为什么不用单周期实现 **:单周期设计中时钟周期对于每条指令必须等长。这样,处理器中最长的路径决定了时钟周期。虽然CPI为1,但由于时钟周期太长,性能是很差的。(单周期实现违反了加速经常性事件这一设计原则) - 流水线更快的原因:采用流水线,单条指令处理的时间并没有缩短,但是因为所有工作都在并行的进行,所以显著提高了系统的吞吐率。换言之,在单周期设计中,每多执行一条指令的开销是一个指令周期,而在流水线设计中,增加的只是一个流水线的时钟周期。
- 流水线可以快多少:一个理想的性能加速比是: 指 令 执 行 时 间 流 水 线 = 指 令 执 行 时 间 非 流 水 线 流 水 线 级 数 指令执行时间_{流水线} = {指令执行时间_{非流水线} \over流水线级数 } 指令执行时间流水线=流水线级数指令执行时间非流水线
- 流水线时钟周期取决于什么: 流水线的时钟周期必须足够长以满足最慢的操作,基本是ALU操作或者存储器访问操作。
- 现代微处理器的流水线级数是不是越来越多:不是。现代的微处理器碰上了功耗墙。因此工业界被迫转向多处理器,以开发更粗粒度的指令级并行,这也导致了最近一些处理器在微体系结构上对流水线进行简化。
微处理器的时钟频率和功率的快速增长几乎保持了几十年,但最近10年却慢了下来。放缓的原因在于功率已经达到实际极限,无法再将普通商用处理器冷却下来。在后PC时代,能量是真正关键的资源。
20多年来时钟频率增长了1000倍,而功耗只增加了30倍,原因在于电压从5V降到了1V,但目前的问题是如果电压继续下降,会导致泄露电流过大,就像水龙头不能完全关闭一样。由此引发了单处理器向多处理器的转变。
功 耗 = = 1 / 2 ∗ 负 载 电 容 ∗ 电 压 2 ∗ 开 关 频 率 功耗 == 1/2 * 负载电容 * 电压^2 * 开关频率 功耗==1/2∗负载电容∗电压2∗开关频率
- 流水线不同于并行编程:流水线对于程序员是不可见的,而并行编程是由程序员完成的。在设计并行编程时,程序员不仅要保证程序正确,还要将应用划分为每个核上由大致相同数量的任务,并同时完成,编程难度非常大。
- 另一项提高并行性的技术:多发射技术:增加 流水线内部的功能部件数量,这样可以每周期发出多条指令。
设计
我们将实现RISC-V的一个核心子集:
- 访存指令:ld、st
- 算数逻辑指令add、sub、and和or
- 条件分支指令beq、bne
流水线阶段划分为五级:
- IF:取指令
- ID:指令译码和读寄存器堆
- EX:ALU运算和brunch跳转地址的计算
- MEM:数据处理器访问,brunch跳转地址返回PC
- WB:写回寄存器堆
每一个运算部件与控制逻辑都属于且只属于唯一的流水线阶段,这是流水线设计的大前提,而且避免了结构冒险(冒险在后面有说)。
数据通路
流水线与单周期CPU最大的不同就是划分了五个流水线层级,这些层级之间是彼此独立的。就如同现实生活中的流水线作业,每个阶段的作用就是利用上一阶段提供给它的数据,完成处理并交给下一阶段。
阶段之间的数据传递我们使用的是流水线寄存器。寄存器的名称由两个被该寄存器分开的阶段的名称命名。
控制逻辑
按照RISC-V的设计逻辑,控制信号与ALU的操作类型完全可以在ID阶段的指令译码过程中完成。
每个控制逻辑只在三、四、五阶段中唯一使用一次,将其按照使用阶段分为三组,使用流水线寄存器向后传递即可。
冒险
在流水线中可能会出现下一个时钟周期中下一条指令无法执行的情况, 我们称之为冒险。
上面我们提到了结构冒险,它是指一个功能部件要在两个阶段中被使用。此外还有数据冒险和控制冒险,它们由两个例外引起。
指令和数据通常随着执行过程从左至右依次通过这五个阶段,不会逆向流动。但两个例外是:
- 在写回阶段,将结果写回位于寄存器堆中。写回值可能还没来得及写回就要在EX阶段使用。
- 在选择PC值时,可能选择EX阶段计算出的brunch分支地址。在确定是否跳转之前,处理器不知道应该执行哪条指令。
数据冒险的解决
数据冒险的原因归根结底是因为当前的ALU运算利用到了还未在WB阶段写回的源操作数。
具体来说又有两种:
- ALU运算的源操作数来自之前的ALU运算
- ALU运算的源操作数来自之前的内存加载
对于情形1的解决采用前递的方式,对于情形2采用停顿的方式。
前递
这连续的五条指令都用到了x2,x2的值初始为10,经sub运算后值为-20。显然我们希望后面的and,or,add指令利用的x2的值是-20。
但x2的值只能在CC5阶段才能写回,and操作确定x2的值是在CC3,or是在CC4,add是在CC5。按照硬件逻辑,我们可以保证寄存器堆在一个时钟周期内先写再读,即保证add指令使用正确的x2的值。但照现有逻辑,and,or使用的仍是错的值。
注意到x2的新值最早是在CC3完成运算得出,而and指令最早使用x2,虽然其提取x2是在CC3,但其真正利用x2的值做运算是在CC4,在得到正确的值之后。对于这种冒险,我们可以用前递的方式解决。增加了两条数据通路。
对于增加的前递数据通路,也要通过控制逻辑保证我们前递的时机和值是正确的,具体实现是加入了一个前递单元。
停顿
这一次我们先执行了ld指令,后续操作采用的x2的值应该是ld加载的结果。ld指令最早在CC4完成后得出,而and指令使用x2的值做运算是在CC4开始。因此在时间上是逆向的,不能用前递解决。
所谓停顿,实际上就是多执行一次and指令,而最初执行的add指令变为一条空指令。
我们最早在CC3阶段判断发生冒险,具体办法是:
- 将后续运算的控制逻辑都置为0。这样第一条and指令就是一条空指令,对程序的运行无影响。
- 给PC和IF/ID寄存器一个写使能信号,发生冒险时写使能置为0,这样我们可以多执行一次and指令。
同样的,停顿处理也有相应的控制逻辑。
控制冒险的解决
控制冒险出现在采取哪个程序分支不确定的情况下。对这种冒险的处理方法,最简单的是假定分支不产生,如果发生了再恢复错误。恢复错误的方式仍然是把当前不应该执行的指令变为空指令。
此外,控制冒险的处理也有一些优化手段。比如硬件逻辑上的动态预测与缩短分支延迟等。