CPU的架构 –by cube

流水线思想

我们以较为简单的 32 位 RISC-V CPU 为例,其常见的基础实现采用五级流水线。

在五级流水线中,一条指令依次经过取指(IF,根据程序计数器 PC 从指令存储器读取指令)、解码(ID,解析指令字段、读取寄存器,并通过 ImmGen 生成立即数)、执行(EX,使用 ALU 完成算术或逻辑运算、地址计算以及分支条件判断)、访存(MEM,按需读写数据存储器)和写回(WB,将计算结果或读取的数据写回目标寄存器)五个阶段。

同一时刻,流水线的不同阶段可以并行处理不同指令。例如,第一条指令进入执行阶段时,第二条指令正在解码,而第三条指令正在取指。

这种并行处理会带来一些问题。假设第一条指令是一条分支或跳转指令,并且要到执行阶段才能确定是否跳转以及目标地址,那么此时第二条、第三条指令已经分别进入解码和取指阶段。由于处理器此前通常只能先按顺序取指,这两条指令可能来自错误的执行路径。

所以在确认跳转后,处理器必须把错误路径上的指令清除(flush),并从正确的目标地址重新取指。错误路径上的指令不能改变寄存器或存储器等架构状态,但清除和重新取指会产生流水线停顿,降低执行效率。上述情况属于流水线冒险中的控制冒险;除此之外,常见的流水线冒险还有数据冒险和结构冒险。

流水线冒险与处理

控制冒险

控制冒险通常由分支或跳转指令改变 PC 引起。常见处理方法包括暂停、冲刷、静态预测和动态分支预测。还可以把分支比较与目标地址计算前移到解码阶段(前递机制),让分支更早得到解析,从而减少预测失败的惩罚周期。

数据冒险

如果后一条指令需要使用前一条尚未写回的结果,就会产生 RAW(Read After Write)数据冒险。常见处理方法包括前递、暂停流水线以及由编译器进行指令调度。更复杂的处理器还会使用寄存器重命名和乱序执行:寄存器重命名可以消除 WAR、WAW 这类名字依赖,但不能消除 RAW 真数据依赖。

结构冒险

结构冒险是指多条指令在同一个周期争用同一硬件资源。常见处理方法包括暂停其中一条指令、错开资源使用时间、增加硬件端口或复制功能单元。

前递机制

简单来说,就是提前搬运一些数据。

数据来源

前递机制绕过尚未完成的寄存器写回过程,直接把后续流水线阶段中已经产生的结果送到当前指令需要使用的位置。最常见的实现是在 ALU 输入端前放置 MUX,使操作数可以来自 ID/EX 中读取的寄存器数据、EX/MEM 中的 ALU Result 或 MEM/WB 中的 WriteBack Data。分支比较和 Store 写入数据也可能需要相应的前递路径。

Forwarding Unit 如何判断

前递单元会比较当前处于 EX 阶段的源寄存器编号,与更老指令保存在 EX/MEM、MEM/WB 流水线寄存器中的目标寄存器编号。例如:

1
2
add x3, x1, x2
sub x4, x3, x5

sub 进入 EX 阶段时,add 通常已经进入 MEM 阶段。此时 ID/EX.rs1EX/MEM.rd 都是 x3。如果 EX/MEM.RegWrite == 1EX/MEM.rd != x0,就说明 sub 需要的值正是 add 刚产生、但还没有写回寄存器堆的结果。前递单元因此控制 MUX,把 EX/MEM.ALUResult 直接送到 sub 的 ALU 输入端。

如果 EX/MEM 不匹配,前递单元还会比较 MEM/WB.rd 与当前指令的 rs1rs2。EX/MEM 和 MEM/WB 同时匹配时,应优先选择距离当前指令更近、结果更新的 EX/MEM 数据。

前递机制可以解决所有问题吗?

