Skip to content

超标量处理器的分支预测

一个TAGE预测器需要3级流水线才能完成一次预测:索引计算、SRAM读取、标签匹配与结果选择。在5 GHz的处理器上,这意味着3个周期(600 ps)的预测延迟。在这3个周期内,取指前端不能"停下来等"——它必须继续取指,否则每3个周期才能发出一次预测请求,预测吞吐率仅为正常的1/3。但取指的前提是知道"下一个取指地址"——而这正是预测器还没来得及给出的结果。即使预测器100%准确,这个延迟本身也会导致取指效率下降和流水线气泡。

从本书的统一视角审视:覆盖式预测(overriding prediction)是投机的嵌套。L0快速预测器用1个周期给出一个低置信度的"快速猜测",填充L1预测器的延迟窗口;L1高精度预测器的结果在2–3个周期后到达,如果与L0不同则覆盖之前的猜测。这是"用低精度投机填充高精度投机的延迟"——投机叠加投机。回顾第 13.0 章中建立的CPIbranch\text{CPI}_{\text{branch}}框架,覆盖式预测在不降低mm(失败率由L1决定)的前提下有效降低了PP(因为大多数情况下L0猜对,气泡消失)。第 14.0 章第 15.0 章中讨论的各种预测算法追求降低mm,而本章讨论的流水线集成技术则同时优化PP的有效值。

读完本章,你将理解分支预测器如何在超标量流水线中高效集成,包括覆盖式预测、多分支取指、预测失败恢复和解耦前端设计。

在前面几章中,我们讨论了分支方向预测的各种算法(第第 14.0 章\sim第 15.0 章章)以及分支目标地址的预测机制(第第 16.0 章章)。这些讨论大多集中在预测算法本身的精度和存储开销上,隐含的假设是预测器在每个周期都能及时提供结果、每次只处理一条分支指令、并且预测失败后能立即恢复。在真实的超标量处理器中,这些假设全部不成立。

本章聚焦于分支预测在超标量流水线中面临的工程挑战。我们将讨论四个核心问题:(1)分支预测器本身的流水线化——一个精确的预测器往往需要多个周期才能产生结果,如何在不引入过多气泡的前提下完成预测?(2)超标量处理器每周期取多条指令,取指块中可能包含多条分支,如何识别和处理它们?(3)预测失败后,如何快速恢复前端和后端的状态?(4)如何通过解耦前端设计来掩盖预测延迟和I-Cache缺失的影响?

分支预测的流水线

预测级数的设计

第 14.0 章第 15.0 章中介绍的分支预测算法——从简单的两位饱和计数器到复杂的TAGE-SC-L——在硬件实现时都需要经过一系列的步骤:计算索引、访问SRAM表、进行标签匹配、读出预测结果、组合多个组件的输出。这些步骤在高频率处理器中无法在单个时钟周期内完成,因此分支预测器本身也需要被流水线化。

以一个典型的TAGE预测器为例,其预测过程至少包含以下步骤:

  1. 索引计算:将PC和各级全局历史寄存器(GHR)进行哈希运算,生成每张表的索引和标签。对于一个拥有5\sim6张表的TAGE预测器,需要并行计算多组哈希值。

  2. SRAM读取:使用计算出的索引访问各张预测表的SRAM,读取预测计数器值和标签。SRAM的访问延迟取决于表的大小——一个4K项的表通常只需要一级流水线,而一个64K项的表可能需要两级。

  3. 标签匹配与选择:将读出的标签与计算的标签进行比较,确定哪些表命中。根据TAGE的优先级规则(最长匹配历史的表优先),选出最终的预测结果。

  4. 统计校正:如果使用了SC(Statistical Corrector),还需要额外的查表和加法操作来决定是否翻转TAGE的预测。

在5 GHz的工作频率下,一个时钟周期只有200ps。即使是一次简单的SRAM读取加上多路选择器,延迟也可能达到300\sim400ps,已经超出了单周期的时间预算。因此,现代高性能处理器的分支预测器通常需要2\sim3级流水线。

分支预测的三级流水线与取指的关系
分支预测的三级流水线与取指的关系

图 17.1展示了一个三级分支预测流水线的典型组织。BP0阶段使用当前PC(或上一次预测的next PC)计算各预测表的索引;BP1阶段访问SRAM读取预测数据;BP2阶段完成标签匹配和结果选择,产生最终的预测地址。预测地址随后被送入I-Cache,启动取指操作。

这意味着从发起一次预测请求到拿到预测结果,需要3个周期。如果不采取任何优化措施,每次预测之间都会有一个3周期的间隔,预测的吞吐率仅为每3个周期一次,这对于一个宽发射的超标量处理器来说是完全不可接受的。

解决这个问题的核心思路与经典流水线相同:让预测器的各阶段同时处理不同的预测请求。在稳态下,BP0计算第n+3n+3个取指块的索引,BP1读取第n+2n+2个取指块的预测数据,BP2产生第n+1n+1个取指块的预测结果,同时I-Cache正在为第nn个取指块提供数据。这样预测器的吞吐率就回到了每周期一次。

但这里有一个关键的循环依赖问题:要启动第n+1n+1次预测的BP0阶段,需要知道第n+1n+1个取指块的起始地址,而这个地址正是第nn次预测的结果——它要到第nn次预测的BP2阶段才能产生。如果BP0到BP2需要3个周期,那么第n+1n+1次预测的启动就要等3个周期,吞吐率又退回到每3个周期一次。

设计提示

分支预测器面临一个根本的循环依赖:预测第n+1n+1条分支需要知道当前PC,而当前PC取决于第nn条分支的预测结果。预测器的流水线越深,这个循环的延迟就越大。打破这个循环的方法有两种:(1)使用更快但可能不太精确的预测器来提供下一个PC的"快速猜测";(2)让慢速预测器的结果在几个周期后覆盖快速预测器的结果。这就是覆盖式预测(overriding prediction)的思想。

预测延迟与取指气泡

当分支预测器无法在单周期内完成预测时,取指流水线中会产生气泡(bubble)——一些空闲的周期,前端无法向后端提供有效指令。气泡的数量取决于预测延迟和取指块中分支指令的密度。

考虑一个两级流水线的分支预测器(BP0和BP1),假设预测结果在BP1末尾产生。当处理器遇到一条被预测为"跳转"的分支指令时,下一个取指块的起始地址需要等到BP1完成后才能确定。在此期间,处理器已经在BP0阶段启动了对下一个顺序地址(PC+取指块大小)的预测,但如果分支确实跳转,这个顺序地址的预测就是无用的,产生了一个周期的气泡。

两级分支预测器产生的取指气泡(每次跳转1个周期的气泡)
两级分支预测器产生的取指气泡(每次跳转1个周期的气泡)

图 17.2展示了气泡的产生过程。当预测器在BP1末尾确定取指块A中的分支要跳转到目标T时,BP0阶段已经启动了对A+1的预测,产生一个气泡。每次遇到被预测为"跳转"的分支(taken branch),都会产生一个气泡。

气泡对前端带宽的影响可以用以下公式量化。设预测器的流水线延迟为dd个周期(即从发起预测到产生结果需要dd个周期),每个取指块中被预测为跳转的分支的概率为ptp_t,则有效取指带宽为:

有效带宽=11+(d1)×pt×峰值带宽 \text{有效带宽} = \frac{1}{1 + (d-1) \times p_t} \times \text{峰值带宽}

对于一个3级预测流水线(d=3d=3),如果pt=0.3p_t = 0.3(约30%的取指块包含一条被预测为跳转的分支),则有效带宽约为峰值带宽的62.5%。换言之,前端每取5个有效取指块就要浪费3个周期的气泡,这对后端的指令供给造成了严重的瓶颈。

性能分析 1 — 预测延迟导致的IPC损失

考虑一个6-wide的超标量处理器,峰值取指带宽为每周期6条指令(假设取指块大小为32字节、平均指令长度4字节、每块8条指令中有效指令平均6条)。如果分支预测器的延迟为3个周期,taken分支的比例pt=0.3p_t=0.3,则有效取指带宽下降为每周期3.75条指令。即使后端的执行资源完全充足,IPC也被前端限制在3.75以下。

这就是为什么高性能处理器需要将分支预测的有效延迟降低到1个周期——通过覆盖式预测来实现。

覆盖式预测

覆盖式预测(overriding prediction)是解决预测延迟问题的标准方法。其核心思想是使用两个(或更多)预测器的层次结构:

  • L0预测器(快速预测器):结构简单、容量小,能够在1个周期内产生结果。典型的L0预测器包括小容量的BTB(例如16\sim64项)配合一个简单的bimodal预测器,或者一个next-line预测器(NLP)。L0预测器的精度较低,但它保证了每个周期都能提供一个预测地址,避免了前端气泡。

  • L1预测器(精确预测器):结构复杂、容量大,需要2\sim3个周期才能产生结果。典型的L1预测器是TAGE-SC-L或感知机预测器,精度远高于L0预测器。

覆盖式预测的工作流程如下:

  1. 在每个周期,L0预测器根据当前PC快速产生一个预测结果(方向和目标地址),前端立即使用这个结果启动取指。同时,L1预测器也开始对同一个PC进行预测,但需要多个周期才能完成。

  2. 当L1预测器在2\sim3个周期后产生结果时,将其与L0预测器的结果进行比较。如果两者一致,则什么都不需要做;如果不一致,则以L1预测器的结果为准,覆盖(override)L0的预测。

  3. 覆盖操作会导致前端流水线中基于L0错误预测而取出的指令被丢弃,前端从L1预测器给出的正确地址重新开始取指。这相当于一次"前端预测修正",代价是d1d-1个周期的气泡(dd为L1预测器的流水线级数)。

覆盖式预测的基本结构
覆盖式预测的基本结构

覆盖式预测本质上是一个精度-延迟的权衡。L0预测器牺牲精度换取低延迟,保证前端在大多数情况下不产生气泡;L1预测器牺牲延迟换取高精度,在少数情况下修正L0的错误。覆盖的代价(d1d-1个周期的气泡)远小于等到执行阶段才发现预测失败的代价(15\sim20个周期的flush)。

案例研究 1 — Intel的分支预测层次

Intel从Sunny Cove微架构开始采用了多层分支预测结构。其前端流水线的分支预测部分大致可以分为:

  • nano-BTB:一个非常小的BTB(约64项),能够在1个周期内给出预测。它只记录最近频繁执行的分支的目标地址,精度有限但速度极快。

  • 主BTB:容量较大(数千项),配合TAGE预测器提供精确的方向和目标预测。访问延迟为2\sim3个周期。

  • 当主预测器的结果与nano-BTB不同时,执行覆盖操作,丢弃前端流水线中1\sim2个周期内取出的指令。

ARM的Cortex-X系列核心也采用了类似的层次结构:一个零周期的nano-predictor(基于BTB的部分标签匹配)和一个多周期的主预测器。零周期意味着nano-predictor的结果可以在同一个周期内反馈到PC选择逻辑,从而在理想情况下完全不产生气泡。

Next-Line Predictor(NLP)是一种极简的L0预测器实现。NLP为每个取指块(通常以cache line为单位)维护一个预测条目,记录这个取指块执行后的下一个取指块的地址。其硬件实现只是一个由当前PC的部分位索引的直接映射SRAM,每个条目存储一个目标地址。NLP不区分分支类型、不预测方向——它只是简单地记录上一次执行时观察到的取指流方向。

NLP的优势在于极低的延迟(1个时钟周期甚至半个周期,取决于SRAM的大小和工作频率)和极简的硬件。其劣势在于精度有限:(1)它无法处理同一个取指块中分支方向在运行时交替变化的情况,因为它只记录上一次的方向;(2)容量受限导致别名冲突(aliasing)频繁。但作为L0预测器与L1精确预测器配合使用时,NLP的低精度被L1预测器的覆盖机制所弥补。

覆盖式预测引入了一个重要的设计指标——覆盖率(override rate),即L1预测器覆盖L0预测器的频率。覆盖率过高意味着L0预测器的精度太差,产生了太多不必要的气泡;覆盖率过低则可能意味着L1预测器与L0相比没有提供足够的精度提升,白白消耗了面积和功耗。理想的覆盖率取决于具体的工作负载,但一般认为5%\sim15%的覆盖率是一个合理的范围。

我们可以量化覆盖式预测的有效预测失败惩罚。设L0预测器的精度为a0a_0,L1预测器的精度为a1a_1a1>a0a_1 > a_0),L0的延迟为1个周期,L1的延迟为dd个周期,执行阶段检测到最终预测失败的惩罚为PP个周期。则有效的分支预测失败惩罚为:

有效惩罚=(a0a1)×(d1)L1覆盖L0的代价+(1a1)×P最终预测失败的代价 \text{有效惩罚} = \underbrace{(a_0 - a_1) \times (d - 1)}_{\text{L1覆盖L0的代价}} + \underbrace{(1 - a_1) \times P}_{\text{最终预测失败的代价}}

其中第一项是L1覆盖L0时产生的气泡,(a0a1)(a_0 - a_1)的绝对值就是覆盖率(L0预测错误但L1预测正确的概率),每次覆盖产生d1d-1个周期的气泡。第二项是最终预测失败(L1也预测错误)时的完整flush惩罚。

假设a0=0.90a_0 = 0.90a1=0.97a_1 = 0.97d=3d = 3P=15P = 15,则:

