跳转至

The Processor

计算机的性能由三个因素决定:指令数,时钟周期以及每条指令的时钟周期数(CPI).而处理器的设计同时决定了时钟周期和CPI

Single Cycle

首先来看单周期CPU的设计, 单周期CPU在一个时钟周期内只能执行一条指令(即\(\text{CPI}=1\)), 而一个单周期CPU由数据通路(Datapath)控制单元(Control) 两部分组成

Datapath

Datapath主要由以下这些组件组成,包括程序计数器(PC),指令存储器,加法器,寄存器堆,ALU,数据存储单元以及立即数生成单元

Elements1

  • 指令存储器(Instruction Memory)保存需要执行的指令,根据输入的地址输出对应的指令.
  • 程序计数器(Program Counter, PC)是一个保存了当前指令地址的寄存器.
  • 加法器(Adder)用于将PC中的地址增加\(4\), 以获取下一条指令.

将上面的三个组件进行组合,就可以得到取指(Fetch)部分,如下图

Fetch

  • 寄存器堆(Registers)包括了RISC-V的32个通用寄存器,通过两个\(5\)

    在写寄存器时,除了需要输入寄存器编号和数据,还需要设置控制信号RegWrite=1. (\(\log_2 32=5\))的读端口和一个\(5\)位的写端口控制对应寄存器的读写.

  • 算术逻辑单元(ALU)用来执行各类算术与逻辑运算,通过一个\(4\)位的ALU operation来指定具体的运算指令.

    ALU中的Zero信号用于判断运算结果是否为\(0\).在判断两数是否相等是会用到.

Elements2

  • 数据存储单元(Data Memory Unit)具有读(lw)和的控制输入MemRead和写(sw)的控制输入MemWrite.
  • 立即数生成单元(Immediate Generator)将一个12位的立即数符号扩展为32位,使其那个作为ALU输入参与运算.

Elements3

将上面的所有组件进行组合,便可以得到一个完整的datapath(暂时省略control)

Datapath

Control

为方便起见,下面只考虑一个RISC-V指令集的子集,即只包括lw, sw,beq,add,sub, and以及or这几条指令.

首先来看最重要的ALU control

The ALU control

ALU control采用的是两级控制(多级译码),control unit首先根据指令类型生成一个2位的ALUop:

  • load/store指令:00
  • 条件分支指令:01
  • R-type指令:10

然后再根据这个2位的ALUop以及指令的funt3func7生成对应的4位ALU control,如下图

ALU_control_conclusion.webp

Main Control Unit

除了ALU control之外,还有6个control信号需要考虑(即图中的蓝色部分),下表给出了每个控制信号的具体含义

Control signal

最后将上面的控制信号组合在一起,便可以得到一个完整的单周期处理器

Signal_cycle.webp

Conclusion

虽然单周期处理器设计简单且可以正确地完成工作,但它的效率不高,这是因为

  • 单周期处理器一个时钟周期只能执行一条指令
  • 执行每条指令所需的时钟周期一样长,因而处理器中的最长通路(耗时最久的指令)决定了时钟周期的长度

提升处理器的效率主要有 多周期(mutlicycle)流水线(pipeline) 两种方法.

Multicycle

Pipeline

流水线(pipeline) 是一种使多条指令能够被重叠(同时)执行的技术.

首先来看一个洗衣店的例子.将洗衣服的流程分为4个阶段,从下图可以看出,不使用流水线需要8个小时,而使用流水线后只需要3.5个小时.因为每个阶段(洗衣、烘干等)原本是串行工作,而流水线让它们并行(同时)进行.

Laundry_analog.webp

流水线减少了系统的总执行时间,提高了吞吐率(throughput)

流水线不能减少单个任务的执行时间,但是可以通过提高吞吐率,减少总的执行时间