当然不能。例如在 load-use 依赖中,Load 指令的 EX 阶段只能计算出访存地址,真正的数据要到 MEM 阶段结束后才能得到,但紧随其后的指令已经需要在 EX 阶段使用该数据。此时 Hazard Detection Unit 会检测到依赖,暂停取指和解码,并向流水线插入一个气泡;数据准备好后,再通过 MEM/WB 前递给后续指令。

分支预测

简单来说,就是 CPU 在分支结果尚未确定时,提前猜测下一条应该从哪里取指,从而减少流水线暂停。

工作过程

在取指阶段,CPU 使用当前 PC 查询分支预测结构:方向预测器判断是否跳转,目标预测结构提供可能的目标地址。随后 CPU 沿预测路径继续取指。分支真正执行后,处理器会比较预测结果与实际结果;预测正确则继续运行,预测错误则清除错误路径上的年轻指令,并从正确地址重新取指。

静态分支预测

预测不跳转

默认下一条地址为 PC + 4。如果分支实际发生跳转,再清空错误路径。教学型或基础五级流水线经常采用这种简单策略,但五级流水线并不必然只能使用这种预测方式。

预测跳转

默认分支会发生跳转,并通过 BTB 等结构尽快获得目标地址。

BTFNT

如果目标地址在当前 PC 之前,即向后分支,则预测跳转;如果目标地址在当前 PC 之后,即向前分支,则预测不跳转。这是因为向后分支通常来自循环,而循环分支大部分时间都会跳回循环开头,只有退出循环时不跳转。

动态分支预测

动态预测器会记录分支过去是否跳转,并根据历史行为预测下一次结果。

1 位预测器

预测表为分支记录一个状态位,表示上一次是否跳转;下一次遇到同一分支时,默认预测它重复上一次的行为。实际硬件中的表项数量有限,不同分支也可能共享同一个表项。

2 位饱和预测器

一般来说,我们使用的较为常见的动态方向预测器会使用 2 位饱和计数器,其四种状态依次为强不跳转、弱不跳转、弱跳转和强跳转。分支实际跳转时,计数器向强跳转方向移动;没有跳转时,则向强不跳转方向移动。这样,一次偶然的相反结果通常不会立即扭转长期预测方向,避免预测循环分支时由于最后一次打破循环导致预测状态被扭转的情况。

BHT:分支历史表

BHT 保存分支的历史状态。简单来说,就是使用 PC 的部分位作为索引,查找对应的 1 位状态或 2 位计数器,进而预测该分支是 Taken 还是 Not Taken。

BTB:分支目标缓冲区

BTB 本质上是以分支 PC 为键的高速缓存表,用于保存分支指令地址与目标地址之间的对应关系。当方向预测结果为 Taken 时,BTB 可以尽快提供预测目标地址。

注:CPU 的预测路径执行但不可见

CPU 沿着预测路径取指和执行的过程称为推测执行。预测路径上的指令可以进入流水线并产生临时结果,但在预测得到确认之前,错误路径不能改变程序可见的架构状态。简单顺序流水线通常通过流水线有效位、控制信号和冲刷机制清除错误路径;更复杂的乱序 CPU 则使用 ROB 等结构保证指令按程序顺序提交。

加速优化算法带来的安全问题

现在有一个低调的黑客,通过训练或污染分支预测器,诱导 CPU 沿错误路径推测执行。错误路径上的结果虽然不会提交到架构状态,但它访问过的数据仍可能改变 Cache 等微架构状态。攻击者可以让秘密数据影响后续访问的 Cache 位置,再通过测量访问延迟推断秘密值。这类攻击需要可利用的代码路径和可观测的侧信道,并不能仅凭一次预测错误读取任意内存,典型代表是 Spectre。

ROB(重排序缓冲区)

当 CPU 允许后面的指令先执行完成时,ROB(Reorder Buffer)用于保证这些指令最终仍按程序顺序提交。换句话说,执行与写回可以乱序,但提交必须有序。

