1.7. sycuricon( 七 ): 指令扩展(SCIE 版)

之前我们介绍了如何用 ROCC 进行处理器的用户自定义指令扩展,现在我们介绍如何用 rocket-chip 的 SCIE 进行指令扩展。

这里我们仍然以 regvault 指令扩展为例子,regvault 指令扩展的细节可以参见“sycuricon( 五 ): 指令扩展(ROCC 版)”。

1.7.1. SCIE 工作机制

ROCC 类似于流水线外的单独调用的协处理器,而 SCIE 则是直接集成在流水线流水集当中的处理单元,定位上类似于乘除法器单元。

SCIE 提供了两种计算模式,对应的也会生成两套计算硬件,一种是不允许指令流水化处理的 SCIEUnpipelined,一种是允许指令流水化处理的 SCIEPipelined。 现在我们分别介绍两种 SCIE 接口的流水线机制、接口特性,和如何实现多周期指令 regvault。

1.7.1.1. SCIEUnpipelined 硬件实现

一种是不支持流水线计算的 SCIEUnpipelined。 该模块是一个单纯的计算单元,被放置在 EXE 内部,其基本的输入输出的接口定义如下:

class SCIEUnpipelinedInterface(xLen: Int) extends Bundle {
  val insn = Input(UInt(SCIE.iLen.W))
  val rs1 = Input(UInt(xLen.W))
  val rs2 = Input(UInt(xLen.W))
  val rd = Output(UInt(xLen.W))
}

class SCIEUnpipelined(xLen: Int) extends BlackBox(Map("XLEN" -> xLen)) with HasBlackBoxInline {
  val io = IO(new SCIEUnpipelinedInterface(xLen))

可以看到 SCIE 是简单的 R 类型指令格式,在 EXE 阶段接受指令、两个元操作数,返回计算结果。 如果我们的 SCIE 指令功能简单可以单周期实现,我们可以直接用这个接口进行硬件设计,CPI 开销为一个时钟周期。

+--------+          EXE stage          +---------+    MEM stage    +--------+   WB stage
|        |     +-----------------+     |         |                 |        |
|        |     |                 |     |         |                 |        |
| ID/EXE |---->| SCIEUnpipelined |---->| EXE/MEM |---------------->| MEM/WB |------------->
|        |     |                 |     |         |                 |        |
|        |     +-----------------+     |         |                 |        |
+--------+                             +---------+                 +--------+

但是因为 regvault 是多周期的,简单的单周期 SCIEUnpipelined 实现没有办法满足 regvault 计算单元硬件实现的需要, 所以我们需要实现多周期的 SCIEUnpipelined。 对应的接口定义如下,valid=1 表示输入有效,ready=1 的时候表示输出有效。

class SCIEUnpipelinedInterface(xLen: Int) extends Bundle {
  val valid = Input(Bool())
  val kill = Input(Bool())
  val insn = Input(UInt(SCIE.iLen.W))
  val rs1 = Input(UInt(xLen.W))
  val rs2 = Input(UInt(xLen.W))
  val rd = Output(UInt(xLen.W))
  val ready = Output(Bool())
  val expt = Output(Bool())
}

Rocket-chip 并没有常规的 stall 机制。 常规设计的时候,如果我们在 EXE 阶段需要执行多周期,这个时候如果执行没有完成, 我们可以让处理器在 EXE 阶段 stall 直到 SCIE 得到计算结果,再继续执行。 但是 Rocket-chip 采用了另一种 replay 机制。 他只给每个阶段预留了一个周期的执行时间,如果一个周期之后还没有执行完毕,他会记录这条指令执行无效, 需要重新执行,然后 rocket-chip 会在后续重新发射这条指令。

以 SCIE 的 regvault 实现为例子。我们的 SCIEUnpipelined 的一种实现需要 5 个周期从才可以执行完毕。 这个时候 EXE 阶段执行 SCIE 指令,但是本周期无法执行完毕。因此指令会被标记为 replay,然后向后传播信号。 但是 SCIE 内部其实会继续执行这条指令后续的计算。 而 replay 的指令会依次进入 MEM 阶段、WB 阶段,然后控制单元发现指令需要 replay,于是会从 ID 阶段重新发射,然后进入 EXE 阶段。 这个时候 SCIE 过了五个周期已经执行完毕了,所以这次指令在 EXE 检查发现执行完毕了,就不 replay 向后流淌。

具体的可以参看下图:

ID

EXE

MEM

WB

insn issue

SCIE idle

SCIE busy/ insn replay

SCIE busy

insn replay

SCIE busy

insn replay

insn issue

SCIE busy

SCIE ready/ insn finish

这里我们可以看到,如果 SCIE 计算周期是 1, 那么流水线只需要一个周期就可以计算完毕。 但是如果是 2、3、4、5, 那么 SCIE 统一需要 replay 一次,花费 5 个周期。 所以 4k+2 - 4k+5 统一需要 4k + 5 个周期,将 4k+5 的周期优化为 4k+2 在不做其他修改的前提下,没有意义。