将同样的思想运用到处理器的指令执行,可以将一条指令的执行分为5个阶段:

  • 取指(IF):从(指令)内存中获取指令
  • 译码(ID):指令译码并读取寄存器
  • 执行(EX):执行算术逻辑运算或计算跳转地址
  • 访存(ME):读取或写入(数据)内存
  • 写回(WB):将计算结果写回寄存器

在各个阶段时间一样且指令数非常多的情况下

\[ \text{Time between instructions}_\text{pipelined}=\frac{\text{Time between instructions}_\text{nonpipelined}}{\text{Number of pipeline stage}} \]

即使用流水线的加速比近似等于流水线的阶段数

如果不满足上述条件,那么这个公式将不成立.比如说上面的洗衣店的例子,\(8/4=2\),也就是说,执行完这4个任务应该需要2h, 但实际上花了3.5小时.

RISC-V指令集的设计很好的迎合了流水线:

  • 所有指令长度相同
  • 只有几种指令类型
  • 只有load/store指令涉及内存操作

Pipeline Datapath

可以将一个单周期处理器按照下图划分为5个阶段

Five_Stage

上图中大部分指令的执行顺序都是从左往右,但是也有2个例外

  1. 写回(WB)阶段,最后的结果是从右写回到中间的寄存器中
  2. 下一个PC的值是从右向左

前者会导致数据冒险,而后者会导致控制冒险

下来来看下面这个例子:

Example

这里执行三个连续的ld指令,指令内存只在每条指令的第一个阶段(IF)使用,因此它可以在其他四个阶段被后续指令共享.但第一条指令后续的四个阶段仍然需要使用自己的指令编码,为了在其他阶段保留该指令的值,从指令存储器中读取的值需要保存到流水线寄存器(pipeline register)中. 流水线寄存器位于两个阶段之间,根据前后两个阶段来命名,分别为IF/ID,ID/EX,EX/MEM,MEM/WB.如下图所示:

Pipeline_regs

Pipeline Control

将之前的单周期控制信号加到流水线中,便可以得到下图

Pipeline_control.webp

由于PC和流水线寄存器在每个周期都需要进行写操作,因此不需要额外的写信号来控制.

每个阶段需要的控制信号:

  • IF:无
  • ID:无
  • EX:ALUOpALUSrc,前者决定ALU的运算,后者决定第二个操作数的来源
  • MEM:Branch,MEMRead,MEMWrite
  • WB:MEMtoRegRegWrite

流水线的控制信号与单周期的并没有什么不同

实线流水线的控制意味着在每个阶段为每条指令将七条控制线设置为对应的值,为了确保当前指令能在后续阶段使用正确的控制,控制信号也应该通过流水线寄存器进行保存,如下图所示.

Control

Pipeline Hazards

在流水线中,无法在下一个时钟周期内执行下一条的指令的情况被称为流水线冒险(pipeline hazard).有三种不同类型的流水线冒险,分别是

  • 结构冒险
  • 数据冒险
  • 控制冒险

Structural Hazards

结构冒险(Structural Hazards)指的是硬件无法在同一个时钟周期内支持我们想要执行的指令组合.因为RISC-V指令在设计时专门考虑了流水线,因此很容易在设计流水线时避免结构冒险.

Data Hazards

数据冒险(Data Hazards)指的是因为指令尚未获得所需的数据而不得不使流水线停顿(stall),直到获得所需的数据.

可以添加被称为前递(forwarding)或者旁路(bypass)的额外硬件,提前从流水线中获得所需的数据,从而减少数据冒险造成的停顿.

Forwarding

来看下面这段指令:

sub x2, x1, x3
and x12, x2, x5
or  x13, x6, x2
add x14, x2, x2
sw  x15,100(x2)

后面的4条指令都依赖第一条指令中的寄存器x2的值,具体可以看下图

Data_hazard

假设x2的值在sub指令之前是10,之后是-20,那么后续的指令在使用x2时,值应该为-20.而从图中可以看出,sub的结果(即x2)需要等到CC5才能写回到寄存器,因此,andor使用的都是错误的值(10).