有效惩罚=0.07×2+0.03×15=0.14+0.45=0.59 个周期/分支\text{有效惩罚} = 0.07 \times 2 + 0.03 \times 15 = 0.14 + 0.45 = 0.59 \text{ 个周期/分支}

相比之下,如果不使用覆盖式预测,直接等L1完成:

惩罚无覆盖=(d1)×pt+(1a1)×P=2×0.3+0.03×15=0.6+0.45=1.05 个周期/分支\text{惩罚}_{\text{无覆盖}} = (d-1) \times p_t + (1 - a_1) \times P = 2 \times 0.3 + 0.03 \times 15 = 0.6 + 0.45 = 1.05 \text{ 个周期/分支}

可见,覆盖式预测将每条分支的平均惩罚从1.05个周期降低到0.59个周期,减少了约44%。

下面的时序图展示了覆盖式预测在流水线中的具体行为。假设L0预测器在周期1对取指块A做出预测"not-taken"(下一个块为A+1),而L1预测器在周期3确定A中有一条taken分支,目标为T。

覆盖式预测的时序:L1在第3周期覆盖L0,产生2个周期的气泡
覆盖式预测的时序:L1在第3周期覆盖L0,产生2个周期的气泡
::: tradeoff 设计权衡 1 — 预测器层次的深度

是否需要三层甚至更多层预测器?在理论上,可以构建L0/L1/L2三层预测器层次:L0在1个周期内给出结果,L1在3个周期后给出更精确的结果,L2在5\sim6个周期后给出最精确的结果。但每增加一层,都会增加覆盖逻辑的复杂性、前端流水线的控制难度以及面积开销。在实践中,两层(L0+L1)是最常见的设计,三层的案例非常少见。AMD Zen 5被推测使用了一个两层方向预测器(快速bimodal + 主TAGE)和一个两层BTB(小L0 BTB + 大主BTB),本质上是方向预测和目标预测各自独立分层,而不是整个预测器统一分三层。

:::

覆盖式预测的精细化设计

覆盖检测的时序要求

覆盖式预测的关键时序路径是:L1预测器在BP2阶段产生结果后,需要在同一个周期内与L0的预测结果比较,并决定是否发出覆盖信号。覆盖信号必须在下一个周期的BP0阶段开始前到达PC选择逻辑,以便将PC重定向到L1预测的目标地址。

这意味着从L1预测结果输出到PC选择逻辑之间的路径延迟不能超过一个时钟周期。在5 GHz的处理器中,这条路径只有200 ps的时间预算——需要完成以下操作:L1结果输出\to与L0结果比较\to生成覆盖信号\to选择新PC\to将新PC写入PC寄存器。

如果覆盖检测的延迟无法在一个周期内完成,覆盖操作将延迟一个额外的周期——这意味着L0错误预测导致的气泡从d1d-1个周期增加到dd个周期。对于一个3级L1预测器,气泡从2个周期增加到3个周期,覆盖式预测的性能收益降低约33%。

部分覆盖

一种优化是部分覆盖(partial override):L1预测器可以只覆盖L0预测的部分信息——例如只覆盖方向预测而保留L0的目标地址预测,或只覆盖目标地址而保留方向预测。部分覆盖在L0和L1的预测结果部分一致时特别有用:如果两者对方向的预测一致但目标地址不同,只需要覆盖目标地址(因为方向已经正确),取指块中已经取出的指令大部分仍然有效,减少了浪费。

覆盖率的动态监控

覆盖率(L1覆盖L0的频率)是一个重要的运行时指标。过高的覆盖率(如>20%>20\%)意味着L0预测器的精度太低,前端产生了过多的覆盖气泡——此时应考虑增大L0预测器的容量或改善其算法。过低的覆盖率(如<2%<2\%)意味着L1预测器与L0几乎总是一致——L1预测器可能存储预算过大而收益不足,可以考虑将部分L1预测器的存储重新分配给其他用途(如增大L2 BTB)。

案例研究 2 — 覆盖率在不同工作负载中的变化

以下是一个典型的两层预测器(L0: 64项NLP,L1: 64KB TAGE-SC-L)在不同工作负载上的覆盖率数据:

工作负载覆盖率覆盖后精度提升
科学计算(规则循环)3%\sim5%+1%
整数计算(复杂控制流)8%\sim12%+4%\sim6%
服务器(大代码足迹)12%\sim18%+5%\sim9%
解释器(极不规则跳转)15%\sim25%+7%\sim12%

覆盖率与工作负载的控制流复杂度正相关:控制流越复杂(分支越多、方向越不规则),NLP的精度越低,L1覆盖的频率越高,L1覆盖带来的精度提升也越大。这说明覆盖式预测的设计在控制流密集型工作负载上的收益最大。

超标量处理器的特殊问题

一个周期多条分支指令

在标量处理器中,每个周期最多取一条指令,因此每个周期最多遇到一条分支。但在超标量处理器中,前端每周期取出一个取指块(fetch block),大小通常为32字节或64字节,其中可能包含多条指令——也可能包含多条分支指令。

以一个32字节取指块、4字节定长指令(如RISC-V的非压缩指令)为例,一个取指块最多包含8条指令。在整数代码中,分支指令的比例约为15%\sim20%,因此平均每5\sim7条指令中就有一条分支,一个取指块中很可能包含1\sim2条分支指令。如果取指块恰好跨越了两个循环体或处于分支密集的代码区域(例如switch-case语句或连续的if-else链),则可能包含更多的分支指令。

一个取指块中可能包含多条分支指令
一个取指块中可能包含多条分支指令

图 17.5展示了这一问题。取指块中包含三条分支指令B0、B1、B2。从前端的角度看,需要回答以下问题:

  1. 这个取指块中第一条被预测为跳转的分支是哪一条?——因为第一条taken分支之后的所有指令都不应该被送入后端。

  2. 这条taken分支的目标地址是什么?——用于启动下一个取指块的预测。

  3. 如果没有任何分支被预测为跳转,下一个取指块的地址是什么?——通常是当前取指块地址加上取指块大小。

在大多数实际设计中,处理器每周期只处理一条被预测为taken的分支。一旦找到取指块中第一条taken分支,该分支之后的所有指令(包括其他分支)都被丢弃,下一个周期从跳转目标开始取指。这种设计大幅简化了预测器的硬件:每周期只需要一个分支目标地址,一次BTB查询即可。

如果所有分支都被预测为not-taken,则取指块中的全部指令都是有效的,下一个取指块从顺序地址开始。Not-taken分支不改变取指流的方向,因此不增加复杂性。

多条分支指令的存在会降低取指块的有效利用率。设取指块包含NN条指令,块中第kk条指令是第一条被预测为taken的分支(1kN1 \leq k \leq N),则有效指令数为kk,利用率为k/Nk/N。对于随机分布的分支,第一条taken分支的期望位置为:

E[k]=1(1pbrt)Npbrt E[k] = \frac{1 - (1-p_b \cdot r_t)^N}{p_b \cdot r_t}

其中pbp_b是每条指令为分支指令的概率,rtr_t是分支被预测为taken的概率。对于N=8N=8pb=0.15p_b = 0.15rt=0.5r_t = 0.5的典型参数,E[k]6.5E[k] \approx 6.5,即取指块的平均有效利用率约为81%。这个利用率损失是超标量处理器前端的一个固有特性,通过增大取指块宽度无法完全消除——取指块越宽,块中包含taken分支的概率越大。

设计提示

一种常见的简化策略是在BTB中只记录取指块中的第一条分支。如果一个取指块中有多条分支,BTB只命中第一条。如果第一条分支被预测为not-taken,后续分支的信息就丢失了——处理器会假设整个取指块没有taken分支,从顺序地址继续取指。这种简化会损失一些精度,但在实践中效果可以接受,因为大多数取指块中只有0或1条分支指令。更精确的做法是在BTB中为每个取指块记录多条分支的信息,但这会增加BTB的存储开销和访问复杂度。

取指块中分支指令的识别

一个更基本的问题是:在取指阶段,如何知道取指块中的哪些指令是分支指令?取指块从I-Cache中取出后,里面只是一串原始的指令编码,还没有经过解码,因此前端不知道哪些指令是分支指令、这些分支指令的目标地址偏移是多少。

这个问题有两种主流解决方案:

方案一:预解码标记(Pre-decode Marking)。当指令从L2 Cache(或更低层次的存储)被填充到I-Cache时,对指令进行一次预解码,提取出指令类型的关键信息,并将这些信息作为额外的标记位存储在I-Cache中。对于RISC-V指令集,预解码只需要检查指令编码的几个固定位就能判断指令是否为分支或跳转指令:

  • opcode[6:0] = 1100011:条件分支指令(BEQ、BNE、BLT等)

  • opcode[6:0] = 1101111:JAL(无条件直接跳转)

  • opcode[6:0] = 1100111:JALR(间接跳转)

预解码标记的典型信息包括:是否为分支指令(1位)、分支类型(条件/无条件/间接/返回,2\sim3位)、指令长度(对于支持压缩指令的ISA,1位标记16位还是32位)。这些标记位被附加在I-Cache的每个指令槽中,总开销约为每条指令3\sim4位。对于一个32KB、64字节cacheline的I-Cache(存储8K条32位指令),额外的标记存储仅需4KB左右。

预解码标记的优势是取指阶段可以立即知道取指块中的分支指令位置和类型,不需要等待解码。缺点是增加了I-Cache的存储开销和L2\toL1填充路径上的延迟(需要进行预解码操作)。预解码逻辑本身非常简单(对于RISC-V只需检查opcode字段的几位),其延迟通常在1\sim2个门延迟以内,远小于L2\toL1的传输延迟,因此对填充路径的延迟影响可以忽略不计。

对于x86指令集,预解码的情况要复杂得多。x86指令的长度从1字节到15字节不等,并且指令的边界在解码之前是未知的——前缀字节、操作码、ModR/M、SIB、位移和立即数的组合方式非常灵活。因此x86处理器的预解码阶段需要进行一次完整的指令长度解码(instruction length decoding),标记出每条指令的起始和结束位置,然后才能识别哪些是分支指令。这个预解码过程本身就可能需要1\sim2个流水段,是x86前端设计中的一个重要复杂性来源。

方案二:BTB隐式标记。另一种方法是不在I-Cache中存储预解码信息,而是依赖BTB来识别分支指令。在这种方案中,BTB的每个条目不仅记录分支的目标地址,还记录分支在取指块中的偏移位置分支类型。当取指块的起始地址命中BTB时,BTB返回取指块中分支指令的位置、类型和目标地址,前端据此截断取指块并启动下一次取指。

这种方案的优势是不需要修改I-Cache的数据格式,但依赖于BTB的覆盖率——如果一条分支指令不在BTB中(冷启动或容量不足导致被驱逐),前端就无法识别它,会将其作为普通指令处理,直到该分支进入后端被解码和执行后才能发现。首次遇到的分支指令(compulsory miss)总是会导致至少一次预测失败。

案例研究 3 — RISC-V香山处理器的分支识别

香山处理器(昆明湖微架构)采用了预解码标记与BTB相结合的方案。指令从L2 Cache填充到I-Cache时进行预解码,标记指令类型和长度(香山支持RISC-V的C扩展,即16位压缩指令)。同时,BTB也记录了分支指令在取指块中的位置信息。在取指阶段,两种信息源被综合使用:

  • 如果BTB命中,直接使用BTB提供的分支位置和预测目标,预解码标记用于验证BTB信息的一致性。

  • 如果BTB缺失,使用预解码标记来检测是否有分支指令,对于检测到的分支使用简单的静态预测(条件后跳预测为taken)。

这种双重机制兼顾了BTB命中时的高性能和BTB缺失时的基本分支处理能力。

香山处理器将这种BTB结构称为FTB(Fetch Target Buffer),其每个条目可以存储一个取指块内最多2条分支的信息(一条条件分支和一条无条件跳转),以及取指块的有效结束位置。FTB的设计思想是以取指块为粒度组织BTB,使得每次BTB查询就能获得整个取指块的分支布局信息,简化了预测器与取指单元之间的接口。

在实际的处理器设计中,预解码标记和BTB隐式标记并不是互斥的选择,两者往往协同工作。对于在BTB中已有记录的"热"分支指令,BTB可以提供快速的分支位置和目标信息;对于BTB中没有记录的"冷"分支指令,预解码标记可以提供基本的分支类型信息,使处理器至少能对其进行静态预测(如backward-taken/forward-not-taken),而不是完全忽略该分支。

压缩指令对分支识别的影响

在支持变长指令的ISA中(如RISC-V的C扩展、ARM的Thumb-2、x86),分支指令的识别变得更加复杂。以RISC-V为例:

RISC-V的C扩展允许16位的压缩指令与32位的标准指令混合使用。一个32字节的取指块可能包含8条32位指令、16条16位压缩指令、或两者的混合。由于压缩指令的长度只有16位,取指块中可能包含更多的指令——也意味着可能包含更多的分支指令。

在取指阶段识别压缩分支指令需要额外的工作:

  1. 首先确定指令边界——RISC-V指令的最低2位决定了指令长度(xx \neq 11为16位,11为32位)。

  2. 然后对每个指令位置检查是否为分支指令——16位压缩指令的opcode编码与32位指令不同。

  3. 对于压缩分支指令,其偏移量字段比标准分支更短(仅8\sim12位),目标地址的计算也不同。

预解码标记需要正确处理所有这些变长指令的情况。一个实用的方案是在I-Cache的每2字节位置附加一个“指令起始位”(Instruction Start Bit, ISB),标记该位置是否为指令的起始。ISB可以在I-Cache行填充时通过一次顺序扫描生成(从行的起始位置开始,逐条指令确定长度并设置ISB)。

