引言

添加icache提升了处理器的指令供给能力

还可以从数据供给和计算效率优化处理器

  • 数据供给 - 添加dcache
  • 计算效率 - 指令流水线(今天的主题)

 

体系结构设计能力的基本素质 - 学会估算一项技术的预期收益

  • 根据合适的性能计数器和Amdahl’s Law, 我们已经可以估算上述技术的理想收益了
    • 尽管我们还没有介绍如何实现它们!

流水线的基本原理

生活中的流水线 - 工厂流水线

工序: 组装 -> 贴纸 -> 包装袋 -> 包装盒 -> 外检

非流水产线的时空图

用1~5标识这些工序, 用A, B, C…标识不同的产品

          ----> 时间
 | 产品
 | +---+---+---+---+---+
 V |A.1|A.2|A.3|A.4|A.5|
   +---+---+---+---+---+
                       +---+---+---+---+---+
                       |B.1|B.2|B.3|B.4|B.5|
                       +---+---+---+---+---+
                                           +---+---+---+---+---+
                                           |C.1|C.2|C.3|C.4|C.5|
                                           +---+---+---+---+---+
================================================================
          ----> 时间
 | 员工
 | +---+               +---+                +---+
 V |A.1|               |B.1|                |C.1|
   +---+               +---+                +---+
       +---+               +---+                +---+
       |A.2|               |B.2|                |C.2|
       +---+               +---+                +---+
           +---+               +---+                +---+
           |A.3|               |B.3|                |C.3|
           +---+               +---+                +---+
               +---+               +---+                +---+
               |A.4|               |B.4|                |C.4|
               +---+               +---+                +---+
                   +---+                +---+                +---+
                   |A.5|                |B.5|                |C.5|
                   +---+                +---+                +---+

流水产线的时空图

          ----> 时间                   * 每个产品的生产时间没有减少
 | 产品
 | +---+---+---+---+---+
 V |A.1|A.2|A.3|A.4|A.5|
   +---+---+---+---+---+
       +---+---+---+---+---+
       |B.1|B.2|B.3|B.4|B.5|
       +---+---+---+---+---+
           +---+---+---+---+---+
           |C.1|C.2|C.3|C.4|C.5|
           +---+---+---+---+---+
               +---+---+---+---+---+
               |D.1|D.2|D.3|D.4|D.5|
               +---+---+---+---+---+
                   +---+---+---+---+---+
                   |E.1|E.2|E.3|E.4|E.5|
                   +---+---+---+---+---+
================================================================
          ----> 时间                   * 但每位员工都能一直保持工作状态
 | 员工
 | +---+---+---+---+---+
 V |A.1|B.1|C.1|D.1|E.1|
   +---+---+---+---+---+
       +---+---+---+---+---+
       |A.2|B.2|C.2|D.2|E.2|
       +---+---+---+---+---+
           +---+---+---+---+---+
           |A.3|B.3|C.3|D.3|E.3|
           +---+---+---+---+---+
               +---+---+---+---+---+
               |A.4|B.4|C.4|D.4|E.4|
               +---+---+---+---+---+
                   +---+---+---+---+---+
                   |A.5|B.5|C.5|D.5|E.5| * 每一时刻都有一件产品完成生产, 从而提升产线的吞吐
                   +---+---+---+---+---+

指令流水线 - 一种指令级并行技术

 

  • 产品 = 指令, 时间 = 周期
  • 工序 = 指令执行的不同阶段: 取指 -> 译码 -> 执行 -> 访存 -> 写回
  • 员工 = 单元: IFU -> IDU -> EXU -> LSU -> WBU

流水线的简单性能分析

假设5个阶段的延迟都是1ns(先不考虑真实的访存延迟)

 

阶段寄存器 关键路径 频率 指令执行延迟 IPC
单周期 5ns 200MHz 5ns 1
多周期 1ns 1000MHz 5ns 0.2
流水线 1ns 1000MHz 5ns 1

 

虽然指令执行的延迟仍然是5ns, 但流水线的频率高, IPC高

  • 频率高的原因: 有阶段寄存器, 关键路径短
  • IPC高的原因: 每个周期都在处理5条不同的指令, 吞吐高

流水线的简单实现

基于握手机制的流水线处理器实现

stage reg -> +----+               +----+
  +-----+ -> |....| -> +-----+ -> |....| -> +-----+
  |     |    +----+    |     |    +----+    |     |
  | IDU |  valid --->  | EXU |  valid --->  | LSU |
  |     |              |     |              |     |
  +-----+  <--- ready  +-----+  <--- ready  +-----+