  val kill_qarma = Wire(Bool())
  val (ex_scie_unpipelined_wdata, ex_scie_unpipelined_ready, ex_scie_unpipelined_integrity_exception) = if (!rocketParams.useSCIE) (0.U, true.B, false.B) else {
    val u = Module(new SCIEUnpipelined(xLen))
    u.io.valid := ex_scie_unpipelined && ex_reg_valid
    u.io.kill := kill_qarma
    u.io.insn := ex_reg_inst
    u.io.rs1 := ex_rs(0)
    u.io.rs2 := ex_rs(1)
    (u.io.rd, u.io.ready, u.io.expt)
  }

SCIEUnpipelined 在 valid=1 的时候开始执行计算,然后在计算结束之后将数据保存到缓存当中,然后将 ready 设置为 1。 之所以需要设置缓存,是因为当结果计算完毕之后, 当前阶段的 EXE 不一定正好在重新执行对应的指令,所以这个结果需要保存起来,等待被流水线接收。 虽然 SCIEUnpipelined 对外只有一个简单的 ready 信号和 valid 信号, 但是因为 SCIE 指令都是顺序执行的,所以向 SCIE 发送的请求和流水线的请求都是保序的, SCIE 只需要在上一条指令没有提交之前,不响应下一条指令的请求即可。 此外因为 replay 机制的存在,SCIE 可能多次受到同一个请求, 这个时候检查输入是否保存一致(也就是是否为同一个请求), 防止对一个请求做多次处理,对于流水线而言一个输入如果会产生多个时序的输出,那就会造成问题。

  val ex_pc_valid = ex_reg_valid || ex_reg_replay || ex_reg_xcpt_interrupt
  val wb_dcache_miss = wb_ctrl.mem && !io.dmem.resp.valid
  val replay_ex_structural = ex_ctrl.mem && !io.dmem.req.ready ||
                             ex_ctrl.div && !div.io.req.ready ||
                             ex_scie_unpipelined && !ex_scie_unpipelined_ready
  val replay_ex_load_use = wb_dcache_miss && ex_reg_load_use
  val replay_ex = ex_reg_replay || (ex_reg_valid && (replay_ex_structural || replay_ex_load_use))

SCIE 内部的实现和 ROCC 没有本质区别,就不过多赘述了,大家可以自行参考代码。

1.7.1.2. SCIEPipelined 实现

SCIEPipelined 计算单元是横亘 EXE、MEM 两个阶段的二阶段流水线。 他在 EXE 阶段接受计算参数,然后预期在下个时钟周期从 MEM 阶段得到计算结果。 相对于 SCIEUnpipelined 来说,SCIEPipelined 有两个好处:

  • 对于 2 周期的计算,SCIEUnpipelined 因为 replay 机制的存在,他的 CPI 是 5;但是 SCIEPipelined 可以是 1

