TAGE、ITTAGE 与 RAS
一个取指块的“下一条 PC”可能由三类完全不同的问题决定:
- 条件分支到底 taken 还是 not-taken;
- 间接跳转的目标地址是什么;
- return 应该回到哪一层调用者。
昆明湖 V2 分别用 TAGE-SC、ITTAGE 和 RAS 处理它们。三者都使用“历史”,但预测对象、表项内容和训练方式并不相同。
| 组件 | 回答的问题 | 输出 | 基本 fallback |
|---|---|---|---|
| TAGE-SC | 条件分支走不走? | taken/not-taken | bimodal/base prediction |
| ITTAGE | 间接 JALR 跳到哪里? | target address | FTB 中的基础目标 |
| RAS | return 回到哪里? | return address | 依赖栈状态;失配时由后续级纠正 |
源码中的默认组合顺序是:
uFTB -> TAGE-SC -> FTB -> ITTAGE -> RAS
它不是简单的串行“覆盖一切”。每一级只修改自己负责的字段:TAGE 修改条件分支方向,FTB 提供块结构和基础目标,ITTAGE 改进间接跳转目标,RAS 改进 return 目标。
一、TAGE-SC:预测条件分支方向
源码入口:frontend/Tage.scala、frontend/SC.scala。
1. 为什么一张方向表不够
最简单的 bimodal predictor 用 PC 索引一个 2-bit 饱和计数器:
PC -> counter -> taken / not-taken
它能学会“这个分支通常 taken”,却无法区分上下文。例如:
if (mode) { // 分支 A
...
}
if (mode && ready) { // 分支 B
...
}
B 的方向与更早的 A 或其他分支结果相关。只看 B 的 PC,会把多个上下文混在同一个计数器里。
TAGE 的做法是同时查询多张表,每张表使用不同长度的全局历史:
短历史表:擅长近期、简单相关
中历史表:擅长循环和阶段性模式
长历史表:擅长距离很远的控制相关
“TAGE”中的 GE 就是 geometrical history length:历史长度大致按几何级数增长,而不是每张表只多一两位。
2. 昆明湖默认表配置
默认 XSCoreParameters 中:
| tagged table | entries | history length | tag bits |
|---|---|---|---|
| T0 | 4096 | 8 | 8 |
| T1 | 4096 | 13 | 8 |
| T2 | 4096 | 32 | 8 |
| T3 | 4096 | 119 | 8 |
此外还有 2048-entry 的 base table,使用 2-bit counter。具体配置可被其他 Config 覆盖;例如 MinimalConfig 会缩小表规模,因此读性能数据时必须同时确认配置。
每个 TAGE tagged entry 的核心内容是:
valid + partial tag + 3-bit direction counter
u(useful)信息在单独的 SRAM 中维护,用于判断表项是否值得保留或可被新模式替换。
3. index 和 tag 如何计算
每张表都用自己的历史长度 histLen。由于完整历史很长,硬件先取得相应的 folded history,再与 PC 混合:
idx = PC_without_byte_offset XOR folded_history_for_index
tag = PC_without_byte_offset
XOR folded_history_for_tag
XOR (another_folded_history << 1)
源码中的核心形式是:
val idx = (unhashed_idx ^ idx_fh)(idxWidth - 1, 0)
val tag = (unhashed_idx ^ tag_fh ^ (alt_tag_fh << 1))(tagLen - 1, 0)
下面把公式中的四个量逐个展开。
3.1 PC_without_byte_offset 怎样得到
源码中的真实名字是 unhashed_idx:
def getUnhashedIdx(pc: UInt): UInt = pc >> instOffsetBits
其中:
instBytes = if (HasCExtension) 2 else 4
instOffsetBits = log2Ceil(instBytes)
昆明湖支持 RISC-V C 扩展,最小指令对齐单位是 2 Byte,因此通常:
instBytes = 2
instOffsetBits = 1
unhashed_idx = PC >> 1
也就是丢弃恒为 0、没有区分能力的 PC[0]。例如:
PC = 0x1042
unhashed_idx = 0x1042 >> 1 = 0x821
如果配置不支持 C 扩展,最小指令宽度为 4 Byte,才会右移 2 位、丢弃 PC[1:0]。
这里没有先把 PC 截成 index 宽度。源码先保留右移后的全部有效 PC 位,与 folded history XOR,最后才根据 index/tag 宽度截取低位。
3.2 一个 folded_history 怎样算出来
对某张表,先确定:
L = histLen:这张表使用多少位全局历史;C = compLen:要把这L位压成多少位。
概念上的折叠函数是:把 H[L-1:0] 从低位开始切成若干个 C 位小段,不足 C 位的最高一段补 0,然后逐段 XOR:
fold(H, L, C) = H[C-1:0]
XOR H[2C-1:C]
XOR H[3C-1:2C]
XOR ...
等价伪代码是:
F = 0
for base = 0; base < L; base += C:
F = F XOR zero_extend_to_C_bits(H[min(base+C,L)-1 : base])
H[0] 是最新的分支结果。预测关键路径中不会临时扫描全部历史;BPU 已经按上一页介绍的增量算法维护了多组 FoldedHistory(L,C)。TAGE 只是通过:
allFh.getHistWithInfo((L, C)).folded_hist
取出所需的那一组。
3.3 folded_history_for_index 的宽度怎样选
方向 TAGE 的一张物理表要同时服务 numBr 个逻辑分支位置,因此每个分支可用的逻辑行数是:
nRowsPerBr = nRows / numBr
idxWidth = log2Ceil(nRowsPerBr)
索引历史的压缩宽度是:
idxCompLen = min(idxWidth, histLen)
idx_fh = fold(H, histLen, idxCompLen)
为什么取最小值?若表只使用 8 位历史,就不可能从它产生 11 位独立历史信息;此时 folded history 只提供 8 位,index 的其余高位仍主要来自 PC。
默认配置中 nRows=4096、numBr=2,所以:
nRowsPerBr = 2048
idxWidth = 11
各表实际请求的 index fold 为:
| 表 | histLen | index (L,C) |
|---|---|---|
| T0 | 8 | (8,8) |
| T1 | 13 | (13,11) |
| T2 | 32 | (32,11) |
| T3 | 119 | (119,11) |
最后:
idx = low_11_bits(unhashed_idx XOR zero_extended(idx_fh))
3.4 folded_history_for_tag 怎样算
默认 tag 宽度 tagLen=8。第一组 tag folded history 使用:
tagCompLen = min(histLen, tagLen)
tag_fh = fold(H, histLen, tagCompLen)
因此默认四张表分别使用:
T0: fold(H, 8, 8)
T1: fold(H, 13, 8)
T2: fold(H, 32, 8)
T3: fold(H, 119, 8)
这就是公式里的 folded_history_for_tag。
3.5 another_folded_history 又是什么
源码名字是 alt_tag_fh,但这里的 alt 不是 alternate predictor/provider。它只是计算 tag 时使用的第二个历史哈希:
altTagCompLen = min(histLen, tagLen - 1)
alt_tag_fh = fold(H, histLen, altTagCompLen)
默认 tagLen=8,所以它通常把同一段历史折成 7 位:
T0: fold(H, 8, 7)
T1: fold(H, 13, 7)
T2: fold(H, 32, 7)
T3: fold(H, 119, 7)
最终计算:
tag = low_8_bits(
unhashed_idx
XOR tag_fh
XOR (zero_extend(alt_tag_fh) << 1)
)
为什么同一段历史要折叠两次?只用一个 8-bit XOR fold 会让某些不同历史以相同方式相消。再加入一个折叠周期不同的 7-bit 结果,并左移一位混合,可以打散一部分这种结构性碰撞。它仍不能消除所有 alias,但比只使用一个 fold 更难碰撞。
默认四张 TAGE 表需要的三组历史表示可以汇总为:
| 表 | index fold | tag fold | second tag fold |
|---|---|---|---|
| T0 | (8,8) | (8,8) | (8,7) |
| T1 | (13,11) | (13,8) | (13,7) |
| T2 | (32,11) | (32,8) | (32,7) |
| T3 | (119,11) | (119,8) | (119,7) |
相同的 (L,C) 只维护一份。例如 T0 的 index fold 和 tag fold 都是 (8,8),AllFoldedHistories 不会为两个用途保存两份重复状态。
3.6 用 T2 做一次完整数值计算
取默认 T2:
histLen = 32
idxWidth = 11
tagLen = 8
PC = 0x1042
H[31:0] = 0xD36A5C91
第一步,去掉 PC byte offset:
P = PC >> 1 = 0x821
第二步,把 32 位历史折成 11 位作为 index hash:
11-bit chunks = 0x491, 0x54B, 0x34D
idx_fh = 0x491 XOR 0x54B XOR 0x34D = 0x297
第三步,把同一历史折成 8 位:
8-bit chunks = 0x91, 0x5C, 0x6A, 0xD3
tag_fh = 0x91 XOR 0x5C XOR 0x6A XOR 0xD3 = 0x74
第四步,再折成 7 位:
7-bit chunks = 0x11, 0x39, 0x29, 0x1B, 0x0D
alt_tag_fh = 0x17
最后混合并截位:
idx = low11(0x821 XOR 0x297)
= 0x2B6
tag = low8(0x821 XOR 0x74 XOR (0x17 << 1))
= 0x7B
因此这次查询在 T2 中读取逻辑 index 0x2B6,并检查读出 entry 的 8-bit tag 是否等于 0x7B。源码随后还会把逻辑 index 映射到物理 SRAM bank 和分支 way;那是存储组织问题,不改变这里的 (PC, history) -> (idx, tag) 哈希结果。
上面的 tag 公式是方向 TAGE 的实现。ITTAGE 的 index 仍使用 P XOR idx_fh,但 tag 使用 P >> log2Ceil(nRows) 后的 PC 高位,再与两组 tag fold 混合。这样 index 主要消费 PC 低位,tag 再引入未被 index 使用的 PC 高位。不要把两份源码公式当成完全相同。
为什么既需要 index 又需要 tag?
- index 决定读 SRAM 的哪一行;
- 不同
(PC, history)仍可能映射到同一行; - tag 用来过滤明显不是当前上下文的表项。
tag 也只有 8 位,所以 collision 仍然可能发生;它只是用有限面积显著降低 alias,而不是做到绝对无冲突。
4. 同时命中多张表时选谁
对某个条件分支,四张 tagged table 同时读取并比较 tag。命中表中使用历史最长的那一张作为 provider:
T0 hit, T1 miss, T2 hit, T3 miss
▲
provider = T2
直觉是:更长历史的匹配更具体,更可能识别出当前上下文。但新分配到长历史表的表项可能还没训练稳定,因此不能无条件相信它。
5. provider、alternate 和 use_alt_on_na
当前昆明湖 Tage.scala 中,方向 TAGE 的 alternate 不是“第二长的 tagged-table hit”,而是 base/bimodal prediction。
决策过程可以简化为:
没有 tagged provider
-> 用 base prediction
有 provider 且 provider counter 不弱
-> 用 provider counter 的最高位
provider 刚分配/置信度弱
-> 由 use_alt_on_na 学习计数器决定
是相信弱 provider,还是退回 base prediction
3-bit provider counter 中,靠近 taken/not-taken 分界线的两个值被视为 unconf。use_alt_on_na 是按 PC 选择的一组 4-bit 学习计数器:当弱 provider 与 base 不同,执行结果会训练“这种情况下该不该用 alternate”。
这解决的是冷启动问题:一个更具体的长历史表项刚分配时,还不应立即压过已经训练很久的 base predictor。
6. 一个 provider 选择例子
假设同一分支查询得到:
| 表 | 是否命中 | counter | 说明 |
|---|---|---|---|
| base | 总是可用 | weak taken | alternate |
| T0(h=8) | hit | taken | 较短历史 |
| T1(h=13) | miss | — | — |
| T2(h=32) | hit | weak not-taken | provider |
| T3(h=119) | miss | — | — |
provider 是 T2,因为它是命中表中历史最长的一张。但 T2 很弱。如果对应的 use_alt_on_na 倾向 alternate,最终仍会选择 base 的 taken;否则选择 T2 的 not-taken。
这说明“provider 命中”不等于“最终一定采用 provider”。调试时要同时观察:
- provider table number;
- provider counter;
- provider 是否 unconfident;
altUsed;- base counter。
7. TAGE 怎样训练和分配
分支真实执行后,FTQ 带回预测时保存的 meta。训练大致分三部分:
更新 provider counter
provider 存在时,其 3-bit counter 向真实方向饱和更新:
实际 taken -> counter + 1,直到最大值
实际 not-taken -> counter - 1,直到 0
更新 useful bit
当 provider 与 alternate 给出不同答案时,才容易判断 provider 是否真正有价值:
- provider 对、alternate 错:提高 provider 的 useful;
- provider 错、alternate 对:降低 useful;
- 两者相同:没有证据说明 provider 比 alternate 更有价值。
在更长历史表分配新项
发生误预测时,硬件寻找比当前 provider 使用更长历史、并且 u=0 的未命中项,在其中选择位置分配新 entry。初始 counter 放在接近真实方向的弱状态,而不是直接强 taken/strong not-taken。
如果候选项长期都很 useful,分配会困难。TAGE 用周期性老化/reset useful 的机制,逐步释放长期不再贡献的表项。
8. SC 为什么存在
TAGE 很擅长识别历史模式,但仍会遇到:
- 多个弱相关因素需要组合;
- provider 被 alias 污染;
- 某些偏置无法靠单个 tagged entry 表达。
SC(Statistical Corrector)查询多组带符号统计计数器,把它们与 TAGE provider 的置信贡献相加:
total = centered(TAGE provider counter)
+ SC_table_0[PC, history_0]
+ SC_table_1[PC, history_1]
+ ...
total >= 0 -> taken
total < 0 -> not-taken
默认 SC 使用 4 组历史长度 0, 4, 10, 16,每组 6-bit counter。只有总和越过动态阈值时,SC 才覆盖 TAGE;靠近零时说明证据不足,仍保留 TAGE 结果。
所以 TAGE-SC 的最终方向不是简单的“TAGE counter 最高位”,而是:
base/provider 选择 -> TAGE direction
│
▼
SC 有足够统计置信度?
│ │
是 否
│ │
SC direction TAGE direction
二、ITTAGE:预测间接跳转目标
源码入口:frontend/ITTAGE.scala。
1. 它与 TAGE 的根本区别
TAGE 输出 1 bit:taken 或 not-taken。ITTAGE 输出一个虚拟地址:
TAGE: (PC, history) -> direction
ITTAGE: (PC, history) -> target address
直接跳转的目标可由 PC 和立即数计算;return 有专用 RAS;ITTAGE 主要处理非 return 的间接 JALR。例如虚函数调用、函数指针、jump table:同一个跳转 PC 在不同程序上下文中可能指向不同目标。
2. 默认表配置
| table | entries | history length | tag bits |
|---|---|---|---|
| I0 | 256 | 4 | 9 |
| I1 | 256 | 8 | 9 |
| I2 | 512 | 13 | 9 |
| I3 | 512 | 16 | 9 |
| I4 | 512 | 32 | 9 |
每个 entry 包含:
valid + tag + 2-bit confidence counter + 1-bit useful
+ compressed target information
index/tag 同样由 PC 与不同长度的 folded history 混合,因此相同 JALR PC 在不同控制历史下可以命中不同 entry。
3. provider 和 alternate 的选择
ITTAGE 会选出两个最长历史命中:
- 最长命中:provider;
- 第二长命中:alternate provider。
当前实现把 providerCtr == 0 视为 provider 无置信度:
provider 存在,ctr != 0 -> provider target
provider 存在,ctr == 0 且 alternate 存在 -> alternate target
没有 provider -> FTB base target
因此 ITTAGE 有三级后备链:
confident provider -> alternate tagged target -> FTB target
与方向 TAGE 相比,这里的 alternate 确实是第二个 tagged-table hit;二者不能混为一谈。
4. 为什么 target 不直接存完整地址
完整虚拟地址较宽,如果每个 ITTAGE entry 都直接保存,会显著增加 SRAM 面积。当前实现把目标拆成:
低 20 位 offset
高位 region
高位 region 有两种来源:
- 目标与当前 PC 位于同一 region:直接复用 PC 高位,
usePCRegion=1; - 目标在其他 region:entry 保存一个指向 16-entry
RegionWays的 pointer。
读取时重构:
target = selected_region ++ 20-bit target_offset
这种设计利用代码地址的局部性:大量间接目标与调用点处于相同或少数几个高位区域,没必要在每个 entry 中重复保存整段高位地址。
5. ITTAGE 如何训练
只有满足条件的非 return 间接跳转才训练 ITTAGE。执行完成后:
- provider target 正确:提高 confidence;
- provider target 错误:降低 confidence;
- provider 与 alternate 目标不同,provider 是否正确决定 useful;
- 发生目标误预测时,在更长历史的可用表中分配真实 target;
- 新 target region 不在 RegionWays 时,同时分配/替换 region entry。
一个细节是:provider confidence 降为 0 后,entry 才更容易改写 target。这样避免一次异常目标就立即覆盖一个已经稳定学习的多次出现目标。
6. ITTAGE 的典型失败
| 现象 | 常见根因 | 应观察什么 |
|---|---|---|
| 首次遇到 JALR 失败 | cold miss | provider/alternate 都无 hit |
| 两个目标来回错 | history 无法区分上下文 | 各表 tag hit、history length |
| provider 命中却不用 | provider counter 为 0 | providerCtr、altProvider |
| target 高位错误 | region entry miss/被替换 | pointer、usePCRegion、region hit |
| 更新后很久才稳定 | target 数量多或 alias | allocation、useful、counter |
三、RAS:预测函数返回地址
源码入口:frontend/newRAS.scala 中的 RAS。
本章先建立一张地图
RAS 这一章只围绕一件事展开:**前端必须立即按照尚未提交的 call/ret 改变返回地址栈,同时又必须能随时撤销错误路径。**昆明湖没有维护“两份相同的完整栈”,而是维护:
已经提交的稳定底座 尚未提交的版本日志
commit_stack + nsp spec_queue + spec_nos + BOS/TOSW
\ /
\ /
当前推测视图
ssp + sctr + TOSR
│
▼
getTop() 给出
下一条 ret 目标
后面的内容按五步阅读:
- 为什么要预测返回地址,以及为什么需要版本化;
- 三层状态和几个指针分别回答什么问题;
- 预测阶段怎样 push/pop,S3 怎样纠正 S2;
- 正确路径怎样 commit,错误路径怎样 redirect;
- 环形队列满时怎样保护,以及如何调试。
先记住三条结论,后面所有细节都是它们的展开:
- call 在预测时先写入推测日志,提交时才在
commit_stack上重做一次 push; - ret 先读取当前 top 作为本次目标,再移动推测视图供下一条 ret 使用;
- redirect 不逐项擦除错误记录,只恢复 checkpoint 中的指针,再重放真实 call/ret。
A. 问题与总体结构
1. 先从一份普通的软件调用栈理解 RAS
假设每条普通指令长 4 Byte:
0x1000: main call A 应在 A 返回后执行 0x1004
0x2000: A call B 应在 B 返回后执行 0x2004
两次 call 依次把 0x1004、0x2004 压栈:
┌────────┐
top ---> │ 0x2004 │ B 返回到 A
├────────┤
│ 0x1004 │ A 返回到 main
└────────┘
B 的 ret 先取出 0x2004,A 的 ret 再取出 0x1004。这正是后进先出。
call 压入的不是 call 自己的 PC,而是 return address:call 之后第一条应执行的指令地址。对普通 32-bit call 通常是 PC+4,对 RVC 16-bit call 是 PC+2。
return 指令本身的 PC 并不能唯一决定目标。同一个函数可以被许多调用点调用,它的同一条 ret 会返回不同地址。ITTAGE 也能从历史中学习目标,但 RAS 直接利用 call/return 的程序语义,通常更准确、更省表项。
2. 为什么硬件 RAS 不能只做一份普通数组栈
前端识别出 call/ret 时,这条指令还没有提交。但如果等到提交才更新 RAS,紧随其后的 return 已经来不及使用新栈顶。因此预测阶段必须先推测地 push/pop。
这里的“一份栈不够”并不是说理论上绝对不能只实例化一块存储,而是说:**只有一份会被 push/pop 原地改写的普通数组和一个栈顶指针不够。**处理器必须同时回答两个问题:
现在沿推测路径预测 ret,应看到哪个栈顶?
如果这条路径稍后被 flush,最后确认正确的 committed 栈又是什么?
这两个答案可以暂时不同,因此至少需要某种版本保存机制。它可以是完整快照、undo log 或 copy-on-write;昆明湖选择的是 commit_stack + spec_queue + checkpoint 指针。
真正说明“普通一份数组不够”的情况,是先推测 pop,再在较浅的位置 push:
已经提交的真实栈:
位置 0 位置 1 位置 2
0x1004 0x2004 0x3004 <- committed top
① 前端推测遇到 ret:
speculative top 从位置 2 退到位置 1
但这个 ret 尚未提交,0x3004 仍属于必须保留的 committed 状态
② 错误路径随后遇到 call X:
若仍使用同一普通数组,就会在“当前推测 top 的下一格”位置 2
写入 X 的返回地址 0x9004,从而覆盖原来的 0x3004
③ 后来发现前面的 ret 预测错误并 redirect:
即使把 top 指针恢复到位置 2,那里也已经变成 0x9004;
正确的 committed 值 0x3004 丢失了,单靠恢复指针无法找回。
因此,危险不只是“错误路径多写了一项”,而是推测 pop 让年轻路径能够回到较浅位置,后续推测 push 可能覆盖仍属于 committed 版本的内容。流水线里还可能同时存在多条尚未提交的 call/ret,S2 的初判也可能被 S3 修正,所以需要同时维护多个时间点的视图。
一种办法是每次预测都复制整份 16 项栈到 FTQ,但存储和写带宽代价较高。另一种办法是给每次覆盖保存旧值,形成 undo log。昆明湖的 spec_queue 本质上采用更适合前端的版本日志:推测 push 写到独立队列,不覆盖 commit_stack;推测 pop 只沿指针关系改变当前视图,也不删除稳定内容;redirect 恢复检查点指针即可。
昆明湖的新 RAS 因此把结构分成两层:
稳定底座:commit_stack
只反映已经提交的 call/ret,真正的 committed top 由 nsp 指出
推测增量:spec_queue(环形队列)
保存尚未提交的 push 记录,TOSW 指向下一写入位置
版本链接:spec_nos
每个推测记录保存“压入它以前的 top 在哪里”
因而 redirect 只要恢复指针,不必逐项撤销错误路径写入
用上面的覆盖例子重新看两层结构:
commit_stack 仍保存 [0x1004, 0x2004, 0x3004],错误路径永远不改它;
call X 的 0x9004 只写入 spec_queue;
若路径正确,X 将来提交时,commit_stack 再独立执行相同的 push;
若路径错误,恢复 TOSR/TOSW/ssp 等 checkpoint,0x9004 自然不再属于当前视图。
所以“两份”的准确含义不是维护两份永远相同的完整栈,而是:
commit_stack保存不可被错误推测破坏的稳定底座;spec_queue保存相对稳定底座的未提交增量和版本关系;getTop()把两层组合成当前推测路径看到的一份逻辑 RAS。
默认容量为:
RasSize = 16 // committed stack
RasSpecSize = 32 // speculative queue
它们是当前配置默认值,不是 RAS 算法不可改变的常数。
B. 状态模型:稳定底座、推测日志和当前视图
3. 先分清三层状态和六个关键字段
这是理解源码最关键的一步。
| 层次 | 保存什么 | 何时改变 |
|---|---|---|
| 稳定底座 | commit_stack 和 nsp | call/ret 真正提交时 |
| 推测版本日志 | spec_queue、spec_nos,有效范围由 BOS/TOSW 描述 | 预测 call 被确认后写日志;提交后逐步回收 |
| 当前推测视图 | ssp、sctr、TOSR | S2 预测 push/pop、S3 修正或 redirect 恢复时 |
“当前视图”不是第三份地址数组。它是一组指针和计数器,让 getTop() 知道当前 top 应从推测日志还是稳定底座读取。
| 字段 | 准确含义 | 它指向什么 |
|---|---|---|
nsp | non-speculative stack pointer | commit_stack 中真正已经提交的栈顶 |
ssp | speculative stack pointer | 推测路径的逻辑栈深在 committed-stack 坐标中的影子位置 |
TOSR | top-of-stack read pointer | 当前年轻推测栈顶对应的 spec_queue 记录 |
TOSW | top-of-stack write pointer | spec_queue 下一次写入的位置,也可看成推测日志尾部 |
BOS | bottom of speculative queue | 尚未退休的最老推测记录边界 |
NOS | next older stack pointer | 某个推测栈顶的前驱链接,来自 spec_nos[TOSR] |
sctr | speculative repetition counter | 当前逻辑栈顶地址还重复了多少层 |
不要把 ssp 当作“当前一定从这里读返回地址”的普通数组下标。当前 top 的选择逻辑是:
getTop =
1. 若本拍刚 push,先走 write-bypass,直接看新 entry;否则
2. 若 TOSR 位于 BOS 到 TOSW 的有效推测区间,读 spec_queue[TOSR];否则
3. 读 commit_stack[ssp]
所以,nsp 回答“已经提交到哪里”,ssp 回答“当前推测路径对应什么逻辑深度”,TOSR 回答“最新的未提交 top 记录在哪里”。
可以把它想成:
commit_stack spec_queue(环形)
┌────────┐ q4: {RA=0x2004, NOS=q1}
│0x1004 │ <- nsp q5: {RA=0x3004, NOS=q4} <- TOSR
├────────┤ q6: 下一空位 <- TOSW
│ ... │
└────────┘ BOS ... q1 ... q4 q5 ... TOSW
逻辑 top 是 q5 的 0x3004,而不是 commit_stack[nsp]。
spec_queue 是环形空间,所以“是否有效”不能只比较普通整数大小;源码用带环绕标志的 RASPtr 和 TOSRinRange 判断某个记录是否仍处在 [BOS, TOSW) 的有效窗口。
4. 为什么需要 NOS,不能简单地令 TOSR--
错误路径、恢复和交错 push/pop 会形成多个推测版本。当前 top 的上一项不一定是物理地址 TOSR-1。
每次 push 到位置 q_new 时,硬件同时写:
spec_queue[q_new] = 新返回地址
spec_nos[q_new] = push 以前的 TOSR
于是推测历史形成链接:
q7 --NOS--> q4 --NOS--> q1 --NOS--> committed stack
pop 只需沿 NOS 回到逻辑上的上一项。redirect 也只需把 TOSR/TOSW 恢复到 checkpoint;错误路径留下的记录即使物理字节尚未清零,也已经不可达,以后会被环形队列覆盖。
到这里可以把六个字段压缩成三个问题:
稳定状态到哪了? nsp
当前推测 top 在哪? ssp + sctr + TOSR
推测日志哪些槽有效? BOS ... TOSW,槽间用 NOS 串成逻辑栈
下面所有流程都可以先从这张表定位:
| 事件 | commit_stack/nsp | spec_queue/spec_nos | 当前推测视图 |
|---|---|---|---|
| 预测 call | 不变 | S3 确认后追加 push 记录 | 立即切到新 top |
| 预测 ret | 不变 | 不删除记录 | 先取旧 top,再沿 NOS/稳定栈退一层 |
| call/ret 提交 | 按真实语义 push/pop | 通过 BOS 逐步回收旧日志 | 通常不靠提交动作重新预测 top |
| redirect | 不变 | 旧字节可保留 | 恢复 checkpoint,再重放真实 call/ret |
因此阅读源码时,不要笼统地问“RAS 被更新了吗”,而要问:更新的是稳定底座、版本日志,还是当前推测视图。
C. 预测路径:push、pop 与流水级纠正
5. 一次 speculative push 到底改了什么
push 不是“同一个周期里把所有数组和指针一起写完”,而分成两个层次:
- S2 先改变逻辑推测状态,让后续预测立刻看见新 top;
- S3 确认这次早期判断没有被取消后,再形成
realPush,把记录真正写入spec_queue/spec_nos。
假设当前 top 是 0x1004,预测到一条 call,其 return address 为 0x2004。
5.1 S2:预留位置并立即改变逻辑 top
push 前:TOSR=q1, TOSW=q5, ssp=k
为本次push计算:
writeAddr = q5 // push前的TOSW
writeNos = q1 // push前的TOSR
writeEntry = {retAddr=0x2004, ctr=0}
S2立即更新逻辑状态:
TOSR = q5 // 新entry成为逻辑top
TOSW = q6 // 下一次push分配q6
ssp = k+1 // 新地址使逻辑栈深增加
sctr = 0
此时 spec_queue[q5] 可能还没有完成物理写入。为了让紧随其后的 return 仍能看到 0x2004,源码把 writeEntry/writeNos 放入 write bypass:getTop() 优先读 bypass,其次才读 spec_queue[TOSR]。
因此在这个瞬间:
TOSR=q5,TOSW=q6,二者物理位置相邻;
但新top的数据可以来自bypass,不要求q5已经可读。
5.2 S3:确认后才写推测队列
若 S3 确认 S2 的 call 判断正确,realPush 成立:
spec_queue[q5] = {retAddr=0x2004, ctr=0}
spec_nos[q5] = q1
若 S3 发现 S2 判断错了,s3_cancel 使用 S2 当时捕获的RAS快照 恢复状态,这次错误 push 不会成为有效的 realPush。反过来,S2 漏掉而 S3 才发现 call 时,s3_missed_push 使用这份S2快照里的 TOSW/TOSR 补写 entry 和 NOS。
TOSW/TOSR 是在S2组成 s2_meta 时采样的;S2 fire后,它们经流水寄存器传到S3,信号名称才变成 s3_meta.TOSW/TOSR。S3只是读取和使用这份旧快照,不会在S3重新采样当前RAS指针。
5.3 NOS 保存的是 push 当时的逻辑 top
spec_nos[q5]=q1 的含义不是“q5 的物理前一格是 q1”,而是“在 q5 被 push 之前,逻辑 top 是 q1”。如果没有发生 pop 或恢复,连续 push 通常形成:
q5 --NOS--> q4 --NOS--> q3
如果 q3、q4 已经被逻辑 pop,当前 top 退到了 q2,然后才在日志尾部 q5 做新 push,则形成:
物理分配顺序:q2, q3, q4, q5
当前逻辑栈: q5 --NOS--> q2
q3、q4仍保留旧字节,但已经不在当前逻辑栈链上。
S2 计算 return address 时还处理了指令长度。正常使用 fall-through address;对于落点最初可能位于指令中部的 RVI call,源码会进行 +2 修正,最终确保 RVC call 压 PC+2、普通 call 压 PC+4。
6. 一次 speculative pop 到底发生什么
ret 的动作顺序在概念上是:
1. 先用当前 getTop.retAddr 作为本次 ret 的预测目标
2. 再把 RAS 状态移动到更老一层,供下一条 ret 使用
pop 不会清空 spec_queue,也不会令 TOSW--。它先取得:
popTarget = getTop(...).retAddr
topNos = spec_nos[TOSR] // 当前推测top的逻辑前驱
然后分三种情况更新状态:
| 当前状态 | TOSR | ssp | sctr |
|---|---|---|---|
sctr > 0,当前地址还有重复层 | 若当前top在推测窗口内,沿NOS回退 | 不变 | sctr-1 |
sctr == 0且topNos仍在推测窗口 | 沿NOS回退 | ssp-1 | 装入spec_queue[topNos].ctr |
sctr == 0且前驱已落入稳定栈 | 推测指针不再决定top | ssp-1 | 装入commit_stack[ssp-1].ctr |
最后 getTop() 重新判断:若新的 TOSR 仍在 [BOS,TOSW),从推测队列读;否则从 commit_stack[ssp] 读。
6.1 为什么 pop 后可能从 q5 直接跳到 q2
完整时间线如下:
初始top=q1,TOSW=q2
push A:q2.NOS=q1 → TOSR=q2, TOSW=q3
push B:q3.NOS=q2 → TOSR=q3, TOSW=q4
push C:q4.NOS=q3 → TOSR=q4, TOSW=q5
pop C:TOSR=q3,TOSW仍为q5
pop B:TOSR=q2,TOSW仍为q5
push D:写日志尾部q5,但q5.NOS=q2
→ TOSR=q5, TOSW=q6
pop D:TOSR=q2,TOSW仍为q6
所以 q4 只是物理上位于 q5 前面;它对应已经 pop 掉的 C。q2 才是 D 被 push 时的逻辑前驱。
因此,看到一次 ret 后的 TOSR 指向上一层并不意味着这次 ret 用了上一层地址;本次目标已经在更新指针之前从旧 top 取出。
7. ctr/sctr:相同返回地址的逻辑重复层数
RASEntry 不是只有地址,而是:
{ retAddr, ctr }
如果连续 push 的 return address 与当前 top 相同,RAS 不增加 ssp 的逻辑深度,而是增加重复计数。可以把:
[0x1004, 0x1004, 0x1004]
理解为:
{retAddr=0x1004, ctr=2} // 一个本体 + 两次重复,共三层
推测状态使用 sctr 表示当前 top 的重复计数。pop 时:
sctr > 0:sctr减一;若 top 位于推测队列,TOSR仍会沿NOS回到上一份同地址记录,所以逻辑 top 地址保持相同;sctr == 0:才沿NOS或commit_stack真正退到更老地址,并装入那一项的ctr。
这对递归、循环中相同调用点反复形成的同地址栈层很有用。实现仍会在推测队列记录这次 push,以保留版本和恢复信息;被压缩的是逻辑栈深/相同地址层数,不是把恢复历史丢掉。
8. S2 先做、S3 再纠正
为了尽早给出取指目标,S2 会根据早期分类推测执行 push/pop:
- S2 认为是 call:立即 push;
- S2 认为是 ret:用 RAS top 覆盖 JALR target,并立即 pop;
- 只有启用 RAS 且当前指令被识别为 return 时,RAS 才接管间接跳转目标。
S3 获得更可靠的 call/ret 分类后检查 S2 是否做对:
| 情况 | 修正动作 |
|---|---|
| S2 多做了一次 push/pop | s3_cancel,恢复动作前状态 |
| S2 漏了 call | s3_missed_push,在恢复状态上补 push |
| S2 漏了 ret | s3_missed_pop,在恢复状态上补 pop |
| S2 与 S3 一致 | 保留早期动作 |
为此流水线保存足以撤销/重放的 RAS meta,而不是只保存一个预测地址。
8.1 s3_meta 究竟从哪里来
源码在 S2 先组合出一份完整快照:
val s2_meta = Wire(new RASInternalMeta)
s2_meta.ssp := stack.ssp
s2_meta.sctr := stack.sctr
s2_meta.TOSR := stack.TOSR
s2_meta.TOSW := stack.TOSW
s2_meta.NOS := stack.NOS
当 S2 fire 时,这份快照被寄存一级,到了下一流水级名为 s3_meta:
val s3_meta = RegEnable(s2_meta, io.s2_fire(2))
因此准确的数据流是:
S2观察到的RAS状态
→ 组成s2_meta
→ S2 fire时寄存
→ 在S3作为s3_meta使用
S3用它做两件事:
- 若S2的call/ret动作做错,恢复
ssp/sctr/TOSR/TOSW; - 若S2漏做动作,在这份旧状态上补做
missed_push/missed_pop。
S3 处理完后,预测器还把这份信息向后送:完整的 ssp/sctr/TOSW/TOSR/NOS/topAddr 用于以后 redirect 恢复;较小的 commit meta 只保留提交所需的 ssp/TOSW。
D. 两条收敛路径:commit 与 redirect
正确预测最终走 commit,把 call/ret 语义写进稳定底座;错误预测走 redirect,恢复旧版本。两条路径都以预测时保存的 meta 为依据,但目的完全不同。
9. commit 时,稳定栈怎样前进
9.1 “speculative 变成 committed”发生在什么时候
当对应 call/ret 沿处理器流水线到达分支预测器的 commit/update 通路,并确认它确实发生时:
updateValid && update.is_call_taken → commit_push_valid
updateValid && update.is_ret_taken → commit_pop_valid
此时才更新 commit_stack/nsp。在此之前,即使预测完全正确,该 call/ret 仍只存在于推测视图中,并可能因 redirect 而被丢弃。
这里的“变成 committed”不是把一个 entry 从 spec_queue 搬到 commit_stack,而是根据提交事件,在稳定栈上重新执行同样的 push 或 pop 语义。
9.2 commit call:用 meta 找到当初写入的返回地址
预测时保存到 FTQ/update meta 中的 TOSW 标识这条 call 当初使用的推测写位置。提交 call 时源码读取:
commit_push_addr = spec_queue[commit_meta_TOSW].retAddr
然后更新稳定栈:
若 commit_stack[nsp].retAddr == commit_push_addr
且 ctr 尚未饱和:
commit_stack[nsp].ctr++
否则:
nsp++
commit_stack[nsp] = {commit_push_addr, ctr=0}
因此,真正被提交的是 call 的语义和返回地址;推测 entry 本身不会被“剪切粘贴”,也不会因为提交就立即清零。
9.3 commit ret:稳定栈独立执行一次 pop
预测 ret 时没有向 spec_queue 写入一个“pop entry”,只是移动了 TOSR/ssp/sctr。ret 提交时直接操作稳定栈:
若 commit_stack[nsp].ctr > 0:
commit_stack[nsp].ctr--
否则:
nsp--
这使推测路径可以很早地 pop,而 commit_stack 仍只包含已经退休的程序历史。
9.4 一个 call 从预测到提交的完整例子
初始稳定栈:
commit_stack[0] = 0x1004,nsp=0
S2预测call,返回地址0x2004:
预留q5,TOSR=q5,TOSW=q6
bypass立即提供0x2004
S3确认call:
spec_queue[q5] = 0x2004
spec_nos[q5] = 旧TOSR
这条call尚未提交时:
推测top = 0x2004
committed top仍是0x1004,nsp仍为0
稍后call提交:
commit_push_addr = spec_queue[q5].retAddr = 0x2004
nsp = 1
commit_stack[1] = {0x2004, ctr=0}
如果 q5 对应的 call 后来被 flush,它就不会产生 commit_push_valid,因此永远不会进入稳定栈。
9.5 BOS 负责回收,而不是数据搬运
随着指令提交,BOS 根据提交 meta 向前调整,使过老的推测日志逐步退出有效窗口,空间以后可以被环形覆盖。不能在一条 call 提交后简单清空它的 queue 槽,因为更年轻的推测记录可能仍通过 NOS 引用这段历史边界。
可以把三者的职责记成:
nsp:稳定栈已经提交到哪里
TOSR:当前推测路径的逻辑top在哪里
BOS:推测日志中哪些旧位置已经可以逐步退休/复用
源码还比较提交 meta 中的 ssp 与当前 nsp。若二者不一致,会先用 meta 校正稳定指针,再执行本次 commit push/pop,避免一次指针错误永久污染后续 return 预测。
10. redirect:恢复 checkpoint,再重放真实指令
每个 FTQ 项保存预测时的 RAS checkpoint:
ssp, sctr, TOSW, TOSR, NOS, topAddr
redirect 时的原则与全局历史恢复完全相同:
恢复到 redirect 指令对应的 checkpoint
+
重放 redirect 指令已经确认的真实 call/ret 语义
- 真实是 call:在恢复后的状态上 push 正确 return address,即
PC+2或PC+4; - 真实是 ret:在恢复后的状态上执行正确 pop;
- 既非 call 也非 ret:只恢复 checkpoint。
完整例子:
checkpoint:top=0x1004, TOSR=q1, TOSW=q5, ssp=k
错误路径 W:
call X,push 0x3004 到 q5
call Y,push 0x4004 到 q6
后端发现 W 错误,且 redirect 指令真实是一条 call,RA=0x2004:
1. 恢复 TOSR=q1, TOSW=q5, ssp=k
2. 在 q5 重新 push 0x2004
结果:逻辑栈只看见 [..., 0x1004, 0x2004]
错路的 0x3004/0x4004 不再可达
这就是为什么无需逐项“擦除”错误路径:恢复的是可达关系和队列边界,不是依赖数组清零。
E. 容量保护与调试
11. 推测队列快满时会怎样
当 TOSW 与 BOS 的距离超过 RasSpecSize-2,源码置位 spec_near_overflowed。此时会抑制新的推测 push/pop,防止环形队列覆盖仍需要恢复的记录。
这首先是正确性保护,但可能降低 return 预测质量。因此性能调试时不能只看 committed stack 深度;还要看:
- 错误路径上产生了多少 call/ret;
- call/ret 距离提交有多远;
spec_near_overflow事件是否频繁;- redirect 是否让大量推测版本长期占据窗口。
12. 用症状定位 RAS 问题
| 症状 | 优先检查 |
|---|---|
| call 后紧邻的 ret 偶尔取到旧 top | push-to-top write bypass、S2 时序 |
| redirect 后连续 ret 全错一层 | checkpoint 的 ssp/sctr/TOSR/TOSW/NOS 与重放动作 |
| 深错误路径后准确率突然下降 | TOSW-BOS 距离、spec_near_overflow |
| 递归/同调用点场景多 pop 或少 pop | ctr/sctr 的“一项代表 N+1 层”语义 |
| 提交后长期整体错位 | nsp、commit meta 的 ssp、commit push/pop |
| return target 总是差 2 Byte | RVC/RVI call 的 return-address 计算 |
最后把 RAS 与 ITTAGE 的职责分开:ITTAGE 是“根据 PC 和历史学习某条间接跳转可能去哪里”;RAS 是“维护动态调用链的后进先出语义”。当指令被确认是 return 且 RAS 可用时,RAS 给出的栈顶地址通常应优先作为返回目标。
四、三者如何共同决定一次预测
假设一个取指块包含两个条件分支和一个尾部 JALR:
slot 0: conditional branch B0
slot 1: conditional branch B1
tail: JALR
预测流程可以抽象为:
- uFTB/FTB 给出块结构:哪里有分支、基础 taken mask、直接目标、JALR 基础目标;
- TAGE-SC 分别修正 B0/B1 的方向;
- 若第一个 taken 条件分支出现,后面的 B1/JALR 在当前路径上不再发生;
- 如果实际到达尾部且它是普通间接 JALR,ITTAGE 修正其 target;
- 如果尾部是 return,RAS 提供更合适的 return target;
- 最早发生的 taken CFI 决定最终 next PC,并决定向全局历史加入多少个条件分支结果。
FTB: 块里“有什么”
TAGE: 条件分支“走不走”
ITTAGE/RAS: 间接控制流“去哪里”
Composer/BPU: 选最早生效的 CFI,形成最终预测
一个容易犯的错误
不能因为 ITTAGE 或 RAS 给出了 target,就认为该 target 一定会被使用。如果更早的条件分支被 TAGE 预测 taken,尾部 JALR/ret 根本不会在这条预测路径上执行。
五、从性能现象反推哪个预测器出了问题
| 性能/调试现象 | 首先怀疑 | 原因 |
|---|---|---|
| conditional direction mispredict 高 | TAGE/SC/history | 方向本身错误 |
| JALR target mispredict 高,return 正常 | ITTAGE/FTB | 普通间接目标错误 |
| return target mispredict 高 | RAS | 调用栈、恢复或容量问题 |
| redirect 后连续多次预测异常 | history/RAS recovery | checkpoint 或重放错误 |
| 冷启动差、热身后正常 | table allocation/training | entry 尚未建立 |
| 某些 PC 周期性互相干扰 | index/tag alias | 多个上下文争用表项 |
| provider hit 很高但准确率不高 | weak provider/SC/alias | 命中不等于有用 |
| ITTAGE target 低位正确、高位错误 | region reconstruction | RegionWays 或 usePCRegion |
调试时应保存预测时 meta,而不是只看最终 taken/target。至少需要:
- PC、完整/折叠历史标识;
- provider table、counter、useful;
- alternate 是否使用;
- SC sum/threshold/是否覆盖;
- ITTAGE provider/alternate target 与 region 信息;
- RAS top、push/pop、redirect checkpoint。
六、设计者应掌握的核心取舍
增加历史长度不一定更好
长历史能捕获远距离相关,但也会增加折叠 alias、tag/index 计算复杂度和训练稀疏性。TAGE 用多种长度同时存在来规避单一选择。
增加 tag 位数不一定更划算
更长 tag 降低 false hit,却直接增加每个 entry 面积。相同总预算下,长 tag 可能意味着更少 entries,容量冲突反而增加。
provider 选择和 replacement 是两件事
provider 是“本次预测相信谁”;useful 是“未来替换时谁值得留下”。把 confidence counter 与 useful bit 混为一谈,会设计出难以稳定训练的预测器。
RAS 深度不是唯一容量指标
还要考虑在途未提交 call/ret 数、重复地址压缩、错误路径长度以及 redirect 延迟。RasSize 与 RasSpecSize 分别约束稳定栈和推测窗口。
七、建议的源码阅读顺序
Parameters.scala:先看表数量、历史长度、tag/counter 配置;TageTable.compute_tag_and_hash:理解 PC 与 folded history 如何形成 index/tag;Tage的 provider 和altUsed:理解最终方向选择;Tageupdate/allocate:理解 counter、useful 与替换;SC.scala:理解统计和与动态阈值;ITTageprovider/alt/baseTarget:对比方向 TAGE 的不同;ITTageOffset、RegionWays:理解 target 压缩;newRAS.scala的RASStack:跟踪一次 call、一次 ret、一次 redirect;- 回到
Parameters.scala的 predictor chain,看五个 predictor 如何逐级改写BranchPredictionResp。
自测题
- 为什么 TAGE 的最长历史命中项只是 provider,而不一定成为最终方向?
- 当前实现中,方向 TAGE 和 ITTAGE 的 alternate 分别来自哪里?
- ITTAGE 为什么要把 target 拆成 region 和 20-bit offset?
- 为什么 return 也能用 ITTAGE 预测,却仍值得设置专用 RAS?
- 如果错误路径执行了两次 call 和一次 ret,redirect 时只恢复一个栈指针是否足够?为什么?
- 某次预测中 TAGE 认为第一个条件分支 taken,而 ITTAGE 给出了尾部 JALR target,最终应该选择哪个 next PC?