对于每个阶段的输入in和输出out, 需要正确处理以下信号(bits指代阶段之间需要传输的负载):

  • out.bits, 由当前阶段生成
  • out.valid, 由当前阶段生成, 通常还与in.valid有关
  • in.ready, 由当前阶段生成, 忙碌时置为无效, 处理完当前指令时置为有效
  • out.ready, 与下一阶段的in.ready相同
  • in.bits, 当前阶段的in.ready和上一阶段的out.valid同时有效时, 更新成上一阶段的out.bits
  • in.valid, 作为作业留给大家

基于握手机制的流水线处理器实现(2)

def pipelineConnect[T <: Data, T2 <: Data](prevOut: DecoupledIO[T],
  thisIn: DecoupledIO[T], thisOut: DecoupledIO[T2]) = {
    prevOut.ready := thisIn.ready
    thisIn.bits := RegEnable(prevOut.bits, prevOut.valid && thisIn.ready)
    thisIn.valid := ???
  }

pipelineConnect(ifu.io.out, idu.io.in, idu.io.out)
pipelineConnect(idu.io.out, exu.io.in, exu.io.out)
pipelineConnect(exu.io.out, lsu.io.in, lsu.io.out)
// ...

RegEnable = 传统教科书的 “流水段寄存器”

 

总线视角的理解: 下游模块接收消息的缓冲区

  • 上下游握手成功后, 上游认为下游已经成功接收到消息
  • 上游不再保存该消息, 故下游需要将收到的消息记录到缓冲区中, 防止消息丢失

冒险(Hazard)

在流水线中, 当前周期不能执行当前指令的情况

  • 强行执行可能会导致指令结果错误
    • ISA状态机和CPU状态机的状态不一致

 

冒险主要有3类: 结构冒险, 数据冒险和控制冒险

 

在流水线设计中需要检测出冒险, 并正确处理它们

  • 要么通过硬件设计消除它们
  • 要么从时间上等待冒险不再发生
    • 可对in.readyout.valid添加等待条件

结构冒险

流水线中的不同阶段需要同时访问同一个部件

  • IFU和LSU都需要访存
    • T4的I1与I4
  • IDU和WBU都需要访问通用寄存器
    • T5的I1与I4
           T1   T2   T3   T4   T5   T6   T7   T8
         +----+----+----+----+----+
I1: lw   | IF | ID | EX | LS | WB |
         +----+----+----+----+----+
              +----+----+----+----+----+
I2: add       | IF | ID | EX | LS | WB |
              +----+----+----+----+----+
                   +----+----+----+----+----+
I3: sub            | IF | ID | EX | LS | WB |
                   +----+----+----+----+----+
                        +----+----+----+----+----+
I4: xor                 | IF | ID | EX | LS | WB |
                        +----+----+----+----+----+

结构冒险的检测和处理

部分结构冒险可从设计上完全避免, 使其不会在CPU执行过程中发生

  • 避免方式 - 让相应部件支持同时被多个阶段访问, 因此也不必检测

 

  • 寄存器堆 - 独立实现读口和写口
  • 内存
    • 实现真双口内存, 类似寄存器堆
      • 只能针对SRAM, 而且增加面积和延迟
      • 但SDRAM存储颗粒无法做到, 存储器总线的一次传输中最多只能发送READ/WRITE命令的其中之一
    • 将内存分为指令存储器和数据存储器
      • 教科书上的坑方案, 违反ISA的内存模型(指令和数据共享存储器)
        • 在这个内存模型中, 无法实现类似bootloader加载程序的功能
    • 引入cache, 若cache命中, 则无需访问内存

结构冒险的检测和处理(2)

有一些结构冒险还是无法完全避免

  • cache缺失时, IFU和LSU还是要同时访问内存
  • SDRAM控制器的队列满了, 无法继续接收请求
  • 除法器计算要花数十个周期, 在一次计算结束之前无法开始另一次

处理方式: 等

 

好消息: 总线天生具备等待的功能

  • 把结构冒险的检测和处理归约到总线状态机, 就无需实现专门的结构冒险检测和处理逻辑
    • 从设备/下游模块/仲裁器把ready置0即可

数据冒险