Fetch Group的定义与管理

在超标量处理器中,一个Fetch Group(取指组)是前端每周期处理的基本单元。Fetch Group的定义涉及以下考虑:

对齐与非对齐。如果Fetch Group严格对齐到Cache line边界(如64字节对齐),则跳转到Cache line中间位置的目标会浪费前半部分的取指带宽。如果Fetch Group不对齐(从任意地址开始),则需要I-Cache提供跨Cache line的读取能力,硬件复杂度增加。

Fetch Group的最大指令数。对于定长指令ISA(如纯RISC-V 32位指令),一个32字节的Fetch Group最多包含8条指令。对于变长指令ISA,最大指令数取决于指令的最小长度——RISC-V C扩展下为16条16位指令。后端的解码器宽度通常为4\sim8条指令,因此Fetch Group中实际被处理的指令数受解码器宽度的限制。

Fetch Group中的分支截断。当Fetch Group中包含一条被预测为taken的分支时,该分支之后的所有指令被标记为“无效”——它们不会被送入解码器。Fetch Group的有效指令数等于从起始到第一条taken分支之间的指令数。

性能分析 2 — Fetch Group利用率分析

Fetch Group利用率(即每个Fetch Group中有效指令数与最大指令数的比率)是前端性能的一个关键指标。影响利用率的因素包括:

  • 跳转目标不对齐。当分支跳转到Fetch Group的中间位置时,前半部分无效。假设跳转目标在Fetch Group内均匀分布,平均利用率为50%。

  • Taken分支截断。当Fetch Group中包含一条被预测为taken的分支时,该分支之后的指令无效。对于一个8条指令的Fetch Group,如果分支位于第kk条指令处(kk均匀分布在1\sim8),平均有效指令数为(1+8)/2=4.5(1+8)/2 = 4.5条,利用率为56%。

  • 两个因素叠加。跳转后的第一个Fetch Group受到目标不对齐的影响(利用率50%),后续的Fetch Group直到下一条taken分支为止是满利用率(100%),taken分支所在的Fetch Group受到截断的影响(利用率50%\sim100%)。

在典型的整数工作负载中,平均每5\sim7个Fetch Group中有一条taken分支。综合计算,平均Fetch Group利用率约为75%\sim85%。这意味着一个8-wide的处理器,前端的有效指令供给速率为每周期6\sim7条指令——与后端的6-wide执行宽度大致匹配。

分支预测与取指的协调

分支预测器和取指单元之间需要紧密协调,这涉及到预测粒度的选择:分支预测器是按指令为粒度进行预测,还是按取指块(或称fetch bundle/fetch group)为粒度进行预测?

按指令粒度预测意味着预测器为每一条分支指令单独维护预测信息(方向、目标、历史等)。BTB用分支指令的完整PC来索引,每个BTB条目对应一条具体的分支指令。这种方式的优点是预测精度高——每条分支有独立的预测状态,不会相互干扰。缺点是与超标量取指逻辑的配合较为复杂:取指块中可能包含多条分支,预测器需要为每条分支都提供预测结果,而这些预测结果可能来自BTB的不同条目,需要多端口BTB或串行查找。

按取指块粒度预测意味着预测器以取指块的起始地址(通常是对齐到32或64字节边界的地址)为单位进行预测。对于每个取指块,预测器给出以下信息:(1)取指块中是否有taken分支?(2)如果有,taken分支在块中的偏移位置是什么?(3)跳转目标地址是什么?这种方式的优点是预测器和取指单元的接口简单——每周期只需要一次BTB查询。缺点是如果一个取指块中有多条分支指令,它们共享预测状态,可能导致精度下降。

现代高性能处理器通常采用一种混合方案:BTB按取指块粒度组织(用取指块地址索引),但每个BTB条目可以存储块内多条分支的信息;方向预测器(如TAGE)则按指令粒度组织,用分支指令的PC来索引。这种混合方案在取指效率和预测精度之间取得了较好的平衡。

另一个协调问题是取指块对齐。I-Cache的读取通常要求地址对齐到cacheline边界(例如64字节对齐),但分支的跳转目标可能落在cacheline的中间位置。这意味着跳转后的第一个取指块可能只包含部分有效指令——从跳转目标到cacheline末尾的部分。例如,如果跳转目标落在64字节cacheline的第48字节处,则这个取指块只包含16字节的有效指令(4条32位指令或8条16位压缩指令),前端的取指带宽利用率仅为25%。

跳转目标不对齐导致取指块利用率下降
跳转目标不对齐导致取指块利用率下降

为了缓解对齐问题,一些处理器采用了取指窗口旋转(fetch window rotation)技术:I-Cache每次读取一整条cacheline(64字节),然后通过一个桶形移位器(barrel shifter)将有效指令旋转到取指缓冲区的起始位置。这样即使跳转目标不在cacheline起始处,取指缓冲区也能被充分利用。但桶形移位器本身有一定的面积和延迟开销,需要在取指效率和硬件成本之间权衡。

另一种解决对齐问题的技术是双行取指(dual-line fetch)或跨行取指(cross-line fetch)。如果跳转目标落在cacheline的后半部分,处理器可以同时从当前cacheline和下一条cacheline中读取数据,拼接出一个完整的取指块。这需要I-Cache提供两个读端口(或通过银行交叉实现),硬件开销较大,但可以保证即使在跳转后的第一个周期也能获得满宽度的取指带宽。Apple的高性能核心被认为采用了这种方案,这是其前端保持极高取指带宽的关键技术之一。

硬件描述 1 — I-Cache双端口与银行交叉的实现

双端口I-Cache为I-Cache的SRAM阵列提供两个独立的读端口,每个端口可以同时访问不同地址的数据。双端口SRAM的面积约为单端口的2倍,功耗也相应增加。但双端口使得每周期可以同时读取两条Cache line的数据,完全消除了跨行取指的问题。

银行交叉I-Cache(Banked I-Cache)将I-Cache的SRAM按地址交叉分为多个银行(bank),相邻的Cache line被放置在不同的银行中。当取指跨越Cache line边界时,两个银行可以被同时访问——每个银行提供一条Cache line的数据。银行交叉的面积开销小于双端口(约为单端口的1.3\sim1.5倍),但如果两次访问恰好落在同一个银行中(银行冲突),则无法并行访问,需要串行化。

对于4银行的I-Cache,如果取指地址均匀分布,银行冲突的概率为1/4=25%1/4 = 25\%。使用8银行可以将冲突概率降低到1/8=12.5%1/8 = 12.5\%。在实践中,由于代码的空间局部性,连续的取指请求通常不会冲突(它们访问相邻的Cache line,位于不同的银行中),银行冲突率远低于理论值。

还有一个与协调相关的微妙问题是预测-取指的时序对齐。分支预测器产生的预测地址需要在下一个周期被I-Cache使用。但在覆盖式预测的设计中,L0和L1预测器可能在不同的时间点给出不同的预测地址,前端需要在这些地址之间进行仲裁。当L1覆盖L0时,I-Cache的请求地址在中途被更改,可能导致I-Cache的访问被取消并重新开始。为了减少这种取消-重启的开销,一种常见的优化是让I-Cache在BP0阶段就开始推测性地使用L0预测的地址进行标签比较,同时在BP1/BP2阶段根据L1的结果决定是否真正使用I-Cache的输出。这种优化在时序上非常紧张,是高频率处理器前端设计中的关键挑战之一。

分支预测失败时的恢复

无论分支预测器多么精确,预测失败总是不可避免的。即使是最先进的TAGE-SC-L预测器,在SPEC CPU 2017等工作负载上的MPKI(每千条指令的预测失败次数)仍然在2\sim5之间。对于一个每周期取6条指令的处理器,平均每300\sim800条指令就会发生一次预测失败。预测失败时,处理器需要执行一系列恢复操作,这些操作的速度直接影响了分支预测失败的代价,进而影响IPC。

前端重定向

分支预测失败的检测发生在两个时间点:

(1)覆盖式预测的修正(L1覆盖L0)。这发生在前端,延迟较小(通常2\sim3个周期)。当L1预测器发现L0预测器给出了错误的预测时,L1的结果覆盖L0的结果。前端需要:

  • 丢弃L0错误预测导致的已取指令(通常只有1\sim2个取指块的指令还在前端流水线中)。

  • 将PC重定向到L1给出的正确预测地址。

  • 重新启动取指。

这种前端修正的代价相对较小,因为被丢弃的指令还没有进入后端(解码、重命名、发射队列),不涉及后端资源的回收。

(2)执行阶段的纠正。当分支指令在执行单元中完成条件计算后,执行结果与预测结果不一致,触发完整的流水线恢复。此时分支指令可能已经在流水线中经过了15\sim20级,其后的数十条指令可能已经被取出、解码、重命名、发射甚至执行。恢复操作需要:

  • 将PC重定向到正确的分支目标地址(如果预测为taken但实际为not-taken,正确地址为分支的下一条指令;反之亦然)。

  • 清除所有在错误路径上取出的指令。

  • 恢复后端的各种微架构状态(寄存器映射表、GHR等)。

分支预测失败时的前端重定向与流水线清空
分支预测失败时的前端重定向与流水线清空

前端重定向的关键在于速度:PC越快被重定向到正确的地址,浪费在错误路径上的周期就越少。在一些处理器设计中,当执行单元检测到分支预测失败时,重定向信号会被优先传送到前端,绕过正常的流水线反馈路径,以减少1\sim2个周期的恢复延迟。

需要特别注意的是,在一个超标量处理器中,可能同时存在多条飞行中的分支指令。如果在同一个周期内有多条分支被确认为预测失败(例如分支A和分支B在不同的执行单元上同时完成),处理器需要仲裁选择哪条分支的重定向优先执行。正确的策略是选择程序顺序中最早的那条分支——因为更早的分支已经让后续所有分支(包括分支B)走上了错误的路径,分支B的预测失败可能只是错误路径上的"假"失败,其正确性并不重要。这种仲裁逻辑需要比较多条分支的ROB序列号,选出最小的作为重定向源。

另一种可能的情况是,执行阶段检测到的分支预测失败需要与L1覆盖L0的前端重定向进行仲裁。如果两者在同一个周期产生冲突的重定向请求,执行阶段的重定向应当具有更高的优先级——因为执行阶段是基于分支的实际计算结果,而L1覆盖只是一个更好的预测,仍然是推测性的。

重定向延迟的优化

前端重定向的速度直接决定了分支预测错误的有效惩罚。以下是几种减少重定向延迟的技术:

旁路重定向(Bypass Redirect)。当执行单元产生重定向信号时,不经过正常的ROB\to前端反馈路径,而是通过一条专门的旁路信号直接将正确的PC值送到取指流水线的BP0阶段。这可以减少1\sim2个周期的反馈延迟。在物理实现上,旁路重定向信号需要从执行单元横跨到前端模块——在大型处理器核心中,这条信号的布线延迟可能很长(跨越核心的物理距离),需要通过中继缓冲器(repeater)来保证信号完整性,增加了布局布线的复杂度。

分支解析提前(Early Branch Resolution)。对于某些简单的条件分支(如比较两个寄存器并跳转),可以在重命名阶段发射阶段就解析分支方向——如果分支的源操作数在该时刻已经可用(如来自之前指令的旁路转发)。提前解析将分支检测点从执行阶段提前了3\sim5个流水级,相应地减少了3\sim5个周期的预测惩罚。

Intel在Golden Cove中实现了一种称为“Branch Resolved at Rename”的优化——对于源操作数已就绪的简单比较分支,在重命名阶段直接计算分支方向并与预测结果比较。如果不一致,立即触发重定向,惩罚仅为5\sim7个周期(而非执行阶段检测的12\sim15个周期)。

条件移动转换。编译器可以将某些短路径条件分支转换为条件移动指令(如RISC-V的条件零移动或CMOV),消除分支指令本身。条件移动指令不产生预测失败——它总是被顺序执行,只是结果值根据条件不同而变化。这种转换在分支的两个路径都很短(1\sim3条指令)时是有利的。

后端流水线的清空

前端重定向只是恢复过程的一部分。更复杂的是后端流水线的清空——需要将所有在错误预测路径上已经进入后端的指令从各个硬件结构中移除。

全部清空(full flush)是最简单也最常见的策略。当分支预测失败被检测到后,处理器将ROB中该分支指令之后的所有指令标记为无效并移除。具体操作包括:

  • ROB清空:将ROB的尾指针回退到预测失败的分支指令的位置。该分支之后的所有ROB条目被释放。

  • 发射队列清空:将发射队列中属于错误路径的所有指令移除。由于发射队列通常不按程序顺序组织,需要比较每条指令的序列号(ROB index)与分支的序列号来判断是否需要清除。

  • 物理寄存器释放:错误路径上的指令在重命名阶段分配了物理寄存器,这些寄存器需要被释放回空闲列表(free list)。

  • Load/Store队列清空:错误路径上的load和store指令已经在LSQ中占用了条目,需要将它们释放。

选择性清空(selective flush)是一种更精细但也更复杂的策略。在选择性清空中,只清除错误路径上的指令,保留正确路径上已经完成的指令结果。这在存在多条飞行中分支的情况下特别有用:如果同时有分支A和分支B在流水线中,A排在B前面,B先解析出预测失败,则只需要清除B之后的指令,不需要清除A和AB之间的指令。