x2的值其实在EX阶段就已经被计算(CC3), 而andor到EX阶段才使用x2的值(即CC4和CC5),因此,我们可以使用forwarding来避免流水线停顿.

为了解决这个问题,首先要做的是依赖检测(dependency detection), 即确定何时会发生数据冒险问题.可以将数据冒险分为两类:

  1. EX/MEM.RegisterRd = ID/EX.RegisterRs1(或 ID/EX.RegisterRs2)
  2. MEM/WB.RegisterRd = ID/EX.RegisterRs1(或 ID/EX.RegisterRs2)

在上面的例子中, suband是第一种情况,即EX/MEM.RegisterRd = ID/EX.RegisterRs1=x2 subor是第二种情况,即MEM/WB.RegisterRd = ID/EX.RegisterRs2=x2

现在,我们将数据冒险细分为执行阶段 (EX) 冒险访存阶段 (MEM) 冒险两类,得到以下判断条件:

  • EX Hazards

    if (EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0)
        and (EX/MEM.RegisterRd == ID/EX.RegisterRs1))
            ForwardA = 10
    
    if (EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0)
        and (EX/MEM.RegisterRd == ID/EX.RegisterRs2))
            ForwardB = 10
    

  • MEM Hazard

    if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0)
        and not(EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0)
            and (EX/MEM.RegisterRd == ID/EX.RegisterRs1))
        and (MEM/WB.RegisterRd == ID/EX.RegisterRs1))
            ForwardA = 01
    
    if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0)
        and not(EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0)
            and (EX/MEM.RegisterRd == ID/EX.RegisterRs2))
        and (MEM/WB.RegisterRd == ID/EX.RegisterRs2))
            ForwardB = 01
    

上面设置的两个控制信号(即ForwardA和ForwardB)实际上是MUX的控制信号,这两个MUX用来决定ALU的操作数,具体内容如下:

Forwarding_Control

最后将前递控制单元(forwarding unit)加到CPU中,便可以得到下面的设计

Datapath_forwarding

Stalls

虽然前递能解决大部分情况,但它不能避免所有的流水线停顿.典型的情况是load指令之后的一条指令需要使用load的结果,这种情况即使使用前递也必须插入一个停顿,称为load-use data hazard.

为解决这个问题,除了forwarding unit之外,还需要一个冒险检测单元(hazards detection unit),用于在加载(load)指令与需要依赖其结果的指令之间插入停顿.可以使用下面的语句判断是否需要插入停顿:

if (ID/EX.MemRead and
    ((ID/EX.RegisterRd = IF/ID.RegisterRS1) or
        (ID/EX.RegisterRd = IF/ID.RegisterRs2)))
    stall the pipeline

要想停止流水线的运行,需要做到: - 停止 IF:不能改变 PC 寄存器的值(读取重复的指令),所以要为 PC 寄存器添加写信号 PCWrite。当 PCWrite = 0 时,就能做到停止 IF 了 - 停止 ID:不能改变 IF/ID 流水线寄存器的值(读取重复的值)所以要为该寄存器添加写信号 IF/IDWrite。当 IF/IDWrite = 0 时,就能做到停止 ID 了 - 停顿的那段时间,虽然 CPU 仍然在运行,但实际上没有改变任何状态,这种情况称为空操作(nops)。为了保证所有元件状态不变,还需要确保所有的控制信号均为 0.

下面为添加了冒险检测单元 后的流水线 CPU

CPU_hazard

Control Hazards

控制冒险(Control Hazards)指的是接下来要执行的指令取决于之前的指令,导致执行了错误的指令.

控制冒险的根源在于 PC(程序计数器) 的更新依赖于分支指令的执行结果,而该结果在流水线的 EX(执行)MEM(访存) 阶段才产生,导致后续指令在 IF 阶段无法确定正确的取指地址。