不同阶段的指令依赖同一个寄存器数据, 且至少一条指令写入该寄存器

  • I1需要写a0, 但要在T5结束时才完成写入
  • I2在T3读到a0旧值; I3在T4读到a0旧值; I4在T5读到a0旧值
  • I5在T6才读到a0新值
                    T1   T2   T3   T4   T5   T6   T7   T8   T9
                  +----+----+----+----+----+
I1: add a0,t0,s0  | IF | ID | EX | LS | WB |
                  +----+----+----+----+----+
                       +----+----+----+----+----+
I2: sub a1,a0,t0       | IF | ID | EX | LS | WB |
                       +----+----+----+----+----+
                            +----+----+----+----+----+
I3: and a2,a0,s0            | IF | ID | EX | LS | WB |
                            +----+----+----+----+----+
                                 +----+----+----+----+----+
I4: xor a3,a0,t1                 | IF | ID | EX | LS | WB |
                                 +----+----+----+----+----+
                                      +----+----+----+----+----+
I5: sll a4,a0,1                       | IF | ID | EX | LS | WB |
                                      +----+----+----+----+----+

写后读(Read After Write, RAW)冒险: 年老指令写, 年轻指令读

RAW的检测和处理 - 1. 编译器检测, 插入空指令

                    T1   T2   T3   T4   T5   T6   T7   T8   T9   T10  T11  T12
                  +----+----+----+----+----+
I1: add a0,t0,s0  | IF | ID | EX | LS | WB |
                  +----+----+----+----+----+
                       +----+----+----+----+----+
    nop                | IF | ID | EX | LS | WB |
                       +----+----+----+----+----+
                            +----+----+----+----+----+
    nop                     | IF | ID | EX | LS | WB |
                            +----+----+----+----+----+
                                 +----+----+----+----+----+
    nop                          | IF | ID | EX | LS | WB |
                                 +----+----+----+----+----+
                                      +----+----+----+----+----+
I2: sub a1,a0,t0                      | IF | ID | EX | LS | WB |
                                      +----+----+----+----+----+
                                           +----+----+----+----+----+
I3: and a2,a0,s0                           | IF | ID | EX | LS | WB |
                                           +----+----+----+----+----+
                                                +----+----+----+----+----+
I4: xor a3,a0,t1                                | IF | ID | EX | LS | WB |
                                                +----+----+----+----+----+
                                                     +----+----+----+----+----+
I5: sll a4,a0,1                                      | IF | ID | EX | LS | WB |
                                                     +----+----+----+----+----+

RAW的检测和处理 - 2. 编译器检测, 进行指令调度

思想: 与其等待, 还不如执行一些有意义的指令

 

编译器尝试寻找一些没有数据依赖关系的指令, 在不影响程序行为的情况下调整其顺序

I1: add a0,t0,s0          I1: add a0,t0,s0
I2: sub a1,a0,t0          I6: add t5,t4,t3  *
I3: and a2,a0,s0          I7: add s5,s4,s3  *
I4: xor a3,a0,t1   --->   I8: sub s6,t4,t2  *
I5: sll a4,a0,1           I2: sub a1,a0,t0
I6: add t5,t4,t3          I3: and a2,a0,s0
I7: add s5,s4,s3          I4: xor a3,a0,t1
I8: sub s6,t4,t2          I5: sll a4,a0,1

编译器只能尽力而为, 实在找不到, 就只能插入nop

  • 除法需要执行数十个周期, 但一般很难找到这么多合适的指令 😂

 

补充: 硬件模块实现指令调度 = 乱序执行处理器

光靠编译器不能解决所有问题

考虑load-use冒险(一种特殊的RAW冒险, 被依赖的是一条load指令)

                    T1   T2   T3  ....  T?   T?   T?   T?   T?   T?   T?
                  +----+----+----+--------------+----+
I1: lw  a0,t0,s0  | IF | ID | EX |      LS      | WB |
                  +----+----+----+--------------+----+
                       +----+----+----+----+----+
    nop X ?            | IF | ID | EX | LS | WB |
                       +----+----+----+----+----+
                                                +----+----+----+----+----+
I2: sub a1,a0,t0                                | IF | ID | EX | LS | WB |
                                                +----+----+----+----+----+

在真实的SoC中, 软件几乎无法预测访存指令在将来执行时的延迟 😂

  • cache命中 - 可能3周期
  • cache缺失访问SDRAM - 可能30周期
  • 正好碰上SDRAM充电刷新 - 可能30+?周期
  • CPU频率从500MHz提升到600MHz(SDRAM频率不变), 数据返回所需的周期数更多