ROB 本质上是一个循环队列,维护两个指针:head 指向下一条准备提交的最老指令,tail 指向下一条新指令将要分配的位置。一个基础的 ROB 表项可以包含以下字段:

1784696388426

在采用 PRF(Physical Register File)的架构中,计算结果通常写入物理寄存器,ROB 主要记录指令的完成状态、异常信息、目标寄存器和提交信息。

举例之前,我们需要知道在一颗乱序 CPU 中,流水线可以简化表示为:

1
取指(IF)→ 解码(ID)→ Rename → Dispatch → Issue → 执行(EX)→ 写回(WB)→ Commit

Rename 阶段通过 RAT 把指令使用的架构寄存器映射到物理寄存器。它可以消除 WAR、WAW 名字依赖,但不能消除 RAW 真数据依赖。

Dispatch 阶段为指令分配 ROB、Issue Queue 等乱序执行资源;Load/Store 指令还需要分配 LSQ 表项。Issue Queue 保存微操作及其操作数就绪状态;

Issue 阶段从中选择已经就绪的操作,发送给对应的 ALU、乘除法器或访存单元执行。LSQ 记录访存指令的地址、数据和状态,维护内存访问顺序;推测执行的 Store 通常要等到提交阶段才允许真正修改 Cache 或内存。

明确这些阶段后,可以把一条写 x5 的指令在 ROB 中的过程概括为:

  1. 指令在 Rename 阶段获得新的目标物理寄存器,并记录旧映射。Dispatch 阶段在 ROB[tail] 分配表项,保存目标寄存器、异常状态等信息,然后移动 tail
  2. 指令携带 ROB 编号和物理寄存器标签进入后端。执行完成后,结果在写回阶段写入 PRF,同时根据 ROB 编号把对应表项标记为 done
  3. 提交阶段每个周期从 ROB[head] 开始检查。只有表项满足 valid == 1done == 1 且没有异常时,才能提交并移动 head。提交后才可以释放目标架构寄存器之前使用的旧物理寄存器。

ROB 按从 head 开始的程序顺序提交,而不是简单按照数组下标从小到大提交,因为循环队列的下标会回绕。如果一条指令产生页错误等异常,CPU 通常等它到达 ROB 头部后再精确触发异常,并清除该指令及其后的年轻指令。分支预测错误则通常在分支执行完成、发现错误时立即恢复:保留分支本身,清除它后面的错误路径指令,并恢复 RAT 等推测状态。

如果使用 Verilog 表示上述基本过程,大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (dispatch_valid && !rob_full) begin
rob[tail].valid <= 1'b1;
rob[tail].done <= 1'b0;
rob[tail].rd <= dispatch_rd;
rob[tail].pc <= dispatch_pc;
tail <= tail + 1'b1;
end

if (writeback_valid) begin
rob[writeback_rob_id].done <= 1'b1;
rob[writeback_rob_id].exception <= writeback_exception;
end

if (rob[head].valid && rob[head].done) begin
if (rob[head].exception) begin
flush_from_head <= 1'b1;
trap_valid <= 1'b1;
end else begin
commit_valid <= 1'b1;
rob[head].valid <= 1'b0;
head <= head + 1'b1;
end
end

乱序执行思想

分支预测与推测执行并不等同于乱序执行,顺序流水线同样可以使用分支预测。乱序执行是在保持最终提交顺序正确的前提下,让已经就绪的年轻指令绕过尚未就绪的老指令先执行,以提高执行单元利用率。

寄存器重命名

在 RISC-V 架构中,x1这类命名来自 ISA 的定义,而CPU内部的物理寄存器,一般是 P0、P1这类命名。架构寄存器来通过 RAT 映射表对应到物理寄存器上。
通过Rename,可以消除掉 WAW (Write After Write,后续命名规则同理,R即Read)、WAR 现象,即由于命名重复导致的数据依赖。但是不能消除 RAW 现象。这是由于read操作必须依赖前面写入的数据,这是真正的数据依赖现象。也就是说,寄存器重命名不能解决 RAW 真数据依赖。