  • SCIEUnpipelined 一次只能处理一条指令;而 SCIEPipelined 可以流水化处理两条指令;连续指令的 CPI 也是 1

  • 当然,如果计算需要 3~6 个周期,那么因为 replay 的存在,SCIEPipelined 也需要 6 个周期

+--------+    EXE stage    +---------+    MEM stage    +--------+   WB stage
|        |                 |         |                 |        |
|        |                 |         |                 |        |
| ID/EXE |-----------++--->| EXE/MEM |---------------->| MEM/WB |------------->
|        |           ||    |         |    /\           |        |
|        |           ||    |         |   /||\          |        |
+--------+          \||/   +---------+    ||           +--------+
                     \/                   ||
              +-----------------+---------++------+
              |                 |                 |
              | SCIEpipelined1  | SCIEpipelined2  |
              |                 |                 |
              +-----------------+-----------------+

具体实现和单周期的 SCIE 大同小异,就是利用一下两阶段流水线就好了。

1.7.2. 译码部件

object SCIE 定义指令的 opcode 等各类参数;SCIEDecoder 对输入的指令进行译码, 决定指令是由 SCIEUnpipelined 执行(io.unpipeline==true)还是 SCIEPipelined(io.pipeline==true)。 SCIE 的译码并没有被紧耦合到 decoder 当中,估计是为了更好的封装在 SCIE 接口内部。

Listing 1.100 SCIEDecoder 定义
object SCIE {
  val opcode = BitPat("b?????????????????????????1101011")
  val iLen = 32
}

class SCIEDecoderInterface extends Bundle {
  val insn = Input(UInt(SCIE.iLen.W))
  val unpipelined = Output(Bool())
  val pipelined = Output(Bool())
  val multicycle = Output(Bool())
}

class SCIEDecoder extends Module {
  val io = IO(new SCIEDecoderInterface)

  io.unpipelined  := true.B
  io.pipelined    := false.B
  io.multicycle   := false.B
}
                         +-----------------+
                         |                 |
                    +--->| SCIEUnpipelined |
                    |    |                 |
    io.unpipeline   |    +-----------------+
                    |       /\       ||
                +---+----+ /||\      ||    +---------+                 +--------+
+------+        |        |  ||      \||/   |         |                 |        |
|      +------->|        |  ||       \/    |         |                 |        |
| SCIE |        | ID/EXE |--++-------++--->| EXE/MEM |---------------->| MEM/WB |------------->
|      +------->|        |           ||    |         |    /\           |        |
+------+        |        |           ||    |         |   /||\          |        |
                +---+----+          \||/   +---------+    ||           +--------+
                    |                \/                   ||
        io.pipeline |         +-----------------+---------++------+
                    |         |                 |                 |
                    +-------->| SCIEpipelined1  | SCIEpipelined2  |
                              |                 |                 |
                              +-----------------+-----------------+

1.7.3. SCIE 和 ROCC 的比较

从性能上来说,SCIE 的性能比 ROCC 要高很多:

  • SCIE 位于处理器流水线内部,指令进入 SCIE 模块不需要额外的握手和处理;但是 ROCC 位于处理器流水线之外,需要做额外的握手和同步

  • SCIE 因为是在处理器 EXE、MEM 阶段,所以执行结果可以很快的 forward 给其他的处理器流水级;但是 ROCC 为了流水线末端,计算结果必须提交给 regfile 才可以给后续指令,导致后续指令在有数据依赖的时候会一直处于等待状态

从实现上来说,ROCC 的模块化程度更高,但是隔离太绝对:

  • SCIE 因为位于流水线内部,是实现紧耦合的,不利于后期维护,所以已经被 rocket 上游砍掉了;ROCC 是独立的模块,可以独立于处理器设计,便于维护

  • 但是 ROCC 和流水线内部的同步比较差,比如如果处理器内部的 CSR 修改了,ROCC 也许很难同步 CSR 信息;ROCC 也没法向处理器发送异常信号