RAW的检测和处理 - 3. 硬件检测, 插入气泡

观察: 寄存器写入操作发生在WBU中, 因此需要写入的寄存器编号会也会随着流水线传播到WBU

检测方法: 若位于IDU的指令要读出的寄存器与后续某阶段中将要写入的寄存器相同, 则发生RAW冒险

def conflict(rs: UInt, rd: UInt) = (rs === rd)
def conflictWithStage[T <: Stage](rs1: UInt, rs2: UInt, stage: T) = {
  conflict(rs1, stage.rd) || conflict(rs2, stage.rd)
}
val isRAW = conflictWithStage(IDU.rs1, IDU.rs2, EXU) ||
            conflictWithStage(IDU.rs1, IDU.rs2, LSU) ||
            conflictWithStage(IDU.rs1, IDU.rs2, WBU)

还需要处理的细节: 有的指令不写寄存器; 有的指令不读rs2; 有的阶段无有效指令; 零寄存器…

 

检测到RAW冒险后的一种简单处理方式: 等

  • 这好办, IDU把in.readyout.valid置0即可
    • 不理解为什么教科书上说 “控制相当复杂” 😂

通过硬件阻塞方式处理RAW

                    T1   T2   T3   T4   T5   T6   T7   T8   T9   T10  T11  T12
                  +----+----+----+----+----+
I1: add a0,t0,s0  | IF | ID | EX | LS | WB |
                  +----+----+----+----+----+
                       +----+-------------------+----+----+----+
I2: sub a1,a0,t0       | IF |         ID        | EX | LS | WB |
                       +----+-------------------+----+----+----+
                            +-------------------+----+----+----+----+
I3: and a2,a0,s0            |         IF        | ID | EX | LS | WB |
                            +-------------------+----+----+----+----+
                                                +----+----+----+----+----+
I4  xor a3,a0,t1                                | IF | ID | EX | LS | WB |
                                                +----+----+----+----+----+
                                                     +----+----+----+----+----+
I5: sll a4,a0,1                                      | IF | ID | EX | LS | WB |
                                                     +----+----+----+----+----+

 

硬件阻塞方案的适用性比软件方案更强

  • 无需提前知道指令何时执行结束, 适用于除法, load-use冒险等
  • 本质原因: 利用了总线握手信号的解耦特性

控制冒险

跳转指令会改变指令执行顺序, 导致IFU可能会取到不该执行的指令

  • T4的IFU需要等到I3在T5计算出跳转结果, 才知道应该取哪条指令
                 T1   T2   T3   T4   T5   T6   T7   T8
               +----+----+----+----+----+
I1: 100   add  | IF | ID | EX | LS | WB |
               +----+----+----+----+----+
                    +----+----+----+----+----+
I2: 104   lw        | IF | ID | EX | LS | WB |
                    +----+----+----+----+----+
                         +----+----+----+----+----+
I3: 108   beq 200        | IF | ID | EX | LS | WB |
                         +----+----+----+----+----+
                              +----+----+----+----+----+
I4: ???   ???                 | IF | ID | EX | LS | WB |
                              +----+----+----+----+----+

 

jaljalr也会造成类似问题

异常引起的控制冒险

                 T1   T2   T3   T4   T5   T6   T7   T8
               +----+----+----+----+----+
I1: 100   add  | IF | ID | EX | LS | WB |
               +----+----+----+----+----+
                    +----+----+----+----+----+
I2: 104   lw        | IF | ID | EX | LS | WB |
                    +----+----+----+----+----+
                         +----+----+----+----+----+
I3: 108   ecall          | IF | ID | EX | LS | WB |
                         +----+----+----+----+----+
                              +----+----+----+----+----+
I4: ???   ???                 | IF | ID | EX | LS | WB |
                              +----+----+----+----+----+

I4应该从mtvec所指的内存位置取指, 但通常在T4时刻无法得知

等待的代价

最早捕获RISC-V异常的模块

 0 - Instruction address misaligned - IFU
 1 - Instruction access fault       - IFU
 2 - Illegal Instruction            - IDU
 3 - Breakpoint                     - IDU
 4 - Load address misaligned        - LSU
 5 - Load access fault              - LSU
 6 - Store/AMO address misaligned   - LSU
 7 - Store/AMO access fault         - LSU
 8 - Environment call from U-mode   - IDU
 9 - Environment call from S-mode   - IDU
