跳转至

流水化处理

在完成了单周期 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
reg [1:0] count = 0;
reg [31:0] temp1, temp2;
always @(posedge clk) begin
    if (count == 2)
        count <= 0;
    else
        count <= count + 1;

    case(count)
        2'h0: temp1 <= b | c;
        2'h1: temp2 <= temp1 & d;
        2'h2: res <= a + temp2;
    endcase
end

在这个例子里,count 的0、1、2作为状态的区分,三个状态分别进行或、与、求和运算,并不断循环。因为每个周期最多进行一次加法运算,这个例子里的时钟周期可以设置为 55ns。

多周期化

这个例子中实现的事实上类似多周期 CPU,例如上学期 ICS 中的 LC-3,通过多个时钟周期完成一条指令,在一条指令完成前不允许下一条的输入。

然而,它事实上不能减小延迟,反而增加了延迟:从一个周期运行到结果变成了三个周期执行到结果,必须每三个周期才能接收新的输入,于是实际的延迟成为了 165ns,比单周期时的两倍还多!但是,这个版本仍然是有意义的,因为它减小了时钟周期,而后续的优化就要从更小的时钟周期出发。

流水线版本?

一个自然(或许)的想法是,如果我们能实现每个周期接收一个输入,就可以达到减小延迟的效果了,为了每个周期接收输入,我们试图让所有的传递同步进行,也即:

1
2
3
4
5
6
reg [31:0] temp1, temp2;
always @(posedge clk) begin
    temp1 <= b | c;
    temp2 <= temp1 & d;
    res <= a + temp2;
end

这个代码中,从输入到输出仍然经历 165ns,但是取消了 count 的限制后,可以达成每个周期都接收输入了——吗?

每周期接收输入的问题

考虑 a、b、c、d 每周期改变的情况,当我们计算 temp2 的时候,事实上通过的是上个周期的 b、c 与这个周期的 d,会导致结果的不正确。

流水线版本

为了实现真正的流水线化,得以每周期接收输入,我们必须把所有需要的输入同步传递,也即

1
2
3
4
5
6
7
8
9
reg [31:0] or_1, and_2, a_1, d_1, a_2;
always @(posedge clk) begin
    or_1 <= b | c;
    a_1 <= a;
    d_1 <= d;
    and_2 <= or_1 & d_1;
    a_2 <= a_1;
    res <= a_2 + and_2;
end

如果将组合逻辑的部分分开,我们可以写成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
reg [31:0] or_1, and_2, a_1, d_1, a_2;
wire [31:0] or_0, and_1;
assign or_0 = b | c;
assign and_1 = or_1 & d_1;
assign res_2 = a_2 + and_2;
always @(posedge clk) begin
    or_1 <= or_0;
    a_1 <= a;
    d_1 <= d;
    and_2 <= and_1;
    a_2 <= a_1;
    res <= res_2;
end

这时,我们的时序部分事实上是将第零段的中间结果 a、d 与 or_0 传到第一段,又将第一段的中间结果 and_1、a_2 传到第二段。这样的中间传输如果看成部件,就称为段间寄存器。这个例子里,我们事实上用了三个段间寄存器进行了输入、第一段、第二段、输出间的传递。

这样的例子中,由于我们可以一直接收输入,虽然每个输入到输出的时间仍然是 165ns,由于我们每 55ns 可以接收一个输入,我们就获得了每 55ns 一个有效输出。

流水线版本-优化

不过,刚才的代码并非不可以再改进。我们将与、或、加法分为三段后,三段的延迟分别是 10ns、10ns、55ns,而实际时钟周期必须以最大的 55ns 作为标准(这里假设加法器不可以再拆分)。在这个意义下,即使将与、或放在一起,也不会影响时钟周期,还能通过减少一段来降低消耗的资源,这也就是:

1
2
3
4
5
6
7
8
9
reg [31:0] temp_1, a_1;
wire [31:0] temp_0;
assign temp_0 = (b | c) & d;
assign res_1 = a_1 + temp_1;
always @(posedge clk) begin
    temp_1 <= temp_0;
    a_1 <= a;
    res <= res_1;
end

之前的五个 32 位寄存器被简化为了两个,大大减小了资源的消耗。

通过这样的过程,我们可以得到一些结论:对复杂的组合逻辑,我们可以拆分为若干段,将耗时最大的段作为新的时钟周期,以达到流水线输入的效果。此外,每段的时间应大致相近,若有两段合起来还没有另一段长,则事实上可以合并。基于此,我们试着对单周期 CPU 进行拆分。

拆分单周期 CPU

时序与组合

在刚才的例子中,我们可以发现必须是组合逻辑才可以中间加入时序进行拆分。可是,要想对单周期 CPU 进行类似的拆分,必须划分出组合的部分。可以发现,整个单周期 CPU 中的时序逻辑事实上只有三处:PC 的更新、寄存器堆的写入、数据存储器的写入。

关于寄存器堆与存储器

在这里,我们事实上将存储器的读写给“分裂”成了两个部分,读取的部分是组合逻辑,而写入的部分是时序逻辑。回顾寄存器堆的代码构造,可以发现这是合理的。

以此进行 CPU 的拆分后,结果如下(省略了写入部分连接到的寄存器堆、数据存储器与 PC):

Image title

拆分出时序逻辑后的 CPU

这个图可以看作,从 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,回写),将需要写回寄存器堆的数据写入。注意此处耗时与数据存储器写入一样,只考虑准备的时间,因为实际写入是在时钟上升沿进行的。

直接拆分并添加段间寄存器(下图橙色部分)后,我们得到的数据通路如下:

Image title

添加段间寄存器后的 CPU

接下来,我们将从此图片出发,进行更多分析。

关于 NPC SEL

注意到,在 npc 的选择中有一个信号来自 IF 段,而两个信号来自 EX 段,这是为了正常进行每周期加 4 的更新。但遭遇跳转指令时,由于在 EX 段才能处理,事实上会导致前两个指令失效,即「错误地进入了流水线」。我们需要对这两条指令进行特殊的处理,这也是接下来我们要讨论的内容。

评论