解决控制冒险有两种方法: - 停顿:在遇到分支指令时立即停顿流水线,直到确定分支的结果和下一条指令的地 - 分支预测:在确定分支指令的结果之前,直接猜测下一条指令的地址并取指

因为停顿直到指令完成的速度太慢,因此一个改进方案是预测分支指令不会跳转(not taken), 这样CPU就会继续沿着顺序指令流继续执行.如果分支指令执行了,那么后续两条被取指和解码的指令必须被丢弃. 为了丢弃指令,在条件指令到底MEM阶段时,需要将IF,ID,EX阶段的指令清除(flush)掉,即把原本的控制信号变为0,

虽然上述的静态分支预测足以应付五级流水线的控制冒险问题,但是对于更高级数,或更高要求的处理器,这种预测的失败成本还是太大,于是引入了动态分支预测(dynamic branch prediction)——在程序执行过程中,根据上一条条件分支指令的运行结果来预测分支是否跳转。

实现动态分支预测需要借助分支预测缓存(branch prediction buffer)(或分支历史表(branch history table)),它是一块由分支指令的低位地址来索引的很小的内存,包含 1 位或多位关于分支跳转的信息。

对于最简单的1 位缓存,可以仅用 0 和 1 区别上次的分支指令是否发生跳转。如果预测失败,则需要翻转这个比特。