11 - Environment call from M-mode   - IDU
12 - Instruction page fault         - IFU
13 - Load page fault                - LSU
15 - Store/AMO page fault           - LSU

有的指令需要等到几乎执行完成, 才能确定是否抛出异常(如load指令)

  • 如果等待, 则流水线完全无法流水

应对控制冒险的方法 - 推测执行

推测执行(speculative execution): 在等待的同时尝试推测一个选择, 如果猜对了, 就相当于提前做出了正确的选择, 从而节省等待开销

  • 本质上是一种预测技术

 

  • 推测执行具体由三部分组成:
    • 选择策略 - 得到正确结果之前, 通过一定的策略推测一个选择
    • 检查机制 - 得到正确结果时, 检查之前的选择是否与正确结果一致
    • 错误恢复 - 如果检查后发现不一致, 则回滚到选择策略时的状态, 并根据得到的正确结果做出正确的选择

一种最简单的推测执行策略

总是推测接下来执行下一条静态指令

  • 选择策略 - 只需要让IFU一直取出PC + 4处的指令即可
  • 检查机制
    • 在执行分支和跳转指令, 以及抛出异常时, 检查跳转结果与推测的选择是否一致
      • 也即, 检查跳转结果是否为PC + 4
    • 其他指令总是顺序执行, 无需检查
  • 错误恢复 - 如果发现上述跳转结果不为PC + 4
    • 需要 “冲刷”因推测而取出的指令
    • 还需要让IFU从正确的跳转结果处取指

推测执行的性能分析

性能提升与推测的准确率有关

  • 如果准确率低, 则IFU经常取到不该执行的指令, 后续又被冲刷
    • 在这段时间内, 流水线的行为等价于未执行任何有效指令

 

  • 异常 - 绝大部分指令的执行都不会抛出异常
    • 针对异常, 上述策略的准确率接近100%
  • 分支指令 - 执行结果只有 “跳转”(taken)和 “不跳转”(not taken), 上述策略相当于总是预测 “不跳转”
    • 从概率上来说, 针对分支指令, 上述策略的准确率接近50%
  • 跳转指令 - 目标地址的可能有很多, 正好跳转到PC + 4的概率非常低
    • 针对跳转指令, 上述策略的准确率接近0%

 

上述分析给我们提供了一些优化的思路

推测执行的一些实现细节

  • 冲刷的实现
    • 冲刷流水线中的指令 - 将valid置为0
    • 恢复影响控制信号的状态(如状态机) - 已经发出的AXI请求无法撤回, 需要等待请求完成
    • 避免推测执行的指令更新状态 - 将更新寄存器堆/更新CSR/写内存/访问外设等操作延后到确认推测正确后
  • 异常的实现
    • 精确异常 = 将mepc设置成发生异常的指令的PC值
      • 但在流水线中, IFU的PC不断变化, 无法与抛出异常的指令匹配
      • 取指后需要让PC随流水线传递到下游模块
    • 抛出异常会改变处理器状态, 也需要延后到确认推测正确后才能处理
    • 异常号也要传递到下游模块, 确认推测正确后才能写入mcause
  • 嵌套 - 流水线中有多条分支/跳转指令, 或发生多个异常, 如何处理?

流水线处理器的测试验证

流水线架构比较复杂

这么多流水级, 若每一级指令类型不同, 可能会有不同的行为

  • 加/减/逻辑/移位等可通过ALU一周期计算结果的指令
  • 控制流转移指令, 分3种: 条件分支, jal, jalr
  • 访存指令, 分2种: load, store
  • CSR指令
  • ecall, mret
  • fence.i

 

  • 共10种指令, 仅考虑传统5级流水线, 就有\(10^5=100000\)种组合
  • 还要考虑各种冒险: 访存可能需要等待, 指令之间存在数据依赖, 控制流转移指令会导致流水线冲刷

你不会愿意设计那么多测试用例的 😂

交给工具吧!

让形式化验证工具帮我们自动找反例

  • REF: 单周期NPC
  • assert: 类似DiffTest
    • 但在形式化验证中, “对比所有寄存器”将作为 “解方程”的约束条件, 引入不小开销

 

更简单的对比方法: 对比状态的转移是否一致, 而不是状态本身