选择性清空的实现难度在于快速判断哪些指令属于错误路径。最常见的方法是使用ROB index进行比较:每条指令在进入ROB时获得一个序列号,如果指令的序列号大于预测失败分支的序列号,则该指令在错误路径上。但ROB index是环形的,简单的大于/小于比较需要处理环绕情况。一种标准的处理方法是为ROB index增加一个最高位作为"纪元位"(epoch bit),每次ROB指针环绕一圈时翻转该位。比较两个带有纪元位的ROB index可以正确处理环绕情况。

另一种实现选择性清空的方法是使用分支掩码(branch mask)。每条进入后端的指令携带一个位掩码,标记它依赖于哪些飞行中的分支。当分支ii被确认为预测失败时,所有掩码中第ii位为1的指令都需要被清除。这种方法的优势是清除操作可以在一个周期内完成(通过广播分支号并比较掩码),但掩码的宽度受限于飞行中分支的最大数量——对于支持64条飞行中分支的处理器,每条指令需要携带64位的掩码,这在高发射宽度的处理器中存储开销不可忽视。

性能分析 3 — 全部清空与选择性清空的性能差异

在大多数情况下,全部清空和选择性清空的性能差异并不大。原因是:在典型的工作负载中,分支预测失败是一个相对稀少的事件(MPKI为2\sim5),并且大多数时候ROB中在预测失败分支之前没有更早的未解析分支。只有在分支非常密集且预测器精度较低的极端情况下,选择性清空才能提供明显的性能提升。

考虑到选择性清空的硬件复杂度——需要在发射队列、LSQ等结构中实现基于序列号的选择性失效——大多数商用处理器选择了全部清空策略。ARM Cortex-X系列和AMD Zen系列均采用全部清空。Apple的处理器由于其超大的ROB(630\sim700+项),全部清空的代价更大,但仍然采用了全部清空策略,通过极高的分支预测精度来弥补。

分支掩码与选择性清空

分支掩码(Branch Mask)是实现选择性清空的核心数据结构。以下详细讨论其实现。

分支掩码的位宽限制

分支掩码的位宽等于处理器同时支持的飞行中分支的最大数量BmaxB_{\max}。每条进入后端的指令需要携带一个BmaxB_{\max}位的掩码,标记它依赖于哪些未解析的分支。当飞行中分支ii被解析为预测错误时,所有掩码中第ii位为1的指令被清除。

BmaxB_{\max}的典型值为16\sim64。过小的BmaxB_{\max}(如16)会导致前端频繁暂停——当飞行中的分支数达到BmaxB_{\max}时,前端必须等待某条分支被解析后才能继续取指和分配。过大的BmaxB_{\max}(如128)会使每条指令的掩码字段过宽,增加ROB和发射队列的存储开销。

分支掩码在发射队列中的功能类似于数据依赖的唤醒逻辑:当分支ii被确认为预测正确时,所有掩码中第ii位为1的指令清除该位(表示不再依赖该分支的解析结果)。当分支ii被确认为预测错误时,所有掩码中第ii位为1的指令被标记为无效。

清除操作需要在一个周期内完成——因此需要一个BmaxB_{\max}位宽的广播总线,将分支解析结果广播到ROB和发射队列的每一个条目。对于Bmax=64B_{\max} = 64的处理器,这意味着ROB的每一项需要64位的分支掩码字段——在一个512项的ROB中,分支掩码总共占用512×64=32768512 \times 64 = 32768=4= 4 KB的存储。

分支标签替代分支掩码

一种减少存储开销的替代方案是使用分支标签(Branch Tag)替代分支掩码。每条指令只携带一个log2Bmax\lceil\log_2 B_{\max}\rceil位的标签,标识它依赖的最近的未解析分支的编号。当该分支被解析后,指令的标签更新为下一个未解析分支的编号。

分支标签的存储开销为每条指令log2Bmax\lceil\log_2 B_{\max}\rceil位(如Bmax=64B_{\max} = 64时为6位),远小于完整掩码的64位。但分支标签无法像掩码那样在一个周期内清除所有相关指令——它需要逐条比较每条指令的标签与被解析分支的编号。此外,分支标签只记录一个依赖,如果一条指令依赖多条飞行中的分支(如控制流合并点的指令),分支标签无法完整表达这种依赖关系。

在实践中,Intel和AMD的高性能处理器通常使用分支掩码(或其变体),因为选择性清空的一周期广播能力对于保持高IPC至关重要。ARM的Cortex-X系列核心由于ROB较小(<400<400项),使用分支标签即可满足性能需求。

预测器状态的恢复

分支预测失败后,除了清空流水线和恢复寄存器映射外,还需要恢复分支预测器本身的状态。最重要的状态是全局历史寄存器(GHR)。

回顾第 14.0 章的内容:GHR是一个移位寄存器,记录最近NN条分支的方向(taken/not-taken)。GHR被用作TAGE等预测器的索引的一部分。在超标量处理器中,GHR的管理面临一个根本性的困难:

  • 推测更新:为了让后续分支的预测使用最新的历史信息,GHR必须在分支被预测时就进行更新(将预测的方向移入GHR),而不是等到分支在执行阶段解析后再更新。这意味着GHR中包含了推测性的(可能是错误的)历史信息。

  • 恢复的必要性:当分支预测失败时,GHR中在该分支之后推测插入的所有历史位都是基于错误路径的,需要被撤销。

GHR恢复有两种主要策略:

(1)检查点恢复(Checkpoint Recovery)。在每条分支指令被预测时,保存当前GHR的一个完整副本(checkpoint)。当该分支在执行阶段发现预测失败时,从对应的检查点恢复GHR。

检查点恢复的优势是恢复速度快——只需要将一个保存的寄存器值加载回GHR,一个周期即可完成。缺点是存储开销大:如果GHR的宽度为128位,ROB中同时可以容纳64条飞行中的分支指令,则需要64×128=819264 \times 128 = 8192位的存储空间来保存所有检查点。对于更长的GHR(如TAGE-SC-L使用的数百位甚至上千位的历史),检查点的存储开销会非常可观。

(2)修复恢复(Repair Recovery)。不保存GHR的检查点,而是在分支预测失败时,利用ROB中记录的分支信息(每条分支的实际方向)来重建GHR。从预测失败的分支开始,扫描ROB中该分支之前的所有分支指令,将它们的实际方向重新填入GHR。

修复恢复的优势是存储开销小——不需要为每条分支保存GHR的副本,只需要在ROB的每个条目中额外存储1位(分支方向)。缺点是恢复速度慢——需要扫描ROB中的多个条目来重建GHR,恢复延迟与ROB中在该分支之前的分支数量成正比,可能需要数个周期。

检查点恢复修复恢复
恢复延迟1周期(加载检查点)多周期(扫描ROB)
存储开销高(Nbr×WGHRN_{\text{br}} \times W_{\text{GHR}}低(ROB每项1位)
实现复杂度中(检查点分配/释放)高(ROB扫描逻辑)
适用场景GHR较短(\leq128位)GHR很长(\geq256位)

GHR检查点恢复与修复恢复的对比

在实践中,很多处理器采用了混合策略。例如,为GHR的低位部分(最近的几十位历史)使用检查点恢复,因为这部分对预测精度影响最大、需要快速恢复;为GHR的高位部分(较远的历史)使用修复恢复,因为这部分的恢复延迟对性能影响较小(远处历史的恢复可以与流水线重新填充并行进行)。

设计提示

GHR恢复策略的选择应该基于以下原则:

恢复延迟的容忍度。GHR恢复延迟被流水线重新填充延迟所掩盖——从重定向PC到第一条新指令进入后端需要约5\sim8个周期(取指流水线深度)。只要GHR恢复能在这5\sim8个周期内完成,它就不会增加分支预测失败的有效惩罚。对于大多数恢复场景(错误路径上的分支数<10<10条),增量修复可以在5\sim8个周期内完成。

检查点存储的成本。检查点存储与飞行中的分支数量×\timesGHR/CSR宽度成正比。对于TAGE-SC-L使用的宽GHR(1000+位),完整GHR检查点的存储不可行。折叠历史(CSR)检查点是更实用的方案——CSR的总宽度远小于GHR,检查点存储可控。

恢复频率。分支预测失败的频率(MPKI约3\sim5,对应每300\sim600条指令一次恢复)远低于预测操作的频率(每5\sim7条指令一次预测)。因此,即使恢复操作比预测操作慢若干倍,其对整体性能的影响也是有限的。优化预测操作的时序(降低预测延迟)比优化恢复操作的时序更有价值。

案例研究 4 — TAGE预测器中的GHR恢复

在TAGE预测器中,不同的表使用不同长度的历史:最短的表可能使用4\sim8位历史,最长的表可能使用数百位甚至上千位历史。为每条飞行中的分支指令保存上千位的检查点是不现实的。

一种实用的解决方案是使用折叠历史(folded history)。TAGE的每张表实际上不需要完整的GHR,而是需要GHR与PC的一个哈希值。这个哈希值可以被增量地维护:当一个新的分支方向被移入GHR时,哈希值可以通过一次XOR操作进行增量更新,而不需要重新计算整个哈希。类似地,恢复折叠历史也可以通过增量操作完成,不需要保存和恢复完整的GHR。

具体而言,对于一张使用LL位历史的TAGE表,其折叠历史可以表示为一个KK位的值(KK通常等于表的索引宽度,如10\sim14位)。保存KK位的检查点远比保存LL位(LL可能为几百位)的完整GHR要经济得多。如果TAGE有6张表,每张表需要一个12位的折叠历史和一个8位的折叠标签,则每个检查点只需要6×(12+8)=1206 \times (12 + 8) = 120位,这是完全可行的。

以下SystemVerilog代码展示了GHR检查点保存与恢复的核心逻辑。每当前端预测一条分支时,当前GHR被保存到以ROB索引为地址的检查点数组中;当后端发现预测失败(mispredict)时,从对应的检查点恢复GHR,同时用分支的实际方向更新恢复后的GHR。

verilog
module ghr_checkpoint #(
  parameter GHR_W   = 128,       // GHR宽度(位)
  parameter ROB_IDX = 7          // ROB索引宽度 -> 128条目
)(
  input  logic                clk, rst_n,

  // --- 前端预测接口 ---
  input  logic                pred_valid,       // 前端正在预测一条分支
  input  logic                pred_taken,       // 预测方向
  input  logic [ROB_IDX-1:0]  pred_rob_idx,     // 分配的ROB条目号

  // --- 后端恢复接口 ---
  input  logic                misp_valid,       // 后端发现mispredict
  input  logic                misp_actual_dir,  // 分支的实际方向
  input  logic [ROB_IDX-1:0]  misp_rob_idx,     // 误预测分支的ROB条目号

  // --- GHR输出(供预测器索引使用)---
  output logic [GHR_W-1:0]    ghr_out
);

  logic [GHR_W-1:0] ghr_q;   // 当前推测GHR
  logic [GHR_W-1:0] ckpt [0:(1<<ROB_IDX)-1]; // 检查点数组

  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      ghr_q <= '0;
    end else if (misp_valid) begin
      // 恢复:从检查点读出 + 填入实际方向
      ghr_q <= {ckpt[misp_rob_idx][GHR_W-2:0], misp_actual_dir};
    end else if (pred_valid) begin
      // 正常推测更新:移入预测方向
      ghr_q <= {ghr_q[GHR_W-2:0], pred_taken};
    end
  end

  // 每次预测分支时,保存当前GHR为检查点
  always_ff @(posedge clk) begin
    if (pred_valid)
      ckpt[pred_rob_idx] <= ghr_q;
  end

  assign ghr_out = ghr_q;

endmodule

设计提示

上述代码中,恢复路径的优先级高于正常更新路径(misp_validpred_valid之前判断)。这是因为mispredict恢复发生时,前端应当被重定向暂停,不会同时发出新的预测请求。检查点数组的大小为2ROB_IDX×GHR_W2^{\text{ROB\_IDX}} \times \text{GHR\_W}位——对于128条目ROB和128位GHR,这需要16 Kbit(2 KB)的存储。在实际设计中,如果GHR过宽(如TAGE-SC-L的1000+位),则改用折叠历史检查点来降低存储开销,如前文所述。

检查点恢复与顺序恢复

GHR的恢复只是分支预测失败恢复的一部分。更广义地,整个处理器状态的恢复——包括寄存器重命名映射表(RAT)、空闲寄存器列表(free list)等——也面临类似的检查点恢复与顺序恢复的选择。

检查点恢复(Checkpoint Recovery)在处理器中的应用如下:在每条分支指令通过重命名阶段时,保存当前RAT的一个完整快照(checkpoint)。当该分支被确认为预测失败时,直接将保存的RAT快照恢复为当前RAT。

  • 优点:恢复速度极快,通常只需要1\sim2个周期(将检查点加载到RAT中)。恢复延迟与分支之后的指令数无关。

  • 缺点:存储开销大。对于一个拥有32个逻辑整数寄存器和200个物理寄存器的处理器,每个RAT快照需要32×log2(200)=32×8=25632 \times \lceil\log_2(200)\rceil = 32 \times 8 = 256位。如果需要支持64个飞行中的分支同时拥有检查点,则总存储为64×256=1638464 \times 256 = 16384位(2KB),这个开销是不小的。此外,如果还需要保存浮点/向量RAT的检查点,开销还会更大。