PRF

PRF 是真正用来保存数据的物理寄存器堆。例如 PRF[p3] = x1 当前值 这样,同时对于物理寄存器我们还需要对应的就绪状态。源操作数的就绪状态通常保存在 Issue Queue 表项中,也可以由独立的物理寄存器就绪表统一维护。

分支预测失败如何恢复 RAT

  • RAT CheckPoint
    分支在 Rename 阶段保存当时的推测 RAT 映射状态。预测失败时,恢复该检查点,清除分支之后的年轻指令,并把这些指令分配的物理寄存器归还 Free List,再从正确 PC 重新取指。
  • RRAT
    RRAT 只记录已经提交的架构映射,更适合精确异常或全流水线冲刷后的恢复。若在普通分支预测失败时直接用 RRAT 覆盖 RAT,就必须连同所有未提交状态一起重建;因此分支的快速恢复通常使用 RAT CheckPoint,或者按照 ROB 中的年轻指令逐项回滚映射。

Issue Queue 与 唤醒

指令在结束Dispatch之后,会进入Issue Queue等待。这个队列中每一个节点,大概包含ID、操作类型、源数据对应物理寄存器编号及其准备状态、ROB编号等。对于需要不同执行单元的指令可以做到要求Issue阶段同时发射。

ready bit

当两个源操作数的ready bit都被置1之后,即默认两个操作数都准备好,或者没有这个操作数的需求。

Wakeup-Select 调度机制

面对上文所述的数据依赖现象,我们可以使用唤醒机制解决。假设现在操作A,和操作B,操作B依赖与操作A,且操作A已准备就绪。此时,操作A被发送给EX阶段处理,完成后,EX Unit会广播目标物理寄存器的tag broadcast_tag,每一个Issue Queue中的项都会比较自己的源操作数标签,此时操作B发现广播tag就是自己尚未准备好的寄存器tag,于是更新ready bit,接下来就可以参与下一阶段。在 PRF 架构中,计算结果写入目标物理寄存器,同时广播目标 tag 和完成信息来唤醒依赖项;具体数据可以随后从 PRF 读取,也可以经旁路网络直接前递。

假设现在有多条指令完成,而ALU数目不够,一般来说会选择最老的继续指令(在IQ里面有记录),Select层一般考虑指令操作数据是否准备完毕,Issue宽度是否占满等等问题。

Commit

Commit(提交)也是乱序CPU多出来的阶段,简单来说,这就是利用ROB表识别各个操作的先后并顺序提交,或许我们在这之间做了无数次乱序处理,但是最后提交一定要按照机器码的原意表达出对应意思。提交之前,推测结果和状态保存在PRF、ROB、Store Queue或者Load Queue等微架构结构中,提交之后,这些结果才被确认为架构上有效的状态。

内存乱序

Load/Store Queue管理CPU中的访存指令,允许不冲突的Load、Store指令提前执行,同时保证内存行为符合体系结构的规定。
LSQ表项一般记录的数据包括:ROB编号、指令类型、访存地址、地址就绪flag、Store数据、访问宽度与掩码、异常flag等

当Load的地址ready之后,会在SQ里面检查比自己更老的Store。如果所有更老的Store都已知地址且互不冲突,则Load直接访问Cache;如果存在地址相同的更老Store,并且其中最年轻匹配项的数据已经就绪,则通过Store-to-Load Forwarding直接前递数据;如果匹配Store的数据还未准备好,Load就必须等待。对于地址尚未确定的更老Store,保守实现会阻塞Load,激进实现则会借助内存依赖预测器判断Load能否提前执行,这个思想类似于我们之前的分支预测器。

假设预测器认为二者不会冲突,则Load可以提前执行。等更老Store的地址得到解析后,CPU会检查是否存在已经执行且地址重叠的年轻Load。如果存在,就标记发生内存顺序违规,并重放该Load及其依赖指令;一些实现也会从违规Load处冲刷流水线,再恢复相应的ROB和Rename状态,随后从Store前递正确数据并重新执行Load。