class PipelineTest extends Module {
  val io = IO(new Bundle {
    val inst = Input(UInt(32.W))
    val rdata = Input(UInt(XLEN.W))
  })
  val dut = Module(new PipelineNPC)
  val ref = Module(new SingleCycleNPC)
  dut.io.imem.inst  := io.inst
  dut.io.imem.valid := ...
  dut.io.dmem.rdata := io.rdata
  dut.io.dmem.valid := ...
  // ...
  ref.io.imem.inst := dut.io.wb.inst
  // ...
  when (dut.io.wb.valid) {
    assert(dut.io.wb.rd  === ref.io.wb.rd)
    assert(dut.io.wb.res === ref.io.wb.res)
    // ...
  }
}
  • RV32E的通用寄存器有\(16\times 32 = 512\)
  • 但一条RISC-V指令最多写入一个通用寄存器, 远小于512位

 

还要考虑很多细节(如使用assume(!isIllegal)), 具体参考讲义

让流水线流起来

先完成, 后完美

实现简单的流水线处理器后, 我们来讨论如何提升流水线的效率

 

阻碍流水线吞吐提升的主要原因:

  • 指令供给能力不足, 无法向流水线提供足够的指令
  • 数据供给能力不足, 访存指令的执行被阻塞
  • 计算效率不足, 由于三种冒险的存在, 流水线需要阻塞

 

根据当前的设计, 你觉得哪个原因占比最高?

你不一定能马上想明白, 所以profiling非常重要

  • 在阻塞来源添加性能计数器, 统计阻塞事件发生的次数/周期数
  • 如果你对CPU的细节足够熟悉, 你甚至能用性能计数器列出若干等式
    • 并用这些等式预测一项优化技术的潜在性能提升
    • 甚至可以用来检验你的实现是否符合预期

提升指令供给能力

  • 多周期: (假设)每条指令执行5个周期, 指令供给能力达到0.2条指令/周期即可
  • 流水线: 指令消费需求提升到了1条指令/周期
    • 若供给能力无法满足, 则无法发挥流水线的优势

 

需要考虑icache在命中时的指令供给能力:

  • 如果icache命中也需要多个周期才能向IFU返回指令, 则icache的指令供给能力最大为0.5条指令/周期
       T1   T2   T3   T4   T5   T6   T7   T8   T9   T10  T11  T12  T13
     +--------------+----+----+----+----+
 I1  |      I$      | ID | EX | LS | WB |
     +--------------+----+----+----+----+
                    +--------------+----+----+----+----+
 I2                 |      I$      | ID | EX | LS | WB |
                    +--------------+----+----+----+----+
                                   +--------------+----+----+----+----+
 I3                                |      I$      | ID | EX | LS | WB |
                                   +--------------+----+----+----+----+

提升指令供给能力(2)

我们希望提升icache的吞吐 - 连续命中时, 每周期都能读出指令

解决方案 - 将icache流水化

       T1   T2   T3   T4   T5   T6   T7   T8   T9
     +----+----+----+----+----+----+----+
 I1  | I$1| I$2| I$3| ID | EX | LS | WB |
     +----+----+----+----+----+----+----+
          +----+----+----+----+----+----+----+
 I2       | I$1| I$2| I$3| ID | EX | LS | WB |
          +----+----+----+----+----+----+----+
               +----+----+----+----+----+----+----+
 I3            | I$1| I$2| I$3| ID | EX | LS | WB |
               +----+----+----+----+----+----+----+

类似处理器流水线, 将icache的访问分成若干阶段, 并在时间上重叠

  • 如果缺失, 则阻塞流水线并等待访存结果
  • 不存在控制冒险, 比处理器流水线更简单
  • 但缺失时需要更新icache, 存在数据冒险

可复用PipelineConnect()来实现icache的流水化

减少数据冒险的阻塞

一个想法: 寄存器的新值并非WB阶段才产生, 能否提前拿到?

                    T1   T2   T3   T4   T5   T6   T7   T8
                  +----+----+----+----+----+
I1: add a0,t0,s0  | IF | ID | EX | LS | WB |
                  +----+----+----+----+----+
                                |    |    |
                                V    |    |
                       +----+----+----+----+----+
I2: sub a1,a0,t0       | IF | ID | EX | LS | WB |
                       +----+----+----+----+----+
                                     |    |
                                     V    |
                            +----+----+----+----+----+
I3: and a2,a0,s0            | IF | ID | EX | LS | WB |
                            +----+----+----+----+----+
                                          |
                                          V
                                 +----+----+----+----+----+