顺序恢复(Sequential Recovery),也称为ROB-walk恢复,不保存RAT的检查点。当分支预测失败时,从ROB的尾部开始向前扫描(walk),逐条撤销在预测失败分支之后重命名的指令的映射关系——将它们分配的物理寄存器归还空闲列表,并恢复被覆盖的旧映射。

  • 优点:不需要额外的检查点存储。

  • 缺点:恢复速度慢,恢复时间与需要撤销的指令数成正比。如果预测失败的分支之后有100条指令已经进入ROB,以每周期撤销4\sim6条的速度,需要17\sim25个周期来完成恢复。在此期间前端必须暂停,进一步加大了分支预测失败的惩罚。

ROB walk顺序恢复的过程(以每周期4条的速度回扫)
ROB walk顺序恢复的过程(以每周期4条的速度回扫)
检查点恢复与顺序恢复的对比
检查点恢复与顺序恢复的对比

一个重要的优化是提前重启(early restart):在顺序恢复开始后,不需要等到整个ROB walk完成就可以重新启动前端取指。具体而言,一旦预测失败被检测到,前端可以立即从正确的目标地址开始重新取指和解码,与此同时后端的ROB walk在并行进行。前端取出的新指令在进入重命名阶段时需要等待ROB walk完成(因为只有在旧的映射关系完全恢复后,新的重命名才能正确进行),但取指和解码的延迟已经被隐藏了。提前重启可以将分支预测失败的有效惩罚减少3\sim5个周期(取决于取指和解码的流水线级数)。

恢复延迟对IPC的量化影响

恢复延迟TrT_r直接加到分支预测失败的总惩罚中:

Ptotal=Ppipeline+Tr+Trefill P_{\text{total}} = P_{\text{pipeline}} + T_r + T_{\text{refill}}

其中PpipelineP_{\text{pipeline}}为流水线刷新的基本延迟(从执行阶段到前端重定向),TrT_r为状态恢复延迟(RAT/GHR恢复),TrefillT_{\text{refill}}为流水线重新填充延迟(从重定向到后端开始接收新指令)。

对于检查点恢复,Tr=12T_r = 1\sim 2周期;对于顺序恢复,TrT_r在最坏情况下可达数十周期。TrT_r对IPC的影响为:

ΔIPC=Tr×MPKI×IPC21000 \Delta\text{IPC} = \frac{-T_r \times \text{MPKI} \times \text{IPC}^2}{1000}

以IPC =5= 5、MPKI =4= 4为例:

  • 检查点恢复(Tr=1T_r = 1):Δ\DeltaIPC =1×4×25/1000=0.10= -1 \times 4 \times 25 / 1000 = -0.10

  • 顺序恢复(Tr=10T_r = 10平均):Δ\DeltaIPC =10×4×25/1000=1.00= -10 \times 4 \times 25 / 1000 = -1.00

顺序恢复导致的IPC损失是检查点恢复的10倍——这解释了为什么高性能处理器必须使用检查点恢复或至少提供快速的“提前重启”机制来减少恢复延迟。

在现代处理器中,最常见的方案是检查点恢复与顺序恢复的结合。由于检查点的存储开销与检查点数量成正比,处理器通常不会为每条分支都分配检查点,而是维护有限数量的检查点(例如8\sim16个),只在特定的时机分配:

  • 在每条分支指令处分配检查点(如果检查点数量足够)。

  • 当检查点耗尽时,前端暂停(stall),等待最早的检查点对应的分支被解析后释放。

  • 对于没有检查点的分支(如果采用选择性分配策略),预测失败时使用顺序恢复。

检查点的生命周期管理可以用以下伪代码描述:

案例研究 5 — Alpha 21264的分支恢复机制

Alpha 21264是最早采用大规模检查点机制的处理器之一。它为每条飞行中的分支指令维护一个完整的寄存器映射表检查点。21264的整数RAT有31个逻辑寄存器映射到80个物理寄存器,每个检查点约需31×7=21731 \times 7 = 217位。21264最多支持20条飞行中的分支,因此检查点总存储为20×217434020 \times 217 \approx 4340位,约0.5KB。当分支预测失败时,恢复只需1个周期——直接将对应的检查点加载为当前RAT。

相比之下,MIPS R10000采用了顺序恢复方案。当分支预测失败时,R10000从ROB的末尾开始逐条撤销重命名操作,恢复时间与在该分支之后已经发射的指令数成正比。在深流水线和大指令窗口的情况下,顺序恢复的延迟可能达到数十个周期,这是R10000性能的一个主要瓶颈。

现代处理器大多吸取了这两种设计的经验教训。例如AMD Zen系列使用了有限数量的检查点(据推测为8\sim12个),在大多数情况下可以实现快速恢复;只有当检查点耗尽时才回退到顺序恢复。Intel的处理器也采用了类似的混合策略。

设计权衡 2 — 恢复速度与硬件开销

假设一个处理器有32个整数逻辑寄存器,256个物理寄存器(8位索引),同时飞行中最多64条分支。

全部检查点方案:需要64×32×8=1638464 \times 32 \times 8 = 16384位(2KB)的检查点存储。恢复延迟为1周期。但检查点的分配和释放逻辑需要管理64个条目,增加了控制逻辑的复杂度。

8个检查点方案:只需要8×32×8=20488 \times 32 \times 8 = 2048位(256B)的存储。大多数分支预测失败时可以在1周期内恢复。但当8个检查点全部被占用时,前端必须暂停直到某个检查点被释放(对应的分支被解析),这可能在分支密集的代码中成为瓶颈。

纯顺序恢复方案:无额外存储开销。但恢复延迟在最坏情况下可能达到ROB大小/恢复宽度个周期。对于一个512项ROB、每周期恢复6条指令的处理器,最坏情况下的恢复延迟为512/685512/6 \approx 85个周期。

在实际设计中,8\sim16个检查点是最常见的选择,它在存储开销和恢复速度之间取得了良好的平衡。

解耦前端

在前面的讨论中,我们假设分支预测器和取指单元是紧密耦合的——预测器每个周期产生一个预测地址,取指单元在下一个周期使用这个地址访问I-Cache。但在现代高性能处理器中,分支预测器和取指单元的工作节奏可能不同步:预测器可能因为预测到连续的not-taken分支而在一个周期内快速"跑"过多个取指块(因为下一个取指块的地址就是当前地址加上取指块大小,不需要额外的查找延迟),而取指单元可能因为I-Cache缺失而暂停数十个周期。

解耦前端(decoupled frontend)的核心思想是在分支预测器和取指单元之间插入一个缓冲队列,让两者可以独立运行。预测器可以"跑在前面",预先生成多个取指地址存入队列;取指单元从队列中取出地址,按自己的节奏进行I-Cache访问。这种解耦使得预测器不会被I-Cache缺失所阻塞,可以提前生成足够多的取指地址。

FTQ

FTQ(Fetch Target Queue,取指目标队列)是解耦前端的核心数据结构。FTQ是一个FIFO队列,位于分支预测器和取指单元之间,每个条目记录一个取指请求的关键信息。

FTQ的结构与在前端中的位置
FTQ的结构与在前端中的位置

图 17.10展示了FTQ在前端中的位置。分支预测器持续生成预测结果并写入FTQ的尾部(write pointer),取指单元从FTQ的头部(read pointer)取出取指地址并访问I-Cache。FTQ的典型容量为16\sim32个条目,每个条目包含以下信息:

  • 起始地址(start_addr):取指块的起始地址。

  • 结束地址或有效长度(end_addr):取指块中有效指令的结束位置。如果取指块中有一条被预测为taken的分支,则结束地址为该分支指令的地址(分支之后的指令无效)。

  • 分支位置(branch_pos):分支指令在取指块中的偏移。

  • 目标地址(target_addr):如果分支被预测为taken,记录跳转目标。

  • 预测方向(pred_taken):分支是否被预测为跳转。

  • 预测元数据(pred_info):预测器的内部状态信息,用于在分支解析后更新预测器。这些信息包括TAGE中命中的表号、计数器值、替代预测等。

FTQ带来的关键优势有以下几个方面:

(1)容忍I-Cache缺失。当取指单元因为I-Cache缺失而暂停时,分支预测器不受影响,可以继续生成后续的取指地址并写入FTQ。当I-Cache缺失被填充后,取指单元可以从FTQ中快速获取后续的取指地址,而不需要重新进行分支预测。这在I-Cache缺失频繁的场景下(如服务器工作负载的大代码足迹程序)可以显著减少前端的暂停周期。

(2)为I-Cache预取提供信息。FTQ中存储的未来取指地址可以被用来驱动I-Cache预取(详见17.4.3 节),提前将即将需要的指令块填充到I-Cache中,从而减少后续的I-Cache缺失。

(2a)FTQ作为分支预测器更新的中间缓存。FTQ中保存的预测元数据(pred_info)在分支被解析后用于更新预测器。如果没有FTQ,预测器的更新信息需要在预测阶段就被写入ROB——ROB的每项需要额外的字段来保存TAGE的命中表号、计数器值、备选预测方向等信息。FTQ将这些信息从ROB中“卸载”出来,减少了ROB每项的宽度(节省约30\sim50位/项),同时将更新信息的管理集中在FTQ中简化了控制逻辑。

(3)简化预测器更新。FTQ中保存的预测元数据(pred_info)为分支解析后的预测器更新提供了必要的信息。当分支在执行阶段被解析后,其对应的FTQ条目中的元数据被读取,连同分支的实际方向一起送回预测器进行训练。这避免了在预测器内部维护复杂的飞行中状态表。

(4)支持多分支预测。在某些设计中,FTQ允许分支预测器在一个周期内"跳过"多个not-taken取指块。如果预测器发现当前取指块中没有taken分支,它可以立即计算下一个顺序取指块的地址,不需要等待任何查表操作(因为顺序地址只需加法运算)。这意味着预测器可以在一个周期内生成多个FTQ条目——对于连续的not-taken取指块,预测器的生产速度可以超过取指单元的消费速度,从而快速积累FTQ中的缓冲深度。

FTQ解耦示例:预测器在I-Cache缺失期间继续运行
FTQ解耦示例:预测器在I-Cache缺失期间继续运行

图 17.11展示了FTQ的解耦效果。在C5\simC7期间,取指单元因I-Cache缺失而暂停,但分支预测器不受影响,继续将预测地址写入FTQ。当I-Cache在C8填充完成后,取指单元可以立即从FTQ中获取下一个取指地址(T),而不需要重新启动分支预测器。这样I-Cache缺失的影响被部分隐藏了。

案例研究 6 — 香山处理器的FTQ设计

RISC-V香山处理器(昆明湖微架构)实现了一个32条目的FTQ。每个FTQ条目记录一个取指块(最多包含16条RVC指令或8条普通指令)的预测信息。FTQ的关键设计选择包括:

  • FTQ条目中保存了完整的TAGE预测元数据(命中表号、计数器值、替代预测方向、统计校正器输出等),以支持精确的预测器更新。

  • FTQ与I-Cache之间有一个指令缓冲区(Instruction Buffer),用于暂存从I-Cache取出但尚未送入解码器的指令。这形成了一个两级解耦结构:预测器\toFTQ\toI-Cache\toIBuffer\to解码器。

  • 当发生分支预测失败时,FTQ中在失败分支之后的所有条目都被作废,写指针回退到失败分支的位置。同时,分支预测器从正确的目标地址重新开始预测。

香山的FTQ设计参考了Reinman等人在2000年提出的Fetch Target Buffer(FTB)概念,但进行了针对RISC-V指令集特点(如压缩指令的变长处理)的适配。

FTQ的重定向处理

当分支预测失败被检测到时,FTQ需要执行以下操作:

  1. 丢弃失效条目。将写指针回退到失败分支对应的FTQ条目位置。该条目之后的所有条目(代表错误路径上的预测)被标记为无效。

  2. 更新预测元数据。失败分支的FTQ条目中的预测元数据被读出,与分支的实际方向一起送回预测器进行训练更新。

  3. 重新启动预测器。分支预测器从正确的目标地址(或顺序后继地址)重新开始预测,生成新的FTQ条目。

重定向过程中的一个关键时序问题是:写指针的回退和预测器的重启必须在尽可能少的周期内完成,以减少前端的暂停时间。在理想情况下,写指针的回退可以在1个周期内完成(直接将写指针设置为失败分支的条目编号),预测器可以在下一个周期就从正确地址开始预测。

FTQ与多级重定向的优先级

在一个完整的前端中,可能同时存在多个重定向源:

  • L1预测器覆盖L0预测器(前端重定向,2\sim3周期气泡)。

  • 执行阶段检测到方向预测失败(后端重定向,15\sim20周期惩罚)。

  • 执行阶段检测到目标预测失败(后端重定向,15\sim20周期惩罚)。

  • 执行阶段检测到内存异常(如page fault),需要跳转到异常处理程序。

这些重定向的优先级为:内存异常 >> 后端分支重定向(最早程序顺序) >> 前端覆盖重定向

当多个重定向同时发生时,FTQ需要选择优先级最高的重定向源,并丢弃低优先级重定向产生的FTQ条目。这个仲裁逻辑在高IPC的超标量处理器中可能每周期都需要运行,是FTQ控制逻辑中的一个热点路径。

FTQ条目的生命周期

一个FTQ条目从创建到释放经历以下阶段:

  1. 创建。分支预测器产生预测结果,写入FTQ的尾部(写指针递增)。

  2. 取指消费。取指单元从FTQ的头部读取取指地址,访问I-Cache(读指针递增)。

  3. 等待提交。取指块中的指令进入后端流水线。FTQ条目需要保持有效直到该取指块中的所有指令(包括分支指令)被提交。

  4. 更新释放。当取指块中的分支指令被提交时,FTQ条目中的预测元数据被读出用于更新预测器,条目被释放。