异常处理

ROB一方面保证最后的提交顺序执行,另一方面也可以用于定位异常的精确位置,这点在前文早已多处体现。
假设现在有操作A、B、C,按照顺序依次提交,在EX阶段发现B有异常,此时异常信息会被记录在ROB表项中。提交阶段允许B之前的指令正常提交;当B到达ROB头部时,B本身不会提交,CPU会清除B及其后的年轻指令并进入异常处理程序。

Rename Map、Free List在异常情况下的恢复

推测执行过程中,RAT、Free List会被不断修改,架构寄存器会不断指向新的物理寄存器。发生精确异常时,可以使用只包含已提交映射的RRAT恢复RAT,并根据已提交映射与仍然有效的物理寄存器重建Free List;分支预测错误则通常恢复对应分支保存的RAT CheckPoint。

Issue Queue在异常情况下的恢复

Issue Queue中属于被清除指令的表项必须全部失效。异常恢复时可以借助ROB顺序确定故障指令及其后的年轻指令;分支恢复还可以为每条指令保存Branch Mask,标记它位于哪些未确认分支之后,分支预测错误时清除包含对应标记的指令。

LSQ在异常情况下的恢复

LSQ中对应故障指令或错误路径的表项也必须失效。推测Load可以读取Cache并留下微架构侧效应,但不会直接修改架构内存;推测Store的数据通常保存在Store Queue中,提交前不允许真正写入Cache或内存。因此恢复时主要清除LSQ表项并丢弃已失效请求的返回结果,而不是删除Cache中的对应数据。

SMT(超线程)

核心思想:使用一个物理的CPU核心,同时维护多个硬件线程的架构状态,让这些线程共享、分区或动态竞争核心内部的执行资源。

每一个线程独有部分

为了让os认为他们是两个逻辑CPU,每一个硬件线程都需要独立保存PC、架构寄存器映射、特权寄存器、中断状态以及当前线程的其他架构可见状态。

线程共享部分

取指与解码前端、分支预测器、ALU、FPU、L/S Unit和Cache等硬件通常由多个线程共享。ROB、Issue Queue、LSQ等结构可以共享并用线程ID区分表项,也可以按线程静态分区或动态分配,具体方式取决于CPU实现。

CPU低功耗处理

DVFS

CPU动态调整工作电压和频率,动态功耗可以近似表示为:

$$
P_{dynamic} \approx \alpha C V^2 f
$$

其中,$\alpha$ 表示电路的开关活动因子,$C$ 表示等效电容。

电压以平方关系影响动态功耗,因此一般我们会降低电压以保证低功耗。但是,高频率往往也需要更高的电压保证时序收敛。这就是为什么高频运行会显著增加功耗和发热。

P-state / C-state

P-state 表示CPU的性能状态,对应不同的电压与频率组合,以此解决上述矛盾。
os借助ACPI接口请求性能状态,现代化的CPU也会根据负载、温度与功耗自主调节。
C-state控制Core空闲时期的功耗,一般来说C0表示正在执行指令,C1表示停止执行但是很快恢复,C3/6/7会关闭更多时钟和电源,功耗更低,但wake的延迟也更高。

Turbo

当只有少数Core工作,且温度、电流和功耗预算仍有余量时,CPU可以将这些Core提高到高于标称频率的Turbo频率。

时钟门控/电源门控

当某个模块不工作时,可以通过时钟门控单元停止该模块内部时钟翻转,进而减少动态功耗,例如FPU在没有浮点指令时可以关闭其局部时钟;
而电源门控更加极端,当模块不再工作则直接关闭供电,大幅降低功耗的同时,唤醒代价也更高。

Core Parking 机制

当系统负载比较低的时候,os会尽量不向部分Core调度任务,使这些Core有机会进入更深的C-state,只使用少量活跃Core来减少功耗。