缺点:内侧循环分支会有 2 次预测错误,一次在内侧循环的最后 1 次迭代(以为是跳转),另一次是在下次内层循环中的第 1 次迭代(以为不跳转

  • 2 位缓存能够提高预测精度,虽然它需要 2 次预测错误才会改变预测值,但是对于执行一连串跳转情况一致的指令时这种方法的优势更大,下面给出对应的有限状态机图

2_bit_predictor

在解决控制冒险问题后,我们得到最终版本的五级流水线处理器的原理图

Five_Stage

Exception

异常(Exception) 和 中断(Interrupt) 是改变正常程序指令执行流(控制流)的突发事件。它们本质上类似于一次“非计划的程序调用”

  • 异常(Exception):通常源自 CPU 内部的非计划事件(例如:执行了未定义的指令、硬件故障等)
  • 中断(Interrupt):通常源自 CPU 外部的事件(例如:I/O 设备请求)

注意:RISC-V 处理器在发生整数或浮点溢出(Overflow / Underflow)时,不会抛出异常;相反,软件可以通过读取浮点控制和状态寄存器(fcsr)来检查是否发生了溢出 。

Exception

在 RISC-V 架构中,硬件处理异常需要借助以下几个特殊的控制与状态寄存器 :

  • SEPC (Supervisor Exception Program Counter):保存发生异常的那条指令的 PC 地址,以便在异常处理程序执行完后,能够返回重新执行该指令
  • SCAUSE (Supervisor Exception Cause Register):一个 64 位的寄存器,用来记录异常发生的原因。例如:编码 2 代表未定义指令,12 代表硬件故障
  • STVEC (Supervisor Trap Vector Register):保存异常处理程序的入口基地址。当异常发生时,PC 会跳转到该寄存器指定的地址 在书中的流水线简化实现中,通常使用一个固定的虚拟入口地址 0x000000001C090000 

在五级流水线中,异常被视作一种特殊的 控制冒险(Control Hazards) .如果一条指令(例如在 EX 阶段执行的 add)发生了异常,处理器必须立即响应,避免对后续状态产生破坏:

  • 清空流水线 (Flush)
    • IF 阶段:将当前正在取指的指令通过将其控制信号置零,转变为一条 nop(即发出 IF.Flush 信号)
    • ID 阶段:使用译码阶段已有的复用器将控制信号全部清零。控制信号 ID.Flush 与挂起信号(Stall)进行“或(OR)”运算,用于在异常发生时清空 ID 阶段
    • EX 阶段:引入一个新的控制信号 EX.Flush 作用于新增的复用器,使 EX 阶段的控制信号全部置为 0,防止发生异常的指令在 WB 阶段向寄存器写入错误结果
  • 更改 PC 跳转:PC 复用器中增加一个输入源,直接将异常入口地址(如 0x000000001C090000)送入 PC,从下一个周期开始提取异常处理程序的首条指令

精确异常 (Precise Exception/Precise Interrupt):是指能够与发生异常的准确指令强绑定的异常处理机制 在精确异常下,发生异常之前的所有指令都必须执行完毕,而异常指令及其之后的所有指令都必须被清空且不改变任何寄存器或内存状态,处理完异常后可以重新启动该指令 RISC-V 架构支持精确异常,这对于虚拟内存的实现至关重要

Instruction-level Parallelism

指令级并行(Instruction-level Parallelism, ILP) 是指通过让多条指令重叠执行来提高处理器性能的技术

除了经典的流水线外,要进一步提升 ILP,核心方法是 多发射(Multiple Issue) —— 即允许在一个时钟周期内发射和执行多条指令

  • 静态多发射(Static Multiple Issue):
    • 发射决策由 编译器 在编译阶段决定
    • 编译器会将无依赖的一组指令打包进一个 发射包(Issue Packet) 中,通常在硬件上被视为一条超长的指令(又称 VLIW, 超长指令字)
  • 动态多发射(Dynamic Multiple Issue):
    • 又称为 超标量(Superscalar) 处理器
    • 发射决策在运行期由 处理器硬件 动态决定。硬件在每个周期检查指令流,动态选出没有依赖的指令并将其并行发射
    • 即使编译器没有进行指令重排,超标量处理器硬件依然能保证执行的正确性,且代码可以跨平台兼容不同的流水线结构

为了克服数据和控制依赖对 ILP 的物理限制,编译器或硬件常常使用 猜测(Speculation) 技术,提前“预测”指令的属性(例如分支是否跳转、Load 和 Store 地址是否冲突)并提前开始执行后续相关指令

  • 软件猜测:由编译器重排指令,并在猜测失败时插入额外的检查和修正(Fix-up)例程
  • 硬件猜测:处理器将猜测执行的结果暂存到缓冲区中(如 重排缓冲区 ROB 或 Store Buffer),直到确定猜测正确时,再最终写入寄存器或内存(即 Commit 提交阶段)。如果猜测失败,硬件会直接丢弃缓冲区中的结果并回滚状态

动态流水线调度是指由硬件动态重排指令的执行顺序,以避免由于慢速操作(如 Cache Miss)导致流水线卡顿的技术

在动态调度的处理器中,流水线通常分为三个核心部分:

  1. 取指和译码单元(Instruction Fetch and Decode Unit):负责按序提取指令,完成基础译码并下发
  2. 多个功能单元(Multiple Functional Units):每个单元有各自的缓冲区(称为 保留站 Reservation Stations),指令一旦数据源准备就绪便可以 乱序执行(Out-of-Order Execution)
  3. 按序提交单元(Commit Unit):包含 重排缓冲区(Reorder Buffer, ROB)。为了保证精确异常,指令对寄存器或内存的写入,必须严格按照程序原本的顺序进行 按序提交(In-Order Commit)

当硬件或编译器尝试重排指令顺序时,会遇到由于寄存器名字有限而导致的 反依赖(Antidependence, 又称 名依赖 Name Dependence / WAR / WAW)

  • 反依赖:指后面的指令要写入某个寄存器,而前面的指令还需要读取该寄存器,这种由于名字重复导致的顺序强制
  • 解决方案寄存器重命名(Register Renaming)。通过硬件或编译器将逻辑寄存器映射到更丰富的物理临时寄存器或 ROB 槽位中,从而消除这种假的数据依赖,极大释放了 ILP 的潜力

评论