FTQ条目的生命周期通常为20\sim50个周期(从创建到释放)。这意味着一个32项的FTQ可以支持约32个取指块同时“在飞行中”——考虑到每个取指块包含约6\sim8条有效指令,这对应约200\sim250条飞行中的指令,通常足以覆盖ROB的容量。

FDIP

FDIP(Fetch-Directed Instruction Prefetching,取指导向的指令预取)是利用FTQ进行I-Cache预取的技术。其基本思想是:分支预测器可以在取指单元实际需要某个指令块之前就已经预测到了该块的地址,这个"提前量"可以被用来驱动I-Cache预取。

在传统的I-Cache预取方案中(如Next-Line Prefetcher或Stride Prefetcher),预取的依据是I-Cache的访问模式——已经观察到的取指地址序列。但这些方案无法预测分支跳转导致的非连续取指模式:如果一条分支指令从地址0x1000跳转到0x5000,基于地址连续性的预取器无法预测到对0x5000的需求。

FDIP利用分支预测器的信息来解决这个问题。由于FTQ中已经提前存储了未来多个取指块的地址(包括分支跳转的目标地址),这些地址可以被直接用作I-Cache预取请求。

FDIP的工作原理
FDIP的工作原理

图 17.12展示了FDIP的工作原理。分支预测器将预测的取指地址写入FTQ。FTQ中靠近读指针(即将被取指单元使用)的条目驱动正常的I-Cache访问;靠近写指针(预测器刚刚产生)的条目驱动I-Cache预取。当一个FTQ条目对应的地址在I-Cache中缺失时,一个预取请求被发送到L2 Cache,提前将所需的指令块填充到I-Cache中。

FDIP的有效性取决于以下几个因素:

(1)提前量(lead distance)。这是FTQ中写指针与读指针之间的距离,以周期数表示。提前量越大,预取请求越早发出,越可能在取指单元实际需要之前完成L2到L1的填充。一个典型的L2 Cache访问延迟为10\sim15个周期,因此FTQ需要提供至少10\sim15个条目的提前量才能完全隐藏L2延迟。在实践中,FTQ的容量(16\sim32个条目)通常足以覆盖L2延迟,但在I-Cache缺失率很高(取指单元频繁暂停,导致写指针迅速追上读指针直到FTQ填满)的情况下,提前量可能不足。

(2)预测准确性。FDIP预取的地址来自分支预测器的推测性预测。如果预测是错误的,预取的指令块也是错误路径上的,不仅浪费了L2 Cache的带宽,还可能将有用的指令块从I-Cache中驱逐(cache pollution)。但在实践中,分支预测器的准确率通常在95%以上,因此预取的有效率也相当高。

(3)预取过滤。为了减少无效预取,FDIP通常配备一个预取过滤器。最简单的过滤策略是:在发出预取请求之前,先检查目标地址是否已经在I-Cache中(通过查询I-Cache的标签数组,不读取数据)。如果已经命中,则跳过预取。更高级的过滤器使用Bloom Filter来记录最近发出的预取请求,避免重复预取同一个地址。

性能分析 4 — FDIP的性能收益

根据公开的学术研究和工业报告,FDIP在I-Cache缺失率较高的工作负载上可以带来显著的性能提升。以下是一些典型的数据:

  • 在SPEC CPU 2017的整数基准测试中,I-Cache缺失率从约3%\sim5%下降到1%\sim2%,IPC提升5%\sim15%。这些基准测试的代码足迹(code footprint)中等,I-Cache缺失主要发生在程序的初始化阶段和冷启动时。

  • 在服务器工作负载(如数据库查询、Web服务器、JIT编译的Java应用)中,由于代码足迹非常大(数MB),I-Cache缺失率可达10%\sim20%。FDIP可以将I-Cache缺失率降低30%\sim50%,IPC提升15%\sim25%。

  • Google在2019年公开了其数据中心工作负载中前端暂停(frontend stall)占总周期的30%以上,其中I-Cache和I-TLB缺失是主要原因。FDIP是缓解这一问题的关键技术之一。

近年来,学术界还提出了FDIP的扩展方案:例如Shotgun(利用BTB中的信息来驱动更远距离的预取)和PIF(Proactive Instruction Fetch,结合时序前缀匹配进行更激进的预取)。这些技术的共同思想都是利用分支预测器提供的取指方向信息来指导I-Cache预取。

值得注意的是,FDIP的效果与BTB的覆盖率密切相关。分支预测器的预测准确性不仅取决于方向预测器(TAGE等)的精度,还取决于BTB是否能够覆盖所有分支指令。如果BTB缺失(即分支指令不在BTB中),预测器根本不知道该地址存在一条分支指令,会将其当作普通指令处理——此时FDIP预取的地址可能是错误的顺序地址,而不是跳转目标。

这就引出了一个"鸡生蛋还是蛋生鸡"的问题:FDIP需要分支预测器给出正确的取指方向来驱动预取,而正确的取指方向需要BTB命中,而BTB命中需要该分支指令之前被执行过,而该分支指令能被执行的前提是它已经在I-Cache中——这正是FDIP要解决的问题。在实践中,这个循环依赖通过以下方式打破:对于第一次遇到的分支指令(compulsory BTB miss),FDIP无能为力,必须经历一次完整的预测失败和I-Cache缺失;但在此之后,BTB记录了该分支的信息,后续的FDIP预取就能正确地工作。

FDIP的实现还需要考虑与I-Cache和L2 Cache交互的微架构细节。预取请求与正常的I-Cache缺失请求在L2 Cache的端口上存在竞争——如果L2 Cache只有一个请求端口,预取请求可能延迟正常缺失请求的处理,反而降低性能。解决方法包括:

  • 给予正常缺失请求更高的优先级,预取请求在L2端口空闲时才发出。

  • 使用专门的预取队列(prefetch queue),当预取请求在队列中等待时间过长(意味着已经来不及在取指之前完成),将其丢弃。

  • 对预取的激进程度进行动态调节:当I-Cache缺失率低时减少预取以节省带宽,当I-Cache缺失率高时增加预取。

FDIP与I-TLB预取

FDIP不仅可以驱动I-Cache预取,还可以驱动I-TLB预取。当FTQ中的预测地址对应一个新的虚拟页面(不在I-TLB中)时,FDIP可以提前发起Page Table Walk请求,将该页面的映射关系预加载到I-TLB中。

I-TLB缺失的延迟通常远大于I-Cache缺失(Page Table Walk可能需要100\sim500个周期,取决于页表深度和TLB层次结构),因此I-TLB预取需要更大的提前量。32项的FTQ提供约32个周期的提前量,通常不足以覆盖Page Table Walk的全部延迟。但I-TLB预取至少可以将Page Table Walk与其他取指操作重叠(overlap),部分隐藏其延迟。

FDIP的准确性与时效性权衡

FDIP的有效性取决于两个因素的乘积:

EFDIP=Paccurate×Ptimely E_{\text{FDIP}} = P_{\text{accurate}} \times P_{\text{timely}}

其中PaccurateP_{\text{accurate}}为预取地址的准确性(预取的地址是否确实会被取指),PtimelyP_{\text{timely}}为预取的时效性(预取是否在取指需要之前完成)。

PaccurateP_{\text{accurate}}主要由分支预测器的精度决定。在97%方向预测精度下,FTQ中最近的几个条目几乎100%准确,但越远的条目(推测路径越深)准确性越低——因为推测误差会累积。第kk个条目的准确性约为(1pmis)k(1 - p_{\text{mis}})^k,对于pmis=0.03p_{\text{mis}} = 0.03k=20k = 20,准确性约为(0.97)200.54(0.97)^{20} \approx 0.54——第20个条目只有约54%的概率是正确的。

PtimelyP_{\text{timely}}取决于FTQ中写指针与读指针之间的距离(提前量)和L2 Cache的访问延迟。如果提前量\geqL2延迟,则预取有足够的时间在取指需要前完成,Ptimely1P_{\text{timely}} \approx 1

综合来看,FDIP的最佳工作点是FTQ深度约等于L2 Cache延迟(10\sim15个条目),此时PtimelyP_{\text{timely}}接近1,且PaccurateP_{\text{accurate}}仍然较高((0.97)150.63(0.97)^{15} \approx 0.63)。超过这个深度的条目主要产生不准确的预取(浪费带宽),收益递减。

设计权衡 3 — FTQ深度的选择

FTQ的深度(条目数量)是一个重要的设计参数:

  • 太浅(如8个条目):提前量不足,无法隐藏L2 Cache的访问延迟,FDIP的预取效果大打折扣。同时,FTQ可能频繁变满(当I-Cache缺失导致取指暂停时),阻塞分支预测器。

  • 太深(如64个条目):提前量充足,FDIP效果好,但每个条目需要存储取指地址和预测元数据(约100\sim200位),64个条目的总存储约800\sim1600B。更重要的是,FTQ中条目越多,预测失败时需要丢弃的条目也越多,浪费的工作越大。此外,过深的FTQ意味着预测器在前方走得太远,预测的准确性可能因为积累的推测误差而下降。

在实践中,16\sim32个条目是最常见的选择。这个深度提供了约16\sim32个周期的提前量(假设预测器每周期产生一个条目),足以覆盖L2 Cache的典型延迟(10\sim15个周期),同时存储开销和预测误差积累都在可控范围内。

案例研究 7 — Intel Skylake及后续微架构中的解耦前端

Intel从Skylake微架构开始在前端引入了更强的解耦设计。Skylake的前端包含以下关键组件:

  • 分支预测器:包含一个nano-BTB(快速L0预测)和一个大容量主BTB,配合TAGE方向预测器。预测器可以每周期预测一个taken分支或两个连续的not-taken取指块。

  • 取指目标队列:在预测器和I-Cache之间提供缓冲。当I-Cache缺失时,预测器可以继续运行数个周期。

  • Decoded Stream Buffer (DSB):也称为微操作缓存(μ\muop cache),缓存已解码的微操作。当取指地址命中DSB时,可以绕过I-Cache和解码器,直接从DSB向后端供给微操作,进一步减少了前端延迟。

  • I-Cache预取:利用前端队列中的地址信息进行I-Cache预取,本质上是FDIP的一种实现。

在Ice Lake及后续微架构中,Intel进一步增大了DSB的容量(从Skylake的约1536条μ\muop增加到约4096条μ\muop),并改进了分支预测器的精度和I-Cache预取的激进程度,使得前端的指令供给能力得到了显著提升。这些改进对于服务器工作负载(大代码足迹、高I-Cache缺失率)的性能提升尤为明显。

作为本章的总结,表表 17.2将本章讨论的各项技术与它们在真实处理器中的应用进行了汇总。

技术解决的问题典型实现
流水线化预测器高精度预测器延迟过大TAGE分2\sim3级流水线
覆盖式预测预测延迟导致取指气泡Intel nano-BTB + 主BTB;ARM nano-predictor
预解码标记取指块中分支识别I-Cache每指令附加3\sim4位类型标记
BTB隐式标记取指块中分支识别BTB条目记录分支偏移和类型
检查点恢复快速恢复RAT/GHRAlpha 21264:每分支一个检查点
顺序恢复低开销恢复MIPS R10000:ROB walk
FTQ解耦预测与取指香山:32项FTQ
FDIP减少I-Cache缺失利用FTQ驱动I-Cache预取

本章关键技术在现代处理器中的应用汇总

最后,我们简要讨论分支预测的一个安全性问题。2018年披露的Spectre漏洞系列揭示了分支预测的推测执行可以被恶意利用来泄露敏感信息。其核心机制是:攻击者通过精心构造的输入"训练"分支预测器,使其对一条特定的分支做出错误的预测,处理器在错误路径上推测执行load指令访问敏感数据,虽然最终结果会被丢弃,但敏感数据已经通过Cache侧信道(如Flush+Reload)被泄露。

Spectre的多个变体利用了分支预测的不同方面:

  • Spectre-v1(Bounds Check Bypass):利用条件分支的方向预测错误,在数组越界检查失败的路径上推测性地访问越界数据。

  • Spectre-v2(Branch Target Injection):利用间接分支的目标预测,将受害者进程的间接分支跳转到攻击者选择的目标地址(gadget)。

  • Spectre-BTB:通过BTB的别名冲突,跨进程"毒化"BTB条目。

针对Spectre的防御措施在软件和硬件两个层面展开。软件层面的措施包括编译器插入推测屏障(如x86的LFENCE、ARM的CSDB)来阻止跨越安全检查的推测执行。硬件层面的措施包括在上下文切换时清空BTB和预测器状态(如Intel的IBRS/IBPB机制)、为不同安全域的代码使用隔离的预测器状态、以及限制推测执行路径上load指令访问的地址范围。这些防御措施不可避免地带来了性能损失(通常在2%\sim15%之间),因为它们本质上是限制了分支预测和推测执行的激进程度。如何在安全性和性能之间取得平衡,仍然是处理器设计中的一个活跃研究领域。

设计权衡 4 — Spectre防御的性能代价

Spectre的各种防御措施对不同类型的工作负载有不同程度的性能影响:

防御措施桌面/HPC服务器虚拟化
IBRS(BTB隔离)1%\sim2%2%\sim5%5%\sim8%
IBPB(BTB清空)<1%<1\%1%\sim3%3%\sim5%
STIBP(单线程隔离)<1%<1\%1%\sim2%2%\sim3%
RSB stuffing<0.5%<0.5\%<1%<1\%1%\sim2%
Retpoline(软件)3%\sim8%5%\sim15%10%\sim20%
推测屏障(LFENCE)2%\sim5%3%\sim8%5%\sim10%
综合影响3%\sim8%5%\sim15%10%\sim25%

虚拟化场景(如云计算的多租户环境)受影响最大,因为VM切换频繁且需要最严格的隔离。桌面/HPC场景影响最小,因为上下文切换不频繁且安全要求相对较低。

这些数据表明,在安全性要求最高的数据中心环境中,Spectre防御可能“吃掉”处理器2\sim3代的微架构改进带来的IPC提升。这是推动处理器厂商投入大量工程资源开发“更精细的硬件隔离机制”(如Intel的eIBRS、ARM的FEAT_CSV2)的直接动力——目标是在保持安全性的同时将性能代价降低到2%\sim3%。

推测执行限制与IPC权衡

一种更根本的Spectre防御方法是限制推测执行的深度——不允许推测执行路径上的load指令访问未经验证的地址。这种方法从根源上阻止了通过推测执行泄露信息的可能,但代价是降低了推测执行的激进程度,减少了乱序执行可以利用的指令级并行性。

具体的限制方式包括:

  • 推测执行窗口限制。限制在未解析分支之后可以推测执行的指令数量。例如,在分支未解析之前最多只推测执行20条指令,超过限制则暂停取指。这降低了错误路径上泄露信息的窗口,但也限制了处理器利用分支预测正确(97%的情况)时的指令级并行性。

  • 延迟推测load。在推测执行路径上的load指令到达L1 Cache之前,检查其地址是否“安全”(如是否在堆栈指针附近、是否在当前函数的局部变量范围内)。只有安全的load才被允许推测执行,不安全的load等待其前面的分支被解析后才执行。

  • STL转发限制。防止推测路径上的load从推测的store中获取数据(Store-to-Load forwarding),因为推测store可能基于错误路径的计算结果。

这些限制措施的IPC代价取决于工作负载的分支密度和推测执行的平均深度。在分支密集的工作负载中(如数据库查询),限制推测窗口的代价较高(约5%\sim10% IPC损失);在分支稀疏的工作负载中(如科学计算),代价较低(<2%<2\%)。

预测器的训练与更新

分支预测器的训练(training)是指在分支被实际解析后,使用实际的分支方向来更新预测器的内部状态(如TAGE的计数器、感知机的权重等)。训练的时机、方式和正确性对预测器的有效精度有着至关重要的影响。

更新时机:提交时 vs. 执行时

分支预测器的更新可以在两个时间点进行:分支指令执行完成(execute)时,或分支指令提交/退休(commit/retire)时。两种时机各有优劣。

提交时更新(Commit-time Update)

在提交时更新方案中,预测器只在分支指令从ROB退休时才被更新。退休意味着该分支指令及其之前的所有指令都已经被确认为在正确的执行路径上——因此提交时更新保证了更新数据的正确性

优势

  • 更新数据一定正确——不会将错误路径上的分支方向写入预测器。

  • 实现简单——不需要回滚预测器状态。

  • 退休是顺序的——多条分支的退休顺序与程序顺序一致,更新操作不需要考虑乱序问题。

劣势

  • 训练延迟大。从分支被预测到退休,可能经过20\sim50个周期(取决于流水线深度和分支之前的指令延迟)。在此期间,同一条分支的后续实例仍然使用旧的预测器状态进行预测。

  • 乱序退休的带宽问题。在高IPC处理器中,每周期可能有4\sim8条指令退休,其中可能包含多条分支指令。如果预测器的更新端口有限(如TAGE每张表只有1个写端口),多条分支的同时退休可能导致更新请求排队。

执行时更新(Execute-time Update)

在执行时更新方案中,预测器在分支指令的执行单元完成方向计算后立即更新。此时分支的实际方向已知,但该分支可能处于推测执行路径上——之前的某条分支可能还没有被解析。

优势

  • 训练延迟小。从分支被预测到执行完成通常只有10\sim15个周期,比提交时更新快约5\sim15个周期。更快的训练意味着同一条分支的后续实例可以更快地使用更新后的预测信息。

劣势

  • 推测更新可能是错误的。如果当前分支所在的路径后来被发现是错误的(之前的某条分支预测错误),则当前分支的“实际方向”本身就是无意义的(错误路径上的分支可能根本不应该被执行),但其方向已经被写入了预测器。这种推测更新污染(speculative update pollution)会降低预测器的精度。

  • 需要回滚机制。当发现推测更新是错误的(之前的分支预测失败导致流水线刷新),需要撤销错误路径上所有分支的预测器更新。这需要记录每次更新的“旧值”,以便在回滚时恢复。

训练延迟对MPKI的影响

训练延迟的影响可以通过以下分析来量化。设一条分支的执行间隔为II条指令(两次连续执行之间的指令数),处理器的IPC为CC,提交时更新的延迟为DcD_c周期,执行时更新的延迟为DeD_e周期。

在提交时更新方案下,从一次预测错误被纠正(训练完成)到该分支下一次被预测,需要Dc+I/CD_c + I/C个周期。如果Dc>I/CD_c > I/C(即训练延迟大于分支执行间隔),则同一条分支的连续多次执行都会使用过时的预测器状态,每次都可能预测错误。

具体的MPKI影响为:

ΔMPKIdelaypmis×min ⁣(DI/C,Kmax) \Delta\text{MPKI}_{\text{delay}} \approx p_{\text{mis}} \times \min\!\left(\frac{D}{I/C}, K_{\text{max}}\right)

其中pmisp_{\text{mis}}为稳态MPKI(训练完成后的错误率),DD为训练延迟,KmaxK_{\text{max}}为在训练延迟期间同一分支最多被预测的次数。

以SPEC CPU 2017的gcc为例:平均分支执行间隔I50I \approx 50条指令,IPC 4\approx 4,提交时更新延迟Dc25D_c \approx 25周期,执行时更新延迟De12D_e \approx 12周期。对于一条MPKI贡献为0.1的频繁执行分支:

  • 提交时更新:ΔMPKIdelay0.1×25/(50/4)=0.1×2=0.2\Delta\text{MPKI}_{\text{delay}} \approx 0.1 \times 25 / (50/4) = 0.1 \times 2 = 0.2。即训练延迟使该分支的有效MPKI增加了0.2。

  • 执行时更新:ΔMPKIdelay0.1×12/(50/4)=0.1×0.96=0.096\Delta\text{MPKI}_{\text{delay}} \approx 0.1 \times 12 / (50/4) = 0.1 \times 0.96 = 0.096。训练延迟的影响减小了约52%。

性能分析 5 — 提交时更新 vs. 执行时更新的全局MPKI比较

在64 KB TAGE-SC-L预测器上对SPEC CPU 2017进行仿真比较:

工作负载提交时更新MPKI执行时更新MPKI
整数(平均)4.524.21
浮点(平均)1.951.82
服务器(平均)6.155.72
全部平均4.213.92

执行时更新将全局MPKI降低了约7%——这一改善纯粹来自缩短训练延迟,不需要任何算法改进或额外的存储。代价是需要处理推测更新污染和回滚机制。

推测更新的问题与解决方案

执行时更新的核心问题是推测更新可能基于错误路径上的分支。以下分析推测更新污染的严重程度和解决方案。

推测更新污染的量化

设方向预测精度为aa(如a=0.97a = 0.97),则每条分支有1a=0.031 - a = 0.03的概率导致流水线进入错误路径。在错误路径上执行的分支的“实际方向”是无意义的——它们的预测器更新只会向预测器中注入噪声。

推测更新污染的严重程度取决于错误路径上更新了多少条分支的预测器状态。设流水线深度为PP周期(从预测到执行),IPC为CC,分支频率为fbf_b(每条指令为分支的概率),则每次进入错误路径后到发现错误为止,大约有P×C×fbP \times C \times f_b条错误路径上的分支会完成执行并更新预测器。

P=15P = 15C=5C = 5fb=0.18f_b = 0.18为例,每次错误路径上更新的分支数约为15×5×0.1813.515 \times 5 \times 0.18 \approx 13.5条。乘以错误率0.030.03每条正确路径上的分支对应约0.4条错误路径上的分支更新

这0.4条错误更新会在预测器表中注入噪声——如果错误更新恰好命中了一个正确路径分支的表项,可能将其计数器推向错误方向。然而,由于预测器的表容量通常很大(TAGE每张表有数千项),一次错误更新命中特定正确路径分支表项的概率很低。

推测更新的回滚机制

如果需要在分支预测错误后回滚推测更新,需要记录每次更新操作的“撤销信息”。对于TAGE预测器,每次更新涉及以下操作:

  • 修改一个计数器值(需要记录旧值:3位)。

  • 修改一个useful位(需要记录旧值:1\sim2位)。

  • 可能分配一个新表项(需要记录被替换表项的完整内容:约16位)。

为每条飞行中的分支保存这些撤销信息的存储开销约为20\sim30位/分支。如果飞行中有64条分支,总撤销信息存储约为64×25=160064 \times 25 = 1600200\approx 200字节——这是一个可接受的开销。

回滚操作在分支预测失败时执行:从ROB中查找所有在错误路径上已经执行(并更新了预测器)的分支,按逆序将它们的预测器更新撤销。回滚需要的周期数等于错误路径上已更新的分支数(约13条),以每周期回滚1\sim2条的速度计算,回滚延迟约7\sim13个周期。

混合更新策略

一种实用的混合策略是:

  • 计数器更新在执行时进行,回滚时恢复旧值。计数器更新是TAGE最频繁的更新操作,也是训练延迟影响最大的部分。

  • 表项分配在提交时进行。分配新表项涉及替换一个已有表项,回滚的代价更高(需要恢复被替换表项的完整内容)。由于分配只在预测错误时发生(频率远低于计数器更新),将分配推迟到提交时不会显著增加训练延迟。

  • GHR更新在预测时进行(推测更新),检查点恢复在预测错误时进行。

这种混合策略在减少训练延迟的同时,降低了回滚的复杂度——只有计数器更新需要回滚(简单的值恢复),表项分配不需要回滚(因为在提交时进行,保证正确)。

GHR的推测管理与恢复

全局历史寄存器(GHR)的推测管理是分支预测子系统中最复杂的状态管理问题之一。GHR需要同时满足三个相互矛盾的要求:(1)快速推测更新——每条被预测的分支的方向必须立即移入GHR,以便后续分支使用最新的历史;(2)准确恢复——分支预测错误时,GHR必须被精确恢复到错误分支之前的状态;(3)低存储开销——检查点的存储不能太大。

GHR检查点的优化

对于使用1000+位GHR的TAGE预测器,为每条飞行中的分支保存完整的GHR检查点是不可行的(64×1000=6400064 \times 1000 = 64000=8= 8 KB)。以下是几种实用的优化方案:

折叠历史检查点。如17.3.4 节中讨论的,TAGE的索引和标签计算只依赖折叠历史(CSR),而非完整的GHR。保存CSR的检查点远比保存完整GHR经济:对于12张表、每张表2个CSR、平均每个CSR约12位,每个检查点只需12×2×12=28812 \times 2 \times 12 = 288位。64个检查点的总存储为64×288=1843264 \times 288 = 184322.3\approx 2.3 KB。

分段检查点。将GHR分为“近端”(最近KK位)和“远端”(K+1K+1NN位)。近端GHR变化频率高(每条分支都会修改),对预测精度影响大,使用检查点恢复(快速)。远端GHR变化频率低(只有当GHR移位超过KK位后才受到影响),使用修复恢复(扫描ROB重建)。

分段检查点的典型配置为:近端K=64K = 64位使用检查点,远端NK=936N - K = 936位使用修复恢复。每个检查点只需64位,64个检查点的总存储为64×64=409664 \times 64 = 4096=512= 512字节。远端GHR的修复恢复可以与流水线重新填充并行进行(因为流水线重新填充需要10\sim20个周期,足够扫描ROB中的30\sim50条分支来重建远端GHR)。

增量修复。利用ROB中每条分支的实际方向记录来增量修复GHR。从错误分支的检查点开始,逐条添加该分支之前(在正确路径上)的分支方向记录。对于TAGE的CSR,每添加一条分支的方向只需要一次CSR增量更新操作(式 (15.7)),延迟仅为一个周期。因此,如果需要修复nn条分支的方向记录,修复延迟为nn个周期。在大多数恢复场景中n<10n < 10,修复延迟可以被流水线重新填充的延迟掩盖。

PHR的推测管理

路径历史寄存器(PHR)的推测管理与GHR类似但更简单——PHR的宽度通常只有16\sim32位,完整的检查点存储很小(64×32=204864 \times 32 = 2048=256= 256字节)。因此,PHR通常使用完整检查点进行恢复,不需要折叠或分段优化。

PHR的推测更新在每条分支被预测时进行:将该分支的PC低位折叠异或到PHR中。恢复时直接从检查点加载即可。

多推测分支的GHR一致性

在超标量处理器中,一个周期内可能有多条分支被同时预测。这些分支需要使用不同的GHR值——第二条分支的GHR应该包含第一条分支的(推测)方向。但如果两条分支在同一个周期被预测,第一条分支的方向在该周期结束前才确定(需要方向预测器的结果),第二条分支无法使用包含第一条分支方向的GHR。

解决方案包括:

串行化预测。每周期只预测一条分支。这保证了GHR的一致性,但限制了前端的吞吐率。对于取指块中有多条分支的情况,需要多个周期才能处理完一个取指块。

并行预测+GHR修正。同时预测取指块中的多条分支,第二条分支使用不包含第一条分支方向的“过时”GHR。在下一个周期,当第一条分支的方向确定后,检查第二条分支的GHR是否需要修正——如果第一条分支的实际预测方向改变了第二条分支使用的GHR值,则需要用修正后的GHR重新查询第二条分支的预测。这种“延迟修正”在大多数情况下不需要实际执行(因为GHR中一位的改变通常不会改变后续分支的预测结果),但在某些极端情况下可能引起1个周期的修正气泡。

GHR预测。在预测第二条分支时,使用第一条分支的预测方向(而非实际方向)来更新GHR。由于方向预测的精度很高(97%+),使用预测方向更新的GHR与使用实际方向更新的GHR几乎相同。只有当第一条分支的预测是错误的(3%概率)时,第二条分支使用的GHR才是不精确的——但此时第一条分支本身就会导致流水线刷新,第二条分支的预测结果无论如何都会被丢弃。

预测器更新的带宽与冲突

在高IPC的超标量处理器中,预测器更新的带宽需求可能成为瓶颈。每周期可能有多条分支指令需要更新预测器,而预测器的SRAM写端口数量有限。

更新带宽需求分析

设处理器的IPC为CC,分支指令占比为fbf_b,则每周期需要更新预测器的分支数的期望值为:

Bupdate=C×fb B_{\text{update}} = C \times f_b

对于C=6C = 6fb=0.18f_b = 0.18Bupdate=1.08B_{\text{update}} = 1.08——平均每周期约1条分支需要更新。但由于分支退休是突发性的(连续多条分支可能在同一周期退休),峰值更新需求可能达到每周期3\sim4条分支。

对于TAGE预测器,每条分支的更新可能涉及以下写操作:

  • 更新提供者表的计数器(1次SRAM写入)。

  • 更新提供者的useful位(可以与计数器写入合并)。

  • 可能分配新表项(1次SRAM写入到另一张表)。

  • 更新SC的多个计数器表(KK次SRAM写入,K=46K = 4\sim 6)。

  • 更新循环预测器(1次写入)。

总计每条分支的更新可能需要2+K+1=792 + K + 1 = 7\sim 9次SRAM写入。如果每周期有2条分支退休,峰值写需求达到14\sim18次——远超SRAM写端口的供给能力。

更新缓冲队列

解决更新带宽不足的标准方法是使用更新缓冲队列(Update Buffer Queue)。分支退休时的更新请求不立即执行,而是暂存到一个FIFO队列中。SRAM在空闲时(预测读取不需要写端口时)从队列中取出更新请求并执行。

更新缓冲队列的深度需要足以应对突发的更新请求。假设峰值更新速率为每周期3条分支(每条9次写入 = 每周期27次写入),而SRAM的写带宽为每周期1次写入/表(12张TAGE表+6张SC表 = 每周期18次写入),则更新缓冲需要能够容纳约2\sim4个周期的突发((2718)×4=36(27-18) \times 4 = 36次写入请求)。一个32\sim64项的更新缓冲队列通常足以覆盖突发需求。

更新缓冲队列还提供了一个优化机会:更新合并(update coalescing)。如果队列中有多条更新请求指向TAGE的同一张表的同一个表项(如同一条分支的连续多次退休),它们可以被合并为一次SRAM写入——只写入最终的计数器值。更新合并可以将更新的SRAM写入次数减少20%\sim30%。

更新延迟与预测精度

更新缓冲队列引入了额外的更新延迟——从分支退休到预测器状态被实际更新的延迟。在队列不繁忙时,延迟约为1\sim2个周期(队列不为空但很快被处理);在队列繁忙时,延迟可能达到10\sim20个周期(队列排队等待)。

这种额外的更新延迟通常对预测精度的影响很小,因为:(1)对于不频繁执行的分支(占大多数),更新延迟不会导致“过时预测”——因为同一条分支的下一次执行距离当前退休时间很远。(2)对于频繁执行的分支(如循环体中的分支),即使更新延迟了几个周期,方向预测器中该分支的计数器已经接近稳态值,一次或两次过时预测不会改变方向。

预测器的预热策略

分支预测器的预热(warm-up)问题在第 15.0 章中已经从算法角度进行了讨论。本节从系统集成的角度讨论预热策略。

分层预热

在覆盖式预测(17.1.3 节)的框架下,L0预测器和L1预测器具有不同的预热特性:

  • L0预测器(如NLP或小型bimodal)的预热极快——只需要每条分支执行1\sim2次即可收敛。在程序启动的前几百条分支指令后,L0预测器就能提供70%\sim80%的预测精度。

  • L1预测器(如TAGE-SC-L)的预热较慢——需要数千到数万条分支的执行才能达到稳态精度。长历史表的训练尤其缓慢。

分层预热的策略是:在冷启动阶段,前端主要依赖L0预测器的快速预测;随着L1预测器逐渐训练成熟,覆盖率逐渐增加(L1覆盖L0的频率增加),最终过渡到以L1预测器为主。这种自然的分层预热过程不需要任何显式的控制逻辑——覆盖式预测的机制自动实现了从L0到L1的平滑过渡。

预测器预热与I-Cache预热的交互

预测器的预热与I-Cache的预热存在相互依赖

  • 预测器需要分支被执行后才能训练——这要求分支指令已经在I-Cache中(否则I-Cache缺失会延迟分支的执行)。

  • FDIP(取指导向的I-Cache预取)需要预测器给出准确的取指方向来驱动预取——这要求预测器已经训练充分。

这种循环依赖使得冷启动阶段的预热过程是“逐步收敛”的:

  1. 初始阶段:预测器和I-Cache都是空的。取指按顺序进行(无分支预测可用),每条分支都在解码后才能识别和纠正。I-Cache逐渐被填充。

  2. 中间阶段:BTB开始记录分支信息,L0预测器开始工作。FDIP开始驱动简单的顺序预取。I-Cache命中率逐渐上升。

  3. 成熟阶段:TAGE方向预测器训练成熟,FDIP能够准确地预取跳转目标的指令块。I-Cache命中率接近稳态。

这个预热过程通常在数十微秒到数百微秒内完成,具体取决于代码足迹的大小和分支密度。

硬件描述 2 — 预测器预热的监控与自适应

现代处理器可以通过性能监控计数器来观测预测器的预热状态,并据此调整前端行为:

预热阶段检测。通过监控最近NN条分支的预测错误率(滑动窗口MPKI),判断预测器是否仍在预热中。当滑动窗口MPKI从高值(如15\sim20)逐渐下降到稳态值(如3\sim5)时,预热完成。

自适应取指宽度。在预热阶段,预测器精度低,前端大量取出错误路径上的指令并在后端被刷新——这浪费了执行带宽和功耗。一种优化是在预热阶段降低取指宽度(如从8条/周期降低到4条/周期),减少错误路径上的浪费。当预热完成后恢复全宽度取指。

保守推测。在预热阶段,对低置信度的分支预测不进行推测执行——而是等待分支实际解析后再继续取指。这以减少推测宽度为代价,避免了大量的错误路径执行和流水线刷新。保守推测策略在预热阶段可以显著降低无效功耗,对于电池供电的移动设备尤其重要。

分支预测器的功耗管理

分支预测器在每个时钟周期都被访问(无论是否有分支指令),其累积功耗在处理器核心中不可忽视。本节讨论分支预测器的功耗管理技术。

时钟门控

时钟门控(Clock Gating)是最基本的预测器功耗管理技术。当前端暂停(如因为后端资源耗尽而暂停取指)时,预测器不需要工作——此时可以关闭预测器SRAM的时钟信号,消除SRAM的动态功耗。

时钟门控的实现需要一个门控信号来控制SRAM的时钟。门控信号的产生逻辑为:当前端流水线有效(正在取指)时开启时钟;当前端暂停时关闭时钟。门控信号需要比SRAM时钟提前半个周期产生,以确保时钟在需要时及时开启。

对于TAGE-SC-L预测器的12张TAGE表+6张SC表,时钟门控可以为每张表独立控制。在前端暂停期间(约占总周期的10%\sim20%),时钟门控可以将预测器的动态功耗降低10%\sim20%。

按需激活

更激进的功耗管理是按需激活(On-demand Activation):只在需要时才激活预测器的特定组件。

  • SC按需激活。SC只在TAGE的预测置信度低(计数器绝对值1\leq 1)时才需要工作——大约70%\sim80%的预测TAGE置信度高,SC的结果不会改变最终预测。因此,可以先完成TAGE查询,然后根据TAGE的置信度决定是否激活SC。但这需要TAGE和SC串行化,可能增加预测延迟。

    一种替代方案是使用一个简单的置信度预测器来预测TAGE的置信度是否会低于阈值。置信度预测器只需一个用PC索引的小型bimodal表(如256项×\times1位),记录每条分支的TAGE预测是否通常是低置信度的。如果置信度预测器预测“高置信度”,SC的SRAM不被激活(功耗节省);如果预测“低置信度”,SC正常激活。这种方案在不增加预测延迟的情况下节省了大部分SC功耗。

  • 长历史表按需激活。TAGE的使用最长历史的几张表(如T10T12T_{10}\sim T_{12},历史长度>500>500位)只对少数需要长历史的分支有用。对于大多数分支,中等历史长度的表(T3T7T_3\sim T_7)就能提供足够精确的预测。因此,长历史表可以被设计为按需激活——只有当中等历史表没有提供高置信度的匹配时,才激活长历史表进行补充查询。

性能分析 6 — 预测器功耗优化的综合效果

以一个5 GHz、64 KB TAGE-SC-L预测器为例,各功耗优化技术的效果如下:

优化技术功耗节省精度影响
时钟门控(暂停时)15%\sim20%
SC按需激活8%\sim12%<0.1%<0.1\% MPKI增加
长历史表按需激活10%\sim15%<0.2%<0.2\% MPKI增加
分级预测(L0+L1)40%\sim60%<0.5%<0.5\% MPKI增加
综合(不含分级预测)30%\sim40%<0.3%<0.3\%
综合(含分级预测)55%\sim70%<0.8%<0.8\%

综合使用所有功耗优化技术,预测器的动态功耗可以从约100 mW降低到约30\sim45 mW,而MPKI增加不到1%。这使得高精度的TAGE-SC-L预测器在功耗受限的移动处理器中也具有可行性。

本章小结

本章系统地讨论了分支预测在超标量处理器中的集成问题。从预测器流水线的级数分析出发,我们看到了高精度预测器(如TAGE-SC-L)因为多周期查询延迟而引入的取指气泡问题,以及覆盖式预测作为解决方案的工作原理和性能收益。

超标量处理器的取指块中可能包含多条分支指令,这要求BTB以取指块为粒度组织(FTB风格),方向预测器为块内多条分支分别提供方向预测。预解码标记和BTB隐式标记提供了两种互补的分支识别机制。取指块的对齐问题和取指窗口旋转技术进一步影响了前端的指令供给效率。

预测失败后的恢复包括前端重定向和后端状态恢复两个方面。GHR的推测管理是恢复过程中最复杂的部分——折叠历史检查点、分段检查点和增量修复提供了在存储开销和恢复速度之间的多种权衡选项。PHR的管理相对简单,通常使用完整检查点。

解耦前端通过FTQ将分支预测器与取指单元解耦,使得预测器可以在I-Cache缺失期间继续运行,积累取指地址缓冲。FDIP利用FTQ中的预测地址驱动I-Cache预取,显著降低了I-Cache缺失率,在大代码足迹的服务器工作负载上带来了15%\sim25%的IPC提升。

预测器的训练时机(提交时vs.执行时更新)是影响有效预测精度的重要因素。执行时更新缩短了训练延迟约50%,将全局MPKI降低约7%,但需要处理推测更新的回滚问题。混合更新策略(计数器执行时更新+表项分配提交时更新)在实现复杂度和性能之间取得了良好的平衡。

分支预测在超标量处理器中的集成远不止算法精度的问题。预测器的流水线组织、与取指单元的协调、预测失败后的快速恢复、以及通过解耦设计来容忍前端延迟,这些工程细节共同决定了分支预测子系统的实际效能。一个精度为97%的预测器在糟糕的流水线集成下,其有效性能可能还不如一个精度为95%但集成良好的预测器。正如处理器设计中反复出现的主题:微架构设计是一个系统工程,每个组件的性能都取决于它与其他组件的交互。

至此,第三篇"分支预测"的讨论告一段落。分支预测解决的是"下一条指令在哪里"的问题——这是处理器前端的第一步。但前端的第二步同样关键:拿到指令之后,如何理解它?这取决于指令集的编码格式。下一章(第 18.0 章)将进入第四篇"指令集体系与前端",首先从宏观层面审视ISA设计对微架构的约束。值得注意的是,ISA的选择反过来也影响分支预测——例如,AArch64的固定32位编码使BTB的对齐计算更简单,RISC-V的BEQ/BNE直接比较两个寄存器(无需条件码)简化了分支执行逻辑,而x86的变长编码使取指块中分支的定位更加困难。本篇讨论的覆盖式预测、GHR检查点和解耦前端等机制,将在第 18.0 章\sim第 23.0 章的前端设计中反复出现。

正文与图片:CC BY-NC-SA 4.0 · 本仓库少量站点配置代码:MIT