流水化处理
在完成了单周期 CPU 的搭建任务后,相信大家已经从作业或是上课的例子里里感受到了其延迟之巨大。由于一条指令要在一个时钟周期内完成,每个部分的延迟都会进行叠加。最小时钟周期必须要满足通路中最耗时的路径(即关键路径)的需求,于是只能设置得非常大。
我们自然希望处理器可以有着更高的工作速度,也就是具有更高的核心时钟频率。要提高时钟频率,自然就需要降低电路中的延迟。——那么,如何进行降低呢?
例子:简单的组合逻辑电路
单周期版本
考虑以下 verilog 代码生成的电路,假设输入 a、b、c、d 与输出 res 都是 32 位寄存器:
always @(posedge clk)
res <= a + ((b | c) & d);
假设加法器的延迟是 55ns,与门、或门的延迟是 10ns,其会导致 b、c 先取或,结果同 d 取与,再加上 a,输入到输出的总延迟 75ns。
这意味着,我们最多可以将时钟周期设置为 75ns,并每 75ns 接收一个输入。
多周期版本
为了对延迟进行优化,我们必须先考虑多周期的改进,也即类似状态机的执行模式,以减小时钟周期:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
在这个例子里,count 的0、1、2作为状态的区分,三个状态分别进行或、与、求和运算,并不断循环。因为每个周期最多进行一次加法运算,这个例子里的时钟周期可以设置为 55ns。
多周期化
这个例子中实现的事实上类似多周期 CPU,例如上学期 ICS 中的 LC-3,通过多个时钟周期完成一条指令,在一条指令完成前不允许下一条的输入。
然而,它事实上不能减小延迟,反而增加了延迟:从一个周期运行到结果变成了三个周期执行到结果,必须每三个周期才能接收新的输入,于是实际的延迟成为了 165ns,比单周期时的两倍还多!但是,这个版本仍然是有意义的,因为它减小了时钟周期,而后续的优化就要从更小的时钟周期出发。
流水线版本?
一个自然(或许)的想法是,如果我们能实现每个周期接收一个输入,就可以达到减小延迟的效果了,为了每个周期接收输入,我们试图让所有的传递同步进行,也即:
1 2 3 4 5 6 |
|
这个代码中,从输入到输出仍然经历 165ns,但是取消了 count 的限制后,可以达成每个周期都接收输入了——吗?
每周期接收输入的问题
考虑 a、b、c、d 每周期改变的情况,当我们计算 temp2 的时候,事实上通过的是上个周期的 b、c 与这个周期的 d,会导致结果的不正确。
流水线版本
为了实现真正的流水线化,得以每周期接收输入,我们必须把所有需要的输入同步传递,也即
1 2 3 4 5 6 7 8 9 |
|
如果将组合逻辑的部分分开,我们可以写成
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
这时,我们的时序部分事实上是将第零段的中间结果 a、d 与 or_0 传到第一段,又将第一段的中间结果 and_1、a_2 传到第二段。这样的中间传输如果看成部件,就称为段间寄存器。这个例子里,我们事实上用了三个段间寄存器进行了输入、第一段、第二段、输出间的传递。
这样的例子中,由于我们可以一直接收输入,虽然每个输入到输出的时间仍然是 165ns,由于我们每 55ns 可以接收一个输入,我们就获得了每 55ns 一个有效输出。
流水线版本-优化
不过,刚才的代码并非不可以再改进。我们将与、或、加法分为三段后,三段的延迟分别是 10ns、10ns、55ns,而实际时钟周期必须以最大的 55ns 作为标准(这里假设加法器不可以再拆分)。在这个意义下,即使将与、或放在一起,也不会影响时钟周期,还能通过减少一段来降低消耗的资源,这也就是:
1 2 3 4 5 6 7 8 9 |
|
之前的五个 32 位寄存器被简化为了两个,大大减小了资源的消耗。
通过这样的过程,我们可以得到一些结论:对复杂的组合逻辑,我们可以拆分为若干段,将耗时最大的段作为新的时钟周期,以达到流水线输入的效果。此外,每段的时间应大致相近,若有两段合起来还没有另一段长,则事实上可以合并。基于此,我们试着对单周期 CPU 进行拆分。
拆分单周期 CPU
时序与组合
在刚才的例子中,我们可以发现必须是组合逻辑才可以中间加入时序进行拆分。可是,要想对单周期 CPU 进行类似的拆分,必须划分出组合的部分。可以发现,整个单周期 CPU 中的时序逻辑事实上只有三处:PC 的更新、寄存器堆的写入、数据存储器的写入。
关于寄存器堆与存储器
在这里,我们事实上将存储器的读写给“分裂”成了两个部分,读取的部分是组合逻辑,而写入的部分是时序逻辑。回顾寄存器堆的代码构造,可以发现这是合理的。
以此进行 CPU 的拆分后,结果如下(省略了写入部分连接到的寄存器堆、数据存储器与 PC):
这个图可以看作,从 PC 出发,经过复杂的组合逻辑,生成 rf_wd、dmem_wdata 与 npc 等信号。在下一个时钟周期的开始,npc 信号成为 PC 的新值,rf_wd、rf_we、rf_wa 结合进行寄存器堆的写入,dmem_wdata、dmem_addr、dmem_we 结合进行数据存储器的写入。
初步拆分
根据实际测试可以发现,读取 IM、读取 RegFile、ALU计算、读取 DM 是实际操作中消耗时间最多的部分,因此,如教材,将单周期拆分为:
- IF(Instruction Fetch,取指令),核心耗时为指令存储器的读取。
- ID(Instruction Decode,译码),包含将指令翻译为各个控制信号并读取寄存器堆,核心耗时为寄存器堆的读取。
- EX(Execution,执行),由算术逻辑单元 ALU 进行运算,得到指令的计算结果,同时计算可能需要的跳转地址,核心耗时为 ALU 计算。
- MEM(Memory,访存),对数据存储器进行读取或写入,核心耗时为数据存储器的读写。
- WB(Write Back,回写),将需要写回寄存器堆的数据写入。注意此处耗时与数据存储器写入一样,只考虑准备的时间,因为实际写入是在时钟上升沿进行的。
直接拆分并添加段间寄存器(下图橙色部分)后,我们得到的数据通路如下:
接下来,我们将从此图片出发,进行更多分析。
关于 NPC SEL
注意到,在 npc 的选择中有一个信号来自 IF 段,而两个信号来自 EX 段,这是为了正常进行每周期加 4 的更新。但遭遇跳转指令时,由于在 EX 段才能处理,事实上会导致前两个指令失效,即「错误地进入了流水线」。我们需要对这两条指令进行特殊的处理,这也是接下来我们要讨论的内容。