I4  xor a3,a0,t1                 | IF | ID | EX | LS | WB |
                                 +----+----+----+----+----+

I1中a0的新值在T3时刻已经可以从EX阶段的计算结果中读出

  • 这种技术叫转发(forward)或旁路(bypass)

数据转发的设计

转发源有3个: EX阶段, LS阶段和WB阶段

但转发并不能无条件进行, 需要转发源满足以下条件:

  • 将要写入寄存器
  • 待写入的寄存器编号和被依赖寄存器编号一致
  • 数据已就绪
    • load指令在LS阶段中等到总线的R通道握手后才得到数据

前两个条件和RAW冒险检测条件一致, 可复用RAW冒险的检测逻辑

 

使用转发技术后, 阻塞流水线的条件将有所变:

  • 如果ID阶段未检测到RAW冒险, 则无需阻塞流水线
  • 如果ID阶段检测到了RAW冒险, 并且存在某个阶段满足转发条件, 则无需阻塞流水线, 同时将转发的数据作为ID阶段的输出
  • 如果ID阶段检测到了RAW冒险, 但无满足转发条件的阶段, 则要阻塞

数据转发的设计(2)

如果存在多条指令同时满足转发条件, 则需要仔细考量

                      T1   T2   T3   T4   T5   T6   T7   T8
                    +----+----+----+----+----+
I1: add a0, a0, a1  | IF | ID | EX | LS | WB |
                    +----+----+----+----+----+
                         +----+----+----+----+----+
I2: add a0, a0, a1       | IF | ID | EX | LS | WB |
                         +----+----+----+----+----+
                              +----+----+----+----+----+
I3: add a0, a0, a1            | IF | ID | EX | LS | WB |
                              +----+----+----+----+----+
                                   +----+----+----+----+----+
I4: add a0, a0, a1                 | IF | ID | EX | LS | WB |
                                   +----+----+----+----+----+

T4时, 对于I3, 位于EX阶段的I2和位于LS阶段的I1均满足转发条件

  • 但从ISA状态机的视角考虑, 指令是串行执行的
  • I3读出的a0应该是最近一次写入a0的结果, 因此应该选择由位于EX阶段的I2进行转发

结论: 多条指令同时满足转发条件时, 应选择最年轻的指令进行转发

减少控制冒险的阻塞

已经采用了推测执行, 需要进一步考虑降低冲刷流水线带来的负面影响:

  • 降低单次冲刷流水线的代价 - 尽快计算出分支指令的结果
    • 有的教科书考虑在ID阶段计算分支结果, 但还需要从频率和面积的角度综合评估
  • 降低冲刷流水线的次数 - 提升推测的准确率
    • “推测异常不发生”的准确率已接近100%, 主要考虑如何提升分支指令和跳转指令的推测准确率

 

通过 “分支预测”(branch prediction)技术提升分支指令的推测准确率

  • 分支指令的执行结果只有 “跳转”(taken)和 “不跳转”(not taken)
    • 只需要在两个选择中预测一个即可
  • 选择策略称为分支预测算法
    • 考虑是否参考运行时的信息, 分为静态预测(今天的主题)和动态预测

静态分支预测

仅根据指令本身来预测

  • 分支指令本身在程序执行过程中不会发生变化, 因此对于给定的一条分支指令和给定的一种静态预测算法, 其预测结果总是相同
  • 方案1 - 总是预测跳转/不跳转
    • 实现简单, 但准确率低
  • 方案2 - BTFN(Backward Taken, Forward Not-taken), 向前方(新指令)跳转时预测不跳转, 向后方(老指令)跳转时预测跳转
    • 利用了分支跳转行为的偏向性(bisa), 这和循环的行为有关
      • 向前方跳转会跳过中间的代码, 可能是循环出口 -> 偏向不跳转
      • 向后方跳转会重新执行之前的代码, 可能是循环体 -> 偏向跳转
    • RISC-V手册也建议编译器按照上述模式生成代码
    Software should also assume that backward branches will be predicted taken and
    forward branches as not taken, at least the first time they are encountered.
    • 实现起来也不难 - 看B型指令offset的符号位即可

分支预测算法的设计空间探索

分支预测算法的一个重要指标: 预测准确率

  • 对于给定的程序, 需要执行的分支指令及其执行结果都是固定的
    • 有了itrace, 就能快速统计分支预测算法的预测准确率
  • 分支指令以外的指令trace, 对分支指令的执行结果没有影响
    • 只需要btrace(branch trace)即可

 

不必每次都完整运行程序, 只需要一个简单的功能模拟器branchsim

  • 用C代码模拟分支预测器的工作流程
  • 接收btrace, 根据分支预测算法预测出每一条分支指令是否跳转
    • btrace可通过NEMU快速生成
  • 与btrace中记录的执行结果进行对比, 统计该算法的预测准确率
  • 还能作为性能表现的REF, 与RTL中的性能计数器进行DiffTest

分支预测器的实现

分支预测器的预测结果需要提供给IFU使用

  • 若预测跳转, 则从分支指令的跳转目标处取指, 否则从PC + 4处取指

但需要在ID阶段才能得知一条指令是否为分支指令及其跳转目标

  • IF阶段只能获取PC值

 

解决方法: 通过一张表来维护PC和分支跳转目标的对应关系, 称为BTB(Branch Target Buffer)

              tag     target
           +-------+----------+   Branch Target Buffer
+----+     +-------+----------+
| PC |---> +-------+----------+
+-+--+     +-------+----------+
  |        +-------+----------+
  |            |         | branch               predicted
  |            v         | target +-----------+  next PC  +-----+
  |          +----+      +------->|  branch   |---------->| IFU |
  +--------->| == |-------------->| predictor |           +-----+
             +----+    is branch  +-----------+

BTB的设计

              tag     target
           +-------+----------+   Branch Target Buffer
+----+     +-------+----------+
| PC |---> +-------+----------+
+-+--+     +-------+----------+
  |        +-------+----------+
  |            |         | branch               predicted
  |            v         | target +-----------+  next PC  +-----+
  |          +----+      +------->|  branch   |---------->| IFU |
  +--------->| == |-------------->| predictor |           +-----+
             +----+    is branch  +-----------+
  • 可看作一个特殊的cache, 通过PC索引
    • 若命中, 表示该PC对应一条分支指令, 可从中读出跳转目标
    • 若缺失, 表示该PC对应非分支指令, 此时应从PC + 4处取指
  • 如果在ID阶段译码出分支指令, 则应更新BTB
    • 若更新时无空闲表项, 根据局部性原理, 应覆盖旧表项
  • 处理器复位时, BTB的表项均无效, 此时无法读出正确的跳转目标
    • 但推测执行包含错误恢复过程, 故不影响正确性

fence.i的处理

fence.i - 让其后的取指操作可以看到在其之前的store结果

  • 但在fence.i执行完之前, 流水线可能已经取出若干年轻指令
    • 这些指令是在fence.i生效前取出的
    • 继续执行会使CPU状态机的转移结果与ISA状态机不一致, 从而出错
                 T1   T2   T3   T4   T5   T6   T7
               +----+----+----+----+----+
I1: add        | IF | ID | EX | LS | WB |
               +----+----+----+----+----+
                    +----+----+----+----+----+
I2: fence.i         | IF | ID | EX | LS | WB |
                    +----+----+----+----+----+
                         +----+----+
I3: ??? may be stale     | IF | ID |
                         +----+----+
                              +----+
I4: ??? may be stale          | IF |
                              +----+
                                   +----+----+----+----+----+
I5: sub                            | IF | ID | EX | LS | WB |
                                   +----+----+----+----+----+

解决方案: 将其冲刷掉, 可复用推测执行错误时的冲刷逻辑

总结

教科书的流水线设计 != 真实的流水线设计

流水线作为组成原理教科书上的技术巅峰, 会给你带来一种幻觉:

  • 让你误以为计算效率就是处理器设计的一切
  • 让你觉得乱序多发射就是处理器设计的终极追求
  • 学会流水线, 就具备了体系结构设计能力

你真正需要的体系结构设计能力:

  • 学会通过性能计数器, Amdahl’s law, 模拟器等分析性能瓶颈
  • 学会从中思考出新的设计方案, 并评估其预期性能收益
  • 学会在面积, 主频和IPC之间作出合理的实现取舍
  • 学会评测你的实现是否符合预期

这些需要通过独立写代码来锻炼, 这正是 “一生一芯”给大家的训练

  • 无法仅仅通过将书上的架构图翻译成RTL代码来获得
  • 自我检验: 如果你脱离了参考书就不知道应该做什么, 或者需要询问别人才知道一个方案的优劣, 那就是不具备体系结构设计能力