DNN加速器综述

硬件方案

两种架构:temporal and spatial architectures
alt text
其中asic和fpga主要为spatial结构,而CPU和GPU主要为temporal结构
本次主要介绍空间架构
这种结构有相当一部分能耗在存储上,故数据重用是研究重点
基本结构
一些重用名词
input reuse:FC层
weight reuse:CONV层
conv reuse:卷积层的滑窗每次都有重复的
Weight Stationary
所有使用相同weight的mac必须映射到同一PE上进行串行处理。这最大限度地提高了RF中权重的卷积和滤波器重用,从而最大限度地减少了访问权重的能量消耗
alt text
这是一个3x3的矩阵2x2的卷积核,最后生成2x2的输出
alt text
先说上面那一种
仅介绍一个输出元素如何产生的,
T1:I0XW0
T2:I0XW0+I1XW1
T3:将结果移入PE2
T4:I0XW0+I1XW1+I2XW2
T5:I0XW0+I1XW1+I2XW2+I3XW3,完成一次卷积操作
下面哪一种和上面类似,不过是再次分块了
Output Stationary
映射规则是对相同的输出像素的所有mac必须依次映射到同一PE上。
alt text
Row Stationary
alt text
No Local Reuse
alt text

摘要

识别编码相关的空间访问模式,然后将这些块预取到Cache

介绍

摘要

识别编码相关的空间访问模式,然后将这些块预取到Cache

介绍

首先得出结论:增加并行性和隐藏访问延迟才是提高服务器性能的关键,商业程序使用重复布局和访问模式的数据结构,例如数据块缓存池页面,或者网络数据包头。当程序访问数据集,访问数据的偏移会出现重复模式,但这些访问通常不连续,并且stride不一,我们使用空间相关性而不思空间局部性来描述访问之间的关系

SMS可以减少多处理器服务器的主缓存缺失和片外缺失,他利用重复访问模式来预测并预取

这份工作的特点:

  1. 高效的空间相关性预测:预测商业负载访问流不需要基于地址的相关性。SMS利用代码和访问模式的相关性来预测模式,甚至是之前没访问的地址,SMS提供的预测覆盖率是其他基于地址的4倍
  2. 精确跟踪空间相关性:以前的缓存耦合结构观察空间相关性是次优的,比如交错的访问模式,之前的检测会引起冲突,我们提出解耦结构,可以识别更少更密集的模式,将预测器存储需求减半
  3. 增加性能:主要讲了SMS在商业负载的优秀

之后第二节描述SMS,第三节给出硬件细节,第四节评估性能,第五节讨论相关工作

Spatial Memory Streaming

在选择Cache block,设计者在各种因素中取出最优解,通常最佳的缓存块大小牺牲了利用密集数据结构的空间局部性,以避稀疏结构带来的过多带宽开销,对于简单的数据结构,如数组,空间关系可以通过简单的预取实现,如stride prefetching

商业负载访问模式较为复杂,不适合用 simple prefetching or streaming schemes,然而,这些应用程序中的数据结构经常显示缓存块之间的空间关系。例如,在数据库中,缓冲池中的页面共享公共结构元素,例如页头中的日志序列号和指示页脚中元组偏移量的槽索引,这些元素总是在扫描/修改页面之前被访问。在web服务器中,包头和包尾具有任意复杂但固定的结构。图1(左)显示了更多的示例。尽管这些结构中的访问可能是不连续的,但它们在相对地址中仍然表现出重复的模式。我们称这些访问之间的关系为空间相关性

1729853439799

SMS在运行时提取空间相关访问模式,并使用这些模式预测,他在可用带宽和资源允许情况尽快将预测的缓存块放入Cache,从而增加内存并行性并隐藏低级缓存和片外访存延迟

Spatial Patterns and Generations

我们形式化了空间相关性的概念,类似于先前的空间足迹研究[4,17]。我们将空间区域定义为系统地址空间中固定大小的部分,由多个连续的缓存块组成。A spatial region generation is the time interval over which SMS records accesses within a spatial region,我们把第一次访问这个空间区域称为触发访问,空间模式是表示在a spatial region generation期间访问区域的blocks集合的位向量???

定义a spatial region generation精确间隔会显著影响空间格局的精度和覆盖范围,a spatial region generation必须定义以确保,当SMS流在未来触发访问时进入缓存,没有预测的块在使用前被驱除或无效,

因此,我们选择从触发访问到在生成期间访问的任何块通过替换或失效从处理器的主缓存中删除的间隔。对该区域中任何块的后续访问都是对新一代的触发访问。

此定义确保在生成过程中访问的块集同时存在于缓存中。

Identifying Recurring Spatial Patterns

在触发访问时,SMS预测区域内相关性块子集,于是其关键问题就是找到一个与重复出现的空间模式强相关的预测指数

空间相关性由于数据结构布局和访问模式重复和规律性产生,例如聚合的几个变量经常被一起访问,因此出现空间相关性,这种情况,空间模式与触发器访问地址相关,地址标识数据结构,还有一种就是数据结构遍历重复出现或者具有规则结构,在这种情况,空间模式将与执行遍历的代码(pc)相关联,文献中研究了多种预测指标,先前的研究发现当相关表存储无界??,结合地址和PC构建索引可以提供最准确的预测,也就是PC+addr indexing,当多个代码序列对同一数据结构进行不同遍历,预测器会生成不同的模式,然而,该预测索引需要随数据集大小缩放预测期存储,而且预测覆盖率会随着存储限制而急剧下降

对于SPEC CPU 2000程序,PC+addr可以通过pc+a spatial region offset实现,这个偏移是cache block中与空间区域起始位置的相对地址,也就是相当于这个区域的哪个block,这个偏移允许预测器区分由相同代码片段生成的重复模式,这些重复模式不同支出就是他们相对空间区域边界对其,大大减少预测表存储需求,applications have far fewer distinct miss PCs than miss addresses.

我们观察到这种索引,除了节省内存,还可以消除cold misses,当代码序列在大型数据集重复相同的访问模式,访问序列刚开始时学习的PC相关空间模式为以前从未访问的数据提供准确预测。Database scan and join operations, which dominate the execution of decision support queries 包含着只访问数据以此的唱重复访问模式,这些应用适合这种index方式

设计

(终于到自己的SMS设计了)

我们的设计目标是多处理器环境下的高性能商业服务器应用程序,区别:之前的设计针对decoupled sectored [22] or sub-blocked caches.

预测和缓存集成简化硬件设计,因为空间区域访问的结构可以2和sub-blocks的tag集成,然而,不同空间区域交错访问导致标签冲突,桃枝空间区域碎片化1,降低准确性,所以SMS不使用子块

他包括两个结构

The pattern history table stores previously-observed spatial patterns, and is accessed at the start of each spatial region generation to predict the pattern of future accesses.

Observing Spatial Patterns

SMS通过记录在AGT空间区域生成过程中访问的块来学习空间模式,当空间区域生成开始时,SMS在AGT分配一个条目,当cache block被访问,更新AGT记录的模式,当eviction/invalidation of any block accessed during the generation),AGT将空间模式转移到PHT,然后释放条目

实际AGT有两个内容表,积累表和过滤表,减少搜索难度和总体大小,AGT处理L1数据访问,所以两个表必须都能匹配数据访问带宽

空间模式记录在累积表,累计表条目由空间区域标记来标记,即区域及地址高位,每个条目存储触发访问的PC和空间区域偏移量,以及指示在生成期间访问了哪些块的空间模式位向量

新的空间区域最初在过滤表,过滤表记录tag,pc和offset,用于在当次只有一次访问的空间区域,有很少的区域永远不会访问第二个快,预测这些没有好处,因为唯一的访问是触发访问,将这些限制在过滤表,减少了累计表的空间占用

1729857733789

上图描述了AGT详细操作,每个L1访问,首先搜索累计表,如果匹配,设置所访问块的空间模式位,否则,在筛选表找,如果没找到,这次访问就是触发访问,并在筛选表重新分配条目,如果在筛选表匹配,则比较偏移,如果不同,该快是区域访问的第二个快,将这个条目移到累计表,对该区域其他访问将在模式中设置相应位,这个位代表一个区域有几个block,当有的快被驱除或无效,则结束生成,这时在过滤表和累积表搜索标签,过滤表的条目将被抛弃,累积表的放入模式历史表,如果其中一个表满了,选择一个表项,终止生成,即,从过滤表中删除该表项或从累积表转移到模式历史表中

Predicting Spatial Patterns

SMS使用模式历史表对空间模式进行存储,并预测在每个区域将访问块的模式,PHT实现和预测如下,其被组织为类似cache的组相连结构,其由预测索引访问,这个索引是PC和空间区域偏移组成,PHT每个条目存储AGT积累的访问模式

1729864558517

在触发访问是,SMS查询PHT来预测访问哪些块,如果找到一个条目,空间区域的基地址和空间模式将被复制到几个预测寄存器的一个,当SMS将模式预测的每个块传输到主缓存,他会清除预测寄存器的位,当预测寄存器整个模式被清除,寄存器释放,如果多个预测寄存器活动,SMS轮训操作,这个流请求类似于缓存一致性协议的读请求

结果

访问密度

1729867839321

indexing

1729867930971

名词:覆盖率表示通过SMS消除的L1读错误的比例。

过度预测表示在取出或无效前已获取但未使用的块,也就是一些无用的预取

在OLTP和web应用,大多数空间相关访问都来自频繁访问的代码序列和数据结构,因此数据地址和PC都和空间模式相关,所以除了PC寻址,其他的覆盖率相似,PC不准确,他不能区分相同代码对不同数据结构的访问模式,PC+偏移索引可以根据空间区域偏移量来区分模式,足以捕捉到常见的情况

而DSS基于程序上下文,PC和PC+off较好

对于科学应用,pc+off接近pc+addr

pc+off的第二个优点就是:他的存储需求与代码大小成正比而不是数据集大小,

4.3. Decoupled Training

训练十分重要,过早终止空间区域生成,可能导致PHT被污染

1729868738111

可以看到AGT覆盖率最高

4.4. Spatial Region Size

Choosing a spatial region size involves a tradeoff between coverage and storage requirements.

4.5. Active Generation Table

a 32-entry filter table and a 64-entry accumulation table are sufficient.

首先得出结论:增加并行性和隐藏访问延迟才是提高服务器性能的关键,商业程序使用重复布局和访问模式的数据结构,例如数据块缓存池页面,或者网络数据包头。当程序访问数据集,访问数据的偏移会出现重复模式,但这些访问通常不连续,并且stride不一,我们使用空间相关性而不思空间局部性来描述访问之间的关系

SMS可以减少多处理器服务器的主缓存缺失和片外缺失,他利用重复访问模式来预测并预取

这份工作的特点:

  1. 高效的空间相关性预测:预测商业负载访问流不需要基于地址的相关性。SMS利用代码和访问模式的相关性来预测模式,甚至是之前没访问的地址,SMS提供的预测覆盖率是其他基于地址的4倍
  2. 精确跟踪空间相关性:以前的缓存耦合结构观察空间相关性是次优的,比如交错的访问模式,之前的检测会引起冲突,我们提出解耦结构,可以识别更少更密集的模式,将预测器存储需求减半
  3. 增加性能:主要讲了SMS在商业负载的优秀

之后第二节描述SMS,第三节给出硬件细节,第四节评估性能,第五节讨论相关工作

Spatial Memory Streaming

在选择Cache block,设计者在各种因素中取出最优解,通常最佳的缓存块大小牺牲了利用密集数据结构的空间局部性,以避稀疏结构带来的过多带宽开销,对于简单的数据结构,如数组,空间关系可以通过简单的预取实现,如stride prefetching

商业负载访问模式较为复杂,不适合用 simple prefetching or streaming schemes,然而,这些应用程序中的数据结构经常显示缓存块之间的空间关系。例如,在数据库中,缓冲池中的页面共享公共结构元素,例如页头中的日志序列号和指示页脚中元组偏移量的槽索引,这些元素总是在扫描/修改页面之前被访问。在web服务器中,包头和包尾具有任意复杂但固定的结构。图1(左)显示了更多的示例。尽管这些结构中的访问可能是不连续的,但它们在相对地址中仍然表现出重复的模式。我们称这些访问之间的关系为空间相关性

1729853439799

SMS在运行时提取空间相关访问模式,并使用这些模式预测,他在可用带宽和资源允许情况尽快将预测的缓存块放入Cache,从而增加内存并行性并隐藏低级缓存和片外访存延迟

Spatial Patterns and Generations

我们形式化了空间相关性的概念,类似于先前的空间足迹研究[4,17]。我们将空间区域定义为系统地址空间中固定大小的部分,由多个连续的缓存块组成。A spatial region generation is the time interval over which SMS records accesses within a spatial region,我们把第一次访问这个空间区域称为触发访问,空间模式是表示在a spatial region generation期间访问区域的blocks集合的位向量???

定义a spatial region generation精确间隔会显著影响空间格局的精度和覆盖范围,a spatial region generation必须定义以确保,当SMS流在未来触发访问时进入缓存,没有预测的块在使用前被驱除或无效,

因此,我们选择从触发访问到在生成期间访问的任何块通过替换或失效从处理器的主缓存中删除的间隔。对该区域中任何块的后续访问都是对新一代的触发访问。

此定义确保在生成过程中访问的块集同时存在于缓存中。

Identifying Recurring Spatial Patterns

在触发访问时,SMS预测区域内相关性块子集,于是其关键问题就是找到一个与重复出现的空间模式强相关的预测指数

空间相关性由于数据结构布局和访问模式重复和规律性产生,例如聚合的几个变量经常被一起访问,因此出现空间相关性,这种情况,空间模式与触发器访问地址相关,地址标识数据结构,还有一种就是数据结构遍历重复出现或者具有规则结构,在这种情况,空间模式将与执行遍历的代码(pc)相关联,文献中研究了多种预测指标,先前的研究发现当相关表存储无界??,结合地址和PC构建索引可以提供最准确的预测,也就是PC+addr indexing,当多个代码序列对同一数据结构进行不同遍历,预测器会生成不同的模式,然而,该预测索引需要随数据集大小缩放预测期存储,而且预测覆盖率会随着存储限制而急剧下降

对于SPEC CPU 2000程序,PC+addr可以通过pc+a spatial region offset实现,这个偏移是cache block中与空间区域起始位置的相对地址,也就是相当于这个区域的哪个block,这个偏移允许预测器区分由相同代码片段生成的重复模式,这些重复模式不同支出就是他们相对空间区域边界对其,大大减少预测表存储需求,applications have far fewer distinct miss PCs than miss addresses.

我们观察到这种索引,除了节省内存,还可以消除cold misses,当代码序列在大型数据集重复相同的访问模式,访问序列刚开始时学习的PC相关空间模式为以前从未访问的数据提供准确预测。Database scan and join operations, which dominate the execution of decision support queries 包含着只访问数据以此的唱重复访问模式,这些应用适合这种index方式

设计

(终于到自己的SMS设计了)

我们的设计目标是多处理器环境下的高性能商业服务器应用程序,区别:之前的设计针对decoupled sectored [22] or sub-blocked caches.

预测和缓存集成简化硬件设计,因为空间区域访问的结构可以2和sub-blocks的tag集成,然而,不同空间区域交错访问导致标签冲突,桃枝空间区域碎片化1,降低准确性,所以SMS不使用子块

他包括两个结构

The pattern history table stores previously-observed spatial patterns, and is accessed at the start of each spatial region generation to predict the pattern of future accesses.

Observing Spatial Patterns

SMS通过记录在AGT空间区域生成过程中访问的块来学习空间模式,当空间区域生成开始时,SMS在AGT分配一个条目,当cache block被访问,更新AGT记录的模式,当eviction/invalidation of any block accessed during the generation),AGT将空间模式转移到PHT,然后释放条目

实际AGT有两个内容表,积累表和过滤表,减少搜索难度和总体大小,AGT处理L1数据访问,所以两个表必须都能匹配数据访问带宽

空间模式记录在累积表,累计表条目由空间区域标记来标记,即区域及地址高位,每个条目存储触发访问的PC和空间区域偏移量,以及指示在生成期间访问了哪些块的空间模式位向量

新的空间区域最初在过滤表,过滤表记录tag,pc和offset,用于在当次只有一次访问的空间区域,有很少的区域永远不会访问第二个快,预测这些没有好处,因为唯一的访问是触发访问,将这些限制在过滤表,减少了累计表的空间占用

1729857733789

上图描述了AGT详细操作,每个L1访问,首先搜索累计表,如果匹配,设置所访问块的空间模式位,否则,在筛选表找,如果没找到,这次访问就是触发访问,并在筛选表重新分配条目,如果在筛选表匹配,则比较偏移,如果不同,该快是区域访问的第二个快,将这个条目移到累计表,对该区域其他访问将在模式中设置相应位,这个位代表一个区域有几个block,当有的快被驱除或无效,则结束生成,这时在过滤表和累积表搜索标签,过滤表的条目将被抛弃,累积表的放入模式历史表,如果其中一个表满了,选择一个表项,终止生成,即,从过滤表中删除该表项或从累积表转移到模式历史表中

Predicting Spatial Patterns

SMS使用模式历史表对空间模式进行存储,并预测在每个区域将访问块的模式,PHT实现和预测如下,其被组织为类似cache的组相连结构,其由预测索引访问,这个索引是PC和空间区域偏移组成,PHT每个条目存储AGT积累的访问模式

1729864558517

在触发访问是,SMS查询PHT来预测访问哪些块,如果找到一个条目,空间区域的基地址和空间模式将被复制到几个预测寄存器的一个,当SMS将模式预测的每个块传输到主缓存,他会清除预测寄存器的位,当预测寄存器整个模式被清除,寄存器释放,如果多个预测寄存器活动,SMS轮训操作,这个流请求类似于缓存一致性协议的读请求

结果

访问密度

1729867839321

indexing

1729867930971

名词:覆盖率表示通过SMS消除的L1读错误的比例。

过度预测表示在取出或无效前已获取但未使用的块,也就是一些无用的预取

在OLTP和web应用,大多数空间相关访问都来自频繁访问的代码序列和数据结构,因此数据地址和PC都和空间模式相关,所以除了PC寻址,其他的覆盖率相似,PC不准确,他不能区分相同代码对不同数据结构的访问模式,PC+偏移索引可以根据空间区域偏移量来区分模式,足以捕捉到常见的情况

而DSS基于程序上下文,PC和PC+off较好

对于科学应用,pc+off接近pc+addr

pc+off的第二个优点就是:他的存储需求与代码大小成正比而不是数据集大小,

4.3. Decoupled Training

训练十分重要,过早终止空间区域生成,可能导致PHT被污染

1729868738111

可以看到AGT覆盖率最高

4.4. Spatial Region Size

Choosing a spatial region size involves a tradeoff between coverage and storage requirements.

4.5. Active Generation Table

a 32-entry filter table and a 64-entry accumulation table are sufficient.

  • event programming
  • new config
  • debug flags
  • out of order

在GEM5退出时,Dump cache中所有的line的地址和内容, 参考Event-driven programming, 但是退出的event callback和这里展示的例子不完全一样,需要自己去阅读相关代码。

首先我使用的CPU类型为TimeSimpleCPU,直接找他与Cache如何交互,也即是下图

1730213552828

然后找到了tags,这里可以打印所有的blk

在cache/tags/base.cc,可以打印所有的blk

1730213976910

registerExitCallback注意这个函数

1730215799144

这个是tag/base.cc

这个如何实现遍历:

1
2
3
4
5
6
7
void forEachBlk(std::function<void(CacheBlk &)> visitor) override {
for (CacheBlk& blk : blks) {
visitor(blk);
}
}


首先将输入参数变为函数,接受一个blk参数,然后这个blks是blk的集合,存了所有blk,然后进行遍历

这个是base_set_assoc.h

所以可以在结束时调用这个函数,这样就可打印全部内容

1
2
3
4
5
6
7
8
9
10
11
12
13
BaseTags::BaseTags(const Params &p)
: ClockedObject(p), blkSize(p.block_size), blkMask(blkSize - 1),
size(p.size), lookupLatency(p.tag_latency),
system(p.system), indexingPolicy(p.indexing_policy),
warmupBound((p.warmup_percentage/100.0) * (p.size / p.block_size)),
warmedUp(false), numBlocks(p.size / p.block_size),
dataBlks(new uint8_t[p.size]), // Allocate data storage in one big chunk
stats(*this)
{
// registerExitCallback([this]() { cleanupRefs(); });
registerExitCallback([this]() { DPRINTF(DumpCache,"%s",print()); });
}

具体就是在这个函数内会注册退出时的回调函数,只需要将里面函数换为print就行

NUMA

非统一内存访问架构的特点是:被共享的内存物理上是分布式的,所有这些内存的集合就是全局地址空间。所以处理器访问这些内存的时间是不一样的,显然访问本地内存的速度要比访问全局共享内存或远程访问外地内存要快些。另外,NUMA中内存可能是分层的:本地内存,群内共享内存,全局共享内存。

SMT

1、SMT为什么能提升并行性能(多线程性能)?

SMT技术虽然是提升多线程下表现的性能,但SMT实际上是提升CPU单核性能大背景下的“副产品”。 一个单核单线程任务,在CPU的视角上,可以被看作是执行一连串连续的指令。比如说是下面这个例子里,假设执行的就是一个包含10个指令的任务:

1
任务1 = A1 A2 B1 C1 D1 A3 A4 D2 C2 B2

我们先看最简单的场景,CPU执行这些任务最直接的办法就是一个指令一个指令的执行,假设这些指令的执行周期都是N,那么执行完这些指令,就需要10N个周期。那么CPU的速度就只和CPU的频率相关了,显然这也不符合我们的认知,不同CPU在相同频率下的性能显然不一样。

除了频率以外,要提升CPU的单核性能,第一个常见手段就是尽可能的缩短每个指令执行的周期,不过在我们假设的这个场景中和SMT关系不大,这里就不说了。第二个常见手段就是指令级并行(ILP)。虽然说这个任务1理论上是得一个指令接着一个指令的执行,但实际上这些指令并一定只能这么顺序执行,只要两个指令之间没有相互依赖和冲突,那么就可以并发执行(一起执行),从而缩短总的执行时间。 例如在上面这个例子中,我将指令分组成A B C D四组,组内必须顺序执行,组间的指令完全没有依赖(彼此不依赖对方执行后的数据)和冲突(不共享资源,不是一类操作),那么我们就可以并发执行这些指令。

1
2
3
4
任务1A:A1 A2 A3 A4
任务1B:B1 B2
任务1C:C1 C2
任务1D:D1 D2

那么我们还是假设每个任务的执行周期是N,可以看到只要我们按照上述分组执行代码,那么整体的执行时间就只取决于最长的一组任务1A,也就是执行时间可以缩短到4N,速度提升到2.5倍。

显然,如果说要在一个CPU核心里同时执行上述几组任务,那么CPU自然得具备至少4组执行端口,这里我们也简化成PA、PB、PC和PD,分别执行上述的1A 1B 1C 1D。所以现代的CPU要提升单核性能,都会尽可能的把后端的执行端口数目变多,并且尽可能的在单位时间内读入更多指令,从而促进指令间的并发。

但是,实际的任务里,指令之间的依赖冲突关系是错综复杂的,不可能完美的将指令均匀的分组到每一个端口上。 就比如说我们上面这个例子里,在并发执行后虽然时间缩短为了4N个周期,但是实际上只有端口PA是一直在工作的,而PB PC PD都会在中途闲下来。

以Skylake为例子,后端执行端口EU不只一个

随着单核性能的不断提升,后端执行资源也越来越丰富,这种执行端口闲置的情况就会越来越明显,造成资源浪费。这时候,为了将这些资源物尽其用,同步多线程SMT就应运而生了。SMT的思路是这样的,既然一个任务填不满后端的资源,那么我们就找不只一个任务来填就好了,不同任务之间的相互依赖和冲突情况很低,放到一起来执行正合适去填满后端资源。

我们接着举例子,假设现在有一个新的任务2,同样是10个指令,同样按照端口分组:

1
2
3
4
5
任务2 = B‘1 A’1 B‘2 C’1 C‘2 D'1 D’2 D‘3 C’3 A‘2
任务2A = A’1 A’2
任务2B = B’1 B’2
任务2C = C’1 C’2 C’3
任务2D = D'1 D’2 D‘3

那么在指令级并发的情况下,这个任务的执行时间就是3N。

那么如果把任务1和任务2在单核CPU上分别执行,它们的执行时间就是4N+3N=7N。这时,如果我们引入SMT技术,虚拟出两个核心来,让他们同样在一个核心内执行,依然使用ABCD来表示互相不冲突的指令:

1
2
3
4
PA:A1 A2 A3 A4 A’1 A’2
PB:B1 B2 B’1 B’2
PC:C1 C2 C’1 C’2 C’3
PD:D1 D2 D'1 D’2 D‘3

那么这时候整个执行时间就变成了6N,比起之前的7N又提升16.7%的速度。

前端有两组维护不同任务上下文的单元就可以

启动流程

gem5 是一个事件驱动(Event-driven)的模拟器。在事件驱动模型中,每个事件(Event)都有一个回调函数用于处理事件。

Gem5 是一个事件驱动的模拟器。因此,事件(Event)是模拟器的基本调度、执行单位,是一个非常重要的核心概念。事件可以理解为一系列改变系统状态的行为,包括但不限于对内存的读写、数据包的发送和到达等。模拟器整个的模拟过程就是对所有事件创建、调度、执行和终止的过程。从技术上讲,事件是由特殊的回调函数和一组状态信息位域实现的。对该事件的执行本质就是执行事件内的回调函数。

每个事件都会包含一个EventFunctionWrapper 用来注册回调函数, 每次执行回调函数都会去执行schedule,用来调度事件

实际上CPU开始仿真时,先通过python设置参数,然后启动simluate,该函数会去进入doSimLoop,然后一个一个事件的执行,实际上目前看到就一个事件:CPU的process,他会去调用回调函数tick,而CPU的回调函数会去调用每个阶段的tick函数,从而推动事件进行

所有的event都是继承于eventbase,其静态变量遵循事件调度的优先级:

image-20250407221755426

其还定义了很多flag来揭示权限和状态

Event

事件类 Event 是 Gem5 中的核心类,是所有事件的父类。该类继承自 Serializable 类(这今后再详聊)和 EventBase 类。此外,Event 类为抽象类,内含有一个纯虚函数 process(),通常程序员需要通过 Event 的派生类来创建事件对象。但在此之前,我们必须深入了解该类的运行机制。下面列出 Event 类中一些重要的成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Event : public EventBase, public Serializable {
Event *nextBin; // Bin defined as when+priority
Event *nextInBin;
Tick _when; // timestamp when event should be processed
Priority _priority; //!< event priority
Flags flags;
Counter instance; // event unique ID
EventQueue *queue;

static Event *insertBefore(Event *event, Event *curr);
static Event *removeItem(Event *event, Event *last);

virtual void process() = 0;
bool scheduled() const { return flags.isSet(Scheduled); }

// Managed event scheduled and being held in the event queue.
void acquire() { if (flags.isSet(Event::Managed)) acquireImpl(); }

// Managed event removed from the event queue.
void release() { if (flags.isSet(Event::Managed)) releaseImpl(); }

void setWhen(Tick when, EventQueue *q)
}

image-20250407222246787

抽象类 Event 描述了事件这一核心概念,因而它也是整个底层机制的核心实现类。上节提到事件本质是由特殊的回调函数和一组状态标志位组成的,这体现在实现中便是类 Event 内部预留的虚函数 process(),以及存有运行时刻 _when,优先级 _priority,标记位 flags 和用于维护二维链表的两个指针变量 nextBinnextInBin 等,其中 nextBin 指向链表中下一项,而 nextInBin 指向 Bin 相同的下一项事件。

为降低用户使用难度,使用 EventManager 类来包装 EventQueue 类,包括常用的许多事件调度函数,而这些包装函数是 SimObject 类内经常调用的:

fetch

o3 cpu的所有阶段都在cpu.cc现在讲解fetch阶段

fetch首先初始化构造函数,初始化的参数容易理解,故不作讲解

注意这里会初始化线程,和smt有关

初始化阶段会去设置decoder(架构相关)

注意目前o3不支持fs的smt模式

CPU初始化阶段会去设置每个阶段,且设置每个阶段的queue的指针,这些函数只在初始化阶段使用

fetch阶段通过processCacheCompletion函数解释是否取指令成功,该过程会把取到的指令写入fetchbuffer,并且设置相应的valid为true(也会去设置fetch状态为IcacheAccessComplete),之后会发出probe1请求

1
2
3
4
5
6
7
8
9
10
11
memcpy(fetchBuffer[tid], pkt->getConstPtr<uint8_t>(), fetchBufferSize);
fetchBufferValid[tid] = true;
。。。
if (checkStall(tid)) {
fetchStatus[tid] = Blocked;
} else {
fetchStatus[tid] = IcacheAccessComplete;
}

pkt->req->setAccessLatency();
cpu->ppInstAccessComplete->notify(pkt);

注意,gem5将tick函数置为每个阶段event的回调函数

fetch阶段会去解码,而decode阶段算是一个伪阶段,具体的:

当执行fetch的tick函数时,会去执行fetch函数,该函数会取出指令,然后去解码,首先会去判断是否这个是一个完整的指令包(比如rv的c扩展,可能有些指令被截断),然后去在指令buffer再取出一些,凑成一条指令,注意,这段代码前面有判断是否fetchBuffer有数据,否则去icache取

1
2
3
4
5
6
7
8
9
memcpy(dec_ptr->moreBytesPtr(),
fetchBuffer[tid] + blkOffset * instSize, instSize);
decoder[tid]->moreBytes(this_pc, fetchAddr);

if (dec_ptr->needMoreBytes()) {
blkOffset++;
fetchAddr += instSize;
pcOffset += instSize;
}

之后进入一个do while循环,这个循环会去解码指令

下面给出对整个 fetch() 函数的详细解释,该函数实现了 O3 处理器中取指阶段的主要操作,其核心逻辑如下:

  1. 获取当前取指线程

    • 调用 getFetchingThread() 获取一个能够取指的线程 ID(tid)。如果返回值为 InvalidThreadID,则说明当前没有可用线程,此时直接更新 threadFetched 并可能记录停顿统计后返回,结束本次取指。
  2. 准备和状态检查

    • 取得当前线程的 PC(程序计数器)状态(this_pc)以及对应的 pcOffset(即当前在缓存行内的偏移)。
    • 根据当前 PC 与偏移,计算出实际取指地址 fetchAddr,并用掩码对齐(pc_mask)确保地址合法。
    • 使用 isRomMicroPC() 判断当前是否处于微操作 ROM 内(用于处理那些内建的微操作)。
  3. 处理缓存访问和中断请求

    • 如果上一次因 icache 访问延迟而导致 fetchStatus 处于 IcacheAccessComplete 状态,那么将其恢复为 Running,并通知状态发生变化。
    • 如果当前状态为 Running,则检查取指数据是否已经在 fetchBuffer 中。如果缓存数据无效或当前 fetchAddr 跨入了另一个缓存块(fetchBufferBlockPC 与原来的 fetchBufferPC 不一致),则发起新的 icache 访问请求(调用 fetchCacheLine)并返回,等待数据返回。
    • 同时,还检查是否有中断等待,如果中断已挂起而当前指令又不允许延迟提交,则也会阻塞本次取指。
  4. 进入取指循环前的准备

    • 增加 fetch 阶段的周期计数(fetchStats.cycles++)。
    • 复制当前 PC 状态生成一个 next_pc,用于记录下一条指令的 PC。
    • 定义用于存放解码得到的静态指令(StaticInstPtr staticInst)和当前正在处理的宏操作(curMacroop),前者将用于构造动态指令。
  5. 循环取指与译码
    进入一个外部 while 循环,条件为:已经取指的指令数未达到 fetchWidth、取指队列未满,并且未遇到预测跳转或 quiesce 指令。

    5.1 从存储器中补充更多字节(needMem 部分)
    - 根据状态判断是否需要向解码器补充更多字节:如果不在 ROM 内、没有正在处理的宏操作,并且解码器还没有足够字节(!dec_ptr->instReady()),则设置 needMem 为 true。
    - 刷新 fetchAddr 和计算所在缓存块(fetchBufferBlockPC)。
    - 如果缓存数据失效或 fetchAddr 已经超出当前缓存块(比较 fetchBufferBlockPC 与 fetchBufferPC),则退出循环,从 icache 再度获取数据。
    - 如果当前缓存块中剩余字节不足(blkOffset >= numInsts),同样退出该循环。
    - 否则,用 memcpy 将取指缓存(比如数组 c[tid],实际可能为 fetchBuffer[tid])中对应位置的一个指令字节复制到解码器的缓冲区(通过 dec_ptr->moreBytesPtr() 得到地址),之后调用 decoder->moreBytes(this_pc, fetchAddr) 告知新字节已加入到解码器内部。
    - 如果解码器仍觉得不足,则将 blkOffset、fetchAddr 和 pcOffset 依次增加一条指令的大小(instSize),为下一次读取做准备。
    5.2 在解码器中提取指令(do … while 循环)
    - 进入 do while 循环,该循环不断从解码器缓冲区或当前宏操作中提取指令,直到满足下列条件之一:
    • 已取指数达到 fetchWidth;
    • 取指队列达到其上限;
    • 碰到预测分支;
    • 或当前没有足够数据继续解码。
    - 在循环中,如果当前不处于宏操作(curMacroop)且不在 ROM 内,则检查解码器是否已有足够字节(instReady())。
    - 如果准备就绪,调用 dec_ptr->decode(this_pc) 得到一条静态指令(staticInst),同时增加取指统计。
    - 若解码出来的是宏操作(staticInst->isMacroop()),则将 curMacroop 设置为该宏操作;否则重置 pcOffset 为 0。
    - 如果正处于宏操作或在 ROM 内,则从当前宏操作中调用 fetchMicroop() 或从解码器中调用 fetchRomMicroop(),以获得其中的一个微操作。
    - 接着调用 buildInst() 生成动态指令(DynInstPtr instruction):该函数为取出的静态指令与当前宏操作状态、当前 PC 及下一 PC 生成一个完整的动态指令对象,同时将该指令加入到取指队列中。
    - 通知相应的 probe(ppFetch),并更新统计信息(numInst++)。
    - 调用 lookupAndUpdateNextPC() 对分支进行预测,更新下一条指令的 PC,并设置 predictedBranch 标志(如果预测分支被认为已取走,则退出本次循环)。
    - 更新当前 PC 状态(set(next_pc, this_pc))和 inRom 状态,判断是否进入新宏操作(如果新指令的 PC 与上一条不一致或者遇到了宏操作结束标志,则清除 curMacroop 并重置偏移)。
    - 如果遇到 quiesce 指令,则设置 fetchStatus 为 QuiescePending 并退出循环。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    if (!(curMacroop || inRom)) {
    if (dec_ptr->instReady()) {
    staticInst = dec_ptr->decode(this_pc);

    // Increment stat of fetched instructions.
    cpu->fetchStats[tid]->numInsts++;

    if (staticInst->isMacroop()) {
    curMacroop = staticInst;
    } else {
    pcOffset = 0;
    }
    } else {
    // We need more bytes for this instruction so blkOffset and
    // pcOffset will be updated
    break;
    }
    }
  6. 循环退出后的状态更新

    • 循环结束后,更新当前线程的 macroop(保存可能未取完的宏操作)和 fetchOffset(留待下次取指时继续使用)。
    • 如果本次 fetch 循环内至少提取了一条指令(numInst > 0),则标记 wroteToTimeBuffer 为 true,后续将把这些指令写入时间缓冲区传递到 decode 阶段。
    • 此外,还会设置 flag issuePipelinedIfetch:如果取指地址已经跨过当前缓存边界,并且没有遇到延迟响应等状态,则触发新的流水线化 I-cache 请求。
  7. 总结
    整个 fetch() 函数完成了以下任务:

    • 选择一个可用的线程以提取指令。
    • 判断当前是否需要从 I-cache 中获取新数据或者从已有的 fetch buffer 中补充数据到解码器。
    • 利用解码器逐条解码并构造动态指令,同时处理可能的宏操作拆分、分支预测以及异常指令(如 quiesce)。
    • 将生成的动态指令放入取指队列,供后续 decode 阶段处理;同时更新各项统计并维护取指状态(例如 fetchStatus、fetchOffset 和 macroop 状态)。

整个函数是 O3 处理器中非常关键的一环,它负责从内存层缓存、解码器和微操作 ROM 中连续地提取、解码以及封装指令,为后续流水线各阶段提供指令流,并处理系统中因分支、异常或缺失数据而产生的各种边界情况。

在打印pc时可以使用inst.pcState():注意需要dync ptr类型,不过还是建议使用dump(),可以直接打印出反汇编

rename

rename阶段主要就是去对源寄存器和目的寄存器去重命名,然后查询scoreboard,送入发射队列(此时也会去分配ROB或者SQ LQ表项),这里源寄存器会去查rat,然后记录到inst

1
2
3
renamed_reg = map->lookup(flat_reg);
。。。
inst->renameSrcReg(src_idx, renamed_reg);

目的寄存器会去分配一个新的,然后将新旧的全部记录到inst

1
2
3
4
rename_result = map->rename(flat_dest_regid);
inst->renameDestReg(dest_idx,
rename_result.first,
rename_result.second);

IEW

此阶段 (IEW) 负责将指令分派到指令队列、通知指令队列发出指令以及执行和写回指令。

后端计算执行函数链

1
2
3
4
5
6
Rename::tick()->Rename::RenameInsts()
IEW::tick()->IEW::dispatchInsts()
IEW::tick()->InstructionQueue::scheduleReadyInsts()
IEW::tick()->IEW::executeInsts()
IEW::tick()->IEW::writebackInsts()
Commit::tick()->Commit::commitInsts()->Commit::commitHead()

后端load执行函数链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IEW::tick()->IEW::executeInsts()
->LSQUnit::executeLoad()
->StaticInst::initiateAcc()
->LSQ::pushRequest()
->LSQUnit::read()
->LSQRequest::buildPackets()
->LSQRequest::sendPacketToCache()
->LSQUnit::checkViolation()
DcachePort::recvTimingResp()->LSQRequest::recvTimingResp()
->LSQUnit::completeDataAccess()
->LSQUnit::writeback()
->StaticInst::completeAcc()
->IEW::instToCommit()
IEW::tick()->IEW::writebackInsts()

后端store执行函数链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
IEW::tick()->IEW::executeInsts()
->LSQUnit::executeStore()
->StaticInst::initiateAcc()
->LSQ::pushRequest()
->LSQUnit::write()
->LSQUnit::checkViolation()
Commit::tick()->Commit::commitInsts()->Commit::commitHead()
IEW::tick()->LSQUnit::commitStores()
IEW::tick()->LSQUnit::writebackStores()
->LSQRequest::buildPackets()
->LSQRequest::sendPacketToCache()
->LSQUnit::storePostSend()
DcachePort::recvTimingResp()->LSQRequest::recvTimingResp()
->LSQUnit::completeDataAccess()
->LSQUnit::completeStore()

首先讲dispatchInsts

该函数会将对应的指令送入IQ,注意,ld st指令会额外送入LSQ,然后将insts_to_dispatch数据取出

gem5使用集中式的发射队列

1
2
3
4
if (add_to_iq) {
instQueue.insert(inst);
}
insts_to_dispatch.pop();

dispatch阶段较为简单,接下来看计算指令的执行

计算指令执行

其调用executeInsts函数

,该函数首先在inst q去取出指令,然后判断该指令是否是isSquashed,这里可以理解为无效指令(因为异常等情况)。如果是,就会把该指令设置为可提交,

之后会进入inst->execute();函数,该函数架构相关,主要为每个指令生成了单独的执行函数,riscv好像是这样,然后setRegOperand函数会把计算结果写入instResult队列,

源寄存器的数从哪个函数读出?

执行阶段获取(getRegOperand函数)

什么时候写入目的寄存器?

执行完就写回了,写回阶段只是去唤醒其他指令(setRegOperand函数)

写回阶段干了什么?

首先搜索依赖链,然后根据该指令去让依赖链的指令ready,指令一般最多有三个寄存器(RISCV),markSrcRegReady里面就是将准备好的寄存器++,等到准备好的寄存器等于源寄存器总数,addIfReady函数检查是否指令准备发射(存入一个专门放准备好的指令的队列,该函数会对指令进行排序,然后更新最老指令的编号,listOrder存储最老的指令),然后设置scoreboard,将依赖指令从依赖链取出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DynInstPtr dep_inst = dependGraph.pop(dest_reg->flatIndex());

while (dep_inst) {
DPRINTF(IQ, "Waking up a dependent instruction, [sn:%llu] "
"PC %s.\n", dep_inst->seqNum, dep_inst->pcState());

// Might want to give more information to the instruction
// so that it knows which of its source registers is
// ready. However that would mean that the dependency
// graph entries would need to hold the src_reg_idx.
dep_inst->markSrcRegReady();

addIfReady(dep_inst);

dep_inst = dependGraph.pop(dest_reg->flatIndex());

++dependents;
}

MEM指令执行

mem执行有专门的exec指令,现在以load举例,initiateAcc架构相关,该函数会去计算地址,调用initiateMemRead发出请求,返回数据

1
load_fault = inst->initiateAcc();

然后一步步调用,最后执行

1
2
3
4
5
iew.ldstQueue.pushRequest
Fault
LSQ::pushRequest(const DynInstPtr& inst, bool isLoad, uint8_t *data,
unsigned int size, Addr addr, Request::Flags flags, uint64_t *res,
AtomicOpFunctorPtr amo_op, const std::vector<bool>& byte_enable)

如果不是突发传输,就会去执行,该函数就是去吧请求设置为一个SingleDataRequest,然后访问TLB进行地址转换

1
2
3
request = new SingleDataRequest(&thread[tid], inst, isLoad, addr,
size, flags, data, res, std::move(amo_op));
request->initiateTranslation();

之后如果指令完成地址转换,就会进入

1
2
3
4
5
6
7
8
Fault
LSQ::read(LSQRequest* request, ssize_t load_idx)
{
assert(request->req()->contextId() == request->contextId());
ThreadID tid = cpu->contextToThread(request->req()->contextId());

return thread.at(tid).read(request, load_idx);
}

下面对 LSQUnit::read(LSQRequest *request, ssize_t load_idx) 进行详细解释,该函数主要负责对 load 指令进行内存读取(或转发)操作,其总体流程如下:(GPT总结)


  1. 获取 Load 队列条目和对应指令
    首先,根据给定的 load 队列索引 load_idx,在 loadQueue 中获得对应的条目(LQEntry)并获取其中绑定的动态指令(load_inst)。

    1
    2
    LQEntry& load_entry = loadQueue[load_idx];
    const DynInstPtr& load_inst = load_entry.instruction();
  2. 绑定请求并基本检查
    将传入的 request 与该 load 队列条目关联,并确保加载指令存在且还未执行(isExecuted() 为 false),防止重复读取。

    1
    2
    3
    load_entry.setRequest(request);
    assert(load_inst);
    assert(!load_inst->isExecuted());
  3. 严格有序 load 检查
    如果当前请求标记为严格有序(isStrictlyOrdered() 为 true),但当前 load 不是位于 loadQueue 的头部或者指令还未处于 commit 阶段,则:

    • 通知上游(IEW/mem dep unit)需要将该指令重新调度;
    • 清除指令的“发射”标记和有效地址标志;
    • 记录并统计该 reschedule 事件;
    • 丢弃此 request(调用 request->discard()),并返回一个 fault(通常为 panic fault),表示此 load 还不能访问内存。

    这样做确保严格有序 load 必须等到它处于队列头部且就绪时才真正执行内存操作。

  4. 打印调试信息
    输出调试信息,显示 load 的索引、与 store 队列相关的位置以及请求的地址信息。

    1
    2
    3
    DPRINTF(LSQUnit, "Read called, load idx: %i, store idx: %i, storeHead: %i addr: %#x%s\n",
    load_idx - 1, load_inst->sqIt._idx, storeQueue.head() - 1,
    request->mainReq()->getPaddr(), request->isSplit() ? " split" : "");
  5. 处理特殊指令:LLSC(读-链接)
    如果请求属于 LLSC 类型:

    • 临时禁用结果记录(recordResult(false)),使得写入 misc 寄存器时不更新结果;
    • 调用 ISA 的 handleLockedRead() 处理锁定读取的操作;
    • 恢复结果记录设置(recordResult(true))。
  6. 处理 Local Access(本地访问)
    如果请求标记为本地访问,则:

    • 为 load 分配存储数据的缓冲区(memData);
    • 从当前线程上下文中调用 localAccessor() 直接进行读操作并计算延迟;
    • 创建一个 WritebackEvent,并调度该事件在延迟后完成 writeback;
    • 返回 NoFault。
  7. Store-to-Load 转发检查
    如果前面几项都不适用,则函数检查 load 指令是否可以从之前的 store 中直接转发数据,而不必去访问内存:

    • 从 load 指令关联的 store 队列迭代器(sqIt)处开始向“更老”(storeWBIt 之前)的 store 条目遍历;
    • 对于每个候选 store,先确保 store 有数据(store_size != 0)、不严格有序且不属于 cache maintenance 操作,同时该 store 的有效地址已经设置;
    • 根据 load 请求的地址范围与 store 有效数据范围计算“coverage”;
      • 如果 store 包含 load 请求地址的全部数据(Full Coverage),则计算偏移后直接将 store 中的数据复制到 load 的 memData 中;
      • 随后创建一个新的 Packet 并包装为 WritebackEvent,再调度该事件进行延迟 writeback,统计转发成功的次数,并返回 NoFault;
    • 如果只满足“部分覆盖”(Partial Coverage):
      • 说明 store 尚未完全写入 load 所需的数据,此时将 stall load(设置 stalled 标志、记录 stallingStoreIsn 和 stallingLoadIdx)并通知上游重新调度(rescheduleMemInst);
      • 丢弃当前 request,返回 NoFault,等待下一个周期重新尝试。
  8. 无转发则发起内存访问
    如果没有找到适合转发的 store,则证明必须访问真正的内存:

    • 分配 load 的 memData(如果尚未分配);
    • 针对 hardware transactional memory(HTM)相关情况检查(例如设置 memData 特殊值);
    • 调用 request->buildPackets() 构造 Memory Packets;
    • 调用 request->sendPacketToCache() 将读取请求发送给缓存;
    • 如果请求未成功发送,则调用 blockMemInst() 阻塞此 load 的发射;
    • 最后返回 NoFault。

总结

LSQUnit::read() 的主要工作是判断当前 load 指令:

  • 是否满足严格有序条件,否则需要 reschedule;
  • 是否属于特殊访问(LLSC或本地访问);
  • 是否可以通过 store-to-load forwarding 直接获得数据(完全或部分转发);
  • 如果都不适用,则构造真实的内存读取请求并发送到缓存。

函数通过一系列检查和分支处理,确保 load 指令在乱序执行器中的数据依赖、顺序性和各类特殊情况(如转发、严格有序、HTM)的正确处理,并返回相应的 fault 状态(一般为 NoFault)或触发 fault 以供上层处理。

什么时候读出数据?

数据通过回调函数返回

调用什么函数执行内存操作?

一般为store_inst->initiateAcc();

其函数调用堆栈如下

image-20250424153234926

然后到最后会调用pushRequest函数,该函数层层调用会调用write函数

LSQUnit::write 函数的主要作用是将存储(store)指令的数据写入存储队列(Store Queue, SQ),并对存储请求进行必要的初始化和处理。以下是对该函数的详细解释:


1. 函数签名

1
2
Fault
LSQUnit::write(LSQRequest *request, uint8_t *data, ssize_t store_idx)
  • request:指向当前存储请求的指针,包含存储操作的详细信息(如地址、大小、标志等)。
  • data:指向要写入的数据的指针。
  • store_idx:存储队列中当前存储指令的索引。

返回值是一个 Fault 对象,表示存储操作的结果。如果没有错误,返回 NoFault


2. 确保存储队列条目有效

1
assert(storeQueue[store_idx].valid());
  • 确保存储队列中对应索引的条目是有效的。如果无效,则触发断言失败。

3. 打印调试信息

1
2
3
4
DPRINTF(LSQUnit, "Doing write to store idx %i, addr %#x | storeHead:%i "
"[sn:%llu]\n",
store_idx - 1, request->req()->getPaddr(), storeQueue.head() - 1,
storeQueue[store_idx].instruction()->seqNum);
  • 打印当前存储操作的详细信息,包括:
    • 存储队列索引(store_idx)。
    • 存储地址(request->req()->getPaddr())。
    • 存储队列头部索引(storeQueue.head())。
    • 存储指令的序列号(seqNum)。

4. 设置存储请求

1
storeQueue[store_idx].setRequest(request);
  • 将当前存储请求与存储队列中的条目关联起来。

5. 设置存储大小

1
2
unsigned size = request->_size;
storeQueue[store_idx].size() = size;
  • 从请求中获取存储操作的大小,并将其设置到存储队列条目中。

6. 检查是否为“无数据存储”

1
2
3
4
bool store_no_data =
request->mainReq()->getFlags() & Request::STORE_NO_DATA;
storeQueue[store_idx].isAllZeros() = store_no_data;
assert(size <= SQEntry::DataSize || store_no_data);
  • 检查存储请求是否为“无数据存储”(STORE_NO_DATA 标志)。
    • 如果是,则将 isAllZeros 标志设置为 true
    • 确保存储大小不超过存储队列条目的最大数据大小(SQEntry::DataSize),或者存储请求是“无数据存储”。

7. 将数据复制到存储队列

1
2
3
4
if (!(request->req()->getFlags() & Request::CACHE_BLOCK_ZERO) &&
!request->req()->isCacheMaintenance() &&
!request->req()->isAtomic())
memcpy(storeQueue[store_idx].data(), data, size);
  • 如果存储请求不是以下情况:
    • CACHE_BLOCK_ZERO:表示将整个缓存块置零。
    • Cache Maintenance:缓存维护操作(如清除或刷新缓存)。
    • Atomic:原子操作。
  • 则将数据从 data 指针复制到存储队列条目的数据缓冲区中。

8. 返回结果

1
return NoFault;
  • 该函数只负责将数据写入存储队列,不涉及实际的内存访问,因此不会产生故障,直接返回 NoFault

总结

LSQUnit::write 函数的主要功能是:

  1. 将存储请求与存储队列条目关联。
  2. 设置存储操作的大小和标志(如是否为“无数据存储”)。
  3. 将存储数据复制到存储队列(如果需要)。
  4. 返回存储操作的结果(通常为 NoFault)。

这是存储指令执行过程中将数据写入存储队列的关键步骤,后续的写回阶段会根据存储队列中的数据将其写入内存或缓存。

write函数将数据写入STQ,等待提交时写入Cache

问题

gem5如何取出一条指令的?

目前已知gem5是通过构建pkt来发送请求的,发送的函数为send_timing

image-20250513143147906

这个函数是在processSendEvent调用的,processSendEvent函数为一个回调函数,主要就是去返回发送请求得到的数据,最后会把他调度到event事件中

这些函数可能是icache端的,因为他是返回的信号

这个resp的流程:

首先cache得到recvTimingReq,该函数会访问cache

如果hit,会调用handleTimingReqHit

如果这个packet需要回复,调用cpuSidePort.schedTimingResp(pkt, request_time);,

这个函数调用respqueue,最后会调用回调函数processSendEvent,一步步到fetch的resp

image-20250513145902769

真相大白,在finishTranslation函数发出取指令

PC在哪更新?

在fetch函数,当取指令请求生成完成后,会去更新pc,

gem5实现FDIP的思路

1.单独实现FTQ

2.实现一个可以将fetch阶段和l2阶段连接的模块,或者就是预取模块,

3.实现一个分支预测管理模块,需要有预测,更新回滚结构

如何实现FTQ

FTQ需要包含的表项

gem5是以什么粒度取指令

mininstsize,也就是指令大小长度,而不是以fetchblock为粒度,一次只取出一条

1. 官方的adder例子

1730820232374

首先定义参数

1
2
3
4
case class UpwardParam(width: Int)
case class DownwardParam(width: Int)
case class EdgeParam(width: Int)

也即是INT,

之后实现节点

1
2
3
4
5
6
7
object AdderNodeImp extends SimpleNodeImp[DownwardParam, UpwardParam, EdgeParam, UInt] {
def edge(pd: DownwardParam, pu: UpwardParam, p: Parameters, sourceInfo: SourceInfo) = {
if (pd.width < pu.width) EdgeParam(pd.width) else EdgeParam(pu.width)
}
def bundle(e: EdgeParam) = UInt(e.width.W)
def render(e: EdgeParam) = RenderedEdge("blue", s"width = ${e.width}")
}

这个edge的意思就是去协商向上传的参数与向下传的参数,最终取最小值,然后bundle是根据协商参数创建数据类型,

然后就是节点,节点主要有SourceNode,SinkNode和NexusNode,由于 SourceNode只沿向外边生成向下流动的参数,节点实现和之前一样。对 AdderDriverNode而言,类型为 Seq[DownwardParam]widths表示初始化该节点(AdderDriver)的模块时输出的数据宽度,这里使用 Seq是因为每个节点可能驱动多个输出,在这个例子中,每个节点会连接到加法器和monitor。SinkNode同理

最后就是Nexus节点,

加法器节点接收两个 AdderDriverNode的输入,并把输出传递给monitor,该节点为 NexusNodedFn将向内边传来的向下的参数,映射到向外边的向下的参数,uFn将向外边的向上的参数,映射到向内边的向上的参数。

(内边可以理解为传入的参数,外边可以理解为向外传的参数)

1
2
3
4
5
6
7
8
9
10
11
class AdderDriverNode(widths: Seq[DownwardParam])(implicit valName: ValName)
extends SourceNode(AdderNodeImp)(widths)

/** node for [[AdderMonitor]] (sink) */
class AdderMonitorNode(width: UpwardParam)(implicit valName: ValName)
extends SinkNode(AdderNodeImp)(Seq(width))

/** node for [[Adder]] (nexus) */
class AdderNode(dFn: Seq[DownwardParam] => DownwardParam,
uFn: Seq[UpwardParam] => UpwardParam)(implicit valName: ValName)
extends NexusNode(AdderNodeImp)(dFn, uFn)

这个里面有两个模板匹配,然后最终传入的AdderNode的值为(dps和ups的head),最后将输入累加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Adder(implicit p: Parameters) extends LazyModule {
val node = new AdderNode (
{ case dps: Seq[DownwardParam] =>
require(dps.forall(dp => dp.width == dps.head.width), "inward, downward adder widths must be equivalent")
dps.head
},
{ case ups: Seq[UpwardParam] =>
require(ups.forall(up => up.width == ups.head.width), "outward, upward adder widths must be equivalent")
ups.head
}
)
lazy val module = new LazyModuleImp(this) {
require(node.in.size >= 2)
node.out.head._1 := node.in.unzip._1.reduce(_ + _)
}

override lazy val desiredName = "Adder"
}

主要就是设置numoutputs个驱动节点,然后给每个节点分配随机值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

/** driver (source)
* drives one random number on multiple outputs */
class AdderDriver(width: Int, numOutputs: Int)(implicit p: Parameters) extends LazyModule {
val node = new AdderDriverNode(Seq.fill(numOutputs)(DownwardParam(width)))

lazy val module = new LazyModuleImp(this) {
// check that node parameters converge after negotiation
val negotiatedWidths = node.edges.out.map(_.width)
require(negotiatedWidths.forall(_ == negotiatedWidths.head), "outputs must all have agreed on same width")
val finalWidth = negotiatedWidths.head

// generate random addend (notice the use of the negotiated width)
val randomAddend = FibonacciLFSR.maxPeriod(finalWidth)

// drive signals
node.out.foreach { case (addend, _) => addend := randomAddend }
}

override lazy val desiredName = "AdderDriver"
}

主要就是设置numoperands个监视节点,和一个adder节点,然后对比nodesum节点和nodeseq节点值的区别,送出error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AdderMonitor(width: Int, numOperands: Int)(implicit p: Parameters) extends LazyModule {
val nodeSeq = Seq.fill(numOperands) { new AdderMonitorNode(UpwardParam(width)) }
val nodeSum = new AdderMonitorNode(UpwardParam(width))

lazy val module = new LazyModuleImp(this) {
val io = IO(new Bundle {
val error = Output(Bool())
})

// print operation
printf(nodeSeq.map(node => p"${node.in.head._1}").reduce(_ + p" + " + _) + p" = ${nodeSum.in.head._1}")

// basic correctness checking
io.error := nodeSum.in.head._1 =/= nodeSeq.map(_.in.head._1).reduce(_ + _)
}

override lazy val desiredName = "AdderMonitor"
}

最后就是顶层,顶层就是通过高阶函数将每个节点链接起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class AdderTestHarness()(implicit p: Parameters) extends LazyModule {
val numOperands = 2
val adder = LazyModule(new Adder)
// 8 will be the downward-traveling widths from our drivers
val drivers = Seq.fill(numOperands) { LazyModule(new AdderDriver(width = 8, numOutputs = 2)) }
// 4 will be the upward-traveling width from our monitor
val monitor = LazyModule(new AdderMonitor(width = 4, numOperands = numOperands))

// create edges via binding operators between nodes in order to define a complete graph
drivers.foreach{ driver => adder.node := driver.node }

drivers.zip(monitor.nodeSeq).foreach { case (driver, monitorNode) => monitorNode := driver.node }
monitor.nodeSum := adder.node

lazy val module = new LazyModuleImp(this) {
// when(monitor.module.io.error) {
// printf("something went wrong")
// }
}

override lazy val desiredName = "AdderTestHarness"
}

2. 根据rocketchip 搭建一个简单的SOC框架(基于ysyxSoC)

首先我们需要包含freechip库,有两种方法,1.直接从云端下载,2.直接导入本地的库,本实验选择第二种,基于ysyxSoC的build.sc来创建自己的sc文件,导入成功后就可以进行自己的SoC搭建

我们的SoC框架如下所示

1730881092351

也就是我们CPU需要一个AXI_master节点,clint,SDRAM和MROM各自需要一个AXI_slave节点,然后AXI_XBAR继承于NexusNode,可支持多个输入节点和多个输出节点

然后设备的地址空间安排如下:

设备 地址空间
clint 0x1000_0000-0x1000_ffff
SDRAM 0x8000_0000-0x9fff_ffff
MROM 0x2000_0000-0x2000_ffff

首先创建clint的slave节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AXI4MyCLINT(address: Seq[AddressSet])(implicit p: Parameters) extends LazyModule {
val beatBytes = 4
val node = AXI4SlaveNode(Seq(AXI4SlavePortParameters(
Seq(AXI4SlaveParameters(
address = address,
executable = true,
supportsWrite = TransferSizes(1, beatBytes),
supportsRead = TransferSizes(1, beatBytes),
interleavedId = Some(0))
),
beatBytes = beatBytes)))

lazy val module = new Impl
class Impl extends LazyModuleImp(this) {
}
}

可以看到我们首先创建了slvae节点,这个节点里面有一个TransferSizes,来揭示最多可以传多少笔数据,这里是按照四笔来说的,然后在之后的LazyModuleImp有具体的实现,我们可以根据传入的node的信号的地址来读写相应的寄存器,然后SDRAM和MROM比较类似,以SDRAM为主要讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AXI4MySDRAM(address: Seq[AddressSet])(implicit p: Parameters) extends LazyModule {
val beatBytes = 4
val node = AXI4SlaveNode(Seq(AXI4SlavePortParameters(
Seq(AXI4SlaveParameters(
address = address,
executable = true,
supportsWrite = TransferSizes(1, beatBytes),
supportsRead = TransferSizes(1, beatBytes),
interleavedId = Some(0))
),
beatBytes = beatBytes)))

lazy val module = new Impl
class Impl extends LazyModuleImp(this) {
val (in, _) = node.in(0)
val sdram_bundle = IO(new SDRAMIO)

val msdram = Module(new sdram_top_axi)
msdram.io.clock := clock
msdram.io.reset := reset.asBool
msdram.io.in <> in
sdram_bundle <> msdram.io.sdram
}
}

SDRAM仍然是先创建slave节点,然后LazyModuleImp中将节点连接到msdram的输入端,这个模块是一个黑盒,这地方的好处就是一般sdram和DDR都使用IP,而现在的IP一般都是verilog,所以包裹一层黑盒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MySoC(implicit p: Parameters) extends LazyModule {
val xbar = AXI4Xbar()
val cpu = LazyModule(new CPU(idBits = ChipLinkParam.idBits))
val lmrom = LazyModule(new AXI4MROM(AddressSet.misaligned(0x20000000, 0x10000)))
val lclint = LazyModule(new AXI4MyCLINT(AddressSet.misaligned(0x10000000, 0x10000)))
val sdramAddressSet = AddressSet.misaligned(0x80000000L, 0x2000000)
val lsdram_axi = Some(LazyModule(new AXI4MySDRAM(sdramAddressSet)))

List(lsdram_axi.get.node ,lmrom.node, lclint.node).map(_ := xbar)
xbar := cpu.masterNode

override lazy val module = new Impl
class Impl extends LazyModuleImp(this) with DontTouch {

cpu.module.reset := SynchronizerShiftReg(reset.asBool, 10) || reset.asBool
cpu.module.slave := DontCare
val intr_from_chipSlave = IO(Input(Bool()))
cpu.module.interrupt := intr_from_chipSlave
val sdramBundle = lsdram_axi.get.module.sdram_bundle
val sdram = IO(chiselTypeOf(sdramBundle))
sdram <> sdramBundle
}
}

首先調用xbar创建XBAR,然后为每个设备分配地址空间,最后连线,也就是将slave node和xbar连线,cpu的master node 和xbar连线,之后就是实现部分,主要也是连线逻辑,然后就结束了整个SOC的创建

1730880825683

最后是生成的代码的一部分,可以看到正确链接

3.rocketchip 的AXIDelayer解析

1
2
val node = AXI4AdapterNode()
require (0.0 <= q && q < 1)

首先可以看到他创建了一个AXI4AdapterNode,这个主要就是master原封不动传进来,slave也是原封不动传进来(只可改变参数,但边不可改变),然后q就是请求延迟的概率

然后在lazymodule定义了一个feed函数

1
2
3
4
5
6
7
8
9
10
11
12
def feed[T <: Data](sink: IrrevocableIO[T], source: IrrevocableIO[T], noise: T): Unit = {
// irrevocable requires that we not lower valid
val hold = RegInit(false.B)
when (sink.valid) { hold := true.B }
when (sink.fire) { hold := false.B }

val allow = hold || ((q * 65535.0).toInt).U <= LFSRNoiseMaker(16, source.valid)
sink.valid := source.valid && allow
source.ready := sink.ready && allow
sink.bits := source.bits
when (!sink.valid) { sink.bits := noise }
}

这个函数就是通过allow来截断sink和source的vaild和ready信号,allow主要有两个信号,一个是hold,另一个是比较电路,假设我们第一次使用这个,那么hold必然为false,只能通过后面的比较电路来决定allow,如果后面的也为false,则会引入噪音,直到后面条件满足,这时控制信号就会通,但是bits仍然是有噪声的

1
2
3
4
5
6
7
8
9
10
11
def anoise[T <: AXI4BundleA](bits: T): Unit = {
bits.id := LFSRNoiseMaker(bits.params.idBits)
bits.addr := LFSRNoiseMaker(bits.params.addrBits)
bits.len := LFSRNoiseMaker(bits.params.lenBits)
bits.size := LFSRNoiseMaker(bits.params.sizeBits)
bits.burst := LFSRNoiseMaker(bits.params.burstBits)
bits.lock := LFSRNoiseMaker(bits.params.lockBits)
bits.cache := LFSRNoiseMaker(bits.params.cacheBits)
bits.prot := LFSRNoiseMaker(bits.params.protBits)
bits.qos := LFSRNoiseMaker(bits.params.qosBits)
}

这个就是给ar和aw通道加noise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
(node.in zip node.out) foreach { case ((in, edgeIn), (out, edgeOut)) =>
val arnoise = Wire(new AXI4BundleAR(edgeIn.bundle))
val awnoise = Wire(new AXI4BundleAW(edgeIn.bundle))
val wnoise = Wire(new AXI4BundleW(edgeIn.bundle))
val rnoise = Wire(new AXI4BundleR(edgeIn.bundle))
val bnoise = Wire(new AXI4BundleB(edgeIn.bundle))

arnoise := DontCare
awnoise := DontCare
wnoise := DontCare
rnoise := DontCare
bnoise := DontCare

anoise(arnoise)
anoise(awnoise)

wnoise.data := LFSRNoiseMaker(wnoise.params.dataBits)
wnoise.strb := LFSRNoiseMaker(wnoise.params.dataBits/8)
wnoise.last := LFSRNoiseMaker(1)(0)

rnoise.id := LFSRNoiseMaker(rnoise.params.idBits)
rnoise.data := LFSRNoiseMaker(rnoise.params.dataBits)
rnoise.resp := LFSRNoiseMaker(rnoise.params.respBits)
rnoise.last := LFSRNoiseMaker(1)(0)

bnoise.id := LFSRNoiseMaker(bnoise.params.idBits)
bnoise.resp := LFSRNoiseMaker(bnoise.params.respBits)

feed(out.ar, in.ar, arnoise)
feed(out.aw, in.aw, awnoise)
feed(out.w, in.w, wnoise)
feed(in.b, out.b, bnoise)
feed(in.r, out.r, rnoise)

这一堆主要就是将node in和out的信号和参数分开,然后为w,r,b通道加噪声,最后将这些噪声通过feed传到总线,其实这个模块就是去延迟vaild和ready,在延迟期间bits是noise,在sink为vaild期间就是source的bit

rocket ICache

一个典型的rocket chip结构

1731144531780

1
val (tl_out, edge_out) = outer.masterNode.out(0)

在看rocket代码中有一个这个语句,masternode的out方法返回了两个变量,一个bundle,另一个是边的参数,这里是outward edge参数

深入挖掘out

1
2
3
4
5
6
7
def out: Seq[(BO, EO)] = {
require(
instantiated,
s"$name.out should not be called until after instantiation of its parent LazyModule.module has begun"
)
bundleOut.zip(edgesOut)
}

发现在diplomacy库中的MixedNode(所有节点都继承了这个类)定义了这个out方法,其注释为将outward的边参数和端口gather起来,只能在LazyModuleImp中使用和访问

1
2
3
4
5
abstract class MixedNode[DI, UI, EI, BI <: Data, DO, UO, EO, BO <: Data](
val inner: InwardNodeImp[DI, UI, EI, BI],
val outer: OutwardNodeImp[DO, UO, EO, BO]
)

MixedNode是一个抽象类,只能被继承或作为基类,然后接下来讲解他的参数

DI:从上游传入的Downward-flowing parameters,对于一个InwardNode节点,他的参数由OutwardNode觉得,他可以多个源连接到一起,所以参数是Seq类型

UI:向上传的参数,一般为sink的参数,,对于InwardNode,参数由节点自身决定

EI:描述内边连接的参数,通常是根据协议对sink的一系列例化

BI:连接内边的Bundle type,他是这个sink接口的硬件接口代表真实硬件

DO:向外边传的参数,通常是source的参数对于一个OutwardNode,这个参数由自己决定

UO:外边传入的参数,通常是描述sink节点的参数,对于一个OutwardNode 这个由连接的inwardNode决定,由于这个可以被多个sinks连接,所以他是seq的

EO:描述外边的连接,通常是source节点的特殊的参数

BO:输出IO

接下来回归原题,可以看到有一个edge_out,这个变量有很多tilelink的方法,如检查是否是req等,是否含有data,但AXI的edge就没u,

rocket ALU

首先ALU继承于下面的抽象类

1
2
3
4
5
6
7
8
9
10
11
abstract class AbstractALU(implicit p: Parameters) extends CoreModule()(p) {
val io = IO(new Bundle {
val dw = Input(UInt(SZ_DW.W))
val fn = Input(UInt(SZ_ALU_FN.W))
val in2 = Input(UInt(xLen.W))
val in1 = Input(UInt(xLen.W))
val out = Output(UInt(xLen.W))
val adder_out = Output(UInt(xLen.W))
val cmp_out = Output(Bool())
})
}

首先dw的含义就是是32位还是64位

重点讲解一下移位操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SLL, SRL, SRA
val (shamt, shin_r) =
if (xLen == 32) (io.in2(4,0), io.in1)
else {
require(xLen == 64)
val shin_hi_32 = Fill(32, isSub(io.fn) && io.in1(31))
val shin_hi = Mux(io.dw === DW_64, io.in1(63,32), shin_hi_32)
val shamt = Cat(io.in2(5) & (io.dw === DW_64), io.in2(4,0))
(shamt, Cat(shin_hi, io.in1(31,0)))
}
val shin = Mux(shiftReverse(io.fn), Reverse(shin_r), shin_r)
val shout_r = (Cat(isSub(io.fn) & shin(xLen-1), shin).asSInt >> shamt)(xLen-1,0)
val shout_l = Reverse(shout_r)
val shout = Mux(io.fn === FN_SR || io.fn === FN_SRA || io.fn === FN_BEXT, shout_r, 0.U) |
Mux(io.fn === FN_SL, shout_l, 0.U)

shamt为移位的位数,我们假设是RV32,shin_r是被移位的数字,shin如果是左移,就将shin_r翻转,如果右移,则不变,shout_r检测是逻辑移位还是算数移位,如果是逻辑移位isSUB为false,也就是最高位符号位为0,,然后转换为有符号数,右移shamt,最后取出低32位,如果是verilog可以设置一个shift_mask(~(32’hffffffff)>>shamt),然后将移位前的符号位和这个mask&,最后或一下移位结果就是算数右移,

为什么逻辑左移可以转换为逻辑右移,将被移位的数字翻转后,最高位变为最低位,我们右移结果,翻转过来就是左移结果

然后就是结果输出模块s

1
2
3
4
5
6
7
8
9
10
11
12
val out = MuxLookup(io.fn, shift_logic_cond)(Seq(
FN_ADD -> io.adder_out,
FN_SUB -> io.adder_out
) ++ (if (coreParams.useZbb) Seq(
FN_UNARY -> unary,
FN_MAX -> maxmin_out,
FN_MIN -> maxmin_out,
FN_MAXU -> maxmin_out,
FN_MINU -> maxmin_out,
FN_ROL -> rotout,
FN_ROR -> rotout,
) else Nil))

这个表默认是shift_logic_cond,然后根据FN类型选择(这里还加入了Zbb扩展)

rocket DecodeLogic

首先这个逻辑里面定义了两个方法

1
2
3
4
5
6
7
8
9
10
11
12
// TODO This should be a method on BitPat
private def hasDontCare(bp: BitPat): Boolean = bp.mask.bitCount != bp.width
// Pads BitPats that are safe to pad (no don't cares), errors otherwise
private def padBP(bp: BitPat, width: Int): BitPat = {
if (bp.width == width) bp
else {
require(!hasDontCare(bp), s"Cannot pad '$bp' to '$width' bits because it has don't cares")
val diff = width - bp.width
require(diff > 0, s"Cannot pad '$bp' to '$width' because it is already '${bp.width}' bits wide!")
BitPat(0.U(diff.W)) ## bp
}
}

其中hasDontCare是检查一个Bitpat是否有dontcare位,padBP是将一个bitpat格式的填充到width位

然后定义了好几个apply方法,这里只讲解rocketchip使用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def apply(addr: UInt, default: Seq[BitPat], mappingIn: Iterable[(BitPat, Seq[BitPat])]): Seq[UInt] = {
val nElts = default.size
require(mappingIn.forall(_._2.size == nElts),
s"All Seq[BitPat] must be of the same length, got $nElts vs. ${mappingIn.find(_._2.size != nElts).get}"
)

val elementsGrouped = mappingIn.map(_._2).transpose
val elementWidths = elementsGrouped.zip(default).map { case (elts, default) =>
(default :: elts.toList).map(_.getWidth).max
}
val resultWidth = elementWidths.sum

val elementIndices = elementWidths.scan(resultWidth - 1) { case (l, r) => l - r }

// All BitPats that correspond to a given element in the result must have the same width in the
// chisel3 decoder. We will zero pad any BitPats that are too small so long as they dont have
// any don't cares. If there are don't cares, it is an error and the user needs to pad the
// BitPat themselves
val defaultsPadded = default.zip(elementWidths).map { case (bp, w) => padBP(bp, w) }
val mappingInPadded = mappingIn.map { case (in, elts) =>
in -> elts.zip(elementWidths).map { case (bp, w) => padBP(bp, w) }
}
val decoded = apply(addr, defaultsPadded.reduce(_ ## _), mappingInPadded.map { case (in, out) => (in, out.reduce(_ ## _)) })

elementIndices.zip(elementIndices.tail).map { case (msb, lsb) => decoded(msb, lsb + 1) }.toList
}

可以看到他接受三个参数,返回一个seq,addr是要解码的数据,default是解码list的默认格式,rocket的格式为

1731152261040

mappin就是传入的decode表

1731152309795

类似于这种

首先我们nElts就是得出这个列表的元素个数,然后一个assert来确保传入的map和default的元素个数一致,然后elementsGrouped将List的各个控制信号分开,这里使用了map(遍历每个元素)和transpose(将元素转置,这样第一个seq就是所有表的val,以此类推),

然后得出每个bitpat的大小,这个elementWidths也就是将default的元素和elementsGrouped配对,然后将default的元素附加到elementsGrouped上,最后算出每个bitpat的大小

resultWidth就是所有bitpat的大小,然后elementIndices就是每个bitpat大小的索引,也就是假如每个bitpat大小为[4,3,2],这个得出的就是[8,4,1,-1]

然后最后哪一行代码就是将上面这个数组转换为(8,5),(4,2),(1,0)在,这样通过decode生成bool信号,然后将这些信号生成list

总体来说,这个模块使用了很多scala的高阶函数:

map:将给定函数作用于每个元素

transpose:将list转置

scan:扫描元组的每个值,并将其进行之后的函数操作,有累积性,比如这里就是给定初值,然后减去其他元素得到新的数组

zip:将两个元素组成一个元组

reduce:将元组的每个元素做相应操作,具有累积性

最后DecodeLogic实现的就是将输入的addr的每部分解码,然后得到解码的信号

PMA(Physical Memory Attribute)

PMA是一个SOC系统的固有属性,所以直接将其设为硬件实现,PMA是软件可读的,

在平台支持pma的动态重新配置的地方,将提供一个接口,通过将请求传递给能够正确重新配置平台的机器模式驱动程序来设置属性。

例如,在某些内存区域上切换可缓存性属性可能涉及特定于平台的操作,例如缓存刷新,这些操作仅对机器模式可用。

3.6.1. 主内存、I/O和空闲区域

给定内存地址范围最重要的特征是它是否符合规则内存,或I/O设备,或为空闲。常规的主存需要有许多属性,如下所述,而I/O设备可以有更广泛的属性范围。不适合常规主存的内存区域,例如设备刮擦板ram,被归类为I/O区域。空区域也被归类为I/O区域,但具有指定不支持访问的属性。

3.6.2. Supported Access Type PMAs

访问类型指定支持哪些访问宽度(从8位字节到长多字突发),以及每个访问宽度是否支持不对齐的访问。

注:虽然在RISC-V hart上运行的软件不能直接生成内存突发,但软件可能必须对DMA引擎进行编程以访问I/O设备,因此可能需要知道支持哪种访问大小。

主存区域始终支持连接设备所需的所有访问宽度的读写,并且可以指定是否支持读指令。

注:有些平台可能要求所有主存都支持指令读取。其他平台可能会禁止从某些主内存区域获取指令。

在某些情况下,访问主存的处理器或设备的设计可能支持其他宽度,但必须能够与主存支持的类型一起工作。

I/O区域可以指定支持哪些数据宽度的读、写或执行访问组合。

对于具有基于页面的虚拟内存的系统,I/O和内存区域可以指定支持哪些硬件页表读和硬件页表写的组合。

注:类unix操作系统通常要求所有可缓存的主内存都支持PTW。

3.6.3. Atomicity PMAs

原子性pma描述在此地址区域中支持哪些原子指令。对原子指令的支持分为两类:LR/SC和AMOs。有些平台可能要求所有可缓存的主存支持附加处理器所需的所有原子操作。

在AMOs中,有四个级别的支持:AMONone、amosswap、AMOLogical和AMOArithmetic。

AMONone表示不支持AMO操作。AMOSwap表示该地址范围内只支持AMOSwap指令。AMOLogical表示支持交换指令加上所有逻辑AMOs (amoand、amoor、amoxor)。“AMOArithmetic”表示支持所有的RISC-V AMOs。对于每个级别的支持,如果底层内存区域支持该宽度的读写,则支持给定宽度的自然对齐的AMOs。主存和I/O区域可能只支持处理器支持的原子操作的一个子集,或者不支持处理器支持的原子操作。

1731160758247

对于LR/SC,有三个级别的支持表示可保留性和可能性属性的组合:RsrvNone、RsrvNonEventual和RsrvEventual。RsrvNone不支持LR/SC操作(位置不可预留)。RsrvNonEventual表示支持这些操作(位置是可保留的),但没有非特权ISA规范中描述的最终成功保证。RsrvEventual表示支持这些操作,并提供最终成功的保证。

注:我们建议在可能的情况下为主内存区域提供RsrvEventual支持。

大多数I/O区域将不支持LR/SC访问,因为它们最方便地构建在缓存一致性方案之上,但有些区域可能支持RsrvNonEventual或RsrvEventual。

当LR/SC用于标记为RsrvNonEventual的内存位置时,软件应该提供在检测到缺乏进度时使用的替代回退机制。

3.6.4. Misaligned Atomicity Granule PMA

Misaligned原子性粒子PMA为失调原子性粒子提供了约束支持。这个PMA(如果存在)指定了不对齐原子颗粒的大小,即自然对齐的2次幂字节数。该PMA的特定支持值由MAGNN表示,例如,MAG16表示不对齐的原子性颗粒至少为16字节。

不对齐的原子性颗粒PMA仅适用于基本isa中定义的AMOs、load和store,以及F、D和Q扩展中定义的不超过MXLEN位的load和store。对于该集中的一条指令,如果所有被访问的字节都位于同一个未对齐的原子颗粒中,则该指令不会因为地址对齐而引发异常,并且该指令将仅出于rvwmo的目的而引发一个内存操作。,它将自动执行。

如果一个未对齐的AMO访问的区域没有指定未对齐的原子性颗粒PMA,或者不是所有访问的字节都位于同一个未对齐的原子性颗粒内,则会引发异常。

对于访问这样一个区域的常规加载和存储,或者并非所有访问的字节都位于同一原子性颗粒内,则会引发异常,或者继续访问,但不保证是原子性的。对于一些不对齐的访问,实现可能会引发访问错误异常,而不是地址不对齐异常,这表明trap处理程序不应该模拟该指令。

LR/SC指令不受此PMA的影响,因此当不对齐时总是引发异常。向量内存访问也不受影响,因此即使包含在未对齐的原子性颗粒中,也可能以非原子方式执行。隐式访问类似

3.6.5. Memory-Ordering PMAs

为了按照FENCE指令和原子指令排序位进行排序,地址空间的区域被分类为主存或I/O。

一个hart对主存区域的访问不仅可以被其他hart观察到,还可以被其他能够在主存系统中发起请求的设备(例如,DMA引擎)观察到。

coherence主存区域总是具有RVWMO或RVTSO内存模型。

非coherence的主存区域有一个实现定义的内存模型。

一个hart对一个I/O区域的访问不仅可以被其他hart和总线控制设备观察到,而且可以被目标I/O设备观察到,并且I/O区域可以以宽松或强顺序访问。其他hart和总线主控设备通常以类似于RVWMO内存区域访问顺序的方式来观察对具有宽松顺序的I/O区域的访问,如本规范第1卷a .4.2节所讨论的那样。相比之下,对具有强顺序的I/O区域的访问通常由其他hart和总线控制设备按照程序顺序观察。

每个强有序I/O区域指定一个编号的排序通道,这是一种在不同I/O区域之间提供排序保证的机制。通道0仅用于表示点对点强排序,其中只有hart对单个关联I/O区域的访问是强排序的。

通道1用于跨所有I/O区域提供全局强排序。hart对与通道1相关联的任何I/O区域的任何访问只能被所有其他hart和I/O设备观察到以程序顺序发生,包括相对于hart对宽松I/O区域或具有不同通道号的强顺序I/O区域的访问。换句话说,对通道1中的区域的任何访问都相当于在该指令之前和之后执行一个栅栏io,io指令。

其他更大的通道号为通过该通道号跨具有相同通道号的任何区域的访问提供程序排序。

系统可能支持在每个内存区域上动态配置排序属性。

强排序可用于改进与遗留设备驱动程序代码的兼容性,或者在已知实现不会重新排序访问时,与插入显式排序指令相比,可以提高性能。

本地强排序(通道0)是强排序的默认形式,因为如果hart和I/O设备之间只有一条有序通信路径,则通常可以直接提供它。

通常,如果不同的强排序I/O区域共享相同的互连路径并且路径不重新排序请求,则它们可以共享相同的排序通道,而无需额外的排序硬件

3.6.6. Coherence and Cacheability PMAs

内存区域的可缓存性不应该影响该区域的软件视图,除非在其他pma中反映出差异,例如主存与I/O分类、内存排序、支持的访问和原子操作以及一致性。出于这个原因,我们将可缓存性视为仅由机器模式软件管理的平台级设置。

如果平台支持内存区域的可配置缓存设置,则特定于平台的机器模式例程将在必要时更改设置并刷新缓存,因此系统仅在可缓存设置之间的转换期间不一致。较低的特权级别不应该看到这个临时状态

一致性很容易提供一个共享内存区域,它不被任何代理缓存。这样一个区域的PMA将简单地表示它不应该缓存在私有或共享缓存中。

对于只读区域,一致性也很简单,可以由多个代理安全地缓存,而不需要缓存一致性方案。该区域的PMA将表明它可以被缓存,但不支持写操作。

一些读写区域可能只由单个代理访问,在这种情况下,它们可以由该代理私下缓存,而不需要一致性方案。这些区域的PMA将表明它们可以被缓存。数据也可以缓存在共享缓存中,因为其他代理不应该访问该区域。

如果代理可以缓存其他代理可以访问的读写区域,无论是缓存还是非缓存,都需要缓存一致性方案来避免使用过时的值。

在缺乏硬件缓存一致性的区域(硬件非一致性区域),缓存一致性可以完全在软件中实现,但众所周知,软件一致性方案难以正确实现,并且由于需要保守的软件定向缓存刷新,通常会对性能产生严重影响。硬件缓存一致性方案需要更复杂的硬件,并且由于缓存一致性探测可能会影响性能,但对软件来说是不可见的。

对于每个硬件缓存相干区域,PMA将指示该区域是相干的,如果系统有多个相干控制器,则指示使用哪个硬件相干控制器。对于某些系统,一致性控制器可能是一个外部共享缓存,它本身可以分层访问其他外部缓存一致性控制器。

平台中的大多数内存区域将与软件一致,因为它们将被固定为非缓存、只读、硬件缓存一致或仅由一个代理访问。

如果PMA表示不可缓存,那么对该区域的访问必须由内存本身满足,而不是由任何缓存满足。

对于具有可缓存性控制机制的实现,可能会出现程序无法访问当前驻留在缓存中的内存位置的情况。在这种情况下,必须忽略缓存的副本。防止这种约束是必要的去阻止高特权模式的推测缓存重新填充不会影响较少特权模式的不可缓存访问行为。

3.6.7. Idempotency PMAs

幂等pma描述对地址区域的读写是否幂等。假定主存储器区域是幂等的。对于I/O区域,读和写的幂等性可以分别指定(例如,读是幂等的,而写不是)。如果访问是非幂等的,即对任何读或写访问都有潜在的副作用,则必须避免推测性访问或冗余访问。

为了定义幂等pma,冗余访问对观察到的内存顺序的改变不被认为是副作用。

虽然硬件应始终设计为避免对标记为非幂等的内存区域进行投机或冗余访问,但也有必要确保软件或编译器优化不会生成对非幂等内存区域的虚假访问。

非幂等区域可能不支持不对齐访问。对这些区域的不对齐访问应该引发访问错误异常,而不是地址不对齐异常,这表明软件不应该使用多个较小的访问来模拟不对齐的访问,这可能会导致意想不到的副作用。

对于非幂等区域,隐式读写不能提前或推测地执行,除了以下例外情况。当执行非推测式隐式读操作时,允许实现在包含非推测式隐式读操作地址的自然对齐的2次幂区域内额外读取任何字节。此外,当执行非推测指令获取时,允许实现额外读取下一个自然对齐的相同大小的2次幂区域内的任何字节(该区域的地址取2XLEN模)。这些额外读取的结果可用于满足后续的早期或推测式隐式读取。这些自然对齐的2次幂区域的大小是由实现定义的,但是,对于具有基于页面的虚拟内存的系统,不能超过所支持的最小页面大小

译者注:这里描述的应该跟预取有关,允许预取特定字节的数据,地址得2的幂次对齐

3.7. Physical Memory Protection

为了支持安全处理和包含错误,需要限制运行在硬件上的软件可访问的物理地址。一个可选的物理内存保护(PMP)单元提供每台机器模式控制寄存器,允许为每个物理内存区域指定物理内存访问特权(读、写、执行)。PMP值与第3.6节中描述的PMA检查并行检查。

PMP访问控制设置的粒度是特定于平台的,但是标准PMP编码支持小至4字节的区域。某些区域的特权可以是硬连接的,例如,某些区域可能只在机器模式下可见,而在低特权层中不可见。

PMP检查区域:PMP检查应用于有效特权模式为访问S和U模式的指令读取和数据访问,

当mstatus中的MPRV位被设置,并且mstatus中的MPP字段包含S或u时,m模式下的数据访问也被应用于虚拟地址转换的页表访问,其有效特权模式为S。可选地,PMP检查还可以应用于m模式访问,在这种情况下,PMP寄存器本身被锁定,因此即使m模式软件也不能更改它们,直到hart被重置。实际上,PMP可以授予S和U模式权限(默认情况下没有),还可以从Mmode撤销权限(默认情况下具有完全权限)。

PMP违规总是被捕捉到精确异常

3.7.1. Physical Memory Protection CSRs

PMP表项由一个8位配置寄存器和一个mxlen位地址寄存器描述。

一些PMP设置还使用与前一个PMP项相关联的地址寄存器。最多支持64个PMP表项。实现可以实现0、16或64个PMP表项;编号最少的PMP表项必须首先实现。所有PMP CSR字段都是WARL,可以是只读零。PMP csr仅在m模式下可访问。

PMP配置寄存器被密集地打包到csr中,以最小化上下文切换时间。对于RV32, 16个csr, pmpcfg0-pmpcfg15,为64个PMP条目保留配置pmp0cfg-pmp63cfg,如图30所示。对于RV64, 8个偶数csr pmpcfg0、pmpcfg2、…、pmpcfg14保存64个PMP条目的配置,如图31所示。对于RV64,奇数配置寄存器pmpcfg1, pmpcfg3,…,pmpcfg15是非法的。

PMP地址寄存器是命名为pmpaddr0-pmpaddr63的csr。每个PMP地址寄存器为RV32编码34位物理地址的第33-2位,如图32所示。对于RV64,每个PMP地址寄存器编码56位物理地址的第55-2位,如图33所示。并非所有的物理地址位都可以实现,因此pmpaddr寄存器是WARL

注:章节10.3中描述的基于Sv32页面的虚拟内存方案支持RV32的34位物理地址,因此PMP方案必须支持RV32的大于XLEN的地址。第10.4节和10.5节中描述的Sv39和Sv48基于页面的虚拟内存方案支持56位物理地址空间,因此RV64 PMP地址寄存器施加了相同的限制。

1731164360120

1731164379398

图34显示了PMP配置寄存器的布局。设置R、W和X位时,分别表示PMP项允许读、写和指令执行。当这些位中的一个被清除时,对应的访问类型被拒绝。R、W和X字段形成一个集合的WARL字段,其中保留R=0和W=1的组合。剩下的两个字段A和L将在下面的部分中描述。

尝试从不具有执行权限的PMP区域获取指令将引发指令访问错误异常。试图执行在没有读权限的情况下访问PMP区域内物理地址的加载或负载保留指令会引发加载访问错误异常。试图执行在没有写权限的情况下访问PMP区域内物理地址的存储、存储条件或AMO指令,将引发存储访问错误异常。

3.7.1.1. Address Matching

PMP表项配置寄存器中的A字段编码了相关联的PMP地址寄存器的地址匹配模式。这个字段的编码如表18所示。当A=0时,该PMP表项被禁用并且不匹配任何地址。支持另外两种地址匹配模式:自然对齐的2次幂区域(NAPOT),包括自然对齐的四字节区域(NA4)的特殊情况;以及任意范围的上边界(TOR)。这些模式支持四字节粒度

1731164814689

NAPOT范围使用相关地址寄存器的低阶位来编码范围的大小,如表19所示。检测连续1的数目

  • pmpaddr值为 yyyy...yy01,即连续1的个数为1,则该PMP entry所控制的地址空间为从 yyyy...yy00开始的16个字节

1731164947670

如果选择TOR,则关联的地址寄存器为地址范围的顶部,前面的PMP地址寄存器为地址范围的底部。如果PMP表项i的A字段设置为TOR,则该表项匹配任何地址y,使pmpaddri-1≤y<pmpaddri(与pmpcfgi-1的值无关)。如果PMP条目0的A字段设置为TOR,则使用0作为下界,因此它匹配任何地址y<pmpaddr0。

如果pmpaddri-1≥pmpaddri和pmpcfgi。A=TOR,则PMP表项i不匹配任何地址。

软件可以通过将0写入pmp0cfg,然后将所有1写入pmpaddr0,然后回读pmpaddr0来确定PMP粒度。如果G是最低有效位集的索引,则PMP粒度为2G+2字节。(NAPOT)

注意:这里的G是0在paddr的位置

如果当前的XLEN大于MXLEN,为了地址匹配的目的,PMP地址寄存器从MXLEN到XLEN位进行零扩展。

3.7.1.2. Locking and Privilege Mode

L位表示PMP表项被锁定。锁定的PMP表项一直处于锁定状态,直到hart被重置。如果PMP表项i被锁定,对pmppfg和pmpaddri的写入将被忽略。此外,如果PMP表项i被锁定并且PMP icfgA被设置为TOR,对pmpadri -1的写入将被忽略。

设置L位锁定PMP表项,即使A字段被设置为OFF。

除了锁定PMP表项外,L位表示是否对m模式访问强制R/W/X权限。当设置L位时,这些权限对所有特权模式强制执行。

当L位清除时,任何匹配PMP表项的m模式访问都将成功;R/W/X权限只适用于S模式和U模式。

3.7.1.3. Priority and Matching Logic

PMP表项的优先级是静态的。与访问的任何字节匹配的编号最低的PMP表项决定该访问是成功还是失败。匹配的PMP表项必须匹配访问的所有字节,否则访问失败,无论L、R、W和X位如何。例如,如果将PMP表项配置为匹配4字节范围0xC-0xF,那么假设PMP表项是匹配这些地址的最高优先级表项,那么对0x8-0xF范围的8字节访问将失败。

如果一个PMP表项匹配一次访问的所有字节,那么L、R、W和X位决定这次访问是成功还是失败。如果L位为空,且访问的特权模式为M,则表示访问成功。否则,如果设置了L位或访问的特权模式为S或U,则只有设置了与访问类型对应的R、W或X位,才能访问成功。

如果没有匹配m模式访问的PMP表项,则访问成功。如果没有匹配s模式或u模式访问的PMP表项,但至少实现了一个PMP表项,则访问失败。如果至少实现了一个PMP表项,但是所有PMP表项的A字段都被设置为OFF,那么所有s模式和u模式内存访问都将失败。

访问失败会产生指令、加载或存储访问错误异常。请注意,一条指令可能产生多个访问,这些访问可能不是相互原子的。如果一条指令产生的至少一次访问失败,则会产生访问错误异常,尽管该指令产生的其他访问可能会成功,但会产生明显的副作用。值得注意的是,引用虚拟内存的指令被分解为多个访问。

在某些实现中,不对齐的加载、存储和指令提取也可以分解为多个访问,其中一些访问可能在访问错误异常发生之前成功。特别是,通过PMP检查的未对齐存储的一部分可能变得可见,即使另一部分未通过PMP检查。即使存储地址是自然对齐的,同样的行为也可能出现在大于XLEN位的存储中(例如,RV32D中的FSD指令)。

3.7.2. Physical Memory Protection and Paging

物理内存保护机制被设计成与第10章中描述的基于页面的虚拟内存系统相结合。当启用分页时,访问虚拟内存的指令可能导致多次物理内存访问,包括对页表的隐式引用。PMP检查应用于所有这些访问。隐式可分页访问的有效特权模式是S。

使用虚拟内存的实现被允许在显式内存访问要求之前推测性地执行地址转换,并被允许将它们缓存在地址转换缓存结构中——包括可能缓存在Bare转换模式和m模式中使用的从有效地址到物理地址的身份映射。结果物理地址的PMP设置可以在地址转换和显式内存访问之间的任何点进行检查(并可能进行缓存)。因此,当修改PMP设置时,m模式软件必须将PMP设置与虚拟内存系统以及任何PMP或地址转换缓存同步。这是通过执行一个SFENCE来完成的。在PMP csr写入后,rs1=x0和rs2=x0的VMA指令。实现虚拟化管理程序扩展时的其他同步要求,请参见18.5.3节。

如果没有实现基于页面的虚拟内存,内存访问将同步检查PMP设置,因此没有SFENCE.VMA是必需的。

BOOM IFU

1731995196160

前端将从ICache读出的数据写入fetch buf

BOOM Front end

前端为5个阶段,f0产生pc,f1进行TLB转换,F2读出数据送入IMem,F3对指令预解码,检查分支预测,(f1,f2,f3每个阶段都可以产生重定向,),然后将指令送入Fetch buffer,将分支预测信息送入FTQ

F0

这个阶段选择pc,并且向icache和bpd发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
when (RegNext(reset.asBool) && !reset.asBool) {
s0_valid := true.B
s0_vpc := io_reset_vector
s0_ghist := (0.U).asTypeOf(new GlobalHistory)
s0_tsrc := BSRC_C
}

icache.io.req.valid := s0_valid
icache.io.req.bits.addr := s0_vpc

bpd.io.f0_req.valid := s0_valid
bpd.io.f0_req.bits.pc := s0_vpc
bpd.io.f0_req.bits.ghist := s0_ghist

s0的信号来自于其他阶段,这个是f1阶段的信号,如果f1有效,并且没有tlb_miss,就把f1的预测结果送入f0,然后标记结果来自BSRC_1,也就是ubtb,然后把f1的ghist送入f0,

1
2
3
4
5
6
7
8
when (s1_valid && !s1_tlb_miss) {
// Stop fetching on fault
s0_valid := !(s1_tlb_resp.ae.inst || s1_tlb_resp.pf.inst)
s0_tsrc := BSRC_1
s0_vpc := f1_predicted_target
s0_ghist := f1_predicted_ghist
s0_is_replay := false.B
}

f2阶段送入的信号分以下情况

  • 如果s2阶段有效,并且icache无回应,或者icache有回应但f3阶段没有准备好接受,此时需要进行重定向,重新发送指令请求,然后清除f1阶段,
  • 如果s2阶段有效且f3准备好接受:1. 如果f2阶段预测的和f1的pc一样,就更新f2阶段的ghist,表示预测正确,2.如果f2的预测结果和f1不一样,或者f1本身就是无效的,就清除f1阶段,并且将pc重定向为预测器的pc,将s0的预测结果设置为BSRC_2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
when ((s2_valid && !icache.io.resp.valid) ||
(s2_valid && icache.io.resp.valid && !f3_ready)) {
s0_valid := (!s2_tlb_resp.ae.inst && !s2_tlb_resp.pf.inst) || s2_is_replay || s2_tlb_miss
s0_vpc := s2_vpc
s0_is_replay := s2_valid && icache.io.resp.valid
// When this is not a replay (it queried the BPDs, we should use f3 resp in the replaying s1)
s0_s1_use_f3_bpd_resp := !s2_is_replay
s0_ghist := s2_ghist
s0_tsrc := s2_tsrc
f1_clear := true.B
} .elsewhen (s2_valid && f3_ready) {
when (s1_valid && s1_vpc === f2_predicted_target && !f2_correct_f1_ghist) {
// We trust our prediction of what the global history for the next branch should be
s2_ghist := f2_predicted_ghist
}
when ((s1_valid && (s1_vpc =/= f2_predicted_target || f2_correct_f1_ghist)) || !s1_valid) {
f1_clear := true.B

s0_valid := !((s2_tlb_resp.ae.inst || s2_tlb_resp.pf.inst) && !s2_is_replay)
s0_vpc := f2_predicted_target
s0_is_replay := false.B
s0_ghist := f2_predicted_ghist
s2_fsrc := BSRC_2
s0_tsrc := BSRC_2
}
}
s0_replay_bpd_resp := f2_bpd_resp
s0_replay_resp := s2_tlb_resp
s0_replay_ppc := s2_ppc

如果f3阶段的信号有效,f3重定向有以下情况

  • 如果f2阶段信号有效,但f2的pc不为f3的预测pc,或者f2的ghist和f3不一样
  • 如果f2阶段无效,f1阶段有效,但f1的pc不为f3的预测pc,或者f1的ghist和f3不一样
  • 如果f1,f2均无效

此时,需要清除f2和f1阶段,然后将s0的pc设置为f3预测的pc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.elsewhen (( s2_valid &&  (s2_vpc =/= f3_predicted_target || f3_correct_f2_ghist)) ||
(!s2_valid && s1_valid && (s1_vpc =/= f3_predicted_target || f3_correct_f1_ghist)) ||
(!s2_valid && !s1_valid)) {
f2_clear := true.B
f1_clear := true.B

s0_valid := !(f3_fetch_bundle.xcpt_pf_if || f3_fetch_bundle.xcpt_ae_if)
s0_vpc := f3_predicted_target
s0_is_replay := false.B
s0_ghist := f3_predicted_ghist
s0_tsrc := BSRC_3

f3_fetch_bundle.fsrc := BSRC_3
}

最后就是后端传来信号

  • 如果执行了sfence,需要冲刷整个前端,将指令设置为sfence的pc
  • 如果后端发来重定向,冲刷整个前端,将pc设置为重定向pc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
when (io.cpu.sfence.valid) {
fb.io.clear := true.B
f4_clear := true.B
f3_clear := true.B
f2_clear := true.B
f1_clear := true.B

s0_valid := false.B
s0_vpc := io.cpu.sfence.bits.addr
s0_is_replay := false.B
s0_is_sfence := true.B

}.elsewhen (io.cpu.redirect_flush) {
fb.io.clear := true.B
f4_clear := true.B
f3_clear := true.B
f2_clear := true.B
f1_clear := true.B

f3_prev_is_half := false.B

s0_valid := io.cpu.redirect_val
s0_vpc := io.cpu.redirect_pc
s0_ghist := io.cpu.redirect_ghist
s0_tsrc := BSRC_C
s0_is_replay := false.B

ftq.io.redirect.valid := io.cpu.redirect_val
ftq.io.redirect.bits := io.cpu.redirect_ftq_idx
}

总结

pc重定向

1、当执行SFENCE.VMA指令时,代表软件可能已经修改了页表,因此此时的TLB里的内容可能是错误的,那么此时正在流水线中执行的指令也有可能是错误的,因此需要刷新TLB和冲刷流水线,也需要重新进行地址翻译和取指,所以此时需要重定向PC值。

2、当执行级发现分支预测失败、后续流水线发生异常或者发生Memory Ordering Failure时(Memory Ordering Failure的相关介绍见参考资料[1]),需要冲刷流水线,将处理器恢复到错误执行前的状态,指令也需要重新进行取指,所以此时也需要重定向PC值。

3、当发生以下三种情况时,需要将PC重定向为F3阶段分支预测器预测的目标跳转地址:

F2阶段的指令有效且F3阶段的分支预测结果与此时处于F2阶段的指令的PC值不相同;

F2阶段的指令无效且F3阶段的分支预测结果与此时处于F1阶段的指令的PC值不相同;

F2阶段和F1阶段的指令均无效。

4、当Icache的响应无效或者F3阶段传来的握手信号没有准备就绪时,需要将PC值重定向为此时处于F2阶段的指令的PC值。

5、当F1阶段的指令有效且F2阶段的分支预测结果与此时处于F1阶段的指令的PC值不相同或者F1阶段的指令无效时,需要将PC重定向为F2阶段分支预测器预测的目标跳转地址。

6、当TLB没有发生miss且F1阶段的分支预测器预测结果为跳转时,需要将PC重定向为预测的目标跳转地址。

F1

F1阶段进行tlb转换,并且得出ubtb结果,如果tlb miss需要终止icache访存,这个周期ubtb给出预测结果,根据结果对前端重定向

TLB访问逻辑

如下面代码,s1_resp的结果来自两部分,如果s1有replay信号,那么结果就是replay的数据(只有f2才会发出replay表示指令准备好了但不能接受),否则就是tlb得出的数据

个人感觉这里是降低功耗的一个小方法,如果f2replay,那么他的物理地址一定计算完了,我们就可以减少一次tlb访问

1
2
3
4
5
6
7
8
9
tlb.io.req.valid      := (s1_valid && !s1_is_replay && !f1_clear) || s1_is_sfence
...
val s1_tlb_miss = !s1_is_replay && tlb.io.resp.miss
val s1_tlb_resp = Mux(s1_is_replay, RegNext(s0_replay_resp), tlb.io.resp)
val s1_ppc = Mux(s1_is_replay, RegNext(s0_replay_ppc), tlb.io.resp.paddr)
val s1_bpd_resp = bpd.io.resp.f1

icache.io.s1_paddr := s1_ppc
icache.io.s1_kill := tlb.io.resp.miss || f1_clear

分支信息处理逻辑

f1阶段得出的分支预测结果可能有多个,我们取最旧的一个作为分支目标地址,然后更新ghist(GHR)

如何选出最旧的分支呢?这里的做法是首先通过fetchMask得到一个指令包的有效指令位置,然后通过通过查询每个指令是否是分支指令并且taken,生成一个新的f1_redirects,然后通过优先编码器得到最旧指令的idx,之后从bpd的resp取出这个idx对应预测结果,如果确实有分支进行预测,就置target为预测的target,否则为pc+4(or 2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
val f1_mask = fetchMask(s1_vpc)
val f1_redirects = (0 until fetchWidth) map { i =>
s1_valid && f1_mask(i) && s1_bpd_resp.preds(i).predicted_pc.valid &&
(s1_bpd_resp.preds(i).is_jal ||
(s1_bpd_resp.preds(i).is_br && s1_bpd_resp.preds(i).taken))
}
val f1_redirect_idx = PriorityEncoder(f1_redirects)
val f1_do_redirect = f1_redirects.reduce(_||_) && useBPD.B
val f1_targs = s1_bpd_resp.preds.map(_.predicted_pc.bits)
val f1_predicted_target = Mux(f1_do_redirect,
f1_targs(f1_redirect_idx),
nextFetch(s1_vpc))

val f1_predicted_ghist = s1_ghist.update(
s1_bpd_resp.preds.map(p => p.is_br && p.predicted_pc.valid).asUInt & f1_mask,
s1_bpd_resp.preds(f1_redirect_idx).taken && f1_do_redirect,
s1_bpd_resp.preds(f1_redirect_idx).is_br,
f1_redirect_idx,
f1_do_redirect,
s1_vpc,
false.B,
false.B)

详解mask

取指令通过mask来屏蔽无效指令,如下面代码,我们只讲解bank=2的情况,首先算出shamt位移量,然后通过是否在同一个set算出end_mask,最后进行编码

举例:假设fetchWidth=8,coreInstBytes=2,block=16bytes,numChunks=2 banks=2

如果地址为0011 1100,

idx=110

shamt=10

那么这个地址显然需要跨两行,mayNotBeDualBanked显然为1,

故end_mask = 0000 1111

故最终结果为0000 1100,也就是他会屏蔽跨行的指令

如果地址为0011 0100,这个没有跨行,所以最终结果为

1111 1100

也就是说,mask是对取出的指令做一个有效编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def isLastBankInBlock(addr: UInt) = {
(nBanks == 2).B && addr(blockOffBits-1, log2Ceil(bankBytes)) === (numChunks-1).U
}
def mayNotBeDualBanked(addr: UInt) = {
require(nBanks == 2)
isLastBankInBlock(addr)
}
def fetchMask(addr: UInt) = {
val idx = addr.extract(log2Ceil(fetchWidth)+log2Ceil(coreInstBytes)-1, log2Ceil(coreInstBytes))
if (nBanks == 1) {
((1 << fetchWidth)-1).U << idx
} else {
val shamt = idx.extract(log2Ceil(fetchWidth)-2, 0)
val end_mask = Mux(mayNotBeDualBanked(addr), Fill(fetchWidth/2, 1.U), Fill(fetchWidth, 1.U))
((1 << fetchWidth)-1).U << shamt & end_mask
}
}

那么br_mask 就是在有效指令中筛选为BR的指令

GHist更新逻辑

以例子来进行讲解

Ghist的更新是采用了update方法,他的输入依次如下:

  • branches: UInt,:这个就是上面讲解的br_mask,
  • cfi_taken: Bool:指令是否taken,这个信号一般指的是最旧的指令是否taken,这个例子就是先得出f1_redirects(重定向指令的mask),然后通过优先编码器得出最旧的指令然后得出是否要重定向信号f1_do_redirect,以及预测目标,所以这个信号就是最旧的分支是否taken,并且是否要重定向。
  • cfi_is_br: Bool:这个信号得出了最旧的分支指令是否为br,(f1分支预测包含br jalr,jalr,但只有条件分支可以更改ghist)
  • cfi_idx: UInt:得出最旧的分支指令的(这个可能包括jal或jalr,而且这个不是oh编码,只是简单的idx)
  • cfi_valid: Bool:是否需要重定向
  • addr: UInt:pc
  • cfi_is_call: Bool
  • cfi_is_ret: Bool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
val f1_mask = fetchMask(s1_vpc)
val f1_redirects = (0 until fetchWidth) map { i =>
s1_valid && f1_mask(i) && s1_bpd_resp.preds(i).predicted_pc.valid &&
(s1_bpd_resp.preds(i).is_jal ||
(s1_bpd_resp.preds(i).is_br && s1_bpd_resp.preds(i).taken))
}
val f1_redirect_idx = PriorityEncoder(f1_redirects)
val f1_do_redirect = f1_redirects.reduce(_||_) && useBPD.B
val f1_targs = s1_bpd_resp.preds.map(_.predicted_pc.bits)
val f1_predicted_target = Mux(f1_do_redirect,
f1_targs(f1_redirect_idx),
nextFetch(s1_vpc))
val f1_predicted_ghist = s1_ghist.update(
s1_bpd_resp.preds.map(p => p.is_br && p.predicted_pc.valid).asUInt & f1_mask,
s1_bpd_resp.preds(f1_redirect_idx).taken && f1_do_redirect,
s1_bpd_resp.preds(f1_redirect_idx).is_br,
f1_redirect_idx,
f1_do_redirect,
s1_vpc,
false.B,
false.B)

not_taken_branches:如果条件分支taken或者不是条件分支,这个就为0,否则就不为0,

然后进入update方法,update方法也是分了bank讨论,首先讨论bank为1的

new_history.old_history更新逻辑:

  • 如果这个分支是条件分支并且taken:histories(0) <<1|1.U
  • 如果是条件分支但没有taken:histories(0) <<1
  • 如果不是条件分支:histories(0)

下面讨论bank为2的情况

他使用的始终histories(1),也就是更新逻辑,

首先判断cfi指令在bank0或者整个packet是否跨行了(ignore_second_bank),然后得出第一个bank是否有条件分支未taken(first_bank_saw_not_taken):

如果忽视bank1,根据new_history.new_saw_branch_not_taken ,new_history.new_saw_branch_taken更新old_hist

否则,new_saw_branch_not_taken:bank1是否有没taken的指令

new_saw_branch_taken:bank1是否有taken的指令并且cfi不在bank0

然后更新old_hist:

感觉这个更新逻辑有问题,ignore_second_bank有两个条件:如果cfi在bank0,otherwise的MUX的cfi_is_br && cfi_in_bank_0必然不会成立,如果mayNotBeDualBanked成立,那么cfi必然在bank1,该条件仍然不会成立,同样first_bank_saw_not_taken也不会成立,所以这个逻辑最后就是得到了histories(1),之前的逻辑都是冗余的(将多余代码去掉仍然可以运行程序)

没什么问题,如果想进入otherwise代码块:

  1. bank0无分支或者分支预测没taken
  2. 分支指令在bank1

但cfi_is_br && cfi_in_bank_0是无效的逻辑,进入when代码块必然不会进入otherwise,所以必然不会触发这个MUX条件(理解问题?)

举个例子来说明这两个条件什么意思:

例:假设fetchWidth=8,coreInstBytes=2,block=16bytes,numChunks=2 banks=2

如果地址为0011 1100,cfi_idx_oh为0000 1000

这个地址mayNotBeDualBanked为1,cfi_in_bank0为1,如果这个不是分支,或者没有taken,cfi_in_bank0为0

如果地址0011 0000,cfi_idx_oh为0001 0000

这个地址mayNotBeDualBanked为0,cfi_in_bank0为0,如果cfi_idx_oh,cfi_in_bank0就为1

这里ignore_second_bank的意思就是第二个分支没有分支或者分支无效,

假设第二个bank有分支,我们会忽视第一个bank的分支历史,只更新第二个bank

In the two bank case every bank ignore the history added by the previous bank

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
  def histories(bank: Int) = {
if (nBanks == 1) {
old_history
} else {
require(nBanks == 2)
if (bank == 0) {
old_history
} else {
Mux(new_saw_branch_taken , old_history << 1 | 1.U,
Mux(new_saw_branch_not_taken , old_history << 1,
old_history))
}
}
}
def update(branches: UInt, cfi_taken: Bool, cfi_is_br: Bool, cfi_idx: UInt,
cfi_valid: Bool, addr: UInt,
cfi_is_call: Bool, cfi_is_ret: Bool): GlobalHistory = {
val cfi_idx_fixed = cfi_idx(log2Ceil(fetchWidth)-1,0)
val cfi_idx_oh = UIntToOH(cfi_idx_fixed)
val new_history = Wire(new GlobalHistory)

val not_taken_branches = branches & Mux(cfi_valid,
MaskLower(cfi_idx_oh) & ~Mux(cfi_is_br && cfi_taken, cfi_idx_oh, 0.U(fetchWidth.W)),
~(0.U(fetchWidth.W)))

if (nBanks == 1) {
// In the single bank case every bank sees the history including the previous bank
new_history := DontCare
new_history.current_saw_branch_not_taken := false.B
val saw_not_taken_branch = not_taken_branches =/= 0.U || current_saw_branch_not_taken
new_history.old_history := Mux(cfi_is_br && cfi_taken && cfi_valid , histories(0) << 1 | 1.U,
Mux(saw_not_taken_branch , histories(0) << 1,
histories(0)))
} else {
// In the two bank case every bank ignore the history added by the previous bank
val base = histories(1)
val cfi_in_bank_0 = cfi_valid && cfi_taken && cfi_idx_fixed < bankWidth.U
val ignore_second_bank = cfi_in_bank_0 || mayNotBeDualBanked(addr)

val first_bank_saw_not_taken = not_taken_branches(bankWidth-1,0) =/= 0.U || current_saw_branch_not_taken
new_history.current_saw_branch_not_taken := false.B
when (ignore_second_bank) {
new_history.old_history := histories(1)
new_history.new_saw_branch_not_taken := first_bank_saw_not_taken
new_history.new_saw_branch_taken := cfi_is_br && cfi_in_bank_0
} .otherwise {
new_history.old_history := Mux(cfi_is_br && cfi_in_bank_0 , histories(1) << 1 | 1.U,
Mux(first_bank_saw_not_taken , histories(1) << 1,
histories(1)))

new_history.new_saw_branch_not_taken := not_taken_branches(fetchWidth-1,bankWidth) =/= 0.U
new_history.new_saw_branch_taken := cfi_valid && cfi_taken && cfi_is_br && !cfi_in_bank_0

}
}
new_history.ras_idx := Mux(cfi_valid && cfi_is_call, WrapInc(ras_idx, nRasEntries),
Mux(cfi_valid && cfi_is_ret , WrapDec(ras_idx, nRasEntries), ras_idx))
new_history
}

F2

f2阶段获得cache数据,注意f2阶段可能收到无效的cache数据,或者收到了数据但f3接收不了,这时就要重定向,然后冲刷f1阶段,f2阶段也会得到预测结果,其处理和f1阶段类似

(s1_vpc =/= f2_predicted_target || f2_correct_f1_ghist),f2分支预测重定向需要前面两个条件,条件1的意思就是f2阶段预测的地址和之前这条指令与目前f1的pc不一样,条件2的意思预测方向不一样

F3

f3阶段使用了IMem Response Queue和BTB Response Queue,两个队列项数均为1,其中IMem Response Queue在F2阶段入队,在F3阶段出队,主要传递Icache响应的指令、PC、全局历史等信息;而BTB Response Queue则设置成“flow”的形式(即输入可以在同一周期内“流”过队列输出),所以它的入队出队均在F3阶段完成,主要传递分支预测器的预测信息。

这个周期也会有来自bpd的预测信息(TAGE),同样会进行重定向,该阶段有一个快速译码单元用于检查分支预测,并且这个周期会检查RVC指令并进行相应处理

有效指令截断处理

也就是32位的指令分布在两个指令包

小插曲:f3_prev_is_half的值来自bank_prev_is_half,而bank_prev_is_half是一个var,也就是可变变量,这里他在for循环内多次被赋值,实际上就是给f3_prev_is_half提供了多个赋值条件

1
2
3
4
5
6
7
8
9
10
...   
bank_prev_is_half = Mux(f3_bank_mask(b),
(!(bank_mask(bankWidth-2) && !isRVC(bank_insts(bankWidth-2))) && !isRVC(last_inst)),
bank_prev_is_half)
...
when (f3.io.deq.fire) {
f3_prev_is_half := bank_prev_is_half
f3_prev_half := bank_prev_half
assert(f3_bpd_resp.io.deq.bits.pc === f3_fetch_bundle.pc)
}

下面是一个测试用例

1732097110110

1732097127143

首先先解析bank信号,bank_data可以看到就是每个bank的data,对于largeboom就是64位的数据(其中bankwidth为4,bank为2)

1
2
3
val bank_data  = f3_data((b+1)*bankWidth*16-1, b*bankWidth*16)
val bank_mask = Wire(Vec(bankWidth, Bool()))
val bank_insts = Wire(Vec(bankWidth, UInt(32.W)))

bank_mask和之前提到的mask类似,揭示了一个bank每条指令是否有效,

当f3的指令有效并且没有收到重定向信号,就对bank_mask赋值

1
2
3
4
5
6
  for (b <- 0 until nBanks) {
.....

for (w <- 0 until bankWidth) {
val i = (b * bankWidth) + w
bank_mask(w) := f3.io.deq.valid && f3_imemresp.mask(i) && valid && !redirect_found

bank_inst主要逻辑在内层循环内

主要有4种情况:

  1. 当w=0,也就是第一条指令,注意这条指令可能是不完整的32bit指令,如果这条指令是不完整,那么就将之前存的half指令拼接到这个不完整的指令,形成32bit(bank_data(15,0), f3_prev_half),注意如果此时b>0,也即是现在是bank1,那么之前的一半指令就是(bank_data(15,0), last_inst)拼接,如果这个指令是完整的指令,就直接为bank_data(31,0),valid一定为true
  2. 当w=1,bank_inst就直接为bank_data(47,16),
  3. 当w=bankWidth -1,注意这里可能会发生32bit的指令不完整的情况,bank_inst为16个0和bank_data(bankWidth*16-1,(bankWidth-1)*16)拼接,
  4. 其他情况,bank_data(w16+32-1,w16)

valid信号四种情况

w=0,恒为高

w=1,如果之前的指令为bank_prev_is_half,或者不满足括号条件(之前的指令有效但不是RVC指令),说明这个inst和之前的inst无关,valid拉高

w=bankWidth -1,这里列举所有情况:

  1. 本条不是RVC,且上条也不是RVC:1.本条指令和上一条是一条指令,那么本条指令就无效,本条指令是下一个bank的前半部分指令,那么本条就为有效
  2. 本条不是RVC,但上一条是RVC:恒为高
  3. 本条是RVC,但上一条不是RVC,恒为高,因为上一条一定是32bit指令的后半部分,其bank_mask一定为低,!((bank_mask(w-1) &&!isRVC(bank_insts(w-1)))一定为高
  4. 本条是RVC,上条也是RVC:恒为高

其他情况:只要上条指令不满足(bank_mask(w-1) &&!isRVC(bank_insts(w-1),就为高(上条指令无效,上条指令为32bit指令的后半部分或上条指令为RVC指令)

如下面的矩形,绿色代表4字节的指令,蓝色代表2字节的指令,四个块一个bank,其中情况1的b>0情况,第四个块就是last_inst,b=0的情况就是第一个块为4字节指令的后一半,前一半在f3_prev_half中存储,也就是之前的指令包的w=bankWidth -1,的指令

1732108893805

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    for (w <- 0 until bankWidth) {
...
val brsigs = Wire(new BranchDecodeSignals)
if (w == 0) {
val inst0 = Cat(bank_data(15,0), f3_prev_half)
val inst1 = bank_data(31,0)
...

when (bank_prev_is_half) {
bank_insts(w) := inst0
...
if (b > 0) {
val inst0b = Cat(bank_data(15,0), last_inst)
...
when (f3_bank_mask(b-1)) {
bank_insts(w) := inst0b
f3_fetch_bundle.insts(i) := inst0b
f3_fetch_bundle.exp_insts(i) := exp_inst0b
brsigs := bpd_decoder0b.io.out
}
}
} .otherwise {
bank_insts(w) := inst1
...
}
valid := true.B
} else {
val inst = Wire(UInt(32.W))
..
val pc = f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U
...
bank_insts(w) := inst
...
if (w == 1) {
// Need special case since 0th instruction may carry over the wrap around
inst := bank_data(47,16)
valid := bank_prev_is_half || !(bank_mask(0) && !isRVC(bank_insts(0)))
} else if (w == bankWidth - 1) {
inst := Cat(0.U(16.W), bank_data(bankWidth*16-1,(bankWidth-1)*16))
valid := !((bank_mask(w-1) && !isRVC(bank_insts(w-1))) ||
!isRVC(inst))
} else {
inst := bank_data(w*16+32-1,w*16)
valid := !(bank_mask(w-1) && !isRVC(bank_insts(w-1)))
}
}
last_inst = bank_insts(bankWidth-1)(15,0)
...
}

OK,bank信号已经解释完了,接下来进行分支指令解码

分支指令预解码

ExpandRVC判断这个指令是否为RVC,如果为RVC,返回相应的扩展指令,如果不是RVC,直接返回输入的inst,inst0和1对应的是两种情况,一种是本指令包的第一条为32位指令,但有一半在上个指令包,另一种就是指令是整齐的

如果这个指令对应两条RVC指令呢?

RVC和RVI指令如何区分的呢

f3_bank_mask信号有用吗

1
2
3
4
5
6
7
val inst0 = Cat(bank_data(15,0), f3_prev_half)
val inst1 = bank_data(31,0)
val exp_inst0 = ExpandRVC(inst0)
val exp_inst1 = ExpandRVC(inst1)//inst0和1分别对应了RVI指令和未知的指令
val pc0 = (f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U - 2.U)
val pc1 = (f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U)

分支预解码也是分情况

  1. w=0,如果遇到了不完整的指令,就采用decoder0的结果,b>0,同样要做出处理,将inst0的f3_prev_half换为last_inst(其实这里bank_prev_half也可以),之后对这个指令解码就可以,否则就使用inst1的解码结果
  2. 其他情况,就直接对inst解码,注意inst的生成也会分情况(之前讲过)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
 for (b <- 0 until nBanks) {
...
for (w <- 0 until bankWidth) {
...
val brsigs = Wire(new BranchDecodeSignals)
if (w == 0) {
val inst0 = Cat(bank_data(15,0), f3_prev_half)
val inst1 = bank_data(31,0)
val exp_inst0 = ExpandRVC(inst0)
val exp_inst1 = ExpandRVC(inst1)//inst0和1分别对应了RVI指令和未知的指令
val pc0 = (f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U - 2.U)
val pc1 = (f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U)
val bpd_decoder0 = Module(new BranchDecode)
bpd_decoder0.io.inst := exp_inst0
bpd_decoder0.io.pc := pc0
val bpd_decoder1 = Module(new BranchDecode)
bpd_decoder1.io.inst := exp_inst1
bpd_decoder1.io.pc := pc1

when (bank_prev_is_half) {
bank_insts(w) := inst0
...
bpu.io.pc := pc0
brsigs := bpd_decoder0.io.out//指令不完整.且一定为32位,选择decode0的br信号
...
if (b > 0) {
val inst0b = Cat(bank_data(15,0), last_inst)
val exp_inst0b = ExpandRVC(inst0b)
val bpd_decoder0b = Module(new BranchDecode)
bpd_decoder0b.io.inst := exp_inst0b
bpd_decoder0b.io.pc := pc0

when (f3_bank_mask(b-1)) {
...
brsigs := bpd_decoder0b.io.out
}
}
} .otherwise {
...
bpu.io.pc := pc1
brsigs := bpd_decoder1.io.out

}
valid := true.B
} else {
val inst = Wire(UInt(32.W))
val exp_inst = ExpandRVC(inst)
val pc = f3_aligned_pc + (i << log2Ceil(coreInstBytes)).U
val bpd_decoder = Module(new BranchDecode)
bpd_decoder.io.inst := exp_inst
bpd_decoder.io.pc := pc
...
bpu.io.pc := pc
brsigs := bpd_decoder.io.out
...
}

...
}

f3阶段的目标来自多个地方(f3_targs):如果是jalr指令,那么目标地址只能为bpd预测的地址,如果是条件分支或者jal,目标地址就是解码出来的地址

如果是jal指令:需要对目标地址检测,如果目标地址预测正确,不刷新BTB表项,

如果进行重定向,那么先检测是不是ret指令.如果是,就从RAS取出数据,否则从f3_targs取数据,

如果不重定向,就对pc+bankbyte或者fetchbyte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
      f3_targs (i) := Mux(brsigs.cfi_type === CFI_JALR,
f3_bpd_resp.io.deq.bits.preds(i).predicted_pc.bits,
brsigs.target)

// Flush BTB entries for JALs if we mispredict the target
f3_btb_mispredicts(i) := (brsigs.cfi_type === CFI_JAL && valid &&
f3_bpd_resp.io.deq.bits.preds(i).predicted_pc.valid &&
(f3_bpd_resp.io.deq.bits.preds(i).predicted_pc.bits =/= brsigs.target)
)


f3_npc_plus4_mask(i) := (if (w == 0) {
!f3_is_rvc(i) && !bank_prev_is_half
} else {
!f3_is_rvc(i)
})
...
val f3_predicted_target = Mux(f3_redirects.reduce(_||_),
Mux(f3_fetch_bundle.cfi_is_ret && useBPD.B && useRAS.B,
ras.io.read_addr,
f3_targs(PriorityEncoder(f3_redirects))
),
nextFetch(f3_fetch_bundle.pc)
)

总结

F3阶段算是前端的一个核心阶段,这个阶段进行分支预解码,TAGE出结果,并且对RVC指令检测,将RVC变为32位指令,之后将f3_fetch_bundle送入F4

F4

F4阶段主要进行的工作就是重定向操作,这个在F0中已经讲解,f4阶段还会将指令写入Fetchbuffer和FTQ

f4阶段还会修复前端的BTB或RAS,首先有一个仲裁器选择重定向信息来自FTQ还是f4阶段的BTB重定向信息,(低位优先级高),如果FTQ传来RAS修复信号,就对RAS进行修复

1
2
3
4
5
6
7
8
9
10
11
12
13
val bpd_update_arbiter = Module(new Arbiter(new BranchPredictionUpdate, 2))
bpd_update_arbiter.io.in(0).valid := ftq.io.bpdupdate.valid
bpd_update_arbiter.io.in(0).bits := ftq.io.bpdupdate.bits
assert(bpd_update_arbiter.io.in(0).ready)
bpd_update_arbiter.io.in(1) <> f4_btb_corrections.io.deq
bpd.io.update := bpd_update_arbiter.io.out
bpd_update_arbiter.io.out.ready := true.B

when (ftq.io.ras_update && enableRasTopRepair.B) {
ras.io.write_valid := true.B
ras.io.write_idx := ftq.io.ras_update_idx
ras.io.write_addr := ftq.io.ras_update_pc
}

F5

虚拟的阶段,主要对将IFU数据送入IDU,进行重定向操作

BOOM FTQ

获取目标队列是一个队列,用于保存从 i-cache 接收到的 PC 以及与该地址关联的分支预测信息。它保存此信息,供管道在执行其微操作 (UOP)时参考。一旦提交指令,ROB 就会将其从队列中移出,并在重定向/误推测期间进行更新。

入队

当do_enq拉高,表示入队信号拉高,进入入队逻辑,new_entry和new_ghist接受入队数据,如现阶段有分支预测失败,就将入队glist写入new_list,否则,按照之前的数据更新new_list,然后写入ghist和lhist

重定向

为什么bpd_idx要增加

为什么要用两个ghist,

如下面波形,bpd_repair就是ftq_idx对应的pc

1732436351046

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//下面是一次预测失败要经过的状态
//| br_info | b2 |reg(b2) | | |
//| b1 | red_val|mispred(false) |mispred(true)|mispred(false)|mispred(false)| |____
//| | |repair(false) |repair(false)|repair(true) |repair(true) | | |一直运行直到修复完成
// | repair_idx repair_idx repair_pc |repair_idx| +__|
// | end_idx repair_idx | |
// (找到分支预测失败的ftq表项) ()
//

when (io.redirect.valid) {
bpd_update_mispredict := false.B
bpd_update_repair := false.B
} .elsewhen (RegNext(io.brupdate.b2.mispredict)) {
bpd_update_mispredict := true.B
bpd_repair_idx := RegNext(io.brupdate.b2.uop.ftq_idx)
bpd_end_idx := RegNext(enq_ptr)
} .elsewhen (bpd_update_mispredict) {//
bpd_update_mispredict := false.B
bpd_update_repair := true.B
bpd_repair_idx := WrapInc(bpd_repair_idx, num_entries)
} .elsewhen (bpd_update_repair && RegNext(bpd_update_mispredict)) {
bpd_repair_pc := bpd_pc
bpd_repair_idx := WrapInc(bpd_repair_idx, num_entries)
} .elsewhen (bpd_update_repair) {
bpd_repair_idx := WrapInc(bpd_repair_idx, num_entries)
when (WrapInc(bpd_repair_idx, num_entries) === bpd_end_idx ||
bpd_pc === bpd_repair_pc) {
bpd_update_repair := false.B
}

}

分支预测失败的状态机如上面所示,

接下来就是传入更新信息,首先将enq_ptr设置为传入的ftq_idx+1,如果这个重定向来自分支预测失败,就将更新信息写入redirect_new_entry,然后下个周期将更新信息写入prev_entry,将重定向的信息写入entry_ram;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  when (io.redirect.valid) {//传入更新信息
enq_ptr := WrapInc(io.redirect.bits, num_entries)

when (io.brupdate.b2.mispredict) {
val new_cfi_idx = (io.brupdate.b2.uop.pc_lob ^
Mux(redirect_entry.start_bank === 1.U, 1.U << log2Ceil(bankBytes), 0.U))(log2Ceil(fetchWidth), 1)
.......
}

.......

} .elsewhen (RegNext(io.redirect.valid)) {//信息传入完成
prev_entry := RegNext(redirect_new_entry)
prev_ghist := bpd_ghist
prev_pc := bpd_pc

ram(RegNext(io.redirect.bits)) := RegNext(redirect_new_entry)
}

恢复逻辑

本节是在读完整个boom代码对ftq重新总结,阅读本节需要理解ftq一系列的regnext

1741010816082

首先截取波形,8000131c发生分支预测错误,此时已经进入了很多不对的ftq项,所以,然后进入恢复处理,第一个周期去重置misspred和repair信号,恢复enq_ptr,将其置为发生分支预测错误的ftq_idx+1,此时也会去更新ftq项,第二个周期去记录enq_ptr,然后置高mispred信号,该信号是指示BP我们是去更新BP,之后进入repair阶段,当repair_idx=之前记录的enq_idx,对BP的修复就结束了

修复阶段只修复br_mask不为0或者cfi_valid=1的,虽然向BP传入了repair和mispred信号,但实际上只是在LOOP中使用,bim只在提交更新才会写入,BTB就算更新错了也无妨,这里可以相当于预取

然后有一个valid_repair信号,个人理解这个信号是为了减少恢复次数

如果在修复时遇到repair_pc =pc,说明之后执行的指令和之前修复的一样,所以不需要重复去修复

后端读pc

有两个端口,其中0端口是送入后端jmp_unit的,端口1主要是进行重定向获取pc的,主要代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (i <- 0 until 2) {
val idx = io.get_ftq_pc(i).ftq_idx
val next_idx = WrapInc(idx, num_entries)
val next_is_enq = (next_idx === enq_ptr) && io.enq.fire
val next_pc = Mux(next_is_enq, io.enq.bits.pc, pcs(next_idx))
val get_entry = ram(idx)
val next_entry = ram(next_idx)
io.get_ftq_pc(i).entry := RegNext(get_entry)
if (i == 1)
io.get_ftq_pc(i).ghist := ghist(1).read(idx, true.B)
else
io.get_ftq_pc(i).ghist := DontCare
io.get_ftq_pc(i).pc := RegNext(pcs(idx))
io.get_ftq_pc(i).next_pc := RegNext(next_pc)
io.get_ftq_pc(i).next_val := RegNext(next_idx =/= enq_ptr || next_is_enq)
io.get_ftq_pc(i).com_pc := RegNext(pcs(Mux(io.deq.valid, io.deq.bits, deq_ptr)))
}

这些bpd_pc和mispred以及repair到底是干什么的

一条分支指令处理的流程

globalhistory的current_saw_branch_not_taken是干什么的

cfi这些信号是干什么的?

Fetch Buffer

Fetch Buffer本质上是一个FIFO,寄存器堆构成,主要是作为缓冲,其可以配置为流式fifo,Fetch Buffer每次从F4阶段输入一个Fetch Packets,根据掩码将无效指令去掉后,从Buffer的尾部进入,每次从Buffer的头部输出coreWidth(后续流水线并行执行的宽度)个指令到译码级。

入队出队信号

might_hit_head得出这次访问可能会满,at_head表示tail已经和head重叠了,只有前面信号都不满足,才可以写入

假如fb大小为8项,每次最多写入4条,最多读出四条,假设连续写入两次,这时候tail和head就重合了,表示写满了

will_hit_tail信号揭示了head是否会和tail重合,也就是指令是否还够取(每次必须corewidth条)

参数和上一个例子一样,假如没有读出head为01,然后tail指针为0000 1000,表示写入了三条指令,这样得出来的tail_collisions就为0000 1000,然后will_hit_tail就为高,表示内部没有四条指令(妙)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  def rotateLeft(in: UInt, k: Int) = {
val n = in.getWidth
Cat(in(n-k-1,0), in(n-1, n-k))
}

val might_hit_head = (1 until fetchWidth).map(k => VecInit(rotateLeft(tail, k).asBools.zipWithIndex.filter
{case (e,i) => i % coreWidth == 0}.map {case (e,i) => e}).asUInt).map(tail => head & tail).reduce(_|_).orR
val at_head = (VecInit(tail.asBools.zipWithIndex.filter {case (e,i) => i % coreWidth == 0}
.map {case (e,i) => e}).asUInt & head).orR
val do_enq = !(at_head && maybe_full || might_hit_head)

io.enq.ready := do_enq
...
val tail_collisions = VecInit((0 until numEntries).map(i =>
head(i/coreWidth) && (!maybe_full || (i % coreWidth != 0).B))).asUInt & tail
val slot_will_hit_tail = (0 until numRows).map(i => tail_collisions((i+1)*coreWidth-1, i*coreWidth)).reduce(_|_)
val will_hit_tail = slot_will_hit_tail.orR

val do_deq = io.deq.ready && !will_hit_tail

转换输入

代码如下,注意当w=0,需要考虑edge_inst,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  for (b <- 0 until nBanks) {
for (w <- 0 until bankWidth) {
val i = (b * bankWidth) + w
val pc = (bankAlign(io.enq.bits.pc) + (i << 1).U)
in_mask(i) := io.enq.valid && io.enq.bits.mask(i)
...

if (w == 0) {
when (io.enq.bits.edge_inst(b)) {
in_uops(i).debug_pc := bankAlign(io.enq.bits.pc) + (b * bankBytes).U - 2.U
in_uops(i).pc_lob := bankAlign(io.enq.bits.pc) + (b * bankBytes).U
in_uops(i).edge_inst := true.B
}
}
in_uops(i).ftq_idx := io.enq.bits.ftq_idx
in_uops(i).inst := io.enq.bits.exp_insts(i)
in_uops(i).debug_inst := io.enq.bits.insts(i)
in_uops(i).is_rvc := io.enq.bits.insts(i)(1,0) =/= 3.U
in_uops(i).taken := io.enq.bits.cfi_idx.bits === i.U && io.enq.bits.cfi_idx.valid

in_uops(i).xcpt_pf_if := io.enq.bits.xcpt_pf_if
in_uops(i).xcpt_ae_if := io.enq.bits.xcpt_ae_if
in_uops(i).bp_debug_if := io.enq.bits.bp_debug_if_oh(i)
in_uops(i).bp_xcpt_if := io.enq.bits.bp_xcpt_if_oh(i)

in_uops(i).debug_fsrc := io.enq.bits.fsrc
}
}

生成oh写索引

向量大小为fetchwidth=8,如果输入指令是有效的,就写入inc的索引,否则写入之前的值

tail初始值为1,之后如果inc就将最高位移入最低位,哪一位为1就说明写入哪一位

1
2
3
4
5
6
7
8
9
10
11
12
val enq_idxs = Wire(Vec(fetchWidth, UInt(numEntries.W)))

def inc(ptr: UInt) = {
val n = ptr.getWidth
Cat(ptr(n-2,0), ptr(n-1))
}

var enq_idx = tail
for (i <- 0 until fetchWidth) {
enq_idxs(i) := enq_idx
enq_idx = Mux(in_mask(i), inc(enq_idx), enq_idx)
}

写入fb

只将有效的写入fb,也就是,如果入队信号拉高,并且输入指令有效,且找到对应的写索引,就将数据写入fb

1
2
3
4
5
6
7
for (i <- 0 until fetchWidth) {
for (j <- 0 until numEntries) {
when (do_enq && in_mask(i) && enq_idxs(i)(j)) {
ram(j) := in_uops(i)
}
}
}

出队信号

deq_vec就是把fb数据转换换为出队的,这里i/coreWidth得出的是出去的是第几行,i%coreWidth表示的是行内的哪条uops,然后使用Mux1H选出head的前corewidth条数据

1
2
3
4
5
6
7
8
// Generate vec for dequeue read port.
for (i <- 0 until numEntries) {
deq_vec(i/coreWidth)(i%coreWidth) := ram(i)
}

io.deq.bits.uops zip deq_valids map {case (d,v) => d.valid := v}
io.deq.bits.uops zip Mux1H(head, deq_vec) map {case (d,q) => d.bits := q}
io.deq.valid := deq_valids.reduce(_||_)

指针状态更新

如果入队信号来了,就修改tail指针为enq_idx,出队就inc head指针,如果clear,就重置指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
when (do_enq) {
tail := enq_idx
when (in_mask.reduce(_||_)) {
maybe_full := true.B
}
}

when (do_deq) {
head := inc(head)
maybe_full := false.B
}

when (io.clear) {
head := 1.U
tail := 1.U
maybe_full := false.B
}

总结

这里使用oh编码来对地址编码,然后fb还通过一些特殊的方法来判断head和tail关系,十分巧妙

分支预测器

composer模块将各个模块的请求和更新连接到IO,然后将各个模块的meta送出,

所有模块共用meta,只不过是使用的位域不同,传入的meta同理,update信息之所以reverse,是因为低位的meta对应的是靠后的components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var metas = 0.U(1.W)
var meta_sz = 0
for (c <- components) {
c.io.f0_valid := io.f0_valid
c.io.f0_pc := io.f0_pc
c.io.f0_mask := io.f0_mask
c.io.f1_ghist := io.f1_ghist
c.io.f1_lhist := io.f1_lhist
c.io.f3_fire := io.f3_fire
if (c.metaSz > 0) {
metas = (metas << c.metaSz) | c.io.f3_meta(c.metaSz-1,0)
}
meta_sz = meta_sz + c.metaSz
}
require(meta_sz < bpdMaxMetaLength)
io.f3_meta := metas


var update_meta = io.update.bits.meta
for (c <- components.reverse) {
c.io.update := io.update
c.io.update.bits.meta := update_meta
update_meta = update_meta >> c.metaSz
}

BranchPredictor

分支预测器的选择都是在下面代码中,这里是分bank的,然后返回的为ComposedBranchPredictorBank

为什么分bank?

respose_in是干什么的

1
2
3
4
5
6
7
8
9
10
11
12
val bpdStr = new StringBuilder
bpdStr.append(BoomCoreStringPrefix("==Branch Predictor Memory Sizes==\n"))
val banked_predictors = (0 until nBanks) map ( b => {
val m = Module(if (useBPD) new ComposedBranchPredictorBank else new NullBranchPredictorBank)
for ((n, d, w) <- m.mems) {
bpdStr.append(BoomCoreStringPrefix(f"bank$b $n: $d x $w = ${d * w / 8}"))
total_memsize = total_memsize + d * w / 8
}
m
})
bpdStr.append(BoomCoreStringPrefix(f"Total bpd size: ${total_memsize / 1024} KB\n"))
override def toString: String = bpdStr.toString

然后这个bank内主要就是分发逻辑,将更新信号分发到每个预测器,以及将预测信息送出,下面代码中getBPDComponents就是获得预测器信息,然后返回预测结果

1
2
val (components, resp) = getBPDComponents(io.resp_in(0), p)
io.resp := resp

最终的分支预测信息来自下面代码,这是典型的TAGE_L结构,分支预测器的主要器件都包含在内

1732447150001

预测请求传入

预测请求分bank讨论,但这里只讨论bank为2的情况,只考虑全局历史

  1. 传入请求的bank为0,这时bank0预测这个vpc,bank1预测下个bank的vpc
  2. 如果传入请求的bank为1,就让bank0预测下一个bank,bank预测这个bank

具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
....
when (bank(io.f0_req.bits.pc) === 0.U) {
.......

banked_predictors(0).io.f0_valid := io.f0_req.valid
banked_predictors(0).io.f0_pc := bankAlign(io.f0_req.bits.pc)
banked_predictors(0).io.f0_mask := fetchMask(io.f0_req.bits.pc)

banked_predictors(1).io.f0_valid := io.f0_req.valid
banked_predictors(1).io.f0_pc := nextBank(io.f0_req.bits.pc)
banked_predictors(1).io.f0_mask := ~(0.U(bankWidth.W))
} .otherwise {
....
banked_predictors(0).io.f0_valid := io.f0_req.valid && !mayNotBeDualBanked(io.f0_req.bits.pc)
banked_predictors(0).io.f0_pc := nextBank(io.f0_req.bits.pc)
banked_predictors(0).io.f0_mask := ~(0.U(bankWidth.W))
banked_predictors(1).io.f0_valid := io.f0_req.valid
banked_predictors(1).io.f0_pc := bankAlign(io.f0_req.bits.pc)
banked_predictors(1).io.f0_mask := fetchMask(io.f0_req.bits.pc)
}
when (RegNext(bank(io.f0_req.bits.pc) === 0.U)) {
banked_predictors(0).io.f1_ghist := RegNext(io.f0_req.bits.ghist.histories(0))
banked_predictors(1).io.f1_ghist := RegNext(io.f0_req.bits.ghist.histories(1))
} .otherwise {
banked_predictors(0).io.f1_ghist := RegNext(io.f0_req.bits.ghist.histories(1))
banked_predictors(1).io.f1_ghist := RegNext(io.f0_req.bits.ghist.histories(0))
}

预测结果传出

首先获得bank0和bank1的有效信号b0_fire,b1_fire,然后预测器送出f3阶段的预测信号,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    val b0_fire = io.f3_fire && RegNext(RegNext(RegNext(banked_predictors(0).io.f0_valid)))
val b1_fire = io.f3_fire && RegNext(RegNext(RegNext(banked_predictors(1).io.f0_valid)))
banked_predictors(0).io.f3_fire := b0_fire
banked_predictors(1).io.f3_fire := b1_fire

banked_lhist_providers(0).io.f3_fire := b0_fire
banked_lhist_providers(1).io.f3_fire := b1_fire
// The branch prediction metadata is stored un-shuffled
io.resp.f3.meta(0) := banked_predictors(0).io.f3_meta
io.resp.f3.meta(1) := banked_predictors(1).io.f3_meta

io.resp.f3.lhist(0) := banked_lhist_providers(0).io.f3_lhist
io.resp.f3.lhist(1) := banked_lhist_providers(1).io.f3_lhist

...

when (bank(io.resp.f3.pc) === 0.U) {
for (i <- 0 until bankWidth) {
io.resp.f3.preds(i) := banked_predictors(0).io.resp.f3(i)
io.resp.f3.preds(i+bankWidth) := banked_predictors(1).io.resp.f3(i)
}
} .otherwise {
for (i <- 0 until bankWidth) {
io.resp.f3.preds(i) := banked_predictors(1).io.resp.f3(i)
io.resp.f3.preds(i+bankWidth) := banked_predictors(0).io.resp.f3(i)
}
}

更新逻辑

将输入的更新信息送入每个bank,这里给出仿真图辅助理解,指令包的起始地址80000004位于bank0,所以bank0的valid一定为1,但cfi_valid却为0,因为输入的cfi_idx为6,说明分支在第六条,不在这个bank,所以bank0的cfi_valid为0

1732456469917

接下来会基于largeboom(tage_l)来解析各个器件的主要逻辑,这些模块的IO都基于BranchPredictorBank,首先就是输入的分支预测请求,然后有预测信号resp,还有就是更新信号update,这三个信号是核心信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val io = IO(new Bundle {
val f0_valid = Input(Bool())
val f0_pc = Input(UInt(vaddrBitsExtended.W))
val f0_mask = Input(UInt(bankWidth.W))
// Local history not available until end of f1
val f1_ghist = Input(UInt(globalHistoryLength.W))
val f1_lhist = Input(UInt(localHistoryLength.W))

val resp_in = Input(Vec(nInputs, new BranchPredictionBankResponse))
val resp = Output(new BranchPredictionBankResponse)

// Store the meta as a UInt, use width inference to figure out the shape
val f3_meta = Output(UInt(bpdMaxMetaLength.W))

val f3_fire = Input(Bool())

val update = Input(Valid(new BranchPredictionBankUpdate))
})

NLP分支预测

NLP的分支预测结构由BIM表,RAS和BTB组成,如过查询BTB是ret,说明目标来自RAS,如果条目是无条件跳转,不查询BIM,

UBTB

1732457973586

每个BTB条目对应的tag都是整个fetch_packet的pc这样的预测粒度就是一整个packet,当前端或者BPD被重定向,BTB更新,如果分支没找到条目,就分配一个条目

BTB更新的tricky:

UBTB默认参数如下

1
2
3
4
case class BoomFAMicroBTBParams(
nWays: Int = 16,
offsetSz: Int = 13
)
预测逻辑

首先检查是否hitBTB,如果hit,就预测地址,从btb取出偏移量,得出最终地址,同时得出是br还是jal,以及是否taken,br默认不taken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  for (w <- 0 until bankWidth) {
val entry_meta = meta(s1_hit_ways(w))(w)
s1_resp(w).valid := s1_valid && s1_hits(w)
s1_resp(w).bits := (s1_pc.asSInt + (w << 1).S + btb(s1_hit_ways(w))(w).offset).asUInt
s1_is_br(w) := s1_resp(w).valid && entry_meta.is_br
s1_is_jal(w) := s1_resp(w).valid && !entry_meta.is_br
s1_taken(w) := !entry_meta.is_br || entry_meta.ctr(1)

s1_meta.hits(w) := s1_hits(w)
}
...
for (w <- 0 until bankWidth) {
io.resp.f1(w).predicted_pc := s1_resp(w)
io.resp.f1(w).is_br := s1_is_br(w)
io.resp.f1(w).is_jal := s1_is_jal(w)
io.resp.f1(w).taken := s1_taken(w)

io.resp.f2(w) := RegNext(io.resp.f1(w))
io.resp.f3(w) := RegNext(io.resp.f2(w))
}

如果未命中,就会采用下面的分配逻辑,

这个分配逻辑暂时未搞明白是什么,可能涉及到了折叠,可以看分支历史的折叠

1
2
3
4
5
6
7
8
9
10
11
12
val alloc_way = {
val r_metas = Cat(VecInit(meta.map(e => VecInit(e.map(_.tag)))).asUInt, s1_idx(tagSz-1,0))
val l = log2Ceil(nWays)
val nChunks = (r_metas.getWidth + l - 1) / l
val chunks = (0 until nChunks) map { i =>
r_metas(min((i+1)*l, r_metas.getWidth)-1, i*l)
}
chunks.reduce(_^_)
}
s1_meta.write_way := Mux(s1_hits.reduce(_||_),
PriorityEncoder(s1_hit_ohs.map(_.asUInt).reduce(_|_)),
alloc_way)
更新逻辑

BTB的更新主要分为更新offset和更新标签,更新offset,只要找到需要更新的way,然后将数据,传入这个way就可以,

更新meta,主要看ctr计数器,如果一开始这一项在预测时没有命中(新分配的项),则先初始化ctr,否则即使根据was_taken更新这个ctr计数器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Write the BTB with the target
when (s1_update.valid && s1_update.bits.cfi_taken && s1_update.bits.cfi_idx.valid && s1_update.bits.is_commit_update) {
btb(s1_update_write_way)(s1_update_cfi_idx).offset := new_offset_value
}

// Write the meta
for (w <- 0 until bankWidth) {
when (s1_update.valid && s1_update.bits.is_commit_update &&
(s1_update.bits.br_mask(w) ||
(s1_update_cfi_idx === w.U && s1_update.bits.cfi_taken && s1_update.bits.cfi_idx.valid))) {
val was_taken = (s1_update_cfi_idx === w.U && s1_update.bits.cfi_idx.valid &&
(s1_update.bits.cfi_taken || s1_update.bits.cfi_is_jal))

meta(s1_update_write_way)(w).is_br := s1_update.bits.br_mask(w)
meta(s1_update_write_way)(w).tag := s1_update_idx
meta(s1_update_write_way)(w).ctr := Mux(!s1_update_meta.hits(w),
Mux(was_taken, 3.U, 0.U),
bimWrite(meta(s1_update_write_way)(w).ctr, was_taken)
)
}
}

BIM

BIM使用pc一部分索引,只在提交时更新(饱和计数器,即使少更新,只要训练到位,预测结果大差不差)

方向预测逻辑

BIM的默认set为2048,并且BIMset只能为2的幂次方,该预测器在f2阶段之后可以给出结果,s2阶段的resp就是预测方向信息,如果s2阶段有效,并且这个bank读出的bim表的项第1位为1,表示taken,否则为0

注意,这里感觉浪费了空间,因为BIM的写入都是对每个w写入相同内容,而且读出也是相同,所以每个w读出的也是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  val s2_req_rdata    = RegNext(data.read(s0_idx   , s0_valid))

val s2_resp = Wire(Vec(bankWidth, Bool()))

for (w <- 0 until bankWidth) {

s2_resp(w) := s2_valid && s2_req_rdata(w)(1) && !doing_reset
s2_meta.bims(w) := s2_req_rdata(w)
}
...
for (w <- 0 until bankWidth) {
io.resp.f2(w).taken := s2_resp(w)
io.resp.f3(w).taken := RegNext(io.resp.f2(w).taken)
}
更新逻辑

更新是在f1阶段,如果一个bank里有br指令(taken)或者jal,就说明taken,旧的BIM值是传入的重定向值,或者就是之前的bypass值

这里设置bypass主要就是为了减少SRAM访问次数,如果上次更新的数据idx和这次的一样,就直接把上次的值作为旧的值,否则就是之前读出的值(只有commit时才可以更新这个bypass值)

s1_update_wdata更新计数器的值,然后在提交时写入data,

old_bim_value要得到的是正确的旧值,s1_update_meta可能是分支预测失败时传来的update值,bypass是提交的值,数据一定正确,而写入又是在提交阶段,所以old_value一定是正确的值,另一种做法就是在提交直接读出旧值,不过可能引入多余的延迟

为什么s1阶段更新,s2阶段给出预测结果?一方面防止同时读写,另一方面,s1阶段更新,s2阶段就可以享受到更新的结果

注意这里更新逻辑条件包括了jal/jalr指令,看之前的issue,说这个地方不对,但目前都没改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
for (w <- 0 until bankWidth) {
s1_update_wmask(w) := false.B
s1_update_wdata(w) := DontCare

val update_pc = s1_update.bits.pc + (w << 1).U

when (s1_update.bits.br_mask(w) ||
(s1_update.bits.cfi_idx.valid && s1_update.bits.cfi_idx.bits === w.U)) {
val was_taken = (
s1_update.bits.cfi_idx.valid &&
(s1_update.bits.cfi_idx.bits === w.U) &&
(
(s1_update.bits.cfi_is_br && s1_update.bits.br_mask(w) && s1_update.bits.cfi_taken) ||
s1_update.bits.cfi_is_jal
)
)
val old_bim_value = Mux(wrbypass_hit, wrbypass(wrbypass_hit_idx)(w), s1_update_meta.bims(w))

s1_update_wmask(w) := true.B

s1_update_wdata(w) := bimWrite(old_bim_value, was_taken)
}


}

when (doing_reset || (s1_update.valid && s1_update.bits.is_commit_update)) {
data.write(
Mux(doing_reset, reset_idx, s1_update_index),
Mux(doing_reset, VecInit(Seq.fill(bankWidth) { 2.U }), s1_update_wdata),
Mux(doing_reset, (~(0.U(bankWidth.W))), s1_update_wmask.asUInt).asBools
)
}

RAS

RAS的逻辑比较简单,主要分为读逻辑和写逻辑

读RAS在f3阶段,判断指令是否为ret,写RAS在ftq传入更新RAS信息或者f3阶段的指令为call指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class BoomRAS(implicit p: Parameters) extends BoomModule()(p)
{
val io = IO(new Bundle {
val read_idx = Input(UInt(log2Ceil(nRasEntries).W))
val read_addr = Output(UInt(vaddrBitsExtended.W))

val write_valid = Input(Bool())
val write_idx = Input(UInt(log2Ceil(nRasEntries).W))
val write_addr = Input(UInt(vaddrBitsExtended.W))
})
val ras = Reg(Vec(nRasEntries, UInt(vaddrBitsExtended.W)))

io.read_addr := Mux(RegNext(io.write_valid && io.write_idx === io.read_idx),
RegNext(io.write_addr),
RegNext(ras(io.read_idx)))

when (io.write_valid) {
ras(io.write_idx) := io.write_addr
}
}

BPD

BPD仅仅对条件分支的方向进行预测,其他信息,比如那些指令是分支,目标是什么,无需在意,这些信息可以从BTB得知,所以BPD无需存储tag和分支目标地址,jal和jalr指令均由NLP预测,如果NLP预测失败,只能之后重定向

1732524502617

BPD在f3给出结果,f4进行重定向,

BPD采用全局历史,GHR进行推测更新,每个分支都有GHR快照,同时在BPD维护提交阶段的GHR

请注意,在F0阶段开始进行预测(读取全局历史记录时)和在F4阶段重定向前端(更新全局历史记录时)之间存在延迟。这会导致“影子”,其中在F0中开始进行预测的分支将看不到程序中一个(或两个)周期之前出现的分支(或其结果)(目前处于F1/2/3阶段)。但至关重要的是,这些“影子分支”必须反映在全局历史快照中。

每个FTQ条目对应一个提取周期。对于每次预测,分支预测器都会打包稍后执行更新所需的数据。例如,分支预测器需要记住预测来自哪个 索引,以便稍后更新该索引处的计数器。此数据存储在FTQ中。当Fetch Packet中的最后一条指令被提交时,FTQ条目将被释放并返回到分支预测器。使用存储在FTQ条目中的数据,分支预测器可以对其预测状态执行任何所需的更新。

FTQ保存着在提交期间更新分支预测器所需的分支预测器数据(无论是正确预测还是错误预测)。但是,当分支预测器做出错误预测时,需要额外的状态,必须立即更新。例如,如果发生错误预测,则必须将推测更新的GHR重置为正确值,然后处理器才能再次开始提取(和预测)。

此状态可能非常昂贵,但一旦在执行阶段解析了分支,就可以释放它。因此,状态与分支重命名快照并行存储。在解码 和重命名期间,会为每个分支分配一个 分支标记 ,并制作重命名表的快照,以便在发生错误预测时进行单周期回滚。与分支标记和重命名映射表快照一样, 一旦分支在 执行阶段由分支单元解析,就可以释放相应的分支重命名快照

抽象分支类

1732526267100

TAGE

TAGE的默认参数如下.可以看到BOOM例化了6个表,最大历史长度为64,并且ubit的更新周期为2048个周期,饱和计数器为3bits,user为2bit,

1
2
3
4
5
6
7
8
9
10
11
case class BoomTageParams(
// nSets, histLen, tagSz
tableInfo: Seq[Tuple3[Int, Int, Int]] = Seq(( 128, 2, 7),
( 128, 4, 7),
( 256, 8, 8),
( 256, 16, 8),
( 128, 32, 9),
( 128, 64, 9)),
uBitPeriod: Int = 2048
)

TageTable

预测阶段

首先计算出hash_idx,根据该idx得出ctr和user_bit以及tag,然后将读出的信息传入tage进一步处理

写入逻辑

写入逻辑主要写入userbit,table

table:写入提交阶段传入的update_idx(这里的update同样有bypass)

1
2
3
4
5
6
table.write(
Mux(doing_reset, reset_idx , update_idx),
Mux(doing_reset, VecInit(Seq.fill(bankWidth) { 0.U(tageEntrySz.W) }), VecInit(update_wdata.map(_.asUInt))),
Mux(doing_reset, ~(0.U(bankWidth.W)) , io.update_mask.asUInt).asBools
)

user_bit分为两个段:hi和lo,主要讲hi:

写入的idx来自reset_idx,clear_idx和update_idx,user_bit需要定期清0,clear前缀的就是清零有关信号,这里就是每2048个周期就去清零高位或者低位,

由于是sram结构,一周期只能读1写1,所以也没啥问题,但为啥不同时清0hi和lo,猜想可能是先缓冲一下

1
2
3
4
5
6
7
8
9
10
  val doing_clear_u = clear_u_ctr(log2Ceil(uBitPeriod)-1,0) === 0.U
val doing_clear_u_hi = doing_clear_u && clear_u_ctr(log2Ceil(uBitPeriod) + log2Ceil(nRows)) === 1.U
val doing_clear_u_lo = doing_clear_u && clear_u_ctr(log2Ceil(uBitPeriod) + log2Ceil(nRows)) === 0.U
val clear_u_idx = clear_u_ctr >> log2Ceil(uBitPeriod)
...
hi_us.write(
Mux(doing_reset, reset_idx, Mux(doing_clear_u_hi, clear_u_idx, update_idx)),
Mux(doing_reset || doing_clear_u_hi, VecInit((0.U(bankWidth.W)).asBools), update_hi_wdata),
Mux(doing_reset || doing_clear_u_hi, ~(0.U(bankWidth.W)), io.update_u_mask.asUInt).asBools
)
TAGE主要逻辑

首先,定义所有产生tag匹配的预测表中所需历史长度最长者为provider,而其余产生tag匹配的预测表(若存在的话)被称为altpred。

  1. 当provider产生的预测被证实为一个正确的预测时,首先将产生的正确预测的对应provider表项的pred计数器自增1。其次,若此时的provider与altpred的预测结果不同,则provider的userfulness计数器自增1。
  2. 当provider产生的预测被证实为一个错误的预测时,首先将产生的错误预测的对应provider表项的pred预测器自减1。其次,若存在产生正确预测的altpred,则provider的usefulness计数器自减1。接下来,若该provider所源自的预测表并非所需历史长度最高的预测表,则此时执行如下的表项增添操作。首先,读取所有历史长度长于provider的预测表的usefulness计数器,若此时有某表的u计数器值为0,则在该表中分配一对应的表项。当有多个预测表(如Tj,Tk两项)的u计数器均为0,则将表项分配给Tk的几率为分配给Tj的2^(k-j)倍(这一概率分配在硬件上可以通过一个LFSR来实现)。若所有TAGE内预测表的u值均不为0,则所有预测表的u值同时减1。
  3. 只有provider和altpred的预测不同时才会更新
预测逻辑

tage预测逻辑分为provider,和altpred,其中provider为历史最长的tag命中对应的table,altpred则是次高历史命中对应的table,如果table没有命中,则选择默认的结果,源论文为bim表得出的结果

这里暂时不清楚默认预测器是什么,应该也是bim表

这里首先遍历所有历史表,如果table hit,就将选择taken结果,如果ctr ===3.U|| ctr ===4.U,认为这个provider不可信,选择altpred的结果作为预测结果,否则选择ctr(2)为预测结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var altpred = io.resp_in(0).f3(w).taken
val final_altpred = WireInit(io.resp_in(0).f3(w).taken)
var provided = false.B
var provider = 0.U
io.resp.f3(w).taken := io.resp_in(0).f3(w).taken
//
for (i <- 0 until tageNTables) {
val hit = f3_resps(i)(w).valid
val ctr = f3_resps(i)(w).bits.ctr
when (hit) {
io.resp.f3(w).taken := Mux(ctr === 3.U || ctr === 4.U, altpred, ctr(2))//预测可能不准
final_altpred := altpred
}

provided = provided || hit
provider = Mux(hit, i.U, provider)
altpred = Mux(hit, f3_resps(i)(w).bits.ctr(2), altpred)
}
f3_meta.provider(w).valid := provided
f3_meta.provider(w).bits := provider
f3_meta.alt_differs(w) := final_altpred =/= io.resp.f3(w).taken//有预测未命中的项
f3_meta.provider_u(w) := f3_resps(provider)(w).bits.u
f3_meta.provider_ctr(w) := f3_resps(provider)(w).bits.ctr
更新逻辑

更新阶段就是去更新ctr和u计数器,如果预测失败可能还会去分配新的表项

allocatable_slots就是找到未命中并且u为0的slot,如果这个多于一个,就通过LSFR大概率选择分支历史长的,这样就得到了要分配的table表项,如果是提交阶段更新,并且是条件分支指令,如果此时provider是有效的,就将信息写入对应的table,然后更新u_bit,以及ctr计数器,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
val allocatable_slots = (
VecInit(f3_resps.map(r => !r(w).valid && r(w).bits.u === 0.U)).asUInt &
~(MaskLower(UIntToOH(provider)) & Fill(tageNTables, provided))
)
val alloc_lfsr = random.LFSR(tageNTables max 2)//如果u=0的个数大于1,使用LSFR选择,概率是历史长的大于历史短的

val first_entry = PriorityEncoder(allocatable_slots)
val masked_entry = PriorityEncoder(allocatable_slots & alloc_lfsr)
val alloc_entry = Mux(allocatable_slots(masked_entry),
masked_entry,
first_entry)

f3_meta.allocate(w).valid := allocatable_slots =/= 0.U
f3_meta.allocate(w).bits := alloc_entry

val update_was_taken = (s1_update.bits.cfi_idx.valid &&
(s1_update.bits.cfi_idx.bits === w.U) &&
s1_update.bits.cfi_taken)
when (s1_update.bits.br_mask(w) && s1_update.valid && s1_update.bits.is_commit_update) {
when (s1_update_meta.provider(w).valid) {
val provider = s1_update_meta.provider(w).bits

s1_update_mask(provider)(w) := true.B
s1_update_u_mask(provider)(w) := true.B

val new_u = inc_u(s1_update_meta.provider_u(w),
s1_update_meta.alt_differs(w),
s1_update_mispredict_mask(w))
s1_update_u (provider)(w) := new_u
s1_update_taken (provider)(w) := update_was_taken
s1_update_old_ctr(provider)(w) := s1_update_meta.provider_ctr(w)
s1_update_alloc (provider)(w) := false.B

}
}

分配逻辑

分配阶段其实是在更新阶段内的,但有自己独特的操作,故列出单讲

首先分配表项是在提交阶段,发现provider预测失败,并且这个表项的表不是分支历史最长的表,进行表项分配,如果找到了可以分配的表项,就对表项分配,并且将对应的table表项u置为0,如果没有找到表项,就将符合条件的表项u置为0,但是不分配表项

分配还会初始化ctr,原论文中新分配的表项为弱taken(4),这里只有这次更新taken才为4,否则为3

这里好像boom和源论文做法不一样,原论文是将ubit递减,而不是直接置为0

主要代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
when (s1_update.valid && s1_update.bits.is_commit_update && s1_update.bits.cfi_mispredicted && s1_update.bits.cfi_idx.valid) {
val idx = s1_update.bits.cfi_idx.bits
val allocate = s1_update_meta.allocate(idx)
when (allocate.valid) {
s1_update_mask (allocate.bits)(idx) := true.B
s1_update_taken(allocate.bits)(idx) := s1_update.bits.cfi_taken
s1_update_alloc(allocate.bits)(idx) := true.B

s1_update_u_mask(allocate.bits)(idx) := true.B
s1_update_u (allocate.bits)(idx) := 0.U

} .otherwise {
val provider = s1_update_meta.provider(idx)
val decr_mask = Mux(provider.valid, ~MaskLower(UIntToOH(provider.bits)), 0.U)

for (i <- 0 until tageNTables) {
when (decr_mask(i)) {
s1_update_u_mask(i)(idx) := true.B
s1_update_u (i)(idx) := 0.U
}
}
}

}

总结

目前前端逻辑还没搞明白ghist内部信号到底什么含义,还有wrbypass是为了干什么

举个例子理解wrbypass,

假设下面指令:

test: addi a1,a1,1

bne a1,a2,test

提交阶段可能是

假设bne为指令包1,addi,bne为指令包2,那么有

bne |addi,bne |

s0 |s1 |s2

|指令包1写入新的bim值 |写入完成,同时也写入bypass内

| |指令包2写入新的bim值,此时旧的bim值来自bypass的,本身带的bim值太老

上面这种情况就解释了wrbypass的作用:及时的更新正确的bim值,防止出现performance bug

ghist是推测更新,也就是在分支预测每个阶段都会更新:

在f1阶段,这主要是UBTB,如果是br指令并且taken,就更新ghist

f2阶段是bim的结果,bim实际上也不需要使用ghist,f2阶段预测的按理一定是br分支,但boom加入了jal,绝对会对ghr产生影响

f3阶段是tage预测阶段,这个阶段ghist才有作用,

在f2和f3对之前的分支预测目标和方向进行检查,只要一个不满足,就重定向

之后就是后端传来的重定向信号,

分支预测全流程

分支指令在boom中会经过预测/推测更新阶段(ifu)->检测/重定向阶段(exu)->更新阶段,boom采用的checkpoint来恢复CPU状态,每个分支都有自己的掩码,分支预测失败根据这个掩码定向冲刷指令,更新,刷新,重定向前端,

预测阶段

分支指令的预测阶段主要在F1,F2,F3阶段.这三个阶段会送出BPD的预测信息,并进行重定向操作,这个可以看之前IFU流水线讲解的F0阶段和F1阶段

目前的问题是,一个fetchpacket可能有多条分支指令,如何去正确记录分支历史,比如bne,bne 指令包.前一个不taken,后一个taken,这时候就要正确记录之前没有taken的指令历史,可能这个是按照bank更新分支历史,

分支预测是将一个指令包的指令全部送进去预测,分别得出结果

预测阶段每个周期都会有新的ghist生成,比如在f3阶段有f3_predicted_ghist,这个就是更新后的历史,注意这个存的还是旧历史,但分支的taken信息已经包含在内了,假如f3 taken,对前面重定向,f1_predicted_ghist,读出的旧历史就是f3阶段更新后的历史(他会延迟更新,等到其他的去update,才会更新旧值)

注意,此时存入ftq的ghist不是f3_predicted_ghist,而是f3_fetch_bundle.ghist,也就是相当于只存入的旧值,并未存入taken信息

1
2
3
4
5
6
7
8
9
10
val f3_predicted_ghist = f3_fetch_bundle.ghist.update(
f3_fetch_bundle.br_mask,
f3_fetch_bundle.cfi_idx.valid,
f3_fetch_bundle.br_mask(f3_fetch_bundle.cfi_idx.bits),
f3_fetch_bundle.cfi_idx.bits,
f3_fetch_bundle.cfi_idx.valid,
f3_fetch_bundle.pc,
f3_fetch_bundle.cfi_is_call,
f3_fetch_bundle.cfi_is_ret
)

检测阶段

(alu)这里主要对br指令进行了检测,br或者jalr,目标地址可能出错,所以会对方向检测,如果pc_sel为npc,就说明实际不taken,预测失败就说明前端预测taken,如果为PC_BRJMP就说明实际taken,就需要对预测的taken信号取反

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
when (is_br || is_jalr) {
if (!isJmpUnit) {
assert (pc_sel =/= PC_JALR)
}
when (pc_sel === PC_PLUS4) {
mispredict := uop.taken
}
when (pc_sel === PC_BRJMP) {
mispredict := !uop.taken
}
}

val brinfo = Wire(new BrResolutionInfo)
// note: jal doesn't allocate a branch-mask, so don't clear a br-mask bit
brinfo.valid := is_br || is_jalr
brinfo.mispredict := mispredict
brinfo.uop := uop
brinfo.cfi_type := Mux(is_jalr, CFI_JALR,
Mux(is_br , CFI_BR, CFI_X))
brinfo.taken := is_taken
brinfo.pc_sel := pc_sel
brinfo.jalr_target := DontCare

如果此时发生分支预测失败,就将分支预测失败路径指令全部删除,并且重定向前端,修改前端信息,重定向信息分为b1,b2,其中b1是在第一个周期br_mask,b2就是携带了重定向信息(第二个周期),

1
2
3
4
val b1 = new BrUpdateMasks
// On the second cycle we get indices to reset pointers
val b2 = new BrResolutionInfo

在core.scala中,如果发现了mispredict,就要得出真正预测的目标,以及重定向信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
val use_same_ghist = (brupdate.b2.cfi_type === CFI_BR &&//只有条件分支预测方向
!brupdate.b2.taken &&//实际不用跳转
bankAlign(block_pc) === bankAlign(npc))//最后一个条件意思是npc也在这个block内,如果是这样,那抹其实不需要更新ghist,
val ftq_entry = io.ifu.get_pc(1).entry
val cfi_idx = (brupdate.b2.uop.pc_lob ^
Mux(ftq_entry.start_bank === 1.U, 1.U << log2Ceil(bankBytes), 0.U))(log2Ceil(fetchWidth), 1)//得到这个分支的位置
val ftq_ghist = io.ifu.get_pc(1).ghist
val next_ghist = ftq_ghist.update(
ftq_entry.br_mask.asUInt,
brupdate.b2.taken,
brupdate.b2.cfi_type === CFI_BR,
cfi_idx,
true.B,
io.ifu.get_pc(1).pc,
ftq_entry.cfi_is_call && ftq_entry.cfi_idx.bits === cfi_idx,
ftq_entry.cfi_is_ret && ftq_entry.cfi_idx.bits === cfi_idx)


io.ifu.redirect_ghist := Mux(
use_same_ghist,
ftq_ghist,
next_ghist)
io.ifu.redirect_ghist.current_saw_branch_not_taken := use_same_ghist

重定向阶段

如果分支预测失败,进入重定向逻辑,刷新前端,此时读出ftq对应表项的内容,包括ghist

猜测分支预测的粒度是bank,这样use_same_ghist就可以解释清楚了,如果没有taken,并且npc和这个指令在同一个bank,则认为这个分支可以使用和ftq一样的历史,然后将current_saw_branch_not_taken置为高,之后如果update就会发现有分支未taken

这样current_saw_branch_not_taken也可以解释清楚了

对于ghist选择有ftq_ghist和next_ghist,根据use_same_ghist选择对应的分支历史

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    val use_same_ghist = (brupdate.b2.cfi_type === CFI_BR &&//只有条件分支预测方向
!brupdate.b2.taken &&//实际不用跳转
bankAlign(block_pc) === bankAlign(npc))//最后一个条件意思是npc也在这个block内,如果是这样,那么其实不需要更新ghist,如果是以bank为粒度预测,那么这个分支相当于没有预测,所以不计入历史
...
val ftq_ghist = io.ifu.get_pc(1).ghist
val next_ghist = ftq_ghist.update(
ftq_entry.br_mask.asUInt,
brupdate.b2.taken,
brupdate.b2.cfi_type === CFI_BR,
cfi_idx,
true.B,
io.ifu.get_pc(1).pc,
ftq_entry.cfi_is_call && ftq_entry.cfi_idx.bits === cfi_idx,
ftq_entry.cfi_is_ret && ftq_entry.cfi_idx.bits === cfi_idx)
io.ifu.redirect_ghist := Mux(
use_same_ghist,
ftq_ghist,
next_ghist)

BOOM Decode

首先就是IO,Decode模块的enq是传入的指令,deq是输出的指令,之后是CSR逻辑,和中断,BOOM模块主要就是复用lrocket的decodelogic模块,其他并无特色的地方

1
2
3
4
5
6
7
8
9
10
11
class DecodeUnitIo(implicit p: Parameters) extends BoomBundle
{
val enq = new Bundle { val uop = Input(new MicroOp()) }
val deq = new Bundle { val uop = Output(new MicroOp()) }

// from CSRFile
val status = Input(new freechips.rocketchip.rocket.MStatus())
val csr_decode = Flipped(new freechips.rocketchip.rocket.CSRDecodeIO)
val interrupt = Input(Bool())
val interrupt_cause = Input(UInt(xLen.W))
}

BOOM RENAME

boom采用的是统一的PRF结构,

1731307352707

RAT就是图中的map table,busytable揭示每个物理寄存器的忙碌情况,

Busy table

busytable在唤醒阶段把寄存器设置为空闲,在rename阶段将寄存器设置为忙

首先列出输入输出信号

1
2
3
4
5
6
7
8
9
10
11
val io = IO(new BoomBundle()(p) {
val ren_uops = Input(Vec(plWidth, new MicroOp))
val busy_resps = Output(Vec(plWidth, new BusyResp))
val rebusy_reqs = Input(Vec(plWidth, Bool()))

val wb_pdsts = Input(Vec(numWbPorts, UInt(pregSz.W)))
val wb_valids = Input(Vec(numWbPorts, Bool()))

val debug = new Bundle { val busytable = Output(Bits(numPregs.W)) }
})

ren_uops表示查询busytable,busy_reps表示寄存器的忙碌状态,wb前缀的表示写回阶段要更新的寄存器状态,最后一个是debug信号

1
2
3
4
5
6
7
8
9
val busy_table = RegInit(0.U(numPregs.W))
// Unbusy written back registers.
val busy_table_wb = busy_table & ~(io.wb_pdsts zip io.wb_valids)
.map {case (pdst, valid) => UIntToOH(pdst) & Fill(numPregs, valid.asUInt)}.reduce(_|_)
// Rebusy newly allocated registers.
val busy_table_next = busy_table_wb | (io.ren_uops zip io.rebusy_reqs)
.map {case (uop, req) => UIntToOH(uop.pdst) & Fill(numPregs, req.asUInt)}.reduce(_|_)

busy_table := busy_table_next

接下来是主要模块,首先将写回的寄存器unbusy,我们看busy_table_wb,首先看io.wb_pdsts zip io.wb_valids表示将两个作为一个元组,然后使用map函数,对每个院组都进行操作,操作的内容是后面{}内容,这个{首先使用模式匹配case,然后输出的值是=>后面的值,也就是把写回的寄存器变成oh编码,然后把这些元素通过reduce按位或,得到写回寄存器的oh编码,然后取非再&busytable,就相当于释放了写回的寄存器

之后的busy_table_next,就是为寄存器分配忙位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Read the busy table.
for (i <- 0 until plWidth) {
val prs1_was_bypassed = (0 until i).map(j =>
io.ren_uops(i).lrs1 === io.ren_uops(j).ldst && io.rebusy_reqs(j)).foldLeft(false.B)(_||_)
val prs2_was_bypassed = (0 until i).map(j =>
io.ren_uops(i).lrs2 === io.ren_uops(j).ldst && io.rebusy_reqs(j)).foldLeft(false.B)(_||_)
val prs3_was_bypassed = (0 until i).map(j =>
io.ren_uops(i).lrs3 === io.ren_uops(j).ldst && io.rebusy_reqs(j)).foldLeft(false.B)(_||_)

io.busy_resps(i).prs1_busy := busy_table(io.ren_uops(i).prs1) || prs1_was_bypassed && bypass.B
io.busy_resps(i).prs2_busy := busy_table(io.ren_uops(i).prs2) || prs2_was_bypassed && bypass.B
io.busy_resps(i).prs3_busy := busy_table(io.ren_uops(i).prs3) || prs3_was_bypassed && bypass.B
if (!float) io.busy_resps(i).prs3_busy := false.B
}

io.debug.busytable := busy_table

然后就是读busytable,这个的意思就是先检查写入的新映射关系有没有和src1一样的,有的话就说明这个可能有依赖(也即是RAW),也就是这个寄存器在使用,之后只要busytable和prs1_was_bypassed一个成立,就说明这个寄存器在使用

Map table

其实就是RAT,首先先把交互信号放上来,以供后续阅读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MapReq(val lregSz: Int) extends Bundle
{
val lrs1 = UInt(lregSz.W)
val lrs2 = UInt(lregSz.W)
val lrs3 = UInt(lregSz.W)
val ldst = UInt(lregSz.W)
}

class MapResp(val pregSz: Int) extends Bundle
{
val prs1 = UInt(pregSz.W)
val prs2 = UInt(pregSz.W)
val prs3 = UInt(pregSz.W)
val stale_pdst = UInt(pregSz.W)
}

class RemapReq(val lregSz: Int, val pregSz: Int) extends Bundle
{
val ldst = UInt(lregSz.W)
val pdst = UInt(pregSz.W)
val valid = Bool()
}

然后就是Maptable的IO信号了,主要就是映射请求,映射答复,重新映射,保存snapshot,恢复snapshot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val io = IO(new BoomBundle()(p) {
// Logical sources -> physical sources.
val map_reqs = Input(Vec(plWidth, new MapReq(lregSz)))
val map_resps = Output(Vec(plWidth, new MapResp(pregSz)))

// Remapping an ldst to a newly allocated pdst?
val remap_reqs = Input(Vec(plWidth, new RemapReq(lregSz, pregSz)))

// Dispatching branches: need to take snapshots of table state.
val ren_br_tags = Input(Vec(plWidth, Valid(UInt(brTagSz.W))))

// Signals for restoring state following misspeculation.
val brupdate = Input(new BrUpdateInfo)
val rollback = Input(Bool())
})

接下来就是这个模块的主要信号,首先map_table就是这个模块的核心了,存储寄存器映射关系的,然后就是snapshot,这里为什么要remap?就是把最新的寄存器关系写进去,具体需要看重命名过程干了什么(逻辑源寄存器读RAT,目的寄存器在freelist找空闲,目的寄存器读RAT,将读出的值写入ROB,目的寄存器写入RAT,更新新的映射关系)这样其实就理解了设置这些信号的含义,remap_pdsts就是把物理寄存器号提取出来,如果一周期重命名2条,那么这个就是一个大小为2的向量,remap_ldsts_oh就是给每个逻辑寄存器编码,假设两条指令目的寄存器为1,3,那么编码后的就是(32‘b…10,32’b…1000)

1
2
3
4
5
6
7
8
9
10
// The map table register array and its branch snapshots.
val map_table = RegInit(VecInit(Seq.fill(numLregs){0.U(pregSz.W)}))
val br_snapshots = Reg(Vec(maxBrCount, Vec(numLregs, UInt(pregSz.W))))

// The intermediate states of the map table following modification by each pipeline slot.
val remap_table = Wire(Vec(plWidth+1, Vec(numLregs, UInt(pregSz.W))))

// Uops requesting changes to the map table.
val remap_pdsts = io.remap_reqs map (_.pdst)
val remap_ldsts_oh = io.remap_reqs map (req => UIntToOH(req.ldst) & Fill(numLregs, req.valid.asUInt))

然后弄明白新的每个指令新的映射关系,第一个意思就是把0号寄存器清0,如果不是0号寄存器,就设置一个remapped_row,这个的大小是plwidth的大小,这个之后的意思就是,为每个逻辑寄存器找到他的映射关系是来自RAT还是传入的映射关系,我们首先需要知道scanleft的意思,这个的工作模式如下(从左到右依次是reduce,fold,scan),这个remapped_row干的事情就是先把ldst位提取出来,这表示哪个逻辑寄存器是有更新请求,然后zip pdst形成元组,假设有如下映射ldst1->pdst2,ldst3->pdst4,这里前面是逻辑。后面是物理,假设一周期2条指令,i=1,这个zip形成的元组就是(true,2),(false,2),然后scanleft(有累积性)的初值为map_table(1),也就是remapped_row第0个元素为来自map的值,然后这句话生成的元组就是(map,pdst2,pdst2),map为来自map-table的物理寄存器,最后把这些赋值给remaptable,然后假如i=3,remapped_row就是(map,map,pdst4),此时remap_table(1)为(0,pdst2,map,map,…)remap(2)为(0,pdst2,map,pdst4,…)所以这里可以看到remaptable的最高索引才是正确的映射关系(巧妙但晦涩难懂的操作)

1731476588727

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Figure out the new mappings seen by each pipeline slot.
for (i <- 0 until numLregs) {
if (i == 0 && !float) {
for (j <- 0 until plWidth+1) {
remap_table(j)(i) := 0.U
}
} else {
val remapped_row = (remap_ldsts_oh.map(ldst => ldst(i)) zip remap_pdsts)
.scanLeft(map_table(i)) {case (pdst, (ldst, new_pdst)) => Mux(ldst, new_pdst, pdst)}

for (j <- 0 until plWidth+1) {
remap_table(j)(i) := remapped_row(j)
}
}
}

然后更新新的映射关系,最后就是读map,注意这个处理了读出的映射关系是来自map_table还是remap请求(处理RAW),当i=0,映射关系来自RAT,(也就是第1条指令,最旧的指令)只讲解i=1情况的prs1,foldleft和scan类似,但只输出最终结果,所以这里就是检查第一条的目的寄存器和这一条指令(也就是第二条)的源寄存器是否相等,如果相等就使用新的映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
when (io.brupdate.b2.mispredict) {
// Restore the map table to a branch snapshot.
map_table := br_snapshots(io.brupdate.b2.uop.br_tag)
} .otherwise {
// Update mappings.
map_table := remap_table(plWidth)
}

// Read out mappings.
for (i <- 0 until plWidth) {
io.map_resps(i).prs1 := (0 until i).foldLeft(map_table(io.map_reqs(i).lrs1)) ((p,k) =>
Mux(bypass.B && io.remap_reqs(k).valid && io.remap_reqs(k).ldst === io.map_reqs(i).lrs1, io.remap_reqs(k).pdst, p))
io.map_resps(i).prs2 := (0 until i).foldLeft(map_table(io.map_reqs(i).lrs2)) ((p,k) =>
Mux(bypass.B && io.remap_reqs(k).valid && io.remap_reqs(k).ldst === io.map_reqs(i).lrs2, io.remap_reqs(k).pdst, p))
io.map_resps(i).prs3 := (0 until i).foldLeft(map_table(io.map_reqs(i).lrs3)) ((p,k) =>
Mux(bypass.B && io.remap_reqs(k).valid && io.remap_reqs(k).ldst === io.map_reqs(i).lrs3, io.remap_reqs(k).pdst, p))
io.map_resps(i).stale_pdst := (0 until i).foldLeft(map_table(io.map_reqs(i).ldst)) ((p,k) =>
Mux(bypass.B && io.remap_reqs(k).valid && io.remap_reqs(k).ldst === io.map_reqs(i).ldst, io.remap_reqs(k).pdst, p))

if (!float) io.map_resps(i).prs3 := DontCare
}

然后这个链接对高阶函数做了简单总结:高级设计

Free list

先列出IO信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val io = IO(new BoomBundle()(p) {
// Physical register requests.
val reqs = Input(Vec(plWidth, Bool()))
val alloc_pregs = Output(Vec(plWidth, Valid(UInt(pregSz.W))))

// Pregs returned by the ROB.
val dealloc_pregs = Input(Vec(plWidth, Valid(UInt(pregSz.W))))

// Branch info for starting new allocation lists.
val ren_br_tags = Input(Vec(plWidth, Valid(UInt(brTagSz.W))))

// Mispredict info for recovering speculatively allocated registers.
val brupdate = Input(new BrUpdateInfo)

val debug = new Bundle {
val pipeline_empty = Input(Bool())
val freelist = Output(Bits(numPregs.W))
val isprlist = Output(Bits(numPregs.W))
}
})

首先明白free list什么时候分配寄存器,什么时候写入用完的寄存器(分别是重命名阶段,和提交阶段),然后就明白上面信号什么意思了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// The free list register array and its branch allocation lists.
val free_list = RegInit(UInt(numPregs.W), ~(1.U(numPregs.W)))
val br_alloc_lists = Reg(Vec(maxBrCount, UInt(numPregs.W)))

// Select pregs from the free list.
val sels = SelectFirstN(free_list, plWidth)
val sel_fire = Wire(Vec(plWidth, Bool()))

// Allocations seen by branches in each pipeline slot.
val allocs = io.alloc_pregs map (a => UIntToOH(a.bits))
val alloc_masks = (allocs zip io.reqs).scanRight(0.U(n.W)) { case ((a,r),m) => m | a & Fill(n,r) }

// Masks that modify the freelist array.
val sel_mask = (sels zip sel_fire) map { case (s,f) => s & Fill(n,f) } reduce(_|_)
val br_deallocs = br_alloc_lists(io.brupdate.b2.uop.br_tag) & Fill(n, io.brupdate.b2.mispredict)
val dealloc_mask = io.dealloc_pregs.map(d => UIntToOH(d.bits)(numPregs-1,0) & Fill(n,d.valid)).reduce(_|_) | br_deallocs

val br_slots = VecInit(io.ren_br_tags.map(tag => tag.valid)).asUInt

然后free_list是一个size为物理寄存器个数的寄存器,介绍sels之前先介绍PriorityEncoderOH,这个就是返回第一个为true的oh编码,然后sel是就是找到4个为true的索引,并且为oh编码,然后就是sel_mask,这个就是将sels得到的oh组合起来,dealloc_mask就是从ROB返回的物理寄存器,把他转换为onehot,(这里不管分支预测的snapshot),

1
2
3
4
5
6
7
8
9
10
11
object PriorityEncoderOH {
private def encode(in: Seq[Bool]): UInt = {
val outs = Seq.tabulate(in.size)(i => (BigInt(1) << i).asUInt(in.size.W))
PriorityMux(in :+ true.B, outs :+ 0.U(in.size.W))
}
def apply(in: Seq[Bool]): Seq[Bool] = {
val enc = encode(in)
Seq.tabulate(in.size)(enc(_))
}
def apply(in: Bits): UInt = encode((0 until in.getWidth).map(i => in(i)))
}

然后freelist更新,之后就是读出分配好的寄存器,这里有个sel_fire,注意这里的逻辑有些混乱,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Update the free list.
free_list := (free_list & ~sel_mask | dealloc_mask) & ~(1.U(numPregs.W))

// Pipeline logic | hookup outputs.
for (w <- 0 until plWidth) {
val can_sel = sels(w).orR
val r_valid = RegInit(false.B)
val r_sel = RegEnable(OHToUInt(sels(w)), sel_fire(w))

r_valid := r_valid && !io.reqs(w) || can_sel
sel_fire(w) := (!r_valid || io.reqs(w)) && can_sel

io.alloc_pregs(w).bits := r_sel
io.alloc_pregs(w).valid := r_valid
}

RenameStage

直接看链接重命名

其实有个问题:maptable本身支持解决RAW,但在rename模块将bypass给关闭了,然后在rename注册了BypassAllocations检查RAW相关,

还有:

rename有两级;第一级主要进行读RAT,第二阶段写RAT,读出freelist,写busytable(链接认为第一阶段还有读freelsit,但代码内使用的却是ren2_uops,也就是第二级)

其实感觉这里是一个比较逆天的操作,只看黄色框内容,由于r_sel是一个寄存器,在en后下个周期才可以得出新的值,这里虽然en(s2送入的请求)了,但实际上下个周期才会响应这个en,这里读出的还是之前的旧数据,但注意,这个旧寄存器值同样也是空闲的,因为他是由上一条指令读的,且freelist已经标记这个寄存器被分配出去了,非常逆天的操作,使用上个指令请求,然后这条指令正好读出,然后s2阶段就可以进行RAW检查了,这个操作完全可以在s1阶段产生请求,然后s2读出数据,还有下面这行代码,这个得结合流水线看,我们重命名一部分在decode/rename,另一部分在rename/dispatch,s1阶段主要进行读物理源寄存器(RAT),s2阶段读物理目的寄存器,然后把新的映射关系写入RAT,所以我们不仅要处理组内相关性,还要处理组间相关性,这句就是处理组间相关性,因为假设B指令的源寄存器和A指令的目的寄存器一样(一周期rename一条,B是新指令),B指令在s1读出的物理源寄存器可能不是最新的映射关系(A指令还没写入RAT),所以需要这行

1
r_uop := GetNewUopAndBrMask(BypassAllocations(next_uop, ren2_uops, ren2_alloc_reqs), io.brupdate)

1731584801027

下面简单讲一条指令在这个模块进行了什么操作:

读RAT请求和写RAT

1
2
3
4
5
6
7
8
9
for ((((ren1,ren2),com),w) <- (ren1_uops zip ren2_uops zip io.com_uops.reverse).zipWithIndex) {
map_reqs(w).lrs1 := ren1.lrs1
map_reqs(w).lrs2 := ren1.lrs2
map_reqs(w).lrs3 := ren1.lrs3
map_reqs(w).ldst := ren1.ldst

remap_reqs(w).ldst := Mux(io.rollback, com.ldst , ren2.ldst)
remap_reqs(w).pdst := Mux(io.rollback, com.stale_pdst, ren2.pdst)
}

注意这里map_reqs是ren1传入,也就是从decode传入的,然后写入RAT就是ren2的逻辑和物理寄存器

读freelist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Freelist inputs.
freelist.io.reqs := ren2_alloc_reqs
freelist.io.dealloc_pregs zip com_valids zip rbk_valids map
{case ((d,c),r) => d.valid := c || r}
freelist.io.dealloc_pregs zip io.com_uops map
{case (d,c) => d.bits := Mux(io.rollback, c.pdst, c.stale_pdst)}
freelist.io.ren_br_tags := ren2_br_tags
freelist.io.brupdate := io.brupdate
freelist.io.debug.pipeline_empty := io.debug_rob_empty

assert (ren2_alloc_reqs zip freelist.io.alloc_pregs map {case (r,p) => !r || p.bits =/= 0.U} reduce (_&&_),
"[rename-stage] A uop is trying to allocate the zero physical register.")

// Freelist outputs.
for ((uop, w) <- ren2_uops.zipWithIndex) {
val preg = freelist.io.alloc_pregs(w).bits
uop.pdst := Mux(uop.ldst =/= 0.U || float.B, preg, 0.U)
}

可以看到我们请求的前缀为ren2

读busytable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
busytable.io.ren_uops := ren2_uops  // expects pdst to be set up.
busytable.io.rebusy_reqs := ren2_alloc_reqs
busytable.io.wb_valids := io.wakeups.map(_.valid)
busytable.io.wb_pdsts := io.wakeups.map(_.bits.uop.pdst)

assert (!(io.wakeups.map(x => x.valid && x.bits.uop.dst_rtype =/= rtype).reduce(_||_)),
"[rename] Wakeup has wrong rtype.")

for ((uop, w) <- ren2_uops.zipWithIndex) {
val busy = busytable.io.busy_resps(w)

uop.prs1_busy := uop.lrs1_rtype === rtype && busy.prs1_busy
uop.prs2_busy := uop.lrs2_rtype === rtype && busy.prs2_busy
uop.prs3_busy := uop.frs3_en && busy.prs3_busy

val valid = ren2_valids(w)
assert (!(valid && busy.prs1_busy && rtype === RT_FIX && uop.lrs1 === 0.U), "[rename] x0 is busy??")
assert (!(valid && busy.prs2_busy && rtype === RT_FIX && uop.lrs2 === 0.U), "[rename] x0 is busy??")
}

同样是在阶段2进行

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
for (w <- 0 until plWidth) {
val can_allocate = freelist.io.alloc_pregs(w).valid

// Push back against Decode stage if Rename1 can't proceed.
io.ren_stalls(w) := (ren2_uops(w).dst_rtype === rtype) && !can_allocate

val bypassed_uop = Wire(new MicroOp)
if (w > 0) bypassed_uop := BypassAllocations(ren2_uops(w), ren2_uops.slice(0,w), ren2_alloc_reqs.slice(0,w))
else bypassed_uop := ren2_uops(w)

io.ren2_uops(w) := GetNewUopAndBrMask(bypassed_uop, io.brupdate)
}

注意这里检测了一个指令包内的RAW,那我们还有WAW,但其实已经解决了,maptable的scanleft会写入最新的映射关系

总结

这里boom用了很多花活,巧妙但晦涩难懂,也体现了chisel的强大之处,本篇解读将分支预测失败的全部略过

BOOM Dispatch

1731596242893

首先上IO.ren_uops由rename传来,然后后面的dis_uops表示送入每个IQ的指令,假设N 个IQ,每个IQ周期每个周期都可以接受dispawidth指令

1
2
3
4
5
6
7
// incoming microops from rename2
val ren_uops = Vec(coreWidth, Flipped(DecoupledIO(new MicroOp)))

// outgoing microops to issue queues
// N issues each accept up to dispatchWidth uops
// dispatchWidth may vary between issue queues
val dis_uops = MixedVec(issueParams.map(ip=>Vec(ip.dispatchWidth, DecoupledIO(new MicroOp))))

然后就是boom目前使用的dispatcher,首先是ren_ready,也就是指令已经被写入IQ,这时把他拉高,注意这里所有指令只能去一个IQ,所以有一个reduce,检查所有指令是否都送入这个IQ了,然后就是把ren_uops请求分发到对应IQ,对于Boom,有三个IQ,FP,MEM和ALU,其中IQ和MEM为一个issue unit,每周期轮换,这个有的问题就是如果一周期指令既有MEM,又有INT,会导致某些指令无法全部发出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BasicDispatcher(implicit p: Parameters) extends Dispatcher
{
issueParams.map(ip=>require(ip.dispatchWidth == coreWidth))

val ren_readys = io.dis_uops.map(d=>VecInit(d.map(_.ready)).asUInt).reduce(_&_)

for (w <- 0 until coreWidth) {
io.ren_uops(w).ready := ren_readys(w)
}

for {i <- 0 until issueParams.size
w <- 0 until coreWidth} {
val issueParam = issueParams(i)
val dis = io.dis_uops(i)

dis(w).valid := io.ren_uops(w).valid && ((io.ren_uops(w).bits.iq_type & issueParam.iqType.U) =/= 0.U)
dis(w).bits := io.ren_uops(w).bits
}
}

接下来为Boom没使用的模块,这个模块是每周期尽可能送入发射队列,也就是没有只能发射到一个IQ的限制,只有在IQ满了才会stall,

这个模块的ren_ready就很清晰,意思和上面的一样,然后循环体内就是主要逻辑,ren大小和ren_ops大小一样(corewidth),然后uses_iq就是指出指令要送去哪个IQ,之后就是为ren_valid赋值,假如这次循环是检测INT的,对于lw,add,sub就是(false,true,true),之后有一个Boom自己的api,Compactor,意思是找出前k个有效的输出,然后将输出链接到dis,最后得出这个IQ是否空闲,如果use_iq为false,就说明空闲,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Tries to dispatch as many uops as it can to issue queues,
* which may accept fewer than coreWidth per cycle.
* When dispatchWidth == coreWidth, its behavior differs
* from the BasicDispatcher in that it will only stall dispatch when
* an issue queue required by a uop is full.
*/
class CompactingDispatcher(implicit p: Parameters) extends Dispatcher
{
issueParams.map(ip => require(ip.dispatchWidth >= ip.issueWidth))

val ren_readys = Wire(Vec(issueParams.size, Vec(coreWidth, Bool())))

for (((ip, dis), rdy) <- issueParams zip io.dis_uops zip ren_readys) {
val ren = Wire(Vec(coreWidth, Decoupled(new MicroOp)))
ren <> io.ren_uops

val uses_iq = ren map (u => (u.bits.iq_type & ip.iqType.U).orR)

// Only request an issue slot if the uop needs to enter that queue.
(ren zip io.ren_uops zip uses_iq) foreach {case ((u,v),q) =>
u.valid := v.valid && q}

val compactor = Module(new Compactor(coreWidth, ip.dispatchWidth, new MicroOp))
compactor.io.in <> ren
dis <> compactor.io.out

// The queue is considered ready if the uop doesn't use it.
rdy := ren zip uses_iq map {case (u,q) => u.ready || !q}
}

(ren_readys.reduce((r,i) =>
VecInit(r zip i map {case (r,i) =>
r && i})) zip io.ren_uops) foreach {case (r,u) =>
u.ready := r}
}

接下来介绍Compactor,作用就是在n个valid选出k个,首先gen为数据的类型,首先IO为n入k出,如果n=k,就直接把输出连到输入,否则就要去选出前k个,sels得出的是选择哪一个的OH编码,假如in_valid为(0,1,1)

n=3,k=2,sels就为(0010,0100),in_readys的意思就是可以传入数据了,也就是这批指令已经分配完IQ了,这个模块的找前几个有效的数据设置也很巧妙,

BOOM ROB

  • 由于ROB有很多信号目前是从执行级传来,导致解析可能有误,之后会更新解析

1731673694620

首先先理清,ROB在Dispatch写入指令信息,在提交阶段读出信息,提交总是最旧的指令,这里ROB是W个存储体(W=dispatch长度),每次写入ROB就是一个W宽度的指令信息,ROB仅存储一个指令包的首地址,bank(0)(指令包地址连续),但遇到分支指令就得产生气泡,重新开一行,不然无法读到正确的PC,运行图就是下图,注意0x0008有问题,跳转地址为0x0028

1731675223257

ROB状态机

ROB状态机有四个状态,这种情况是不含CRAT,也就是checkpoint,然后还有含有CRAT,这时候就会少一个s_rollback

1731739470401

is_unique 信号是定义在 MicroOp 中的一个成员,表示只允许该指令一条指令存在于流水线中,流水线要对 is_unique 的指令做出的响应包括:

  • 等待 STQ (Store Queue) 中的指令全部提交
  • 清空该指令之后的取到的指令
  • ROB 标记为 unready,等待清空

RISCV 指令集中 is_unique 有效的指令主要包括:

  • CSR(Control and Status Register) 指令
  • 原子指令
  • 内存屏障指令
  • 休眠指令
  • 机器模式特权指令

下面是状态机代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ROB FSM
if (!enableCommitMapTable) {
switch (rob_state) {
is (s_reset) {
rob_state := s_normal
}
is (s_normal) {
// Delay rollback 2 cycles so branch mispredictions can drain
when (RegNext(RegNext(exception_thrown))) {
rob_state := s_rollback
} .otherwise {
for (w <- 0 until coreWidth) {
when (io.enq_valids(w) && io.enq_uops(w).is_unique) {
rob_state := s_wait_till_empty
}
}
}
}
is (s_rollback) {
when (empty) {
rob_state := s_normal
}
}
is (s_wait_till_empty) {
when (RegNext(exception_thrown)) {
rob_state := s_rollback
} .elsewhen (empty) {
rob_state := s_normal
}
}
}
} else {
switch (rob_state) {
is (s_reset) {
rob_state := s_normal
}
is (s_normal) {
when (exception_thrown) {
; //rob_state := s_rollback
} .otherwise {
for (w <- 0 until coreWidth) {
when (io.enq_valids(w) && io.enq_uops(w).is_unique) {
rob_state := s_wait_till_empty
}
}
}
}
is (s_rollback) {
when (rob_tail_idx === rob_head_idx) {
rob_state := s_normal
}
}
is (s_wait_till_empty) {
when (exception_thrown) {
; //rob_state := s_rollback
} .elsewhen (rob_tail === rob_head) {
rob_state := s_normal
}
}
}
}

ROB输入

输入就是在dispatch入队

BOOM 为每个bank中的所有指令定义了若干变量记录重命名缓存的状态信息,主要包括:

  • rob_val :当前 bank 中每行指令的有效信号,初始化为0
  • rob_bsy :当前 bank 中每行指令的 busy 信号,busy=1时表示指令还在流水线中,当入队的指令不是fence或者fence.i都为busy,fence是保证内存顺序,不执行任何操作,故不busy
  • rob_unsafe :当前 bank 中每行指令的 unsafe 信号,指令 safe 表示一定可以被提交
  • rob_uop :当前 bank 中的每行指令

其中unsafe有四种情况:

  • 使用LD队列
  • 使用ST队列,并且不是fence指令
  • 是分支或者jalr
1
def unsafe           = uses_ldq || (uses_stq && !is_fence) || is_br || is_jalr

当输入的指令有效时,就把相关信息写入ROB的tail位置,注意这里会写入异常信息,也就是rob之前遇到的异常

rob_tail为入队指针,head为出队指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
when (io.enq_valids(w)) {
rob_val(rob_tail) := true.B
rob_bsy(rob_tail) := !(io.enq_uops(w).is_fence ||
io.enq_uops(w).is_fencei)
rob_unsafe(rob_tail) := io.enq_uops(w).unsafe
rob_uop(rob_tail) := io.enq_uops(w)
rob_exception(rob_tail) := io.enq_uops(w).exception
rob_predicated(rob_tail) := false.B
rob_fflags(rob_tail) := 0.U

assert (rob_val(rob_tail) === false.B, "[rob] overwriting a valid entry.")
assert ((io.enq_uops(w).rob_idx >> log2Ceil(coreWidth)) === rob_tail)
} .elsewhen (io.enq_valids.reduce(_|_) && !rob_val(rob_tail)) {
rob_uop(rob_tail).debug_inst := BUBBLE // just for debug purposes
}

写回级操作

这个就是响应写回级操作,当写回有效,并且匹配到相关的bank,将busy和unsafe置为低,然后rob的pred设置为写回的pred,

1
2
3
4
5
6
7
8
9
10
for (i <- 0 until numWakeupPorts) {
val wb_resp = io.wb_resps(i)
val wb_uop = wb_resp.bits.uop
val row_idx = GetRowIdx(wb_uop.rob_idx)
when (wb_resp.valid && MatchBank(GetBankIdx(wb_uop.rob_idx))) {
rob_bsy(row_idx) := false.B
rob_unsafe(row_idx) := false.B
rob_predicated(row_idx) := wb_resp.bits.predicated
}
}

响应LSU输入

注意:这里引用的ROB,目前我还不太清楚LSU操作,故先引用,之后可能会加入自己理解

  • lsu_clr_bsy :当要 LSU 模块正确接受了要保存的数据时,清除 store 命令的 busy 状态,同时将指令标记为 safe。clr_bsy 信号的值与存储目标地址是否有效、TLB是否命中、是否处于错误的分支预测下、该指令在存储队列中的状态等因素有关。
  • lsu_clr_unsafe :推测 load 命令除了 Memory Ordering Failure 之外不会出现其他异常时,将 load 指令标记为 safe。lsu_clr_unsafe 信号要等广播异常之后才能输出,采用 RegNext 类型寄存器来延迟一个时钟周期。
  • lxcpt :来自LSU的异常,包括异常的指令、异常是否有效、异常原因等信息。异常的指令在 rob_exception 中对应的值将置为1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for (clr_rob_idx <- io.lsu_clr_bsy) {
when (clr_rob_idx.valid && MatchBank(GetBankIdx(clr_rob_idx.bits))) {
val cidx = GetRowIdx(clr_rob_idx.bits)
rob_bsy(cidx) := false.B
rob_unsafe(cidx) := false.B
assert (rob_val(cidx) === true.B, "[rob] store writing back to invalid entry.")
assert (rob_bsy(cidx) === true.B, "[rob] store writing back to a not-busy entry.")
}
}
for (clr <- io.lsu_clr_unsafe) {
when (clr.valid && MatchBank(GetBankIdx(clr.bits))) {
val cidx = GetRowIdx(clr.bits)
rob_unsafe(cidx) := false.B
}
}
when (io.lxcpt.valid && MatchBank(GetBankIdx(io.lxcpt.bits.uop.rob_idx))) {
rob_exception(GetRowIdx(io.lxcpt.bits.uop.rob_idx)) := true.B
when (io.lxcpt.bits.cause =/= MINI_EXCEPTION_MEM_ORDERING) {
// In the case of a mem-ordering failure, the failing load will have been marked safe already.
assert(rob_unsafe(GetRowIdx(io.lxcpt.bits.uop.rob_idx)),
"An instruction marked as safe is causing an exception")
}
}
can_throw_exception(w) := rob_val(rob_head) && rob_exception(rob_head)

store 命令特殊之处在于不需要写回 (Write Back) 寄存器,因此 LSU 模块将 store 指令从存储队列提交后,store 命令就可以从流水线中退休,即 io.lsu_clr_bsy 信号将 store 指令置为 safe 时同时置为 unbusy。

MINI_EXCEPTION_MEM_ORDERING 是指发生存储-加载顺序异常(Memory Ordering Failure)。当 store 指令与其后的 load 指令有共同的目标地址时,类似 RAW 冲突,若 load 指令在 store 之前发射(Issue),load 命令将从内存中读取错误的值。处理器在提交 store 指令时需要检查是否发生了 Memory Ordering Failure,如果有,则需要刷新流水线、修改重命名映射表等。Memory Ordering Failure 是处理器乱序执行带来的问题,是处理器设计的缺陷,不属于 RISCV 规定的异常,采用 MINI_EXCEPTION_MEM_ORSERING 来弥补。

1
can_throw_exception(w) := rob_val(rob_head) && rob_exception(rob_head)

当位于 ROB头(head) 的指令有效且异常时,才允许抛出异常。

响应提交

在 ROB 头的指令有效且已不在流水线中且未收到来自 CSR 的暂停信号(例如wfi指令)时有效,表示此时在 ROB 头的指令可以提交。

1
can_commit(w) := rob_val(rob_head) && !(rob_bsy(rob_head)) && !io.csr_stall

提交和抛出异常只能在提交阶段

will_commit 这一段代码的主要作用是为 head 指针指向的 ROB 行中的每一个 bank 生成 will_commit 信号,will_commit 信号指示下一时钟周期指令是否提交。will_commit 信号有效的条件是:

  • 该 bank 中的指令可以提交
  • 该 bank 中的指令不会抛出异常
  • ROB 的提交没有被封锁

block_commit block_commit=1 时,ROB 既不能提交指令,也不能抛出异常。对于每个bank,都有一个自己的 block_commit 信号,只要一个 bank 被封锁提交,其后的所有 bank 都将被封锁提交。block_commit 信号保证 ROB 只能顺序提交。若 ROB 处于 s_rollback 或 s_reset 状态,或在前两个时钟周期内抛出异常时,block_commit将被初始化为1,即该行所有指令的提交都被封锁。

will_throw_exception : 表示下一时钟周期将要抛出异常,该信号初始化为0,使信号有效的条件包括:

  • 当前bank可以抛出异常
  • 没有封锁提交
  • 上一个bank没有要提交的指令
1
2
3
4
5
6
7
8
9
10
11
12
var block_commit = (rob_state =/= s_normal) && (rob_state =/= s_wait_till_empty) || RegNext(exception_thrown) || RegNext(RegNext(exception_thrown))
var will_throw_exception = false.B
var block_xcpt = false.B

for (w <- 0 until coreWidth) {
will_throw_exception = (can_throw_exception(w) && !block_commit && !block_xcpt) || will_throw_exception

will_commit(w) := can_commit(w) && !can_throw_exception(w) && !block_commit
block_commit = (rob_head_vals(w) &&
(!can_commit(w) || can_throw_exception(w))) || block_commit
block_xcpt = will_commit(w)
}

异常跟踪逻辑

ROB接受的异常信息来自两个方面:

  • 前端发生的异常,输入端口为 io.enq_valid 和 io.enq_uops.exception
  • LSU发生的异常,输入端口为 io.lxcpt

只存储最旧的异常,因为本来异常就冲刷流水线,之后的异常无意义,首先将dispatch异常原因写入enq_xcpts,

然后就是r_xcpt_uop的更新逻辑,

如果发生回滚,或者冲刷流水线,或者异常被抛出了,不更新,

如果是lsu的异常,首先将uop更新为lsu的uop,然后检查这个是否是最旧的异常(IsOlder)或者是否有效,如果是最旧的异常,或者r_xcpt_val无效,就进入更新逻辑更新next_xcpt_uop(其实就是next_xcpt_uop),

如果是dispatch的,且是最旧的指令,更新信息

如果这个异常位于分支预测失败路径,直接把r_xcpt_val无效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
val next_xcpt_uop = Wire(new MicroOp())
next_xcpt_uop := r_xcpt_uop
val enq_xcpts = Wire(Vec(coreWidth, Bool()))
for (i <- 0 until coreWidth) {
enq_xcpts(i) := io.enq_valids(i) && io.enq_uops(i).exception
}

when (!(io.flush.valid || exception_thrown) && rob_state =/= s_rollback) {
when (io.lxcpt.valid) {
val new_xcpt_uop = io.lxcpt.bits.uop

when (!r_xcpt_val || IsOlder(new_xcpt_uop.rob_idx, r_xcpt_uop.rob_idx, rob_head_idx)) {
r_xcpt_val := true.B
next_xcpt_uop := new_xcpt_uop
next_xcpt_uop.exc_cause := io.lxcpt.bits.cause
r_xcpt_badvaddr := io.lxcpt.bits.badvaddr
}
} .elsewhen (!r_xcpt_val && enq_xcpts.reduce(_|_)) {
val idx = enq_xcpts.indexWhere{i: Bool => i}

// if no exception yet, dispatch exception wins
r_xcpt_val := true.B
next_xcpt_uop := io.enq_uops(idx)
r_xcpt_badvaddr := AlignPCToBoundary(io.xcpt_fetch_pc, icBlockBytes) | io.enq_uops(idx).pc_lob

}
}

r_xcpt_uop := next_xcpt_uop
r_xcpt_uop.br_mask := GetNewBrMask(io.brupdate, next_xcpt_uop)
when (io.flush.valid || IsKilledByBranch(io.brupdate, next_xcpt_uop)) {
r_xcpt_val := false.B
}

分支预测失败

主要是消除mask一样的分支,否则就更新这个指令的br_mask,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// -----------------------------------------------
// Kill speculated entries on branch mispredict
for (i <- 0 until numRobRows) {
val br_mask = rob_uop(i).br_mask

//kill instruction if mispredict & br mask match
when (IsKilledByBranch(io.brupdate, br_mask))
{
rob_val(i) := false.B
rob_uop(i.U).debug_inst := BUBBLE
} .elsewhen (rob_val(i)) {
// clear speculation bit even on correct speculation
rob_uop(i).br_mask := GetNewBrMask(io.brupdate, br_mask)
}
}

ROB Head Logic

当一个bank的所有指令都可以提交,才可以改变head指针状态,finished_committing_row只有当commit指令有效,并且将在下个周期提交,并且head有效

  • 弄明白r_partial_row是什么意思

这里r_partial_row是指rob这个corewidth的bank还没被填满,此时不能更新tail指针

这时就会自增ROB的head指针,否则将rob_head_lsb指向第一个为1的bank

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val rob_deq = WireInit(false.B)
val r_partial_row = RegInit(false.B)

when (io.enq_valids.reduce(_|_)) {
r_partial_row := io.enq_partial_stall
}

val finished_committing_row =
(io.commit.valids.asUInt =/= 0.U) &&
((will_commit.asUInt ^ rob_head_vals.asUInt) === 0.U) &&
!(r_partial_row && rob_head === rob_tail && !maybe_full)

when (finished_committing_row) {
rob_head := WrapInc(rob_head, numRobRows)
rob_head_lsb := 0.U
rob_deq := true.B
} .otherwise {
rob_head_lsb := OHToUInt(PriorityEncoderOH(rob_head_vals.asUInt))
}

ROB Tail Logic

tail主要有以下优先级:

  1. 当处于回滚状态,并且还没操作完(也就是head指针和tail指针还没有重合)或者ROB满了,此时自减tail,设置deq为true,
  2. 当处于回滚,但tail等于head并且没有满,lsb设置为head的lsb
  • lsb意思就是bank的偏移
  1. 当分支预测失败,自增
  2. 当dispatch,自增,然后指向第0个bank
  3. 当指令未派遣完,将LSB设置为最后一个有效指令的下一个bank
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val rob_enq = WireInit(false.B)

when (rob_state === s_rollback && (rob_tail =/= rob_head || maybe_full)) {
// Rollback a row
rob_tail := WrapDec(rob_tail, numRobRows)
rob_tail_lsb := (coreWidth-1).U
rob_deq := true.B
} .elsewhen (rob_state === s_rollback && (rob_tail === rob_head) && !maybe_full) {
// Rollback an entry
rob_tail_lsb := rob_head_lsb
} .elsewhen (io.brupdate.b2.mispredict) {
rob_tail := WrapInc(GetRowIdx(io.brupdate.b2.uop.rob_idx), numRobRows)
rob_tail_lsb := 0.U
} .elsewhen (io.enq_valids.asUInt =/= 0.U && !io.enq_partial_stall) {
rob_tail := WrapInc(rob_tail, numRobRows)
rob_tail_lsb := 0.U
rob_enq := true.B
} .elsewhen (io.enq_valids.asUInt =/= 0.U && io.enq_partial_stall) {
rob_tail_lsb := PriorityEncoder(~MaskLower(io.enq_valids.asUInt))
}

ROB PNR逻辑

  1. TODO

ROB输出逻辑

提交是否有效,以及回滚是否有效

1
2
3
4
5
6
7
    io.commit.valids(w) := will_commit(w)
io.commit.arch_valids(w) := will_commit(w) && !rob_predicated(com_idx)
io.commit.uops(w) := rob_uop(com_idx)
io.commit.debug_insts(w) := rob_debug_inst_rdata(w)
...
io.commit.rbk_valids(w) := rbk_row && rob_val(com_idx) && !(enableCommitMapTable.B)
io.commit.rollback := (rob_state === s_rollback)

送往前端的flush信号

1
2
3
4
5
6
7
8
9
10
 // delay a cycle for critical path considerations
io.flush.valid := flush_val
io.flush.bits.ftq_idx := flush_uop.ftq_idx
io.flush.bits.pc_lob := flush_uop.pc_lob
io.flush.bits.edge_inst := flush_uop.edge_inst
io.flush.bits.is_rvc := flush_uop.is_rvc
io.flush.bits.flush_typ := FlushTypes.getType(flush_val,
exception_thrown && !is_mini_exception,
flush_commit && flush_uop.uopc === uopERET,
refetch_inst)

输出异常信息,提交异常

1
2
3
4
5
6
7
8
9
10
11
12
13
  // Note: exception must be in the commit bundle.
// Note: exception must be the first valid instruction in the commit bundle.
exception_thrown := will_throw_exception
val is_mini_exception = io.com_xcpt.bits.cause === MINI_EXCEPTION_MEM_ORDERING
io.com_xcpt.valid := exception_thrown && !is_mini_exception
io.com_xcpt.bits.cause := r_xcpt_uop.exc_cause
...
io.com_xcpt.bits.badvaddr := Sext(r_xcpt_badvaddr, xLen)
...
io.com_xcpt.bits.ftq_idx := com_xcpt_uop.ftq_idx
io.com_xcpt.bits.edge_inst := com_xcpt_uop.edge_inst
io.com_xcpt.bits.is_rvc := com_xcpt_uop.is_rvc
io.com_xcpt.bits.pc_lob := com_xcpt_uop.pc_lob

送往ren2/dispatch的信号

1
2
io.empty        := empty
io.ready := (rob_state === s_normal) && !full && !r_xcpt_val

BOOM V3 ISSUE 模块解析

issue slot

1731223168047

首先明确:这个slot需要能写入东西,能读出东西,控制信号可以改变(唤醒)

写入就是dispatch模块写入,读出就是准备好了可以发射了

然後列出状态机:

1
2
3
4
5
6
7
trait IssueUnitConstants
{
// invalid : slot holds no valid uop.
// s_valid_1: slot holds a valid uop.
// s_valid_2: slot holds a store-like uop that may be broken into two micro-ops.
val s_invalid :: s_valid_1 :: s_valid_2 :: Nil = Enum(3)
}

可以看到有三个状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
val io = IO(new IssueSlotIO(numWakeupPorts))

// slot invalid?
// slot is valid, holding 1 uop
// slot is valid, holds 2 uops (like a store)
def is_invalid = state === s_invalid
def is_valid = state =/= s_invalid

val next_state = Wire(UInt()) // the next state of this slot (which might then get moved to a new slot)
val next_uopc = Wire(UInt()) // the next uopc of this slot (which might then get moved to a new slot)
val next_lrs1_rtype = Wire(UInt()) // the next reg type of this slot (which might then get moved to a new slot)
val next_lrs2_rtype = Wire(UInt()) // the next reg type of this slot (which might then get moved to a new slot)

val state = RegInit(s_invalid)
val p1 = RegInit(false.B)
val p2 = RegInit(false.B)
val p3 = RegInit(false.B)
val ppred = RegInit(false.B)

// Poison if woken up by speculative load.
// Poison lasts 1 cycle (as ldMiss will come on the next cycle).
// SO if poisoned is true, set it to false!
val p1_poisoned = RegInit(false.B)
val p2_poisoned = RegInit(false.B)
p1_poisoned := false.B
p2_poisoned := false.B
val next_p1_poisoned = Mux(io.in_uop.valid, io.in_uop.bits.iw_p1_poisoned, p1_poisoned)
val next_p2_poisoned = Mux(io.in_uop.valid, io.in_uop.bits.iw_p2_poisoned, p2_poisoned)

val slot_uop = RegInit(NullMicroOp)
val next_uop = Mux(io.in_uop.valid, io.in_uop.bits, slot_uop)

接下来为主要信号,next_state這個slot的下一個狀態,之后这些next前缀的都是这个意思,他们是去构造压缩式队列使用的,然后state是这个slot的状态,p1,p2,p3表示操作数是否准备好了,ppred涉及到load的推测唤醒,但目前他们文档说不支持,下面的p1_poisoned表示推测唤醒失败,需要将这个p1给置为false,next_p1_poisoned是指输入的bit的p1是否被poisoned,slot_uop保存这个slot内容,然后next_uop,仍然用于压缩队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//-----------------------------------------------------------------------------
// next slot state computation
// compute the next state for THIS entry slot (in a collasping queue, the
// current uop may get moved elsewhere, and a new uop can enter

when (io.kill) {
state := s_invalid
} .elsewhen (io.in_uop.valid) {
state := io.in_uop.bits.iw_state
} .elsewhen (io.clear) {
state := s_invalid
} .otherwise {
state := next_state
}

然后就是下一个slot状态计算,kill表示冲刷流水线,clear表示slot被移到其他的地方了,如果输入的uop.valid有效,就把state置为输入uop的state,否则就为next_state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//-----------------------------------------------------------------------------
// "update" state
// compute the next state for the micro-op in this slot. This micro-op may
// be moved elsewhere, so the "next_state" travels with it.

// defaults
next_state := state
next_uopc := slot_uop.uopc
next_lrs1_rtype := slot_uop.lrs1_rtype
next_lrs2_rtype := slot_uop.lrs2_rtype

when (io.kill) {
next_state := s_invalid
} .elsewhen ((io.grant && (state === s_valid_1)) ||
(io.grant && (state === s_valid_2) && p1 && p2 && ppred)) {
// try to issue this uop.
when (!(io.ldspec_miss && (p1_poisoned || p2_poisoned))) {
next_state := s_invalid
}
} .elsewhen (io.grant && (state === s_valid_2)) {
when (!(io.ldspec_miss && (p1_poisoned || p2_poisoned))) {
next_state := s_valid_1
when (p1) {
slot_uop.uopc := uopSTD
next_uopc := uopSTD
slot_uop.lrs1_rtype := RT_X
next_lrs1_rtype := RT_X
} .otherwise {
slot_uop.lrs2_rtype := RT_X
next_lrs2_rtype := RT_X
}
}
}

when (io.in_uop.valid) {
slot_uop := io.in_uop.bits
assert (is_invalid || io.clear || io.kill, "trying to overwrite a valid issue slot.")
}

当冲刷流水线,就把next_state设置为无效,当grant为高,可以并且状态为v1(s_valid_1),或者是v2,且操作数准备好了,就说明可以发射了,如果没有遇到load推测唤醒失败,就把next_state设置为s_invalid,假如state为v2并且grant,如果没发生load推测唤醒失败,就把next_state设置为v1,然后看准备好的是数据还是地址,分别被uopc赋值为相应类型,如果in_uop.valid,就把slot更新为io.in_uop.bits

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Wakeup Compare Logic

// these signals are the "next_p*" for the current slot's micro-op.
// they are important for shifting the current slot_uop up to an other entry.
val next_p1 = WireInit(p1)
val next_p2 = WireInit(p2)
val next_p3 = WireInit(p3)
val next_ppred = WireInit(ppred)

when (io.in_uop.valid) {
p1 := !(io.in_uop.bits.prs1_busy)
p2 := !(io.in_uop.bits.prs2_busy)
p3 := !(io.in_uop.bits.prs3_busy)
ppred := !(io.in_uop.bits.ppred_busy)
}

when (io.ldspec_miss && next_p1_poisoned) {
assert(next_uop.prs1 =/= 0.U, "Poison bit can't be set for prs1=x0!")
p1 := false.B
}
when (io.ldspec_miss && next_p2_poisoned) {
assert(next_uop.prs2 =/= 0.U, "Poison bit can't be set for prs2=x0!")
p2 := false.B
}

for (i <- 0 until numWakeupPorts) {
when (io.wakeup_ports(i).valid &&
(io.wakeup_ports(i).bits.pdst === next_uop.prs1)) {
p1 := true.B
}
when (io.wakeup_ports(i).valid &&
(io.wakeup_ports(i).bits.pdst === next_uop.prs2)) {
p2 := true.B
}
when (io.wakeup_ports(i).valid &&
(io.wakeup_ports(i).bits.pdst === next_uop.prs3)) {
p3 := true.B
}
}
when (io.pred_wakeup_port.valid && io.pred_wakeup_port.bits === next_uop.ppred) {
ppred := true.B
}

for (w <- 0 until memWidth) {
assert (!(io.spec_ld_wakeup(w).valid && io.spec_ld_wakeup(w).bits === 0.U),
"Loads to x0 should never speculatively wakeup other instructions")
}

// TODO disable if FP IQ.
for (w <- 0 until memWidth) {
when (io.spec_ld_wakeup(w).valid &&
io.spec_ld_wakeup(w).bits === next_uop.prs1 &&
next_uop.lrs1_rtype === RT_FIX) {
p1 := true.B
p1_poisoned := true.B
assert (!next_p1_poisoned)
}
when (io.spec_ld_wakeup(w).valid &&
io.spec_ld_wakeup(w).bits === next_uop.prs2 &&
next_uop.lrs2_rtype === RT_FIX) {
p2 := true.B
p2_poisoned := true.B
assert (!next_p2_poisoned)
}
}

接下来是唤醒逻辑,首先定义了四个next前缀的信号,这些信号用于压缩队列,然后就是如果输入有效数据,检查输入的rs1,rs2,rs3是否busy,也就是是否被写入prf(在Busytable没表项),如果推测唤醒失败,就把p1置为false,其他同理,然后检查每个wakeupport,如果有port有效,并且pdst等于slot的src,就把该寄存器ready,然后是推测唤醒逻辑:

TODO

1
2
3
4
5
6
7
8
9
10
11
12
13
// Request Logic
io.request := is_valid && p1 && p2 && p3 && ppred && !io.kill
val high_priority = slot_uop.is_br || slot_uop.is_jal || slot_uop.is_jalr
io.request_hp := io.request && high_priority

when (state === s_valid_1) {
io.request := p1 && p2 && p3 && ppred && !io.kill
} .elsewhen (state === s_valid_2) {
io.request := (p1 || p2) && ppred && !io.kill
} .otherwise {
io.request := false.B
}

接下来为req逻辑,只要p1,p2,p3准备好就可以req了,由于大部分指令为两个src,所以p3一般为默认值,也就是true,最后就是一些连线逻辑

Issue Unit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* Abstract top level issue unit
*
* @param numIssueSlots depth of issue queue
* @param issueWidth amoutn of operations that can be issued at once
* @param numWakeupPorts number of wakeup ports for issue unit
* @param iqType type of issue queue (mem, int, fp)
*/
abstract class IssueUnit(
val numIssueSlots: Int,
val issueWidth: Int,
val numWakeupPorts: Int,
val iqType: BigInt,
val dispatchWidth: Int)
(implicit p: Parameters)
extends BoomModule
with IssueUnitConstants
{
val io = IO(new IssueUnitIO(issueWidth, numWakeupPorts, dispatchWidth))

//-------------------------------------------------------------
// Set up the dispatch uops
// special case "storing" 2 uops within one issue slot.

val dis_uops = Array.fill(dispatchWidth) {Wire(new MicroOp())}
for (w <- 0 until dispatchWidth) {
dis_uops(w) := io.dis_uops(w).bits
dis_uops(w).iw_p1_poisoned := false.B
dis_uops(w).iw_p2_poisoned := false.B
dis_uops(w).iw_state := s_valid_1

if (iqType == IQT_MEM.litValue || iqType == IQT_INT.litValue) {
// For StoreAddrGen for Int, or AMOAddrGen, we go to addr gen state
when ((io.dis_uops(w).bits.uopc === uopSTA && io.dis_uops(w).bits.lrs2_rtype === RT_FIX) ||
io.dis_uops(w).bits.uopc === uopAMO_AG) {
dis_uops(w).iw_state := s_valid_2
// For store addr gen for FP, rs2 is the FP register, and we don't wait for that here
} .elsewhen (io.dis_uops(w).bits.uopc === uopSTA && io.dis_uops(w).bits.lrs2_rtype =/= RT_FIX) {
dis_uops(w).lrs2_rtype := RT_X
dis_uops(w).prs2_busy := false.B
}
dis_uops(w).prs3_busy := false.B
} else if (iqType == IQT_FP.litValue) {
// FP "StoreAddrGen" is really storeDataGen, and rs1 is the integer address register
when (io.dis_uops(w).bits.uopc === uopSTA) {
dis_uops(w).lrs1_rtype := RT_X
dis_uops(w).prs1_busy := false.B
}
}

if (iqType != IQT_INT.litValue) {
assert(!(io.dis_uops(w).bits.ppred_busy && io.dis_uops(w).valid))
dis_uops(w).ppred_busy := false.B
}
}


我们这个抽象类,主要参数有issue queue大小,一次可以发射多少,唤醒port,issue的类型(mem,int,fp),然后创建了一个dis_uops,将来自dispatch的信号传入,然后将dip_uops初始化为dispatch数据,状态设置为v1(代表一般指令,),然后根据iq类型来分别进一步初始化,对于int类型的之后将prs3置为空闲,而mem不仅置为空闲,还检查是STA对state初始化为v2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//-------------------------------------------------------------
// Issue Table

val slots = for (i <- 0 until numIssueSlots) yield { val slot = Module(new IssueSlot(numWakeupPorts)); slot }
val issue_slots = VecInit(slots.map(_.io))

for (i <- 0 until numIssueSlots) {
issue_slots(i).wakeup_ports := io.wakeup_ports
issue_slots(i).pred_wakeup_port := io.pred_wakeup_port
issue_slots(i).spec_ld_wakeup := io.spec_ld_wakeup
issue_slots(i).ldspec_miss := io.ld_miss
issue_slots(i).brupdate := io.brupdate
issue_slots(i).kill := io.flush_pipeline
}

io.event_empty := !(issue_slots.map(s => s.valid).reduce(_|_))

val count = PopCount(slots.map(_.io.valid))
dontTouch(count)

接下来就是创建slot,连线,

IssueUnitStatic

然后讲解非压缩队列

1
2
3
4
5
6
val entry_wen_oh = VecInit(Seq.fill(numIssueSlots){ Wire(Bits(dispatchWidth.W)) })
for (i <- 0 until numIssueSlots) {
issue_slots(i).in_uop.valid := entry_wen_oh(i).orR
issue_slots(i).in_uop.bits := Mux1H(entry_wen_oh(i), dis_uops)
issue_slots(i).clear := false.B
}

首先是表项写使能,这个entry_wen_oh会在后面赋值,这个是dispatch传来的,然后将数据传入issue slot,这里使用one hot 编码,这个会在之后讲解,将clear设置为false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//-------------------------------------------------------------
// Dispatch/Entry Logic
// find a slot to enter a new dispatched instruction

val entry_wen_oh_array = Array.fill(numIssueSlots,dispatchWidth){false.B}
var allocated = VecInit(Seq.fill(dispatchWidth){false.B}) // did an instruction find an issue width?

for (i <- 0 until numIssueSlots) {
var next_allocated = Wire(Vec(dispatchWidth, Bool()))
var can_allocate = !(issue_slots(i).valid)

for (w <- 0 until dispatchWidth) {
entry_wen_oh_array(i)(w) = can_allocate && !(allocated(w))

next_allocated(w) := can_allocate | allocated(w)
can_allocate = can_allocate && allocated(w)
}

allocated = next_allocated
}

这是分发逻辑,首先创建一个entry_wen_oh_array,记录每个slot是否有dispatch的指令,然后allocated表示这个指令已经被分配了,然后进入两重循环,最底层循环就是看看这个slot是否空闲,如果空闲就将使能信号写入进去,然后把这个表项锁住,也就是将can_allocate置低,举例:

假设dispatch为4位使用一个四位变量allocate=(0,0,0,0)表示指令都没分发出去,假设指令0,找到了一个空slot,我们就可以把这个空槽占据了,然后next_allocate=(1,0,0,0)然后can_allocate由于allocated为false,所以置低,最后第一次循环完,next_allocate为(1,0,0,0),can_allocate=false,这个slot接受不到其他的指令了,已经被指令0占据了,内层循环完毕,把next_allocate赋值给allocate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// if we can find an issue slot, do we actually need it?
// also, translate from Scala data structures to Chisel Vecs
for (i <- 0 until numIssueSlots) {
val temp_uop_val = Wire(Vec(dispatchWidth, Bool()))

for (w <- 0 until dispatchWidth) {
// TODO add ctrl bit for "allocates iss_slot"
temp_uop_val(w) := io.dis_uops(w).valid &&
!dis_uops(w).exception &&
!dis_uops(w).is_fence &&
!dis_uops(w).is_fencei &&
entry_wen_oh_array(i)(w)
}
entry_wen_oh(i) := temp_uop_val.asUInt
}

for (w <- 0 until dispatchWidth) {
io.dis_uops(w).ready := allocated(w)
}

这段代码将上面得出的wen信号进一步处理,然后将wen赋值给一开始的entry_wen_oh,这样最上面的代码就可以找到哪个slot这次会被写入了,并且这个也得出了是那一条指令占据了哪个slot,假设有4个slot,dis大小也是4,最后这个entry_wen_oh可能是(1,0,0,0),(0,1,0,0),(0,0,1,0),(0,0,0,1),也就是得到了每条指令要写入哪个slot的信息,完成分配的信号就是allocate对应位为1,

1
2
3
4
5
6
7
8
9
10
for (w <- 0 until issueWidth) {
io.iss_valids(w) := false.B
io.iss_uops(w) := NullMicroOp
// unsure if this is overkill
io.iss_uops(w).prs1 := 0.U
io.iss_uops(w).prs2 := 0.U
io.iss_uops(w).prs3 := 0.U
io.iss_uops(w).lrs1_rtype := RT_X
io.iss_uops(w).lrs2_rtype := RT_X
}

接下来为仲裁逻辑,首先对issue信号初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// TODO can we use flatten to get an array of bools on issue_slot(*).request?
val lo_request_not_satisfied = Array.fill(numIssueSlots){Bool()}
val hi_request_not_satisfied = Array.fill(numIssueSlots){Bool()}

for (i <- 0 until numIssueSlots) {
lo_request_not_satisfied(i) = issue_slots(i).request
hi_request_not_satisfied(i) = issue_slots(i).request_hp
issue_slots(i).grant := false.B // default
}

for (w <- 0 until issueWidth) {
var port_issued = false.B

// first look for high priority requests
for (i <- 0 until numIssueSlots) {
val can_allocate = (issue_slots(i).uop.fu_code & io.fu_types(w)) =/= 0.U

when (hi_request_not_satisfied(i) && can_allocate && !port_issued) {
issue_slots(i).grant := true.B
io.iss_valids(w) := true.B
io.iss_uops(w) := issue_slots(i).uop
}

val port_already_in_use = port_issued
port_issued = (hi_request_not_satisfied(i) && can_allocate) | port_issued
// deassert lo_request if hi_request is 1.
lo_request_not_satisfied(i) = (lo_request_not_satisfied(i) && !hi_request_not_satisfied(i))
// if request is 0, stay 0. only stay 1 if request is true and can't allocate
hi_request_not_satisfied(i) = (hi_request_not_satisfied(i) && (!can_allocate || port_already_in_use))
}

// now look for low priority requests
for (i <- 0 until numIssueSlots) {
val can_allocate = (issue_slots(i).uop.fu_code & io.fu_types(w)) =/= 0.U

when (lo_request_not_satisfied(i) && can_allocate && !port_issued) {
issue_slots(i).grant := true.B
io.iss_valids(w) := true.B
io.iss_uops(w) := issue_slots(i).uop
}

val port_already_in_use = port_issued
port_issued = (lo_request_not_satisfied(i) && can_allocate) | port_issued
// if request is 0, stay 0. only stay 1 if request is true and can't allocate or port already in use
lo_request_not_satisfied(i) = (lo_request_not_satisfied(i) && (!can_allocate || port_already_in_use))
}
}

首先把低级req和高级req从issue slot读出来,将grant置为低(初始化),然后进入仲裁逻辑,首先检查高优先级的req,首先有一个can_allocate信号,也就是匹配FU,如果匹配到FU,并且有高优先级请求,并且port_issue没有置为高,就发出grant信号,表示可以发射了,将slot的uop读出来,然后将这个port_issued置为高,接下来重新赋值低位请求,必须没有高位请求,低位请求才生效,如果有高级请求,但FU没匹配成功或者这个FU在用,就一直置为高位请求,接下来就是低级请求,其和高级请求的思路类似

IssueUnitCollapsing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//-------------------------------------------------------------
// Figure out how much to shift entries by

val maxShift = dispatchWidth
val vacants = issue_slots.map(s => !(s.valid)) ++ io.dis_uops.map(_.valid).map(!_.asBool)
val shamts_oh = Array.fill(numIssueSlots+dispatchWidth) {Wire(UInt(width=maxShift.W))}
// track how many to shift up this entry by by counting previous vacant spots
def SaturatingCounterOH(count_oh:UInt, inc: Bool, max: Int): UInt = {
val next = Wire(UInt(width=max.W))
next := count_oh
when (count_oh === 0.U && inc) {
next := 1.U
} .elsewhen (!count_oh(max-1) && inc) {
next := (count_oh << 1.U)
}
next
}
shamts_oh(0) := 0.U
for (i <- 1 until numIssueSlots + dispatchWidth) {
shamts_oh(i) := SaturatingCounterOH(shamts_oh(i-1), vacants(i-1), maxShift)
}

首先定义最大位移的数字maxshift,然后vacants就是把issue slot和要写入的看看是不是有效的,之后讲解SaturatingCounterOH方法,这个方法定义了每个位置要位移多少,首先最底部的绝对不用位移,之后的位置位移取决于下面的是否是空的,如果是空的,就在下面的一个位置位移的基础上左移一位(one hot编码),如果不是one hot,只要在下面位置位移的基础+1即可,然后我们经过这个循环就得到了每一项要位移的数(one hot),

不太明白这个maxshift为什么要以dispatchwidth为最大值,不该为issuewidth吗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//-------------------------------------------------------------

// which entries' uops will still be next cycle? (not being issued and vacated)
val will_be_valid = (0 until numIssueSlots).map(i => issue_slots(i).will_be_valid) ++
(0 until dispatchWidth).map(i => io.dis_uops(i).valid &&
!dis_uops(i).exception &&
!dis_uops(i).is_fence &&
!dis_uops(i).is_fencei)

val uops = issue_slots.map(s=>s.out_uop) ++ dis_uops.map(s=>s)
for (i <- 0 until numIssueSlots) {
issue_slots(i).in_uop.valid := false.B
issue_slots(i).in_uop.bits := uops(i+1)
for (j <- 1 to maxShift by 1) {
when (shamts_oh(i+j) === (1 << (j-1)).U) {
issue_slots(i).in_uop.valid := will_be_valid(i+j)
issue_slots(i).in_uop.bits := uops(i+j)
}
}
issue_slots(i).clear := shamts_oh(i) =/= 0.U
}

这几段代码主要讲的就是issue和dispatch的表项是否在下个周期还有效,也就是他是否发射出去了或者被清除了,然后循环内主要就是对slot移位,就是设置一个小循环,这个小循环检测是哪个移位进来的,

举例:

假设我们有四个slot,然后slot(0)是空的,其他都有数据,那么shamt(0)=0,shamt(1)=01,shamt(2)=01,shamt(3)=01,所以我们移位后就是3->2,2->1,1->0,假设i=0,小循环第一次进入when,此时j=1,这就完成了1->0的操作,由于slot(1)不是空的,所以这个循环只会进入一次when,最后出小循环将slot(0)的clear根据shamt(0)置为false

最后一步的clear对移位后有数据的没什莫影响,因为in_valid优先级大于clear,但对高位置的slot有影响,比如这里就是对3有影响(假设没有指令dispatch进来)

1
2
3
4
5
6
7
8
9
10
//-------------------------------------------------------------
// Dispatch/Entry Logic
// did we find a spot to slide the new dispatched uops into?

val will_be_available = (0 until numIssueSlots).map(i =>
(!issue_slots(i).will_be_valid || issue_slots(i).clear) && !(issue_slots(i).in_uop.valid))
val num_available = PopCount(will_be_available)
for (w <- 0 until dispatchWidth) {
io.dis_uops(w).ready := RegNext(num_available > w.U)
}

这段代码就是检测dispatch的指令是否写进来,will_be_available检查空的slot并且之后还被移入数据,然后num_available得到空slot的数目,如果num_available大于dispatchwidth,就说明分发好了,这里也就是空的slot大于分发的数目,注意,这里不保证每个都写进去,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

//-------------------------------------------------------------
// Issue Select Logic

// set default
for (w <- 0 until issueWidth) {
io.iss_valids(w) := false.B
io.iss_uops(w) := NullMicroOp
// unsure if this is overkill
io.iss_uops(w).prs1 := 0.U
io.iss_uops(w).prs2 := 0.U
io.iss_uops(w).prs3 := 0.U
io.iss_uops(w).lrs1_rtype := RT_X
io.iss_uops(w).lrs2_rtype := RT_X
}

val requests = issue_slots.map(s => s.request)
val port_issued = Array.fill(issueWidth){Bool()}
for (w <- 0 until issueWidth) {
port_issued(w) = false.B
}

for (i <- 0 until numIssueSlots) {
issue_slots(i).grant := false.B
var uop_issued = false.B

for (w <- 0 until issueWidth) {
val can_allocate = (issue_slots(i).uop.fu_code & io.fu_types(w)) =/= 0.U

when (requests(i) && !uop_issued && can_allocate && !port_issued(w)) {
issue_slots(i).grant := true.B
io.iss_valids(w) := true.B
io.iss_uops(w) := issue_slots(i).uop
}
val was_port_issued_yet = port_issued(w)
port_issued(w) = (requests(i) && !uop_issued && can_allocate) | port_issued(w)
uop_issued = (requests(i) && can_allocate && !was_port_issued_yet) | uop_issued
}
}

最后是仲裁逻辑,首先将issue信息初始化,然后找slot的req,之后去寻找可以issue的项,这里和非压缩类似,

总结

无论是压缩还是非压缩,issue都使用相同的slot,而且仲裁逻辑都是一样的,也就是从低slot扫描到高slot,直到凑齐发射指令

Boom regfile

Regfile模块

读逻辑

首先检查是否有bypass数据,如果有的话,就选择读出的数据是bypass数据,注意这里选择bypass数据时是选择最新写入这个寄存器的值,,也就是采用Mux1H,得到bypass数据,注意这里提交都是等一个ROB行算完才可以提交并bypass,如果无bypass数据,就直接读出regfile的数

这里的bypass是指W->R bypass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (bypassableArray.reduce(_||_)) {
val bypassable_wports = ArrayBuffer[Valid[RegisterFileWritePort]]()
io.write_ports zip bypassableArray map { case (wport, b) => if (b) { bypassable_wports += wport} }

for (i <- 0 until numReadPorts) {
val bypass_ens = bypassable_wports.map(x => x.valid &&
x.bits.addr === read_addrs(i))
//使用Mux1H得出最新的指令的bypass的结果
val bypass_data = Mux1H(VecInit(bypass_ens.toSeq), VecInit(bypassable_wports.map(_.bits.data).toSeq))

io.read_ports(i).data := Mux(bypass_ens.reduce(_|_), bypass_data, read_data(i))
}
} else {
for (i <- 0 until numReadPorts) {
io.read_ports(i).data := read_data(i)
}
}

写逻辑

代码如下.

1
2
3
4
5
for (wport <- io.write_ports) {
when (wport.valid) {
regfile(wport.bits.addr) := wport.bits.data
}
}

RegisterRead模块

读端口逻辑

首先读出issue模块送入的rs的addr,将其送入rf模块,然后根据addr读出相应数据,主要这里读寄存器在issue,读出寄存器在RF阶段,然后exe_reg_uops是送往exe阶段的uops,这里的idx的意思就是充分利用每个端口,端口不与指令绑定,比如我有两条指令,一个需要2个读,一个需要1个写,所以我的读idx在循环内为(0,2)

  • 暂时不知道为什么延迟的原因
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var idx = 0 // index into flattened read_ports array
for (w <- 0 until issueWidth) {
val numReadPorts = numReadPortsArray(w)

// NOTE:
// rrdLatency==1, we need to send read address at end of ISS stage,
// in order to get read data back at end of RRD stage.

val rs1_addr = io.iss_uops(w).prs1
val rs2_addr = io.iss_uops(w).prs2
val rs3_addr = io.iss_uops(w).prs3
val pred_addr = io.iss_uops(w).ppred

if (numReadPorts > 0) io.rf_read_ports(idx+0).addr := rs1_addr
if (numReadPorts > 1) io.rf_read_ports(idx+1).addr := rs2_addr
if (numReadPorts > 2) io.rf_read_ports(idx+2).addr := rs3_addr

if (enableSFBOpt) io.prf_read_ports(w).addr := pred_addr

if (numReadPorts > 0) rrd_rs1_data(w) := Mux(RegNext(rs1_addr === 0.U), 0.U, io.rf_read_ports(idx+0).data)
if (numReadPorts > 1) rrd_rs2_data(w) := Mux(RegNext(rs2_addr === 0.U), 0.U, io.rf_read_ports(idx+1).data)
if (numReadPorts > 2) rrd_rs3_data(w) := Mux(RegNext(rs3_addr === 0.U), 0.U, io.rf_read_ports(idx+2).data)

if (enableSFBOpt) rrd_pred_data(w) := Mux(RegNext(io.iss_uops(w).is_sfb_shadow), io.prf_read_ports(w).data, false.B)

val rrd_kill = io.kill || IsKilledByBranch(io.brupdate, rrd_uops(w))

exe_reg_valids(w) := Mux(rrd_kill, false.B, rrd_valids(w))
// TODO use only the valids signal, don't require us to set nullUop
exe_reg_uops(w) := Mux(rrd_kill, NullMicroOp, rrd_uops(w))

exe_reg_uops(w).br_mask := GetNewBrMask(io.brupdate, rrd_uops(w))

idx += numReadPorts
}

BYPASS逻辑

bypass不bypass寄存器rs3(FU),也就是只bypass INT,其中rs1_cases,rs2_cases得出了mux控制信号和data,然后MUXcase的意思就是默认为rrd_rs1_data,如果之后的条件满足,就选择之后的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
for (w <- 0 until issueWidth) {
val numReadPorts = numReadPortsArray(w)
var rs1_cases = Array((false.B, 0.U(registerWidth.W)))
var rs2_cases = Array((false.B, 0.U(registerWidth.W)))
var pred_cases = Array((false.B, 0.U(1.W)))

val prs1 = rrd_uops(w).prs1
val lrs1_rtype = rrd_uops(w).lrs1_rtype
val prs2 = rrd_uops(w).prs2
val lrs2_rtype = rrd_uops(w).lrs2_rtype
val ppred = rrd_uops(w).ppred

for (b <- 0 until numTotalBypassPorts)
{
val bypass = io.bypass(b)
// can't use "io.bypass.valid(b) since it would create a combinational loop on branch kills"
rs1_cases ++= Array((bypass.valid && (prs1 === bypass.bits.uop.pdst) && bypass.bits.uop.rf_wen
&& bypass.bits.uop.dst_rtype === RT_FIX && lrs1_rtype === RT_FIX && (prs1 =/= 0.U), bypass.bits.data))
rs2_cases ++= Array((bypass.valid && (prs2 === bypass.bits.uop.pdst) && bypass.bits.uop.rf_wen
&& bypass.bits.uop.dst_rtype === RT_FIX && lrs2_rtype === RT_FIX && (prs2 =/= 0.U), bypass.bits.data))
}

for (b <- 0 until numTotalPredBypassPorts)
{
val bypass = io.pred_bypass(b)
pred_cases ++= Array((bypass.valid && (ppred === bypass.bits.uop.pdst) && bypass.bits.uop.is_sfb_br, bypass.bits.data))
}

if (numReadPorts > 0) bypassed_rs1_data(w) := MuxCase(rrd_rs1_data(w), rs1_cases)
if (numReadPorts > 1) bypassed_rs2_data(w) := MuxCase(rrd_rs2_data(w), rs2_cases)
if (enableSFBOpt) bypassed_pred_data(w) := MuxCase(rrd_pred_data(w), pred_cases)
}

送往执行阶段信号

代码如下,主要送了valid,数据和uops,注意这里是有pipe reg的

1
2
3
4
5
6
7
8
9
10
11
// set outputs to execute pipelines
for (w <- 0 until issueWidth) {
val numReadPorts = numReadPortsArray(w)

io.exe_reqs(w).valid := exe_reg_valids(w)
io.exe_reqs(w).bits.uop := exe_reg_uops(w)
if (numReadPorts > 0) io.exe_reqs(w).bits.rs1_data := exe_reg_rs1_data(w)
if (numReadPorts > 1) io.exe_reqs(w).bits.rs2_data := exe_reg_rs2_data(w)
if (numReadPorts > 2) io.exe_reqs(w).bits.rs3_data := exe_reg_rs3_data(w)
if (enableSFBOpt) io.exe_reqs(w).bits.pred_data := exe_reg_pred_data(w)
}

总结

regfile和regfile_read均有bypass逻辑,但前者只bypassW->R ,后者bypass所有有效的FU的数据(不包括FPU)

BOOM EXU

1731814497133

BOOM是非数据捕捉模式,可以看到alu模块插入了寄存器,这里是为了和mul与FPU匹配,简化写入端口调度

执行单元

1731843369344

这个例子是一个INT ALU,和乘法器,issue每个issue端口,只与一个FU对话,执行单元就是一个抽象单元,其包装的rocketchip的功能单元

PipelinedFunctionalUnit模块

这是流水线功能单元的抽象类,主要补充下面ALU的模块

Response 信号

这里分了两种情况

  1. pipestage>0:这时候,输出有效信号就是r_valid的最高索引,r_valid每个周期都检测是否有kill信号,以及分支预测失败,
1
2
3
4
io.resp.valid    := r_valids(numStages-1) && !IsKilledByBranch(io.brupdate, r_uops(numStages-1))
io.resp.bits.predicated := false.B
io.resp.bits.uop := r_uops(numStages-1)
io.resp.bits.uop.br_mask := GetNewBrMask(io.brupdate, r_uops(numStages-1))
  1. pipestage==0,这时候,输出有效信号直接是输入的有效信号并且不能在失败路径上,
1
2
3
4
io.resp.valid    := io.req.valid && !IsKilledByBranch(io.brupdate, io.req.bits.uop)
io.resp.bits.predicated := false.B
io.resp.bits.uop := io.req.bits.uop
io.resp.bits.uop.br_mask := GetNewBrMask(io.brupdate, io.req.bits.uop)

bypass 信号

只有stage>0才有bypass,如果earliestBypassStage为0(表示第一个周期就可以bypass),那么第一个bypass的uops就是输入的uops,之后的的bypass_uops就是相对应的r_uops,

注:这里bypass为i,但r_uops为i-1,主要就是r_uops为流水线寄存器,在下一个周期才可以获得数据

  • 暂时不知道第一句是干什莫的,似乎在earliestBypassStage不为0才有用,但目前都是为0的情况
1
2
3
4
5
6
7
8
9
10
11
      if (numBypassStages > 0) {
io.bypass(i-1).bits.uop := r_uops(i-1)
}
...
if (numBypassStages > 0 && earliestBypassStage == 0) {
io.bypass(0).bits.uop := io.req.bits.uop

for (i <- 1 until numBypassStages) {
io.bypass(i).bits.uop := r_uops(i-1)
}
}

ALU模块

alu逻辑包含BR分支计算,以及正常指令计算

数据选择

  • op1的数据来源有两个地方,PC以及读出的rs1
  • op2的数据来源有四个来源,IMM,IMM_C(仅限于CSR指令),RS2,NEXT(也即是下一个pc的位移,2or4)

分支处理

  • BR_N:也就是PC+4
  • BR_NE:不相等
  • BR_EQ:相等
  • 。。。
  • BR_J:JUMP(jal)
  • BR_JR:JUMP REG(jalr)
  • PC_PLUS4:pc+4
  • PC_BRJMP:BR 目标地址
  • PC_BRJMP:jalr目标地址

这里是检查送入的指令的类型是什么分支类型,根据控制信号该选什么样的target

is_taken的意思是这个分支是否跳转,假如输入有效,没有在错误路径,是分支指令并且PC不为pc+4,就进行跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val pc_sel = MuxLookup(uop.ctrl.br_type, PC_PLUS4,
Seq( BR_N -> PC_PLUS4,
BR_NE -> Mux(!br_eq, PC_BRJMP, PC_PLUS4),
BR_EQ -> Mux( br_eq, PC_BRJMP, PC_PLUS4),
BR_GE -> Mux(!br_lt, PC_BRJMP, PC_PLUS4),
BR_GEU -> Mux(!br_ltu, PC_BRJMP, PC_PLUS4),
BR_LT -> Mux( br_lt, PC_BRJMP, PC_PLUS4),
BR_LTU -> Mux( br_ltu, PC_BRJMP, PC_PLUS4),
BR_J -> PC_BRJMP,
BR_JR -> PC_JALR
))
val is_taken = io.req.valid &&
!killed &&
(uop.is_br || uop.is_jalr || uop.is_jal) &&
(pc_sel =/= PC_PLUS4)

分支地址计算

主要就是計算jalr的target,然后得出cfi_idx,访问前端FTQ,获得pc,next_val意思是下一条指令是否有效

jalr指令的误预测逻辑:

  • 下一条指令无效
  • 下一条指令有效但pc不是实际计算的pc
  • 没有被预测跳转,(在cfi找不到或者找到了但是无效预测)

br指令的分支预测目标地址为target,供重定向使用

1
2
3
4
5
6
7
8
9
10
    brinfo.jalr_target := jalr_target
val cfi_idx = ((uop.pc_lob ^ Mux(io.get_ftq_pc.entry.start_bank === 1.U, 1.U << log2Ceil(bankBytes), 0.U)))(log2Ceil(fetchWidth),1)

when (pc_sel === PC_JALR) {
mispredict := !io.get_ftq_pc.next_val ||
(io.get_ftq_pc.next_pc =/= jalr_target) ||
!io.get_ftq_pc.entry.cfi_idx.valid ||
(io.get_ftq_pc.entry.cfi_idx.bits =/= cfi_idx)
}
brinfo.target_offset := target_offset

分支预测失败检测

首先,jal不参与检查,因为jal是必然跳转,且地址固定,jalr和br进行地址检测

如果pc_sel为PC_PLUS4,说明实际为不跳转,如果之前为taken,就说明地址预测失败

如果pc_sel为PC_BRJMP,说明实际跳转,如果之前预测taken,则地址预测成功

1
2
3
4
5
6
7
8
9
10
11
when (is_br || is_jalr) {
if (!isJmpUnit) {
assert (pc_sel =/= PC_JALR)
}
when (pc_sel === PC_PLUS4) {
mispredict := uop.taken
}
when (pc_sel === PC_BRJMP) {
mispredict := !uop.taken
}
}

Response逻辑

ALU out有以下来源:

  • 如果是is_sfb_shadow,并且pred_data,如果是ldst_rs1需要rs1,则把rs1当作结果,否則就是rs2(这个和BOOM的SFB有关)
  • 如果为MOV指令,就选择rs2为输出,否则就是选择alu计算的结果

然后就是流水线逻辑,在s1将数据送入流水线,时候根据numstage选择流水级,最后输出的数据就是r_data的最高索引

1
2
3
4
5
6
7
8
9
10
r_val (0) := io.req.valid
r_data(0) := Mux(io.req.bits.uop.is_sfb_br, pc_sel === PC_BRJMP, alu_out)
r_pred(0) := io.req.bits.uop.is_sfb_shadow && io.req.bits.pred_data
for (i <- 1 until numStages) {
r_val(i) := r_val(i-1)
r_data(i) := r_data(i-1)
r_pred(i) := r_pred(i-1)
}
io.resp.bits.data := r_data(numStages-1)
io.resp.bits.predicated := r_pred(numStages-1)

Bypass逻辑

将各阶段的输出进行bypass,注意这里是有延迟一个周期的,也就是计算出来下个周期再bypass,

1
2
3
4
5
6
io.bypass(0).valid := io.req.valid
io.bypass(0).bits.data := Mux(io.req.bits.uop.is_sfb_br, pc_sel === PC_BRJMP, alu_out)
for (i <- 1 until numStages) {
io.bypass(i).valid := r_val(i-1)
io.bypass(i).bits.data := r_data(i-1)
}

其他模块

  • MemAddrCalcUnit:完成地址计算以及store_data接受,同时进行misalign检查
  • DIV模块,是unpipe的,调用rocket模块
  • MUL模块,调用了rocket的模块

ALUExeUnit

这个模块是各种单独FU的集合,目前允许,ALU和MUL和DIV在一块,但MEM只能单独一个ALUExeUnit,

ALU Unit:这个模块包含BRU,他接受输入信号,然后只有ALU支持bypass

输出逻辑

输出信号主要有有效信号,数据,以及uops等,根据数据有效信号来得出数据

1
2
3
4
5
6
7
io.iresp.valid     := iresp_fu_units.map(_.io.resp.valid).reduce(_|_)
io.iresp.bits.uop := PriorityMux(iresp_fu_units.map(f =>
(f.io.resp.valid, f.io.resp.bits.uop)).toSeq)
io.iresp.bits.data := PriorityMux(iresp_fu_units.map(f =>
(f.io.resp.valid, f.io.resp.bits.data)).toSeq)
io.iresp.bits.predicated := PriorityMux(iresp_fu_units.map(f =>
(f.io.resp.valid, f.io.resp.bits.predicated)).toSeq)

ExecutionUnits

就是简单的连线模块

为什么执行完直接写入寄存器,不会改变ARCH state吗?虽然写入寄存器,但仍然属于推测状态,这时,如果之前指令发生异常情况,这个指令的计算结果无效,从发生异常的指令重新执行,假如:r0之前的映射关系为(r0:p21),由于这里是统一PRF,只有改变了映射关系(提交阶段),状态才算改变,也就是虽然向p30写入数据了,但r0的映射关系目前还是p21,只有正确提交,r0的映射关系才会变为p30,如果下面指令之前有分支预测失败,假设我要是读取寄存器r0,那么还是p21的值,也就是最近正确写入的值

1
2
3
4
5
add r0,r1,r2
add r0,r3,r0
重命名后
add p30,p11,p12
add p31,p13,p30

BOOM LSU

LSU工作流程:

  1. decode阶段将lw,sw指令送放入ldq或者stq,并且分配ldq_idx和stq_idx
  2. 执行阶段计算虚拟地址,如果TLB确实或者权限不足,将会把虚拟地址写入LDQ或者STQ,否则,物理地址会写入LDQ或者STQ,然后会向DCache发送请求
  3. 在执行阶段会检查是否有访存违例,并且是否可以bypass(ST->LD),如果有可能的转发请求,会将mem的reqkill,访存违例会触发异常,dcache为三周期访存,如果miss会将信息送入MSHR,然后等待l2cache返回数据
  4. 写回阶段,选择优先级高的写回

Boom遵循RVWMO模型

decode阶段入队逻辑

入队总是在corewidth中选出lw或者st指令,stq的入队逻辑很简单,所以主要说明ldq的入队逻辑

又都是var,不喜欢这样的写法

这两个入队指针是var,所以改变的话就是组合逻辑

1
2
3
var ld_enq_idx = ldq_tail
var st_enq_idx = stq_tail

ldq_full当ld_enq_idx+1为 ldq_head,说明ldq满,stq同理,如果use_ldq并且此时没有发生异常,并且指令有效,则会认为有需要入队的lw指令,此时会写入ld_enq_idx位置的ldq_entry,具体的:

写入uop:

写入youngest_stq_idx:也就是stq_enq_idx,这个是用来缩小bypass检查的逻辑的

写入st_dep_mask:这个同样也是,说明了stq有效entry的mask

然后ld_enq_idx的变化是一个组合逻辑,只有在ld_dis_val才会加1

但这种写法个人感觉违背硬件设计的理念,我个人更推荐下面写法:

这个是我个人写的一个fetchbuffer的入队逻辑(灵感来自香山的fetch_buffer)

假设fetchwidth为4,那么enq_idxs也就是一个包含4个元素的向量,enq_offset也就是存储了fetchwidth每个向量的ptr,然后下面的for循环,就是每个ibuf的enq逻辑均含有一个fetchwidth的选择器,去选择输入的数据

这种写法虽然更长,但至少知道我们可能使用了哪些数字逻辑器件,我们使用了多少个wire变量

1
2
3
4
5
6
7
8
9
10
11
12
13
val enq_idxs = VecInit.tabulate(fetchWidth)(i => PopCount(io.enq.bits.mask.asBools.take(i)))
val enq_offset = VecInit(enq_idxs.map(_+enq_ptr(iqSz-1,0)))
dontTouch(enq_idxs)
dontTouch(enq_offset)
for(i <- 0 until fetchWidth){
for(j <- 0 until iqentries){
when(do_enq&&io.enq.bits.mask(i)&&enq_offset(i)===j.U){
ibuf(j) := in_entry(i).bits
}

}
}

Execute 阶段

这里主要先介绍几个重要的fire信号

can_fire_sfence:是否可以去fire sfence,sfence会将输入的exe请求全部置为高,也就是会goes through all pipes

can_fire_release:

can_fire_load_retry:这里列举了load指令retry的条件,目前就只有tlb miss

  • 但个人感觉可能会有dcache的bank冲突,mshr分配满了,等都需要retry,等待后面解读

can_fire_load_retry:同理,但会去考虑retry sta时是否和std_incoming冲突

can_fire_store_commit:当commit的entry有效,且不是fence,无异常,committed信号为高,或者指令为amo指令,且数据和地址都有效,即可进入提交,注意store commit只使用通道1

  • 为什么有fence和amo的限制

can_fire_load_wakeup:wakeup只使用通道w-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// Can we fire an incoming sfence
val can_fire_sfence = widthMap(w => exe_req(w).valid && exe_req(w).bits.sfence.valid)

// Can we fire a request from dcache to release a line
// This needs to go through LDQ search to mark loads as dangerous
val can_fire_release = widthMap(w => (w == memWidth-1).B && io.dmem.release.valid)
io.dmem.release.ready := will_fire_release.reduce(_||_)

// Can we retry a load that missed in the TLB
val can_fire_load_retry = widthMap(w =>
( ldq_retry_e.valid &&
ldq_retry_e.bits.addr.valid &&
ldq_retry_e.bits.addr_is_virtual &&
!p1_block_load_mask(ldq_retry_idx) &&
!p2_block_load_mask(ldq_retry_idx) &&
RegNext(dtlb.io.miss_rdy) &&
!store_needs_order &&
(w == memWidth-1).B && // TODO: Is this best scheduling?
!ldq_retry_e.bits.order_fail))

// Can we retry a store addrgen that missed in the TLB
// - Weird edge case when sta_retry and std_incoming for same entry in same cycle. Delay this
val can_fire_sta_retry = widthMap(w =>
( stq_retry_e.valid &&
stq_retry_e.bits.addr.valid &&
stq_retry_e.bits.addr_is_virtual &&
(w == memWidth-1).B &&
RegNext(dtlb.io.miss_rdy) &&
!(widthMap(i => (i != w).B &&
can_fire_std_incoming(i) &&
stq_incoming_idx(i) === stq_retry_idx).reduce(_||_))
))
// Can we commit a store
val can_fire_store_commit = widthMap(w =>
( stq_commit_e.valid &&
!stq_commit_e.bits.uop.is_fence &&
!mem_xcpt_valid &&
!stq_commit_e.bits.uop.exception &&
(w == 0).B &&
(stq_commit_e.bits.committed || ( stq_commit_e.bits.uop.is_amo &&
stq_commit_e.bits.addr.valid &&
!stq_commit_e.bits.addr_is_virtual &&
stq_commit_e.bits.data.valid))))

// Can we wakeup a load that was nack'd
val block_load_wakeup = WireInit(false.B)
val can_fire_load_wakeup = widthMap(w =>
( ldq_wakeup_e.valid &&
ldq_wakeup_e.bits.addr.valid &&
!ldq_wakeup_e.bits.succeeded &&
!ldq_wakeup_e.bits.addr_is_virtual &&
!ldq_wakeup_e.bits.executed &&
!ldq_wakeup_e.bits.order_fail &&
!p1_block_load_mask(ldq_wakeup_idx) &&
!p2_block_load_mask(ldq_wakeup_idx) &&
!store_needs_order &&
!block_load_wakeup &&
(w == memWidth-1).B &&
(!ldq_wakeup_e.bits.addr_is_uncacheable || (io.core.commit_load_at_rob_head &&
ldq_head === ldq_wakeup_idx &&
ldq_wakeup_e.bits.st_dep_mask.asUInt === 0.U))))

接下来介绍调度函数,该函数主要揭示哪个器件正在被使用

以will_fire_load_incoming举例,假设can_fire_load_incoming为高,那么此时经过lsu_sched函数,tlb,dc,lcam均不能访问,也就是之后的操作会被阻塞

1
2
3
4
5
6
7
8
9
10
11
12
13
    def lsu_sched(can_fire: Bool, uses_tlb:Boolean, uses_dc:Boolean, uses_lcam: Boolean, uses_rob:Boolean): Bool = {
val will_fire = can_fire && !(uses_tlb.B && !tlb_avail) &&
!(uses_lcam.B && !lcam_avail) &&
!(uses_dc.B && !dc_avail) &&
!(uses_rob.B && !rob_avail)
tlb_avail = tlb_avail && !(will_fire && uses_tlb.B)
lcam_avail = lcam_avail && !(will_fire && uses_lcam.B)
dc_avail = dc_avail && !(will_fire && uses_dc.B)
rob_avail = rob_avail && !(will_fire && uses_rob.B)
dontTouch(will_fire) // dontTouch these so we can inspect the will_fire signals
will_fire
}
will_fire_load_incoming (w) := lsu_sched(can_fire_load_incoming (w) , true , true , true , false) // TLB , DC , LCAM

事件的优先级如下:

will_fire_load_incoming
will_fire_stad_incoming
will_fire_sta_incoming
will_fire_std_incoming
will_fire_sfence
will_fire_release
will_fire_hella_incoming
will_fire_hella_wakeup
will_fire_load_retry
will_fire_sta_retry
will_fire_load_wakeup
will_fire_store_commit

其中又有以下限制:

  1. incoming的uop必须优先级高,不能反压memaddrgen
  2. hellacache incoming必须优先于retry(PTW必须优先于retry)

这里为什么

  1. will_fire_release优先级高可以加速cacheline回写以及refill
  2. store commit优先级最低,除了stq满会导致stall,否则他不会阻塞任何指令

目前大致的前置信号介绍完毕,进入流水线介绍

s0 stage

该阶段主要进行TLB访问,向dcache发送请求,写入LDQ或者STQ地址和数据

TLB访问

TLB访问的事件:

will_fire_load_incoming
will_fire_stad_incoming
will_fire_sta_incoming
will_fire_sfence
will_fire_load_retry
will_fire_sta_retry
will_fire_hella_incoming

根据不同的事件会选择对应的uop,然后赋值到exe_tlb_uop,exe_tlb_vaddr,exe_size,exe_cmd同理

注意这里exe_sfence会去赋值

  • 目前不知道exe_passthr和exe_kill的作用

此阶段访问tlb的信号

其中passthr和ptw ,exe_kill,exe_sfence暂时不清楚什么意思

1
2
3
4
5
6
7
8
9
10
11
for (w <- 0 until memWidth) {
dtlb.io.req(w).valid := exe_tlb_valid(w)
dtlb.io.req(w).bits.vaddr := exe_tlb_vaddr(w)
dtlb.io.req(w).bits.size := exe_size(w)
dtlb.io.req(w).bits.cmd := exe_cmd(w)
dtlb.io.req(w).bits.passthrough := exe_passthr(w)
dtlb.io.req(w).bits.v := io.ptw.status.v
dtlb.io.req(w).bits.prv := io.ptw.status.prv
}
dtlb.io.kill := exe_kill.reduce(_||_)
dtlb.io.sfence := exe_sfence

本阶段会抛出6个异常,分别为地址错误,page fault 以及权限错误

1
2
3
4
5
6
ma_ld
ma_st
pf_ld
pf_st
ae_ld
ae_st

同时会根据以下优先级选出异常,之后会根据rob_idx找出最老的异常

1
2
3
4
5
6
7
val mem_xcpt_causes = RegNext(widthMap(w =>
Mux(ma_ld(w), rocket.Causes.misaligned_load.U,
Mux(ma_st(w), rocket.Causes.misaligned_store.U,
Mux(pf_ld(w), rocket.Causes.load_page_fault.U,
Mux(pf_st(w), rocket.Causes.store_page_fault.U,
Mux(ae_ld(w), rocket.Causes.load_access.U,
rocket.Causes.store_access.U)))))))

同时异常信息也会更新进入LDQ或者STQ

dcache访问

xdmem_req信号的优先级如下:

will_fire_load_incoming

will_fire_load_retry

will_fire_store_commit

will_fire_load_wakeup

will_fire_hella_incoming

will_fire_hella_wakeup

还会向dcache送入rob_head_idx以及rob_pnr_idx

  • 目前暂时不知道这个是为什么

LDQ,STQ数据地址写入

对于LDQ的地址写入:will_fire_load_incoming和will_fire_load_retry事件可以写入地址,并且如果tlb_miss写入的是虚拟地址,且标记addr_is_virtual

对于STQ的地址写入,will_fire_sta_incoming,will_fire_stad_incoming,will_fire_sta_retry三个事件均可写入

对于STQ的数据写入,这里可能会写入FP的数据,FP数据只会通过通道1写入

will_fire_std_incoming和will_fire_stad_incoming,fp_stdata_fire事件均可写入数据,写入的数据来自FPU或者exu

s1阶段

s1阶段首先将s0阶段的信号寄存(同时会检查是否有br预测失败)

清除ROB bsy信号

store拿到数据即可清除bsy

bsy可以类似于指令执行完毕,对于store指令,只要拿到数据就可以认为执行完毕,之后load需要数据可以forward

注意:最早s2阶段可以去清除rob bsy bit,s1阶段只是对清除信号赋值

该事件的优先级:

fired_stad_incoming

fired_sta_incoming

fired_std_incoming

fired_sta_retry

注意:fp数据单独占据一个通道

forward及order failures检查

search事件如下:

do_st_search,

do_ld_search

do_release_search:与多核有关

首先计算该周期需要计算的ldq_idx,stq_idx,lcam_is_release,lcam_st_dep_mask等信号

can_forward信号揭示了是否可以去bypass

然后遍历LDQ,

优先级高的为release的搜索,当release的地址满足block_addr_matches,并且ldq该表项有效,则设置observed位

这里可能和LRSC指令有关,总之应该是维护LW SW指令的顺序

其次为st的搜索,当此时执行st指令,并且地址匹配,lw和sw的mask至少存在交集(注意,这里地址必须全部匹配(不需要匹配低3位)),同时必须满足store比现在的load老

ldq需要满足以下任意一项:

  1. 执行完毕
  2. 成功执行
  3. 正在forward
1
2
3
4
5
6
7
8
.elsewhen (do_st_search(w)                                                                                                &&
l_valid &&
l_bits.addr.valid &&
(l_bits.executed || l_bits.succeeded || l_is_forwarding) &&
!l_bits.addr_is_virtual &&
l_bits.st_dep_mask(lcam_stq_idx(w)) &&
dword_addr_matches(w) &&
mask_overlap(w))

如果此时该LDQ表项满足以下条件:

  1. 该表项并未接受过转发数据
  2. 虽然接受过转发的idx,但转发的的stq_idx和目前idx相比,目前的idx更新

此时发生SW LW违例,将该LDQ表项的order_fail置为高,并且赋值failed_loads

之后会去检查LW-LW违例,如果满足以下条件

LW-LW违例检查同样需要去全地址匹配(不需要匹配低3位)

如果本LW比现在访问的表项老,且表项已经被观察,同时该load不在s1的流水线中,此时若满足以下条件:

  1. 当前表项执行中
  2. 当前表项执行成功
  3. 当前表项正在forward

说明发生LW-LW违例,置高信号和SW-LW违例一样

如果此时访问的lw指令与当前表项不同(实话来说这里的意思就是当前执行的LW比表项的年轻):此时如果该表项还没执行完,或者被阻塞住,不能去执行这条指令(LW-LW order),

此时将s1_set_execute对应位置低,s1_kill赋值(大部分为置高操作),禁止转发

这些做法就是将这个LW指令无效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
elsewhen (do_ld_search(w)            &&
l_valid &&
l_bits.addr.valid &&
!l_bits.addr_is_virtual &&
dword_addr_matches(w) &&
mask_overlap(w)) {
val searcher_is_older = IsOlder(lcam_ldq_idx(w), i.U, ldq_head)
when (searcher_is_older) {
when ((l_bits.executed || l_bits.succeeded || l_is_forwarding) &&
!s1_executing_loads(i) && // If the load is proceeding in parallel we don't need to kill it
l_bits.observed) { // Its only a ordering failure if the cache line was observed between the younger load and us
ldq(i).bits.order_fail := true.B
failed_loads(i) := true.B
}
} .elsewhen (lcam_ldq_idx(w) =/= i.U) {
// The load is older, and either it hasn't executed, it was nacked, or it is ignoring its response
// we need to kill ourselves, and prevent forwarding
val older_nacked = nacking_loads(i) || RegNext(nacking_loads(i))
when (!(l_bits.executed || l_bits.succeeded) || older_nacked) {
s1_set_execute(lcam_ldq_idx(w)) := false.B
io.dmem.s1_kill(w) := RegNext(dmem_req_fire(w))
can_forward(w) := false.B
}
}
}

接下来遍历STQ,遍历STQ主要是去forward数据

当正在进行ld指令,stq表项有效,且比该LW指令老:此时若满足以下条件

  1. 如果LW指令和SW指令的mask完全重合.并且stq不是fence指令,且该load可以forward.此时设置该LW的ldst_addr_matches,ldst_forward_matches为true,此时回向DCACHE发出kill请求,因为地址和mask全部匹配.,所以不在需要dcache数据,然后将s1_set_execute置为false
  2. 如果mask不是全部匹配,此时ldst_forward_matches不置为高,其他和1一样
  3. 如果STQ的指令为fence或者amo,此时和2一样

这里的疑问是:第二种情况为什么要kill dcache:kill dcache难道不会导致数据不一定对,

好像这种情况会去wakeup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (w <- 0 until memWidth) {
when (do_ld_search(w) && stq(i).valid && lcam_st_dep_mask(w)(i)) {
when (((lcam_mask(w) & write_mask) === lcam_mask(w)) && !s_uop.is_fence && dword_addr_matches(w) && can_forward(w))
{
ldst_addr_matches(w)(i) := true.B
ldst_forward_matches(w)(i) := true.B
io.dmem.s1_kill(w) := RegNext(dmem_req_fire(w))
s1_set_execute(lcam_ldq_idx(w)) := false.B
}
.elsewhen (((lcam_mask(w) & write_mask) =/= 0.U) && dword_addr_matches(w))
{
ldst_addr_matches(w)(i) := true.B
io.dmem.s1_kill(w) := RegNext(dmem_req_fire(w))
s1_set_execute(lcam_ldq_idx(w)) := false.B
}
.elsewhen (s_uop.is_fence || s_uop.is_amo)
{
ldst_addr_matches(w)(i) := true.B
io.dmem.s1_kill(w) := RegNext(dmem_req_fire(w))
s1_set_execute(lcam_ldq_idx(w)) := false.B
}
}
}

之后会得到

forward_valid以及forward_idx

注意forward在s1阶段查询,s2阶段读出forward数据

这里采用了ForwardingAgeLogic,这个逻辑只要知道最比LW指令最年轻的stq_idx,就可以去得到forward_idx

1
2
3
4
5
6
7
8
9
10
11
12
13
val forwarding_age_logic = Seq.fill(memWidth) { Module(new ForwardingAgeLogic(numStqEntries)) }
for (w <- 0 until memWidth) {
forwarding_age_logic(w).io.addr_matches := ldst_addr_matches(w).asUInt
forwarding_age_logic(w).io.youngest_st_idx := lcam_uop(w).stq_idx
}
val forwarding_idx = widthMap(w => forwarding_age_logic(w).io.forwarding_idx)

// Forward if st-ld forwarding is possible from the writemask and loadmask
mem_forward_valid := widthMap(w =>
(ldst_forward_matches(w)(forwarding_idx(w)) &&
!IsKilledByBranch(io.core.brupdate, lcam_uop(w)) &&
!io.core.exception && !RegNext(io.core.exception)))
mem_forward_stq_idx := forwarding_idx

之后会clear unsafe

这里unsafe总是为false

然后会处理本周期的异常,本周期的异常就是LW-LW以及SW-LW违例,将和上阶段的比较,最后得出最旧的异常

最后会去推测唤醒

只有ld会推测唤醒

S2 stage

s2阶段需要去写回数据,处理dcache的resp信号以及forward信号

处理dcache的resp及nack

若此时dcache nack且nack的指令为LW,此时将ldq的executed置为false,并且置高nacking_loads信号

若nack指令为SW指令,且dmem送来的stq_idx比现在的旧,需要去恢复stq_head

若dcache执行正常,且使用的为ldq,会返回dcache数据,且置高ldq的succeeded,以及dmem_resp_fired

若使用stq,直接在本阶段找succeeded

如果该sw指令为AMO指令.此时需要去返回数据,且置高dmem_resp_fired,AMO会返回是否读写成功的信息,

如果dcache已经被kill,且wb_forward_valid为高,此时将stq的数据forward到LW的resp中

并且如果此时数据有效且没有被br kill,将LDQ对应表项的forward_std_val置高,forward_stq_idx设置为forward_idx,以便之后检查order-failure

我们初始认为ld的推测唤醒是失败的,但如果有以下条件:

  1. s1阶段没有发出wakup请求
  2. s2阶段写回有效,且写回的idx等于mem_incoming_uop的寄存值

此时设置io.core.ld_miss为false

1
2
3
4
5
6
7
8
9
10
io.core.ld_miss         := RegNext(io.core.spec_ld_wakeup.map(_.valid).reduce(_||_))
val spec_ld_succeed = widthMap(w =>
!RegNext(io.core.spec_ld_wakeup(w).valid) ||
(io.core.exe(w).iresp.valid &&
io.core.exe(w).iresp.bits.uop.ldq_idx === RegNext(mem_incoming_uop(w).ldq_idx)
)
).reduce(_&&_)
when (spec_ld_succeed) {
io.core.ld_miss := false.B
}

commit阶段

对于store,会将commit的指令设置为committed

对于load会将LDQ表项的FLAG置低

store可能会被拒绝

然后会更新STQ(commit指针)和LDQ的指针

如果此时storecommit后已经被成功送入mem,并且dcache返回resp了,此时可以去更新stq_exe_ptr_head,并且将flag置为低

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
when (stq(stq_head).valid && stq(stq_head).bits.committed)
{
when (stq(stq_head).bits.uop.is_fence && !io.dmem.ordered) {
io.dmem.force_order := true.B
store_needs_order := true.B
}
clear_store := Mux(stq(stq_head).bits.uop.is_fence, io.dmem.ordered,
stq(stq_head).bits.succeeded)
}

when (clear_store)
{
stq(stq_head).valid := false.B
stq(stq_head).bits.addr.valid := false.B
stq(stq_head).bits.data.valid := false.B
stq(stq_head).bits.succeeded := false.B
stq(stq_head).bits.committed := false.B

stq_head := WrapInc(stq_head, numStqEntries)
when (stq(stq_head).bits.uop.is_fence)
{
stq_execute_head := WrapInc(stq_execute_head, numStqEntries)
}
}

之后还有br 预测失败,此时会恢复tail指针

异常处理会把stq_tail指针置为stq_commit_head,reset清空所有表项

Hella Cache状态机

主要和PTW交互

1740647141030

TLB

TLB在s0阶段接受请求,同周期返回数据

1740796738743

目前BOOM采用SV39,V代表PTE是否有效,如果无效,就可以被写入,RWX分别代表了页面的权限,如果RWX均为0,说明这个是一个指向下一级页表的指针,否则为叶子节点,这里还会涉及到superpage的问题

1740796836229

U位代表用户模式是否可以访问,如果SUM位置高,S模式也可以访问user page,但S模式不能执行U模式代码

G:全局位

A和D分别代表访问位和脏位,A位表示自上次清除A位以来,该虚拟页已被读、写或提取。D位表示自上次清除D位以来,该虚拟页已被写入。

两种方法管理AD位:

  1. 当访问一个虚拟页并且a位清除,或者写入一个虚拟页并且D位清除时,将引发一个页面错误异常。
  2. 当一个虚拟页面被访问并且a位清除,或者被写入并且D位清除时,实现在PTE中设置相应的位。PTE更新必须是原子的,相对于其他对PTE的访问,必须自动检查PTE是否有效并授予足够的权限。A位的更新可以作为推测的结果执行,但对D位的更新必须是精确的(即,不是推测的),并且由局部hart按照程序顺序观察。此外,PTE更新必须出现在全局内存顺序中,不晚于显式内存顺序 对于引起更新的显式内存访问,PTE更新不需要是原子性的,并且该序列是可中断的。但是,在PTE更新全局可见之前,hart不能执行显式内存访问

这个第二没太明白什么意思

系统中的所有hart必须彼此使用相同的pte更新方案。

1741059987528

整个LSU都逃脱不了sfence的处理

The supervisor memory-management fence instruction SFENCE.VMA is used to synchronize updates to in-memory memory-management data structures with current execution.SFENCE.VMA is also used to invalidate entries in the address-translation cache associated with a hart

sfence只对本地hart隐式引用做排序:

当内存管理数据结构被修改时,必须单独通知其他harts。一种方法是使用:

1)本地数据fence以确保本地写入全局可见,

2)对其他线程的处理器间中断,

3)远程线程的中断处理程序中的本地SFENCE.VMA,

4)向原始线程发送操作完成的信号。

sfence行为:

rs1=x0,rs2=x0:则fence对所有地址空间的页表的任何级别进行的所有读写进行排序。还使所有地址空间的所有TLB条目无效

rs1=x0,rs2!=x0:fence对页表任何级别的所有读写进行排序,但仅对整数寄存器rs2标识的地址空间进行排序。对全局映射的访问(参见第4.3.1节)不排序。fence还使与整数寄存器r 2标识的地址空间匹配的所有地址转换缓存条目无效,全局映射的条目除外。

rs1!=x0,rs2=x0:则对于所有地址空间,栅栏仅对与rs1中的虚拟地址相对应的叶页表条目进行读写排序。对于所有地址空间,栅栏还使包含与rs1中的虚拟地址相对应的叶页表条目的所有TLB条目无效。

rs1!=x0,rs2!=x0:则栅栏仅命令对与rs1中的虚拟地址对应的叶页表条目进行的读取和写入以及对应的整数寄存器rs 2标识的地址空间排序(并集),对全局映射的访问是不排序的。栅栏还使包含与rs 1中的虚拟地址相对应的叶页表条目并且与asid相匹配的所有TLB条目无效,全局映射的条目除外。

如果rs1中保存的值不是有效的虚拟地址,则SFENCE.VMA指令无效。在这种情况下不会引发异常。

当rs 2!= x 0时,rs 2中保存的值的位SXLEN-1:ASIDMAX被保留以供将来的标准使用。在标准扩展定义了它们的使用之前,它们应该被软件归零并被当前的实现忽略。此外,如果ASIDLEN < ASIDMAX,则实现应忽略rs 2中保存的值的位ASIDMAX-1:ASIDLEN。

For example, simpler implementations can ignore the virtual address in rs1 and the ASID value in rs2 and always perform a global fence. The choice not to raise an exception when an invalid virtual address is held in rs1 facilitates this type of simplification

使用场景

  1. 回收 ASID :当操作系统需要重新分配 ASID 给不同的页表时,应先将 satp 指向新页表,然后执行 SFENCE.VMA(rs1 = x0,rs2 = 目标 ASID)。
  2. 全局刷新 :若硬件不支持 ASID,系统切换页表后,需执行 SFENCE.VMA(rs1 = x0),并确保 rs2 设置为非零值以避免刷新全局映射。
  3. 更新非叶页表条目 :当修改非叶页表条目时,执行 SFENCE.VMA(rs1 = x0),若路径中存在全局 PTE,则 rs2 必须为 x0。
  4. 更新叶页表条目 :修改叶页表条目后,执行 SFENCE.VMA(rs1 设置为虚拟地址),若路径中存在全局 PTE,则 rs2 必须为 x0。
  5. 延迟刷新 :对于权限提升或使无效 PTE 变为有效叶 PTE 的操作,可延迟执行 SFENCE.VMA,但在页错误发生时需按要求执行。

DCACHE

tag组织结构

tag每个mem通道均含有,写时需要同时写(保证同步),读时需要同时读,但每个通道有独立的读信号

这里tag好像为双端口器件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val meta = Seq.fill(memWidth) { Module(new L1MetadataArray(onReset _)) }
val metaWriteArb = Module(new Arbiter(new L1MetaWriteReq, 2))
// 0 goes to MSHR refills, 1 goes to prober
val metaReadArb = Module(new Arbiter(new BoomL1MetaReadReq, 6))
// 0 goes to MSHR replays, 1 goes to prober, 2 goes to wb, 3 goes to MSHR meta read,
// 4 goes to pipeline, 5 goes to prefetcher

metaReadArb.io.in := DontCare
for (w <- 0 until memWidth) {
meta(w).io.write.valid := metaWriteArb.io.out.fire
meta(w).io.write.bits := metaWriteArb.io.out.bits
meta(w).io.read.valid := metaReadArb.io.out.valid
meta(w).io.read.bits := metaReadArb.io.out.bits.req(w)
}
metaReadArb.io.out.ready := meta.map(_.io.read.ready).reduce(_||_)
metaWriteArb.io.out.ready := meta.map(_.io.write.ready).reduce(_||_)

数据通道

megaoom默认使用BoomDuplicatedDataArray,一个cacheline为128bit,rowOffBits=4,encDataBits=64,rowWords=2,data写端口有两个请求,读端口含有3个请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val data = Module(if (boomParams.numDCacheBanks == 1) new BoomDuplicatedDataArray else new BoomBankedDataArray)
val dataWriteArb = Module(new Arbiter(new L1DataWriteReq, 2))
// 0 goes to pipeline, 1 goes to MSHR refills
val dataReadArb = Module(new Arbiter(new BoomL1DataReadReq, 3))
// 0 goes to MSHR replays, 1 goes to wb, 2 goes to pipeline
dataReadArb.io.in := DontCare

for (w <- 0 until memWidth) {
data.io.read(w).valid := dataReadArb.io.out.bits.valid(w) && dataReadArb.io.out.valid
data.io.read(w).bits := dataReadArb.io.out.bits.req(w)
}
dataReadArb.io.out.ready := true.B

data.io.write.valid := dataWriteArb.io.out.fire
data.io.write.bits := dataWriteArb.io.out.bits
dataWriteArb.io.out.ready := true.BBoomDuplicatedDataArray十分简易,主要讲BoomBankedDataArray:

下面介绍读写端口请求

  1. pipe请求:该读请求占据meta arbit的端口4和data 的端口2
  2. mshr replay请求,读请求占据data和meta的端口0
  3. mshr读meta请求。占据meta的端口3
  4. 写回端口,占据meta的读端口2和data的读端口1
  5. Prober请求,占据meta读端口1
  6. prefetch,占据meta端口5

BoomProbeUnit模块

先介绍BOOM的四种状态

1
2
3
4
5
6
7
8
9
10
11
object ClientStates {
val width = 2

def Nothing = 0.U(width.W)
def Branch = 1.U(width.W)
def Trunk = 2.U(width.W)
def Dirty = 3.U(width.W)

def hasReadPermission(state: UInt): Bool = state > Nothing
def hasWritePermission(state: UInt): Bool = state > Branch
}

这四种状态从上到下依次为ISEM

接下来介绍ClientMetadata

isValid函数指示干cacheline是否有效

growStarter给出了缓存块在命中与没命中下状态的转换:

命中的情况下可以直接转换,没命中的情况下需要进入中间态(等下级回复)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private def growStarter(cmd: UInt): (Bool, UInt) = {
import MemoryOpCategories._
import TLPermissions._
import ClientStates._
val c = categorize(cmd)
MuxTLookup(Cat(c, state), (false.B, 0.U), Seq(
//(effect, am now) -> (was a hit, next)
Cat(rd, Dirty) -> (true.B, Dirty),
Cat(rd, Trunk) -> (true.B, Trunk),
Cat(rd, Branch) -> (true.B, Branch),
Cat(wi, Dirty) -> (true.B, Dirty),
Cat(wi, Trunk) -> (true.B, Trunk),
Cat(wr, Dirty) -> (true.B, Dirty),
Cat(wr, Trunk) -> (true.B, Dirty),
//(effect, am now) -> (was a miss, param)
Cat(rd, Nothing) -> (false.B, NtoB),
Cat(wi, Branch) -> (false.B, BtoT),
Cat(wi, Nothing) -> (false.B, NtoT),
Cat(wr, Branch) -> (false.B, BtoT),
Cat(wr, Nothing) -> (false.B, NtoT)))
}

growFinisher函数根据接收到的信号,来选择将状态转换到哪一个

onSecondaryAccess 函数用于处理在缓存块上发生的二次访问命令,确定是否需要另一个获取消息,并返回相关信息。通过调用 growStarter 函数,根据第一次和第二次访问命令,确定是否命中以及命中后的新状态或未命中时需要发送的参数。这在缓存一致性协议中用于处理二次访问命令,确保缓存块的状态和权限正确更新。

shrinkHelper 函数用于处理权限收缩操作。它根据给定的参数和当前缓存块的状态,确定是否有脏数据、响应参数以及缓存块的下一个状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private def shrinkHelper(param: UInt): (Bool, UInt, UInt) = {
import ClientStates._
import TLPermissions._
MuxTLookup(Cat(param, state), (false.B, 0.U, 0.U), Seq(
//(wanted, am now) -> (hasDirtyData resp, next)
Cat(toT, Dirty) -> (true.B, TtoT, Trunk),
Cat(toT, Trunk) -> (false.B, TtoT, Trunk),
Cat(toT, Branch) -> (false.B, BtoB, Branch),
Cat(toT, Nothing) -> (false.B, NtoN, Nothing),
Cat(toB, Dirty) -> (true.B, TtoB, Branch),
Cat(toB, Trunk) -> (false.B, TtoB, Branch), // Policy: Don't notify on clean downgrade
Cat(toB, Branch) -> (false.B, BtoB, Branch),
Cat(toB, Nothing) -> (false.B, NtoN, Nothing),
Cat(toN, Dirty) -> (true.B, TtoN, Nothing),
Cat(toN, Trunk) -> (false.B, TtoN, Nothing), // Policy: Don't notify on clean downgrade
Cat(toN, Branch) -> (false.B, BtoN, Nothing), // Policy: Don't notify on clean downgrade
Cat(toN, Nothing) -> (false.B, NtoN, Nothing)))
}

接下来进入信号定义

Probe模块主要和tL、MSHR以及wb阶段做交互,该模块要做的就是其他核去probe该核心的cacheline,获得cacaeline 状态,并更改权限状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val io = new Bundle {
val req = Flipped(Decoupled(new TLBundleB(edge.bundle)))
val rep = Decoupled(new TLBundleC(edge.bundle))
val meta_read = Decoupled(new L1MetaReadReq)
val meta_write = Decoupled(new L1MetaWriteReq)
val wb_req = Decoupled(new WritebackReq(edge.bundle))
val way_en = Input(UInt(nWays.W))
val wb_rdy = Input(Bool()) // Is writeback unit currently busy? If so need to retry meta read when its done
val mshr_rdy = Input(Bool()) // Is MSHR ready for this request to proceed?
val mshr_wb_rdy = Output(Bool()) // Should we block MSHR writebacks while we finish our own?
val block_state = Input(new ClientMetadata())
val lsu_release = Decoupled(new TLBundleC(edge.bundle))

val state = Output(Valid(UInt(coreMaxAddrBits.W)))
}

该模块最主要的就是状态机部分,

s_invalid:当外界送入probe信息握手成功,会进入s_meta_read状态,记录此时的请求信号

s_meta_read:该状态会去读取meta数据,当读取完成会进入s_meta_resp

s_meta_resp:进入s_mshr_req,因为s2阶段才可以得到读出的meta,和命中信息

s_mshr_req:准备好后进入s_mshr_resp,

s_mshr_resp:如果cache line 数据为脏数据,需要去将该数据写回,否则向lsu发送release信号,然后更新权限

s_writeback_req:s_writeback_resp:写回脏数据

s_meta_write:写入新的权限,写入完成进入s_meta_write_resp

s_meta_write_resp:返回为s_invalid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// state === s_invalid
when (state === s_invalid) {
when (io.req.fire) {//TL B 握手成功
state := s_meta_read
req := io.req.bits//从代理送入的请求信号
}
} .elsewhen (state === s_meta_read) {
when (io.meta_read.fire) {
state := s_meta_resp
}
} .elsewhen (state === s_meta_resp) {
// we need to wait one cycle for the metadata to be read from the array
state := s_mshr_req
} .elsewhen (state === s_mshr_req) {
old_coh := io.block_state//s2阶段读出的meta
way_en := io.way_en//s2阶段得到的命中信息
// if the read didn't go through, we need to retry
state := Mux(io.mshr_rdy && io.wb_rdy, s_mshr_resp, s_meta_read)//等待wb和mshr准备好
} .elsewhen (state === s_mshr_resp) {
state := Mux(tag_matches && is_dirty, s_writeback_req, s_lsu_release)//若此时命中且dirty,进入s_writeback_req,如果其他命中但不脏
} .elsewhen (state === s_lsu_release) {
when (io.lsu_release.fire) {
state := s_release
}
} .elsewhen (state === s_release) {
when (io.rep.ready) {
state := Mux(tag_matches, s_meta_write, s_invalid)//更新权限
}
} .elsewhen (state === s_writeback_req) {
when (io.wb_req.fire) {
state := s_writeback_resp//脏的数据写回成功,转换为s_writeback_resp
}
} .elsewhen (state === s_writeback_resp) {
// wait for the writeback request to finish before updating the metadata
when (io.wb_req.ready) {
state := s_meta_write
}
} .elsewhen (state === s_meta_write) {
when (io.meta_write.fire) {//此时写入新的权限
state := s_meta_write_resp
}
} .elsewhen (state === s_meta_write_resp) {
state := s_invalid
}

BoomWritebackUnit模块

该模块是去写回脏数据(主动release或者probe请求)

信号定义

1
2
3
4
5
6
7
8
9
val req = Flipped(Decoupled(new WritebackReq(edge.bundle)))
val meta_read = Decoupled(new L1MetaReadReq)
val resp = Output(Bool())
val idx = Output(Valid(UInt()))
val data_req = Decoupled(new L1DataReadReq)
val data_resp = Input(UInt(encRowBits.W))
val mem_grant = Input(Bool())
val release = Decoupled(new TLBundleC(edge.bundle))
val lsu_release = Decoupled(new TLBundleC(edge.bundle))

该模块主要也是一个状态机

s_invalid:此时置高req的ready,如果请求握手,则记录下req信号,然后进入s_fill_buffer

s_fill_buffer:该模块主要就是去读出meta和data,读取完成后转换为s_lsu_release

注意这里的refillcycles,为4,block byte和row bit不同?

s_lsu_release:release完成后转换为s_active

s_active:准备写回数据,设置写回数据与信号

其他信号暂时不讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
when (state === s_invalid) {
io.req.ready := true.B
when (io.req.fire) {
state := s_fill_buffer
data_req_cnt := 0.U
req := io.req.bits
acked := false.B
}
} .elsewhen (state === s_fill_buffer) {
//读出tag和data
io.meta_read.valid := data_req_cnt < refillCycles.U
io.meta_read.bits.idx := req.idx
io.meta_read.bits.tag := req.tag
io.data_req.valid := data_req_cnt < refillCycles.U
io.data_req.bits.way_en := req.way_en
io.data_req.bits.addr := (if(refillCycles > 1)
Cat(req.idx, data_req_cnt(log2Up(refillCycles)-1,0))
else req.idx) << rowOffBits
//由于这里他row和block大小不同,需要refillCycles个请求,才可以读出整个cache block
。。。
when (io.data_req.fire && io.meta_read.fire) {
r1_data_req_fired := true.B
r1_data_req_cnt := data_req_cnt
data_req_cnt := data_req_cnt + 1.U
}
when (r2_data_req_fired) {
wb_buffer(r2_data_req_cnt) := io.data_resp
when (r2_data_req_cnt === (refillCycles-1).U) {
io.resp := true.B
state := s_lsu_release
data_req_cnt := 0.U
}
}
} .elsewhen (state === s_lsu_release) {
io.lsu_release.valid := true.B
io.lsu_release.bits := probeResponse
when (io.lsu_release.fire) {
state := s_active
}
} .elsewhen (state === s_active) {//lsu release完成,进入release
io.release.valid := data_req_cnt < refillCycles.U
io.release.bits := Mux(req.voluntary, voluntaryRelease, probeResponse)

when (io.mem_grant) {
acked := true.B
}
when (io.release.fire) {
data_req_cnt := data_req_cnt + 1.U
}
when ((data_req_cnt === (refillCycles-1).U) && io.release.fire) {
state := Mux(req.voluntary, s_grant, s_invalid)//probe 不会置高voluntary
}
} .elsewhen (state === s_grant) {
when (io.mem_grant) {
acked := true.B
}//完成写回
when (acked) {
state := s_invalid
}
}

S0 stage

s0阶段的valid信号来自lsu的优先级最高,而req信号优先级如下:

  1. lsu请求
  2. wb请求
  3. prober请求
  4. prefetch请求
  5. mshr读meta请求

s0阶段的事件如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val s0_valid = Mux(io.lsu.req.fire, VecInit(io.lsu.req.bits.map(_.valid)),
Mux(mshrs.io.replay.fire || wb_fire || prober_fire || prefetch_fire || mshrs.io.meta_read.fire,
VecInit(1.U(memWidth.W).asBools), VecInit(0.U(memWidth.W).asBools)))
val s0_req = Mux(io.lsu.req.fire , VecInit(io.lsu.req.bits.map(_.bits)),
Mux(wb_fire , wb_req,
Mux(prober_fire , prober_req,
Mux(prefetch_fire , prefetch_req,
Mux(mshrs.io.meta_read.fire, mshr_read_req
, replay_req)))))
val s0_type = Mux(io.lsu.req.fire , t_lsu,
Mux(wb_fire , t_wb,
Mux(prober_fire , t_probe,
Mux(prefetch_fire , t_prefetch,
Mux(mshrs.io.meta_read.fire, t_mshr_meta_read
, t_replay)))))
val s0_send_resp_or_nack = Mux(io.lsu.req.fire, s0_valid,
VecInit(Mux(mshrs.io.replay.fire && isRead(mshrs.io.replay.bits.uop.mem_cmd), 1.U(memWidth.W), 0.U(memWidth.W)).asBools))

s0_send_resp_or_nack给出了需要作出回复或者nack的时间

S1 stage

s1阶段主要就是一个寄存器regnext,如果此时有以下条件:

  1. 分支预测失败
  2. lsu发生异常,且该请求为ld请求
  3. s2 store失败,且该请求为来自lsu的st请求

此时,s1_valid置低

1
2
3
4
5
6
val s1_valid = widthMap(w =>
RegNext(s0_valid(w) &&
!IsKilledByBranch(io.lsu.brupdate, s0_req(w).uop) &&
!(io.lsu.exception && s0_req(w).uop.uses_ldq) &&
!(s2_store_failed && io.lsu.req.fire && s0_req(w).uop.uses_stq),
init=false.B))

s1阶段大部分信号为s0的reg,s1_nack解释了该阶段会不会阻塞,和prober有关

1
2
3
4
5
6
7
8
val s1_addr         = s1_req.map(_.addr)
val s1_nack = s1_addr.map(a => a(idxMSB,idxLSB) === prober.io.meta_write.bits.idx && !prober.io.req.ready)
val s1_send_resp_or_nack = RegNext(s0_send_resp_or_nack)
val s1_type = RegNext(s0_type)

val s1_mshr_meta_read_way_en = RegNext(mshrs.io.meta_read.bits.way_en)
val s1_replay_way_en = RegNext(mshrs.io.replay.bits.way_en) // For replays, the metadata isn't written yet
val s1_wb_way_en = RegNext(wb.io.data_req.bits.way_en)

s1阶段还会去对tag作出匹配为,给出hit的way,这里需要注意的是只有普通的请求才会去进行tag匹配,其他的请求自带way_en

1
2
3
4
5
6
7
8
val s1_tag_eq_way = widthMap(i => wayMap((w: Int) => meta(i).io.resp(w).tag === (s1_addr(i) >> untagBits)).asUInt)
val s1_tag_match_way = widthMap(i =>
Mux(s1_type === t_replay, s1_replay_way_en,
Mux(s1_type === t_wb, s1_wb_way_en,
Mux(s1_type === t_mshr_meta_read, s1_mshr_meta_read_way_en,
wayMap((w: Int) => s1_tag_eq_way(i)(w) && meta(i).io.resp(w).coh.isValid()).asUInt))))

val s1_wb_idx_matches = widthMap(i => (s1_addr(i)(untagBits-1,blockOffBits) === wb.io.idx.bits) && wb.io.idx.valid)

S2 stage

s2阶段会去检查权限,并且给出新的状态,然后检查是否hit,当reply和wb总是命中的,还会去检查权限更新

Cat(wr, Trunk) -> (true.B, Dirty),这种情况如何解决

s2阶段还会去检查lrsc指令,lrsc指令之间不能有任何其他存储指令,故设置s2_sc_fail,如果s2遇到sc但是s2_lrsc_addr_match为低,说明执行失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
val lrsc_count = RegInit(0.U(log2Ceil(lrscCycles).W))
val lrsc_valid = lrsc_count > lrscBackoff.U//3
val lrsc_addr = Reg(UInt())
val s2_lr = s2_req(0).uop.mem_cmd === M_XLR && (!RegNext(s1_nack(0)) || s2_type === t_replay)
val s2_sc = s2_req(0).uop.mem_cmd === M_XSC && (!RegNext(s1_nack(0)) || s2_type === t_replay)
val s2_lrsc_addr_match = widthMap(w => lrsc_valid && lrsc_addr === (s2_req(w).addr >> blockOffBits))
val s2_sc_fail = s2_sc && !s2_lrsc_addr_match(0)
when (lrsc_count > 0.U) { lrsc_count := lrsc_count - 1.U }
when (s2_valid(0) && ((s2_type === t_lsu && s2_hit(0) && !s2_nack(0)) ||
(s2_type === t_replay && s2_req(0).uop.mem_cmd =/= M_FLUSH_ALL))) {
when (s2_lr) {
lrsc_count := (lrscCycles - 1).U
lrsc_addr := s2_req(0).addr >> blockOffBits
}
when (lrsc_count > 0.U) {
lrsc_count := 0.U
}
}
for (w <- 0 until memWidth) {
when (s2_valid(w) &&
s2_type === t_lsu &&
!s2_hit(w) &&
!(s2_has_permission(w) && s2_tag_match(w)) &&
s2_lrsc_addr_match(w) &&
!s2_nack(w)) {
lrsc_count := 0.U
}
}

s2阶段还会去设置nack原因

  1. hit nack,有probe请求(s1阶段的),也就是被probe的meta写请求阻塞住
  2. hit到一个正在被evict的行
  3. mshr没有准备好
  4. data 的bank conflict
  5. 有同样的mshr正在被写回

boom默认使用复制的data array,所以不会出现bank冲突

之后对于miss的请求会将请求送入mshr,hit的请求如果是load会返回数据

s2阶段出现首次写入脏数据,dcache会该次请求为miss,之后重新发出(需要提升权限)

AMO/store指令

AMO指令在s3阶段后处理(前提为hit)

AMO为读修改写指令

之后s3,s4,s5会向s2bypass数据,因为此时AMO可能修改的数据并未写入cache,(这里主要就是覆盖整个load流水线)

s3会去写入dcache,注意AMO ALU默认如果cmd不是AMO就输出rhs,也就是普通的store写的数据

  • 这里需要仿真去验证

AMOALU 的MUX最低优先级为minmax,而min,和max如果不是AMO指令总是为0,故总是选择io.rhs,也就是store的数据

1
val minmax = Mux(Mux(less, min, max), io.lhs, io.rhs)

MSHR

先讲解MSHR的状态机,状态很多接下来一个一个解释

s_invalid:如果tag_match且首次请求(缺失或者evict),若请求为写请求,更新coh,然后进入s_drain_rpq,否则进入s_refill_req

s_refill_req:发出acquare请求,如果握手成功进入s_refill_resp,这里的acquare可能是获取数据也可能是获取权限,比如cache line行变为脏行,需要通知下一级

s_refill_resp:如果有数据,给出grant信号,并且写入lb,如果grant握手,给出grant是否包含数据的信号,如果refill_done,给出grantack,如果有数据,说明为读请求,进入s_drain_rpq_loads,否则进入s_drain_rpq

s_drain_rpq_loads://当一个load得到结果,其他相同地址的load都可以退出了,该阶段清空load请求,然后如果rpq为空或者deq不是load,进入s_meta_read,

这里需要去仿真验证,不知道该状态为了什么

s_meta_read:读出meta,完成后进入s_meta_resp_1

s_meta_resp_1:s_meta_resp_2

s_meta_resp_2:如果此时为wr指令,且含有脏数据,此时s_meta_clear,否则为s_commit_line,注意这里有一个小的reply

s_meta_clear:写入meta新的权限

s_wb_req:写回脏数据,且设置voluntary为true

s_wb_resp:写回完成进入s_commit_line

s_commit_line:写回refill数据,转换为s_drain_rpq

s_drain_rpq:重新执行请求,转为s_meta_write_req

s_meta_write_req:写入新的权限,进入s_mem_finish_1

s_mem_finish_1:送出grantack,转为s_mem_finish_2

s_mem_finish_2:如果不是prefetch转为s_invalid

1
s_invalid :: s_refill_req :: s_refill_resp :: s_drain_rpq_loads :: s_meta_read :: s_meta_resp_1 :: s_meta_resp_2 :: s_meta_clear :: s_wb_meta_read :: s_wb_req :: s_wb_resp :: s_commit_line :: s_drain_rpq :: s_meta_write_req :: s_mem_finish_1 :: s_mem_finish_2 :: s_prefetched :: s_prefetch :: Nil = Enum(18)

L2Cache

CDE学习记录

CDE其中有site/here//up:

site:记录查找发生时的Module的偏函数

here:在本模块的参数

up:上级链表的参数

接下来结合图片来解析如何进行参数创建和查询的,

引用自cde

1733282944206

首先是参数创建(Myconfig),参数继承于Config类,在这里定义的chain是这个参数列表的chain,Config使用辅助构造函数得到this(Parameters(f)),也就是传入的参数变为Parameters(f),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Config(p: Parameters) extends Parameters {
def this(f: (View, View, View) => PartialFunction[Any, Any]) =
this(Parameters(f))

protected[config] def chain[T](
site: View,
here: View,
up: View,
pname: Field[T]
) = p.chain(site, here, up, pname)
override def toString = this.getClass.getSimpleName
def toInstance = this
}

Config继承自Parameters,而parameter内部含有apply方法,该方法返回新的对象PartialParameters(f),而这个PartialParameters,重载了chain:利用函数将数据转换为偏函数,然后找这个数据,如果找到了就返回数值,否则去上层链表查询,到这里其实已经完成了参数创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    def apply(f: (View, View, View) => PartialFunction[Any, Any]): Parameters =
new PartialParameters(f)
...
private class PartialParameters(
f: (View, View, View) => PartialFunction[Any, Any])
extends Parameters {
//如果找到了参数,就返回参数值,否则去上级链表查询
//使用Config创建的类chain的具体实现
protected[config] def chain[T](
site: View,
here: View,
up: View,
pname: Field[T]
) = {
val g = f(site, here, up)
if (g.isDefinedAt(pname)) Some(g.apply(pname).asInstanceOf[T])
else up.find(pname)
}
}

接下来为链表增长,链表增长会使用alter方法,这个方法返回一个ChainParameters(rhs, this)类,这个类的第一个参数为新的参数,第二个参数为现在的参数,

1
2
3
4
5
6
7
8
final def alter(rhs: Parameters): Parameters =
new ChainParameters(rhs, this)
final def alter(
f: (View, View, View) => PartialFunction[Any, Any]
): Parameters =
alter(Parameters(f))
final def alterPartial(f: PartialFunction[Any, Any]): Parameters =
alter(Parameters((_, _, _) => f))

ChainParameters内部重载了方法chain,主要区别在于up变为了newChainView(y, site, up),也就是上级链表为y参数(传入的this),到这里完成了参数链表增长,组织成链表的最主要的就是ChainParameters

1
2
3
4
5
6
7
8
9
  private class ChainView(head: Parameters, site: View, up: View) extends View {
def find[T](pname: Field[T]) = head.chain(site, this, up, pname)
}
//将新的链表的up设置为本级链表
private class ChainParameters(x: Parameters, y: Parameters) extends Parameters {
def chain[T](site: View, here: View, up: View, pname: Field[T]) = {
x.chain(site, here, new ChainView(y, site, up), pname)
}
}

接下来为参数链表查询,首先在Hello world模块内部含有p(WIDTH),这个调用了View类内部的apply方法,其作用就是查询pname,然后在Parameters类里面定义了find方法,调用chain,由于我们的p参数继承于Config,首先就会将chain方法重载为PartialParameters的方法,由于Hello world本身就是最底部链表,所以直接查询这个链表就行,显然这里是可以查询到的

1
2
3
4
5
6
7
8
9
    final def apply[T](pname: Field[T]): T = {
val out = find(pname)
require(out.isDefined, s"Key ${pname} is not defined in Parameters")
out.get
}
....
protected[config] def find[T](pname: Field[T]): Option[T] =
chain(this, this, new TerminalView, pname)

ModuleA内部也含有p(WIDTH),这个参数来自新的参数链表,使用的chain为PartialParameters重载的chain方法,也就是先在本级chain查询,查询不到参数,去up列表查询,注意我们之前链表增长在ChainParameters修改了新的链表的up为newChainView(y, site, up),而这个里面有个find方法,这对应了PartialParameters内部chain的find方法,也就是在head参数的列表找参数

1
2
def find[T](pname: Field[T]) = head.chain(site, this, up, pname)

1733290412999

假设我在Hello world模块的参数如上所示,在ModuleA内部搜索p(DBWIDTH),这个链表搜不到,去上级链表搜索,上级链表DBWIDTH = >2 *here(WIDTH),这里就是在Myconfig参数表搜索WIDTH

1733290760437

1733290799819

step 1: 在ModuleA的参数表中查找DBWIDTH。发现没找到。

step 2: 上一层查找。找到了DBWIDTH。发现调用了site(A_TYPE)

step3: 由于site指向的是ModuleA的参数表,所以返回来执行ModuleA的参数表,查到ATYPE=big_a

step 4:根据big_a查到参数等于6

site相当定位此时在寻找参数的属于哪个参数列表

BOP介绍

主要相对于SandBox有更好的timeliness,

OFFSET PREFETCHING

offset 预取是next line 的扩展,偏移量预取器预取行X +D,其中D为预取偏移量。情况D = 1对应于下一行预取。本节提供一些示例来说明为什么偏移预取是一种有效的预取技术。下面的示例假设为64字节行。为方便起见,在存储器区域中访问的行用位向量表示,相邻的位表示相邻的行。位值告诉行是否被访问(“1”)或不被访问(“0”)。我们忽略了页面边界的影响,只考虑长访问流的稳定状态。

3.1 Example 1: sequential stream

考虑下面的顺序流:111111111111111111…

也就是说,程序访问的行是X、X+1、X+2,等等。在这个示例中,下一行预取器产生100%的预取覆盖率和准确性。但是,在访问X之后为X+1发出预取可能太迟,无法覆盖从最后一级缓存或内存中获取X+1的全部延迟,从而导致延迟预取。延迟预取可能会加快执行速度,但不如及时预取快。偏移量预取器在顺序流上产生100%的预取覆盖率和准确性,就像下一行预取器一样,但是如果偏移量足够大,可以提供及时的预取。

另一个可能降低预取覆盖率的因素是scrambling(乱序访存),即内存访问的时间顺序可能与程序顺序不完全匹配[11]。

一般来说,在长序列流上,对scrambling的容忍度随着偏移量的增大而提高。

3.2 Example 2: strided stream

考虑一个加载指令访问一个常量步长为+96字节的数组。对于64字节的缓存行,在内存区域中访问的行是:110110110110110110…

如果在L1处没有步进预取器(或者如果它发出延迟预取),则观察L2访问(如AC/DC[22])的增量相关预取器将在这里完美地工作,因为行步进序列是周期性的(1,2,1,2,…)。尽管如此,在这个例子中,一个简单的偏移量预取器以3的倍数作为偏移量,可以获得100%的覆盖率和准确性。

通过将偏移量设置为一个周期内跨步的总和或该数字的倍数,理论上,偏移量预取可以在任何周期的行跨步序列上提供100%的覆盖率和准确性。

3.3 Example 3: interleaved streams

考虑两个交错的流S1和S2访问不同的内存区域,并具有不同的行为:S1: 101010101010101010…

S2: 110110110110110110…

流S1单独可以用2的倍数作为偏移量完美地预取。流S2单独可以用3的倍数作为偏移量完美地预取。两个流都可以用6的倍数作为偏移量完美地预取。

BEST-OFFSET (BO) PREFETCHING

1731049049568

图1显示了BO预取器的示意图。

图1中的符号D表示当前预取偏移量,即当前用于预取的偏移量。当对行X的读请求访问L2缓存时,如果这是一个未命中或预取命中(即,设置了预取位),并且如果X和X +D位于同一内存页,则对行X +D的预取请求被发送到L3缓存。

4.1 Best-offset learning

预取偏移量D是自动动态设置的,试图适应应用程序的行为,这些行为可能会随着时间的推移而变化。

最佳偏移学习算法试图通过测试几个不同的偏移来找到最佳的预取偏移量。如果在访问行X时,行X - d最近有一次访问,那么偏移量d可能是一个很好的预取偏移量。然而,X - d最近被访问的事实并不足以保证行X会及时预取。我们希望预取在任何可能的情况下都是及时的。也就是说,对于行X来说,d是一个很好的预取偏移量,行X - d必须最近被访问过,但不是太近。

理想情况下,访问行X - d和X之间的时间应该大于完成预取请求的延迟。

我们的解决方案是在最近的请求(RR)表中记录已完成的预取请求的基址。

基址为触发预取请求的地址:预取行为X +D,则基址为X。从插入L2的预取行地址减去当前预取偏移量得到基址。

如果行X−d在RR表中,则表示最近下发了对行X−d + D的预取请求,并且已经完成。因此,如果一个预取请求发出的偏移量是d而不是D,那么它将是当前访问的行X的预取,并且这个预取将是及时的(假设取行X的延迟等于取行X - d + D的延迟)。

注:这句话的意思就是偏移量为d的已经预取到X了,也就是D=d

对于RR表有几种可能的实现。在本研究中,我们选择最简单的实现:直接映射RR表,通过哈希函数访问,每个表项都包含一个标签(tag)。标签不需要是完整的地址,部分标签就足够了

除了RR表之外,BO预取器还具有偏移列表分数表。分数表将分数与偏移列表中的每个偏移量关联起来。分数值在0到SCOREMAX之间(例如,SCOREMAX=31表示5位分数)。

预取偏移量在每个学习阶段结束时更新。一个学习阶段包括几个回合。在学习阶段开始时,所有分数都重置为0。对于每个符合条件的L2读访问(未命中或预取命中),我们从列表中测试偏移量di。如果X−di命中RR表,则增加偏移量di的分数。在一轮中,列表中的每个偏移都测试一次:我们在一轮中第一次访问时测试d1,在下一次访问时测试d2,然后是d3,以此类推。当列表中的所有偏移量都测试完后,当前一轮结束,从偏移量d1再次开始新一轮。

注:预取命中就是命中了预取来的块

当前的学习阶段在一轮结束时,当以下两个事件中的任何一个首先发生:其中一个分数等于SCOREMAX,或者轮数等于ROUNDMAX(一个固定参数)。当学习阶段结束时,我们搜索最佳偏移量,即得分最高的偏移量。这个偏移量成为新的预取偏移量,开始一个新的学习阶段。

4.2 Offset list

没有什么可以阻止BO预取器使用负偏移值。虽然一些应用程序可能从负偏移中受益,但我们在实验中没有观察到任何好处。因此,我们在本研究中只考虑正偏移。有用的偏移量值取决于内存页大小,因为BO预取器不会跨页边界预取。例如,假设4KB页和64B行,一个页包含64行,并且没有必要考虑大于63的偏移值。但是,对于具有超级页面的系统,考虑大于63的偏移量可能是有用的。在偏移列表中包含的偏移量的选择有些随意。例如,一种可能性是包含从1到最大偏移量之间的所有偏移量。然而,这个最大偏移量不能太大,因为较大的偏移量列表意味着较大的分数表和较长的学习阶段。如果我们想让列表包含较大的偏移量,但又不想让列表太大,我们必须采样1和最大偏移量之间的偏移量(这需要将偏移量列表实现为ROM)。使用数千个代表性基准测试的微架构师可能希望对要放在列表中的偏移量进行广泛的探索。我们没有足够的基准来进行这样的探索。

我们提出了一种偏移采样的方法,它是算法的,不是完全任意的:我们在我们的列表中包括所有在1到256之间的偏移,其质因数分解不包含大于5的质数。这给出了以下52个偏移量的列表:12 3 45 6 8 9 10 12 15 16 18 20 24 25 27 30 32 36 40 45 48 50 54 60 64 72 75 80 81 90 96 100 108 120 125 128 135 144 150 160 162 180 192 200 216 225 240 243 250 256。

仅考虑具有小质数因子的偏移量有两个好处:

•小偏移量比大偏移量更具代表性(小偏移量更可能有用)。

•偏移列表比全偏移范围小得多。

此外,这种方法与3.3节的例子是一致的:如果列表中有两个偏移量,那么它们的最小公倍数也是如此(前提是它不是太大)。

4.3 Prefetch throttling

BO预取器是一级预取器:每次访问最多发出一次预取。

可以想象一个预取度大于1的偏移预取器。例如,二级偏移量预取器将同时预取两个不同的偏移量,最佳偏移量和次最佳偏移量。这可能会为具有不规则访问模式的应用程序带来一些额外的性能。但是,这将增加预取请求的数量,给内存带宽和缓存标签带来更大的压力,除非实现预取过滤器。不需要带有一级BO预取器的预取过滤器。此外,使用两个偏移量的预取可能会在不规则的内存访问模式下产生许多无用的预取。

举例:With two different prefetch offsets D1 and D2, redundant prefetch requests are issued when accessing lines X and X +D1 −D2.

尽管如此,与其他一些预取方法(如跨步预取)相比,BO预取是相对积极的。对不规则访问模式发出的无用预取浪费了能量和内存带宽。在学习阶段结束时获得的最佳分数给出了关于预取准确性的一些信息。如果分数很低,这可能意味着偏移预取失败,我们可能决定关闭预取。我们定义了一个固定的阈值BADSCORE,这样当最佳分数不大于BADSCORE时,就会关闭预取。然而,最佳偏移学习永远不会停止,即使在预取关闭时也会继续,因此当应用程序行为发生变化并需要预取时,可以再次打开预取。

图1说明了预取打开的情况:对于插入L2的每个预取行Y,我们写入地址Y−D 4。这将是一个过拟合的情况。由于D1和D2两个不同的预取偏移量,访问线路X和X +D1−D2时会发出冗余预取请求。到RR表中(如果Y和Y−D在同一页)。在预取关闭的学习阶段,在RR表中的插入被修改:对于每个提取的行Y,我们将地址Y写入RR表(即D = 0)。

这里也就是预取关闭D一定为0,所以将Y写入D

4.4 Implementation details

BO预取器具有3个加法器,如图1所示。这些加法器只需要生成页面内一行的位置。例如,对于4MB页面和64B行,每个加法器的宽度为22−6 = 16位。页码位只是简单地从基址X或从预取的行地址Y复制(参见图1)。通过简单的哈希函数访问RR表。例如,对于一个包含256个条目的RR表,我们将最低8位行地址位与次低的8位进行异或,以获得表索引。对于12位标签,我们跳过8个最低有效行地址位并提取接下来的12位。

BASELINE MICROARCHITECTURE

预取不是独立模型,需要考虑其他微结构影响

1731054466977

首先这是基线模型,然后测试了1,2,4核工作对core0的影响,462程序会大大降低core0 的ipc

1731054598236

1731055018638

他之后的实验工作就是通过固定offset测试BOP的偏移表是否含在内部,同时对比性能

目录式协议(directory)—-物理存储器中数据块的共享状态被保存在一个称为目录的地方。目录式协议的实现开销比监听式协议的稍微大一些,但可用于实现更大规模的多处理机。
监听式协议(snooping)—-当物理存储器中的数据块被调入 Cache 时,其共享状态信息与该数据块一起放在该 Cache 中。系统中没有集中的状态表。这些 Cache 通常连在共享存储器的总线上。当某个 Cache 需要访问存储器时,它会把请求放到总线上广播出去,其他各个 Cache 控制器通过监听总线(它们一直在监听)来判断它们是否有总线上请求的数据块。如果有,就进行相应的操作。

无论是MSI还是MESI,MOESI,都差不多按照以下思路分析:r_hit,r_miss,w_hit,w_miss讨论状态机实现,其中每个transaction对应3,4,5个状态(MSI->MESI->MOESI),且都要考虑本地cache和其他cache,如右图所示

1729944912699

  • M,modified,表示该数据已修改,即上述所说的“脏”数据,需要在合适的时机写回内存;
  • E,Exclusive,表示该数据独占,意指此时该数据只存在某个核中,其他核没有该数据,于是就不需要所谓的广播给其他的核,也就没有缓存一致性的问题;
  • S,Shared,表示数据共享,是从数据独占状态转移过来的。意指多个核中都有该数据,这个时候就会存在缓存一致性的问题;
  • I,Invalidated,表示数据已失效,可以丢弃掉Cache Block的数据了(高并发情况下可能出现两个CPU同时修改变量a,并同时向总线发出将各自的缓存行更改为M状态的情况, 此时总线会采用相应的裁决机制进行裁决 ,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效);

MESI四种状态标志是存储在Cache Line中的

内存模型

SC

所有线程访存一定能构成全局序

1730373908924

所有load结果都是全局序中最近的同地址store值

1730373977856

TSO

17303740944561730374216003

RVWMO

1730374321974

1730374367495

香山乱序访存

数据也会存到SQ

1730380794423

设置一个SQ,在派遣时将进入SQ,FIFO结构,当指令

1730380916400

1730381086089

1730381113849

sw遇到TLBmiss 让她退出流水,然后在SQ标记重发1730381020649

load流水线

load指令一方面数据来自DCache,另一方面来自SQ,而且SQ优先级更高,在读数据要对SQ和DCache的数据合并

Store/load违例:load先于store执行

1.阻塞,load得等,等到之前的store指令地址计算完,算是部分乱序

2.检测:检测到了违例,直接从lw之后把指令刷掉,(一种是ROB,但ROB太大)

所以可以设置一个LQ,FIFO,是ROB子集只用存LW指令,dispatch入队,sw指令会检测是否有比其更年轻的load(ROB地址值作为age)已经执行完了,从lw下面指令全部刷了.重新取

1730381962804

load异常事件:

TLBmiss

1730382130178

1730382342283

取回数据然后对唤醒LQ的指令

分支预测

Cache

一般设计

1.时间相关性:被访问的数据将来很有可能再次被访问

2.空间相关性:一个数据被访问,其周围空间的数据也有可能被访问

Cache主要由两部分:TAG和DATA

Cache术语

Cache line:一个tag和他对应的数据组成,其中的data部分称为data block

Cache set:一个index对应多个cache line 这些line组成cache set

1729585610357

cache实现方式

  1. 直接映射:物理内存中数据,在cache中只有一个地方可以存放

1729586075965

特点:缺失率相对高,但延迟低

  1. 组相联:物理内存中数据,在cache中有多个地方可以存放

1729586093262

特点:

延迟比直接的大,但缺失率低

  1. 全相连:物理内存中数据,在cache中任意地方都可以存放

1729586217909

特点:

相当于只有一个index的组相连

灵活度高,缺失率低,但延迟大(一次访存要比较大量内容),所以设计时大小不会太大

流水线设计

  1. 并行访问tag和data

    1729587002847

  2. 1729587015169

    这是一个四级流水的并行访问结构,第一个周期将addr分为tag,index,offset,第二个周期访问data和tag ram,第三个周期进行hit判断,并选出数据,第四个周期输出数据

    特点:低时钟频率,高功耗

  3. 串行访问tag和data

1729587338356

1729587367038

这个主要与并行的区别体现在234周期,第二个周期只发出tag ram请求,第三个周期判断hit,然后发出data ram请求,第四个周期得出数据送入第五个周期,第五个周期通过offset选出相应数据

特点:频率高,但处理器性能可能降低

Cache写入

当发生写入时

  • write through:数据写入Dcache同时也写入下级存储器
  • write back:数据写入DCache,但不写入下级存储器,这时给这个写入的cache line设置 dirty位,当这个cacheline被替换时,将这个脏数据写入下级存储

当发生写缺失时

  • write allocate:首先从下级存储器取出缺失行的数据,然后将写入DCache的数据合并到这个数据块中,最后将这个数据块写入DCache
  • write non-allocate:将数据直接写入下级存储器,而不写入DCache

两种常见配合方法

  • write non-allocate + write through

    1729588238715

  • write allocate + write back

    1729588282799

Cache替换策略

  • PLRU
    实现方式通常为二叉树,每次更新(hit or miss)都是对从叶节点一直更新到根节点

    1729588503421

  • Random

    随机找一个行替换

  • SRRIP

    抗扫描,insertion为RRPV=2,也就是替换完初始化为2,这样一些之后不会被使用的块可以被快速驱逐,一些常用的块也会随着命中promotion为0

    1729588673325

  • DRRIP

    抗抖动+扫描,主要实现方式是通过set dueling来选择到底是SRRIP还是BRRIP

    BRRIP就是小概率在RRPV=2插入,大概率在RRPV=3插入

    参考文献:

    High Performance Cache Replacement Using Re-Reference Interval Prediction (RRIP)

一些提高cache性能的方法

写缓存

1729588903433

对于write back:dirty数据被替换,存入write buf,然后就可以在下级存储读出数据了,这个write buf数据空闲时写入下级存储

对于write through:数据写入DCache同时写入write buf

但cache发生缺失,不仅要在下级存储寻找数据,还要在write buf 寻找数据,而且写缓存的数据为最新数据,如果同时读出write buf和下级存储的数据,采用writebuf 的数据

流水线

1729589648859

load在hit情况只需要一个周期,但store指令在hit时,第一个周期比较,第二个中秋选择是否写入数据,假如store后紧接着load,这时load的数据可能在delayed store data中,需要做一个bypass,但其实这个有一个问题,也就是假如发生load aft store,这样会导致两个指令竞争addr端口,必须对两个指令进行调度,或者使用双端口cache

多级结构

inclusive:L2包含L1全部内容,且要求L2相连度大于L1(便于cache一致性管理,检查数据只需要检查l2cache的数据,l2含有,那就置为无效,l2没有那么l1必然没有)

当L1 miss时,访问L2cache,如果L2 miss,会找到替换的line,并将该line对应的l1cache 的line无效,然后返回数据

exclusive:L2与l1数据不同

Victim Cache

和write buf 较为相似(他是保存被替换的数据)

预取

硬件预取

  • 总是取出下一行

软件预取

多端口Cache

True Multi-port

需要对TAG和DATA SRAM进行复制,改变的是SRAM

Multiple Cache Copies

1729591163588

不需要改变SRAM,写时候,同时写入两个SRAM,一个cache 替换,另一个也得替换

Multi-banking

将Cache 的DATA分为多个bank,每个bank都有一个端口,如果一个周期内,多个端口访问的地址位于不同bank,不会引起任何问题,如果位于相同bank,会引起bank冲突,这种可以采用多个bank来缓解这个问题

1729591581007

下图是AMD Opteron 处理器的cache,其参数如下

total size:64KB

two way

Data block size:64B

故offset:[5:0]

index[14:6]

而由于采用了bank,所以offset的高三位[5:3]是寻址bank的

这是一个VIPT的结构,使用虚拟的index寻址,物理的tag做对比,每个端口都含有一个TLB和TAG RAM,但DATA RAM大小不变

1729600137859

虚拟存储器

本章假设页大小为4K

本章术语

VA;虚拟地址

VPN:虚拟页

PT:页表

PTR:页表寄存器

PTE:页表表项

PA:物理地址

多级页表组织方式

下图为三级页表组织方式,在riscv中64位至少为3级页表(SV39,SV48,SV57),RV32的是SV32

1729601490679

下面讲解RISCV的组织方式,RISCV使用SATP这个csr寄存器来保存第一级页表在物理内存的基地址,而第一级页表基地址+VA给的偏移地址就组成了这个地址在第一级PT的位置,然后第一级页表读出的地址为第二级页表的基地址,以此类推

所以含有三级页表的CPU执行LW或SW指令,要访问4次物理内存(3次页表访问,一次数据访问)

Page Fault

如果发生页缺失,且物理内存无空间,此时OS会对一个页进行替换,覆盖这个页之前,需要将他内容写入硬盘,所以在每个PTE上加一个脏位,一个页内某个地址被写入,这个页对应的PTE脏位为1

硬件上需要做到:

  1. 发现 page fault,生成异常,跳转到相应地址
  2. 当store指令指令,需要将PTE的脏位置为1
  3. 当访问物理内存,需要将PTE的USE位置为1

RV分页机制

1730514239279

1730514249374

1731208221157

1731208201787

首先将a=satp.ppnxPAGESIZE,让i=LEVELS-1,SV32是PAGESIZE=4k,LEV=2,有效的模式为S和U,然后pte就是a+va.vpn[i]×PTESIZE.(PTESIZE=4 for SV32)(为什么要xPTEsize呢,vpn存储的是表项的个数,乘size才会变为地址),然后会进行PMP和PMA检查,如果PTE有效,且R=0,X=0这个是指向下一个节点的指针,i=i-1然后a=pte.ppn×PAGESIZE ,接着寻址下一个页表的PTE,如果R=1,X=1,就说明叶子PTE找到了,然后进行PMP

如果i>0且pte.ppn[i-1:0]≠0,这个是一个superpage并且没有对齐

然后翻译完成:

地址:pa.pgoff = va.pgoff.

如果i>0,这就是一个superpage翻译,pa.ppn[i-1:0] = va.vpn[i-1:0]

pa.ppn[LEVELS-1:i] = pte.ppn[LEVELS-1:i].

SFENCE.VMA

1730370582883

当rs1=x0并且rs2=x0时,将刷新TLB所有表项。
当rs1!=x0并且rs2=x0时,将刷新TLB中虚拟页号与rs1中储存的虚拟页号相等的表项。
当rs1=x0并且rs2!=x0时,将刷新TLB中ASID与rs2中储存的ASID相等的表项。
当rs1!=x0并且rs2!=x0时,将刷新TLB中虚拟页号与rs1中储存的虚拟页号相等,并且ASID与rs2中储存的ASID相等的表项。

加入TLB和Cache

TLB设计

为了减少TLB缺失(只有时间相关性),一般采用全相联来组织TLB,现代处理器一般采用两级TLB,第一级私有,第二级共享

1729604171685

如果送入的地址和TLB其中一个VPN相等,则TLB命中,送出PFN,如果没有,则发生TLB缺失,要去物理内存访问页表:

  1. 假设物理内存找到的PTE有效,那么直接从页表得到这个地址,同时写回TLB
  2. 假设未找到,这样应该产生Page Fault,操作系统从硬盘将相应页搬移到物理内存,将他在物理内存的首地址放在页表对应的PTE,将PTE内容写入TLB

TLB缺失

缺失的一些情况
  1. 虚拟地址对应的页不在物理内存,此时页表没有对应PTE,而TLB内容来自页表,所以必然缺失
  2. 虚拟地址对应的页在物理内存,但这个PTE并未放入TLB
  3. 虚拟地址对应的页在物理内存,这个PTE被替换掉了

解决TLB缺失的本质是找到映射关系,然后将映射关系写入TLB,这个过程称为PTW,目前的两种方式:

  1. 软件:一旦TLB缺失,硬件吧产生tlb缺失的虚拟地址保存到一个特殊寄存器,然后产生TLB缺失异常,软件通过这个寄存器内容寻址页表,找到对应的PTE,写回TLB(处理器需要有支持TLB的指令)
  2. 硬件:由MMU完成
TLB写入

加入tlb后,处理器送出的地址首先会访问tlb,如果命中,从tlb得出物理地址,如果tlb采用写回.那么dirty的页表不会立马从tlb写回到页表,导致页表的状态为可能是过时的,,操作系统可以认为tlb记录的页都是要被使用的,操作系统可以得知哪些pte被放到了tlb中,

操作系统在page fault时如果从物理内存选出要替换的页是脏的,首先将这个页写入硬盘,然后再覆盖,但如果使用了dcache,若dcache的内容保留着这个页的数据,那麽dcache的数据也得写入硬盘

当TLB缺失时,需要从页表中将一个新的pte写入tlb,如果tlb此时已经满了那么就要替换掉tlb的表项,但假如被替换的表项数据存在于dcache,那么就需要操作系统可以控制dcache,从而使得最新内容在物理内存

TLB控制

tlb是页表的子集,如果页的映射关系在页表不存在,那么在tlb也不该存在,需要有可以对tlb表项无效的指令

  1. 进程结束,进程的指令数据堆栈占用的表项就要无效,此时需要将ITLB和DTLB的相关表项全部无效,如果无ASID,就直接无效整个TLB,如果有,只需无效对应asid的表项
  2. 进程占用物理空间太大,操作系统就会将一些不常用的写入硬盘,这样这些页在页表的映射关系应该置为无效,页需要将TLB对应表项置为无效

对TLB进行管理需要以下:

  1. 能将TLB(包含ITLB和DTLB)所有表项置为无效
  2. 能将TLB某个ASID对应表项置为无效
  3. 能将VPN对应表项置为无效

arm风格TLB管理

  1. arm在协处理器CP15提供了ITLB控制寄存器

    1. 将TLB的VPN匹配的表项置为无效,还需满足以下条件:
      如果一个表项的Global无效,则需要ASID相等
      如果Global有效,无需比较ASID

      1729651150915

    2. 将TLB的ASID对应表项无效,但有global位的不受影响

      1729651240363

    3. 将TLB没有锁定的表项无效,为了加快处理器关键程序的执行时间,将tlb一些表项设为锁定,这些内容不会被替换

  2. DTLB控制寄存器(和ITLB一样)

  3. 可写入和读出TLB的寄存器

    1729651376237

    由于TLB一个表项大于32位,使用两个寄存器对对应一个表项,读TLB,TLB内容放入这两个寄存器,写TLB,这两个寄存器内容写入TLB

总的来说,ARM的TLB管理主要由协处理器实现,只需要访问协处理器的指令就行

MIPS的tlb管理

主要由专门的TLB指令完成

RISCV的TLB管理

有粗粒度的sfence.vma,还有细粒度的Svinval扩展

Cache设计

Virtual Cache

使用TLB+物理cache,这样CPU先访问TLB,地址转换后才可以访问cache,增加了LOAD延迟

1729652474236

所以可以直接使用虚拟cache,直接通过虚拟地址访问Cache,如果没有在Cache找到数据,那么就会在TLB进行地址转换然后从下级存储取出数据

1729652639233

但会存在两个问题:

同义问题:多个不同的虚拟地址对应同一个物理地址,如printf

1729652819051

如图,当执行store指令向虚拟地址VA1写入数据时,虚拟cache中的VA1改变,但如果之后有load读取VA2,那么会得到过时数据

这个问题取决于Cache的大小和页的大小,对于大小为4K的页,虚拟地址低12位不会变,如果有一个直接相连的Cache,这个Cache小于4K,那么index不会大于12位,即使两个虚拟地址对应同一位置,他们寻址Cache的地址也是相同的,只有Cache大于4K,才会出现同义问题

1729653244081

可以使用bank解决(8K,直接相连)

  1. 读取cache时,两个bank都会被读取,然后送到多路选择器,通过PA[12]选择数据
  2. 写Cache,通过PA[12]选择bank写入(提交的时候才会被写入,此时物理地址早就知道了,同名问题对应的物理地址一样,所以使用物理地址选择bank可以选出唯一的bank)

同名问题:相同虚拟地址对应不同物理位置

这个只需要在TLB设置ASID就可以

对Cache控制

主要对以下情况控制

  1. DMA从外界搬运数据到物理内存的一个地址,这个地址在dcache被缓存,这样需要将dcache内容无效
  2. DMA将数据搬运到外界,这个地址在DCache被缓存,需要先将DCache的新数据写入物理内存
  3. 发生page fault,需要从硬盘读取一个页写入物理内存,如果物理内存被覆盖的页是脏的,并且这个页的内容一部分存在于Dcache,需要将DCache的内容写入物理内存,然后进行覆盖
  4. 处理器的自修改指令,将修改的指令写入DCache,然后写入物理内存,ICache清空,将物理内存对应指令写入ICache

也就是需要以下操作

  1. 将ICache无效
  2. 将ICache的某个line无效
  3. 将DCache所有line clean
  4. 将DCache的某个line clean
  5. 将DCache所有line clean,然后无效
  6. 将DCache的某个line clean,然后无效

对于ARM,仍然使用协处理器实现,MIPS使用Cache指令实现,RISCV使用zifence扩展(粗粒度)实现

TLB和Cache放入流水线

PIPT

1729655372235

完全串行了TLB和Cache访问,但有以下优化方案,直接使用虚拟地址的offset寻址Cache(VIPT的一种情况)

1729655459830

VIPT

1729655542483

1.2的情况不会出现同义问题,实际上相当于图3.36的设计,但是会严重显示Cache容量大小,要严格控制一个WAY大小小于页的大小

所以将1.2归为情况一,3归为情况2

重点讲解情况2

1729655973192

此时不能保证VA1和VA2寻址Cache的index是相同的,他们位12可能不同,于是他们可能放在不同的cache set,造成空间浪费,之前有bank可以解决

还可以采用让这些重名的虚拟地址只有一个存在于Cache,其他不允许存在,可以使用L2Cache实现,L2包含L1全部内容

1729657181999

下面是一种实现方法,L2cache中保存着a的部分

1729657227475

VIVT

1729657611870

小结

1729657649741

方向预测

局部历史

1729666858722

每个BHT中的BHR都有自己的PHT,由于上图方法使用PC的一部分,所以会有重名问题,各分支相互干扰,而且PHT的表还很大,占用大量空间

1729667068859

一种极端就是所有分支只使用一个PHT,此时不需要使用PC寻址PHTs,BHT所有的BHR来寻址这个PHT,但会发生两个不同指令的BHR相等,这样会寻址到同一个PHT表项,产生干扰导致预测精确率下降

其实每个BHR只会使用PHT的少数表项,所以多个BHR可以分别使用PHT的不同部分,但一定会有冲突发生:

  1. 两条指令的PC的k部分相等,这样会干扰PHT表项
  2. 两条分支对对应不同的BHR,但BHR内容一样,这样也会索引到PHT同一表项

为了避免这种情况,可以对PC和对应的BHR做处理,下图是对PC做哈希处理

1729667539150

一般处理有两种方法,如下图所示

1729667608315

全局历史

1729668135079

所有分支都使用一个BHR,BHR索引PHTs的具体表项,PC进行HASH处理索引PHTs,还可以只使用一个PHT,节省空间,但这样导致两条不同指令对于GHR相等的话造成冲突

1729668248761

所以采用以下方法解决

1729668321244

基本的2-Level Predictor的方式,BHR是每个branch各自有的,而PHT是使用的branch是使用同一个,所以这样可以称之为per-address BHR - global PHT的方式,简写为PAg[2]。除了per-address BHR,global BHR的方式之外,[2]中提到了一种set的方式,即在per-address和global中间的一种折中的方式,使用来自一个set的branchs的分支历史。除了BHR是否使用global的,还是per-address的,还是set的方式,PHT也将其分为了global,per-address和set三种方式,这样组合下来,就有9中的组合方式,分别简写为 GAg, GAp, GAs, PAg, PAp, PAs, SAg, SAp 和SAs。比如per-address BHR的示意图如下:

1729671251364

GPT小例子:

例子背景

假设我们有四条分支指令,分别位于地址 A、B、C 和 D,每条指令的行为可以是“跳转”(即分支被选中)或“不跳转”(即分支未被选中)。这些分支指令执行时的历史如下:

  • 地址 A 的分支:行为是 跳转、不跳转、跳转、不跳转 ,周期性循环。
  • 地址 B 的分支:行为是 跳转、跳转、不跳转、跳转
  • 地址 C 的分支:行为是 不跳转、不跳转、跳转、不跳转
  • 地址 D 的分支:行为是 跳转、跳转、跳转、不跳转

我们使用这些行为数据来解释两种分支预测方案。


方案一:每地址历史(Per-Address History, P)

在每地址历史方案中,每个分支指令地址(A、B、C、D)都有独立的历史寄存器。因此,预测系统会记录每条分支指令的独立行为模式:

  • 地址 A 的历史记录:跳转、不跳转、跳转、不跳转
  • 地址 B 的历史记录:跳转、跳转、不跳转、跳转
  • 地址 C 的历史记录:不跳转、不跳转、跳转、不跳转
  • 地址 D 的历史记录:跳转、跳转、跳转、不跳转

当预测系统遇到地址 A 的分支指令时,它只参考地址 A 的历史记录来预测。这样可以确保预测是基于该分支自身的历史行为,不受其他分支的影响。

优势 :每个地址有独立的历史记录,因此预测更精确,尤其在分支行为周期性强或特定地址的分支行为一致时。

劣势 :硬件成本较高,因为每个分支指令地址都需要一个单独的历史寄存器。


方案二:每集合历史(Per-Set History, S)

在每集合历史方案中,系统将分支指令按地址分组。假设这里将地址 A 和 B 归为一组,地址 C 和 D 归为另一组,每组共享一个历史寄存器。于是,每组的分支指令 共享同一个历史信息 ,不再有独立记录。

例如:

  • 集合 1 (包括地址 A 和 B):历史记录会被更新为跳转、不跳转、跳转、不跳转、跳转、跳转、不跳转、跳转
  • 集合 2 (包括地址 C 和 D):历史记录为不跳转、不跳转、跳转、不跳转、跳转、跳转、跳转、不跳转

由于集合 1 中的地址 A 和 B 共享一个历史寄存器,预测系统会参考集合 1 的整体历史来预测 A 或 B 的行为,而不是它们各自独立的行为。

优势 :节省硬件资源,因为多个分支指令共用一个历史寄存器。

劣势 :同一个集合内的分支行为会互相干扰。例如,地址 A 的周期性行为和地址 B 的不规则行为在同一历史寄存器中混合后,可能会降低预测准确性。

TAGE

1729671704805

结构和PPM大致相似,特点:更新策略不同;

更新策略:1.更新u计数器,u计数器为2bit计数器,provider component的u计数器在alpred和其预测不同时递减,否则递增;并且该计数器会周期性重置,当预测失败时,首先更新provider component的pred计数器,其次如果provider component不是最长的历史.那么分配表项(分支历史大于provider component的),如果存在i<j<M,且u=0,直接分配该项,其他u计数器全部递减(i<j<M);假如有多个u=0,分配概率大的(一般历史短的),最后初始化表项(u置为0,pred计数置为弱跳转)

分支预测更新

对于方向预测,需要更新的有BHR和PHT的饱和计数器

  1. 更新BHR
    在全局历史分支预测:

    1. 取指阶段分支预测,然后根据预测结果直接更新GHR
    2. 当实际方向计算出来,此时更新BHR
    3. 提交阶段,离开流水线时,更新GHR

    使用方法3最为保守,最为安全,但会降低性能,其他分支也不会享受这个分支的结果

1729672921097

BR2-BR5使用的都不是最新的GHR值,会对准确度造成影响

使用方法2更新提前了很多,但对于超标量处理器影响较大,乱序执行阶段分支预测可能会位于预测失败路径,导致结果不一定对

使用方法1,其根本原因是分支预测准确率较高,预测失败需要对GHR进行修复:

  1. 提交阶段修复法:提交阶段放一个GHR,当分支指令退休时,将结果写入GHR
    1729673275891
    这种缺点是造成分支预测失败的惩罚增大
  2. checkpoint修复法,对前端GHR更新时将旧的GHR保存起来,保存的值为checkpoint GHR,一旦这条分支结果被计算出来,就可以检查预测正误,如果预测正确,继续执行,预测失败,将checkpoint ghr恢复到前端,请从正确PC取指令

1729673568437

上图有一个存储器保存所有的GHR,由于新指令结果从GHR右侧移入,此时将预测方向相反的值写入就行,前端是顺序的,所以按照FIFO写入就行,而后端如果是乱序的,那么执行阶段得到的结果也有可能是错的(预测失败路径或者异常),此时仍然需要在提交阶段检查,提交阶段仍然需要RGHR

而对于基于局部历史的,可以在退休阶段更新,因为他只在乎一条跳转指令的行为,如果训练完成,预测正确率较高,少一次结果不会影响大局

目标预测

PC-relative

BTB

不发生跳转:target=pc+sizeof(fetch group)

发生跳转:target = pc + signed(imm)

1729676174524

我们可以保存下来BTA,然后预测成功时,取出BTA,但这样得保存整个pc地址,可以采用下面的方法

1729676271119

这个是缩短pc位数,但缩短位数就会导致冲突,不同的pc寻址到相同BTB表项,这样其实概率不高,况且之后还可以对其进行修复

也可以采用异或的方法

1729676437002

通过这种方式,既降低PC位数,又避免重名问题

BTB缺失处理

  1. 停止执行,直到指令被计算出来,这个时候会导致有较多的气泡
  2. 继续执行,使用顺序的PC取指令,如果后续执行发现地址和计算的PC不同,则将分支指令之后的全部抹除

间接跳转

CALL/RETURN指令

CALL指令的目的地址比较固定,所以采用BTB

1729690828057

return指令返回地址为上一次CALL指令调用的PC,所以使用RAS保存

1729690912314

整个过程如下操作

1729690951738

首先当遇到CALL指令,需要将其下一条PC写入RAS,然后读BTB得到TARGET,当遇到return指令,要弹出RAS,然后得到target
所以要得知指令是否为CALL还是return指令,但指令直到解码才会被得出,这可以通过在BTB添加指令类型,call、return、其他类型,再遇到这个指令,通过查询BTB就可以得出类型

1729691186846

假如RAS满了,该如何进行操作,

  1. 不对最新的call指令压栈,这样下一次执行return必然预测失败,而且RAS地址不能改变
  2. 继续写入,这样最旧的会被覆盖,所以也会出现一条预测失败,但考虑到递归这种情况,会增加正确率,但递归会大量消耗RAS空间,所以可以弄一个计数器,检测本次call是否跟上次一样,如果一样,那么只增加计数器,如果不一样,进行压栈,弹栈的时候,首先看计数器是否为0,如果不为0,计数器-1,然后不改变RAS指针,直到计数器为0,才可以改变RAS指针

其他预测方法

target cache

1729691627500

分支预测失败恢复

在流水线很多阶段可以对分支预测是否正确进行检查,

  1. 解码阶段,可以检查出直接跳转类型的指令,还可以得到这个指令地址
  2. 读取物理寄存器时,读到寄存器值,就可以对间接跳转进行检查
  3. 执行阶段,所以类型指令都可以得到结果,对分支预测是否正确做出检查

分支预测失败可以使用ROB来解决,暂停取指令,但仍然执行流水线,当分支变为最旧的时候,将整个流水线清除,这种方法的缺点就是分支预测惩罚大,

1729694791283

另一种就是使用checkpiont恢复,也就是发现分支指令,在分支指令改变CPU state之前,先保存CPU state,包括RAT,PC等,预测失败,直接把checkpoint的搬运过来,使用这种方法需要识别哪条指令位于错误路径,这可以对每个指令进行标号(解码阶段),所有在分支指令之后的都会得到这个编号,直到下一个分支指令

1729695140867

所有在流水线的分支指令都会得到编号,编号保存在FIFO,他的容量就是流水线可以最多支持的分支指令,一旦满了,如果再解码出指令,就必须stall流水线,

一般性的设计方法可以是一个表格存没使用的编号,另一个存被使用的编号(和rename的RAT差不多),解码时刻对分支指令编号,之后的非分支指令均有这个编号,这两个表格本质就是FIFO

1729695418178

前面说过,解码时刻也可以检查分支预测,如果预测失败,也要对其进行分配编号,因为其之前有可能有预测失败的指令

当分支指令在执行预测失败,需要抹除之后的所有指令

  1. 发射阶段前的全部被清除,
  2. 发射阶段后的使用编号列表清除,这样不一定会在一个周期完成

如何使用编号列表找到指令呢?

如果ROB存储了编号,编号列表就可以进行广播,ROB中所有的指令将自己的编号与广播值比较,如果相等,进行无效,假如一个有32个表项的编号列表,最坏情况ROB需要与32个编号比较,其实可以每周期只广播一个或几个值,当编号不是很多,几个周期就可以完成,必须状态恢复才可以允许新指令进入发射阶段

1729695901482

但其实在流水线解码阶段,最多解码1,2条导致FIFO浪费,所以可以只允许一个周期解码一条分支指令,如果多条,那么第二条指令及之后的无法进入解码阶段

还有一个问题就是,在执行阶段如何得知预测的目标地址,可以把目标地址写入缓存PTA

1729696659213

1729696830422

超标量分支预测

1729696935982

要对指令组进行预测,就需要得到预测非跳转的PC,但这种不容易被接受,因为一个周期取出四条分支情况很少,大部分情况只会使用一个,而且一般为PC-relative,所以可以取出直接进行计算目标地址,也可以预解码,提前得知目标地址信息

多端口可以使用交叠,下面给出PHT的做法

1729697495138

寄存器重命名

程序中存在很多相关性:

  1. 数据相关性

    1. WAW:两条指令都写入同一寄存器
    2. WAR:一条指令目的寄存器和他前面某条指令的源寄存器是一样的
    3. RAW:一条指令源寄存器来自他前面某条指令计算结果
  2. 存储器数据相关性
    load和store指令WAW,WAR,RAW

  3. 控制相关性

  4. 结构相关性
    指令必须等到功能器件可以使用的时候才可以执行

数据相关性的三种类型,只有RAW是真相关,其他两种可以通过使用不同寄存器名字解决

1729740268426

所以这两种相关性称为假的相关性:

  1. 有限个数寄存器
  2. 程序中循环体,假如一直向R1写入值,那么就会产生大量WAW
  3. 代码重用,很小的函数被频繁调用,原理同2

总之就是寄存器有限,所以引入寄存器重命名,处理器中实际存在但寄存器个数要多于指令集定义的寄存器,指令集定义的寄存器被称逻辑寄存器,处理器实际存在的称为物理寄存器

1729740664883

上图使用寄存器重命名解决WAW和WAR

寄存器重命名方式

一般有三种:

  1. 将ARF扩展实现重命名
  2. 使用统一的PRF
  3. 使用ROB

一般实现寄存器重命名,要考虑

  1. 什么时候占用寄存器,来自哪里
  2. 什么时候释放,去往何处
  3. 分支预测失败如何解决
  4. 异常如何解决

ROB进行重命名

当一条指令被写入ROB中,ROB的表项就是这个指令目的寄存器对应的寄存器,这样就完成了一条指令目的寄存器重命名过程,如果一条指令离开流水线,另一条指令执行也使用了相同但目的寄存器,那么ARF就不是最新值了,需要重命名映射表指示逻辑寄存器值是位于ROB还是ARF

1729751980699

1729752011724

图7.5中如果一个逻辑寄存器位于ROB,那么重命名映射表也会给出这个逻辑寄存器在ROB的编号,指令计算完毕,存储到ROB对应空间,指令离开流水线将结果从ROB搬出送入ARF,位置变动信息需要更新到重命名映射表,这个方法缺点如下:

1.很多指令无目的寄存器,但仍占用ROB表项

2.对于一条指令,他既可以在ROB取操作数,也可以在ARF取操作数,所以会需要ROB和ARF读端口变多

ARF扩展

1729752909299

很明显,这个是ROB的延伸,原理与ROB类似

使用统一的PRF寄存器

这统一的PRF,没有和指令产生映射关系的都是空闲,会使用free list 记录PRF哪各处于空闲,当一条指令被寄存器重命名,并且存在目的寄存器,他就会占据PRF一个寄存器,这个寄存器会经历值没有被计算,值被计算但没退休,退休三个过程,

1729753351278

图7.7的free list可以使用FIFO实现,所有空闲的物理寄存器编号都位于其中,对于一个4-way,需要每周期最多可以读出4个值,每周期可以退休也是4条,最多有4个空闲编号送入这个free lsit(交叠)

一条指令在寄存器重命名时,需要对源寄存器和目的寄存器都处理,源寄存器查询RAT,目的寄存器就在free list找出一个空闲的,如果空闲列表空了,那么就要暂停重命名及其之前流水线,直到指令有退休的释放

还需要一个退休RAT,每当指令退休,把映射关系写入RAT,外界查找这个寄存器,就可以得到逻辑寄存器对应的物理寄存器

一个物理寄存器被占用后,何时变成空闲?当后边指令不再使用这个物理寄存器就可以,只要最后使用这个物理寄存器的退休,那么就可以变空闲,但不容易获得谁是最后一个使用的指令,所以处理器采用以下方法

1729754030585

当一条指令和之后的指令都写同一个目的寄存器时,后面指令退休,那么前边指令对应的物理寄存器就没作用了,为了实现这个,在ROB除了记录逻辑寄存器对应的物理寄存器,还需要记录之前对应的物理寄存器,以便在退休时,将旧的映射关系释放

重命名映射表

一种是基于SRAM,一种是基于CAM,

1729755104297

SRAM的方法相对比较常用,每次新写入sRAT的值会覆盖原来的值,将原来的写入ROB,但cRAT也不错,他checkpoint只需要保存V位,不需要保存整个cRAT,只需要保存V位,易于状态保存(之前有个疑问是,如果映射关系变了呢,但显然映射关系不会改变)

超标量寄存器重命名

一条指令重命名过程:

  1. RAT找到src1和src2的映射关系
  2. 从Free list找到空闲寄存器(为目的寄存器找映射)
  3. 将之前的映射关系写入ROB,将映射关系写入RAT,这样就完成了重命名

对于超标量4-WAY,需要RAT 至少12R4W

1729757341771

但其实这只是一小部分,还需要解决WAW,WAR,RAW

1729757396874

A指令和B指令存在RAW,那么B的源寄存器的r0应该来自P30,而不是RAT

A,B,D存在WAW,但实际寄存器重命名消除不了相关性:

  1. 写入RAT,只需将一个逻辑寄存器最新映射写入RAT就行,所以一个周期重命名的寄存器,只需将最新的指令映射关系写入即可
  2. 当将旧的映射关系写到ROB,关系不止来自RAT,还来自WAW相关指令

所以需要进行WAW检查

B,D存在WAR,B读r0,D写r0,这个可以消除

所以超标量设计需要考虑RAW和WAW,

解决RAW

1729758083027

如果指令存在RAW相关性,对于R5=R6XR1,源寄存器R1对应的物理寄存器来自第一条指令,所以他的映射关系来自空闲列表读出的P31,而不是RAT读出的P25,其一般性电路如下

1729758231238

解决WAW

主要就是检查写RAT和ROB

写RAT

1729758743463

写ROB

1729758836199

选择写入ROB的旧映射关系是RAT还是freelist得出的值

寄存器重命名恢复

三种一般性恢复方法:

  1. 使用Checkpoint恢复
  2. 使用WALK对RAT恢复
  3. 使用ARCH state对RAT恢复

使用checkpoint

1729759401159

随机访问的一个周期完成,串行的慢一些

使用WALK

可用ROB恢复,每条指令ROB都存了他之前对应的物理寄存器,当分支预测失败,通过这个分支编号将所有相关指令抹除,暂停流水线,从ROB底部开始逐条将每条指令之前对应的映射关系写到RAT,这个过程被称为WALK,这个过程慢,但消耗硬件少

使用Arch state

在统一PRF重命名中,处理器提交阶段也有一个RAT,称为aRAT,这个信息必然正确,当执行阶段发现分支预测失败,不立马进行RAT状态恢复,而是等分支指令变为最旧指令,这时候对sRAT进行恢复,但这个可能会加大分支预测惩罚(,其之前还有load导致的cache缺失)

发射

集中VS分布

总结来说集中式从储量庞大的指令寻找到几条可以执行的指令,这些指令还需要唤醒其他相关指令,增加了选择电路和唤醒电路的面积和延迟,分布式为每一个FU配备一个发射队列,所以每个发射队列容量小,简化选择电路设计,但当一个发射队列满了,即使其他发射队列还有空间,也不能继续向其中写入新的指令,需要暂停流水线,所以现代处理器一般结合两种,使得某种FU共同使用一个发射队列

数据捕捉VS非数据捕捉

数据捕捉:在发射前读取寄存器,这种方式设计重命名后读取寄存器,然后将读取的值写入发射队列,如果有些寄存器没被计算出来,就把编号写入以此供唤醒电路使用,他会被标记为无法获得,之后通过bypass获得值(一般结合ROB重命名)

1729761311665

非数据捕捉:

被重命名的指令不会去读取物理寄存器堆,而是在发射队列被选中后,将读取值送入FU执行,不需要payloadRAM,省空间(一般结合统一PRF)

压缩VS非压缩

1729762087736

1729762098732

压缩队列优点:分配电路简单,选择电路简单,但面积大,功耗大

非压缩:写入空闲位置即可,但空闲位置随机,一个周期找到多个空闲位置也不简单

1729762226487

这种电路:

要实现old-first,需要更复杂的电路

分配电路负载,需要扫描所有空间写入发射队列

发射阶段流水线

简单介绍,之后着重介绍每个流水线要操作的事情

非数据捕捉

1729762789556

数据捕捉

1729762808596

1729762967889

分配

1729763031739

对于4-way,需要每周期找到四个空闲位置,首先扫描空间找到四个空闲表项,为了更快,也可以按照下面的设计

1729763121408

但缺点就是如果一个段有空间,但其他段有空间,仍然无法使用其他的段,而且如果最老的A写入不了指令队列,之后的BCD也写入不了

仲裁

1-of-M仲裁

对于非数据捕捉,要实现old-first需要ROB的帮助,可以通过读取ROB的指针来得出年龄,然后作为指令年龄信息,ROB本质就是FIFO,如果直接使用指针,无法得出正确值,需要加入一个指针位,具体就是下面情况

1729766055564

这样就实现了old-first基础,分发阶段,将重命名之后的指令写入ROB和发射队列,然后一并将地址信息写入发射队列,然后就有年龄信息了,就可以仲裁选择最老指令,实际要考虑以下情况:

如何屏蔽发射队列没准备好的指令?

将其年龄信息设置为无限大,但无限大不现实,可以采用下面方法,当两个ready为1,进行年龄比较,只有一个ready为1,选择ready为1的那条

1729767391050

如何根据年龄值找到指令呢

1729767561728

N-of-M仲裁

1729768263657

这种方案整个延迟就是1-of-M,但无法实现理想功能,如下图,本周期有五条准备好的指令,每个FU对应的仲裁电路只能选择一个属于他的指令,所以本周期只能选择三条,可以增加FU数量,但又引入该分配到那个ALU,一种实现就是轮换分配,本周期ALU0,下周期ALU1

1729768400078

唤醒

单周期唤醒

唤醒电路如下,

1729768916559

1729769259348

1729769416310

单周期指令唤醒主要:

  1. 被仲裁电路选中的指令将目的寄存器送入对应总线
  2. 总线跟发射队列所有源寄存器对比,如果相等,将这个源寄存器设置为准备好
  3. 当发射队列指令所有操作数准备好了,并且没有向仲裁电路发过请求,他就可以发送请求
  4. 如果有更高优先级发出了请求信号,则这条指令不会得到响应,等下个周棋继续发,如果已经选中,就会标记为ISSUED,表示已经选中,但不离开
  5. 发射队列指令收到响应信号,将目的寄存器送入对应总线,唤醒其他指令

多周期唤醒

唤醒其实就是将被选中的指令的目的寄存器编号送到总线上,然后将总线值与源寄存器对比,所以要将唤醒延迟,就有两种方法:

延迟唤醒和延迟广播,

延迟广播如下图,如果发现被仲裁电路选中指令执行数大于1(N),就延迟N-1周期,然后唤醒

1729771089390

但这可能会导致其他指令也使用这个总线,导致冲突

1729771292722

可以增加tag bus解决,但情况多的时候,需要大量bus,如果不加总线,就要记录每个指令执行周期,后续被选中的指令如果发现冲突,不会送入FU

1729771352672

上图做法访问表格和仲裁同时工作,即使B被仲裁选中,也可能被表格给毙了,然后下个周期参加仲裁,接下来讲延迟唤醒,被仲裁指令会正常的将目的寄存器送往总线,但比较相等的寄存器不一定马上置为准备好的状态,而是根据这条指令执行周期,进行相应的延迟,然后再改变发射队列的源寄存器状态,这种方法不会出现总线竞争,如下图,延迟唤醒本质就是在ready位前➕延迟寄存器,其中一种实现如下:

采用移位寄存器,解码阶段可得知指令执行周期数,将执行周期进行编码,称为DELAY,然后每条存在的目的寄存器分配一个物理寄存器,每个物理寄存器含有一个DELAY值,每次写发射队列,这个DELAY也会写入

1729776477690

1729776856037

1729776942292

推测唤醒

主要针对执行周期不确定的,如load和一些可以early out的CPU,一种最简单的方法就是等指令执行完再进行其他指令唤醒,Cache命中率一般较高,所以可以先推测load指令命中,这样就可以按照下面的方式执行

1729777336923

但一旦A缺失,B就不能通过旁路网络获得操作数,也就无法在FU中执行,而且B还不能停,一停下来FU无法接受其他指令了,最好的方法就是把B重新放回发射队列,等到从L2得到数据,再对其进行唤醒

假设load缺失,就要进行恢复,此时被唤醒的寄存器重新变为未准备好,还有的已经出去发射队列了,需要在流水线抹掉,然后放入发射队列,然后load预测L2周期唤醒

不同结构的DCache对于推测唤醒的过程是有影响的,假设流水线结构如下,load电路被仲裁电路选中后,需要等待两个周期才可以唤醒,这两个周期可以放一些无相关性的指令称为IW,然后后面两个周期是load从唤醒其他指令到检测到自身是否缺失,而且SW窗口的指令不一定和load有相关性,IW也不一定和load无关,因为他可能是其他load的SW

1729778055320

下图就是Load1和Load3的IW窗口重合了,这两个周期被仲裁电路选中的指令未必可以获得操作数,取决于load1是否命中,当发现缺失,load的SW窗口指令就要被抹除,被抹除的指令会重新回到发射队列

1729778268240

不同DCache结构的IW和SW结构不同,但处理方式相似

这种方法主要两种:

Issue Queue Based Replay

这种指令被选中后不会立马离开发射队列,只有等指令被确定正确执行,才可以离开发射队列,这也就是之前的issued位

前文介绍的load指令的DCache缺失只是replay的一种情况,比如DCache的bank冲突,store、load违例,load、load违例

,只有指令执行正确才可以离开,对于load、store完全乱序,即使load命中,甚至load执行完毕,但只要没有离开流水线,就有可能被送回去,而部分乱序和完全顺序则不会出现这个问题,此时只有和load相关的才会被replay,跟精确说就是SW内的指令才可能被replay,这种方法缺点就是造成发射队列的可用容量减少,所以要进行tradeoff

可以只对load指令采取基于发射队列的replay,一旦load指令在执行阶段得到了DCache的结果,就可以将发射队列相关指令释放,但对于store、load违例等,如果发生,那些被刷去的指令可能回不去发射队列(满了),这时,可以将需要replay指令抹除,重新取指令

load的replay仍然需要replay:

一种方法就是将所有在load指令之后被仲裁电路选中的指令全部放回发射队列。这种方法不会发生错误,但可以改进,比如IW内指令与该load无关,SW有的也无关,所以可以non-selective REPLAY,他是无差别选指令

1729779388679

假设DATA阶段,得到DCache是否命中,那么如果没有命中,将Data和wake-up阶段全部抹除,但只有OR和SUB和load有相关性,改进:IW窗口中cal和TLB阶段不需要抹除,只需抹除SW窗口的,将所有和load有相关性的放回发射队列置为not ready,其他不用关心,他们仍然准备好了,可在下周其参与仲裁,这要求可以识别相关性,

一是直接相关性,也即是OR,二是间接相关性,也即是SUB,可以采用一个load-vector来实现这个位数表示流水线最多可以存在几条指令

1729779730582

每个物理寄存器都有一个这个,非load存在目的寄存器,他的向量值就是两个源寄存器向量值异或的结果,load指令,除了来自源寄存器,他还会占用向量一个新的位,这样可以得到目的寄存器的向量值,更新到表格,指令被重命名,就可以将每个源寄存器的向量值写入发射队列,每当laod缺失,就根据这条指令在向量中对应位,找出有相关性的指令,这个适合流水线浅的,深的话需向量宽度增加,不然经常暂停

load指令只有在SW窗口的才和他有相关性,为了识别相关性,使用一个5位的值,表示load位于哪一位值,称为LPV,每当load被选中,LPV为10000,表示load处于select,这个周期进行唤醒(延迟唤醒),然后所有比较结果相等的都会获得这个值,每个周期,发射队列LPV右移一位

为了识别相关性,每条指令被选中后,唤醒其他指令都会讲LPV赋值给被唤醒的寄存器,这样就可以识别那个和load有相关性,当load到data阶段发现缺失,直接将LPV最低位为1的送入发射队列

1729780555348

1729780570220

1729780601171

注意上图需要注意:AND指令的LPV是两个源寄存器或

如果直接清除SW,然后选择性清除LPV最低为1的指令,就是non-selective,如果SW窗口的也选择性清除LPV最低为1,则是selective

这个方法适合数据捕捉,因为replay指令少

Replay Queue Based Replay

1729782039186

指令被选中,立刻离开发射队列,进入RQ,当一个指令退休时,就可以将RQ指令退出,如果发生访存违例,抹去指令,将RQ相关指令唤醒并向仲裁电路发出申请,而且RQ优先级高,这样可以将replay指令重新送回FU执行(他们更老)适合非数据捕捉,S-》E时间长,replay指令多

执行

执行阶段最主要的就是旁路网络设计和存储指令加速

旁路网络

当处理器只有两个FU,其旁路网络如下

1729822603681

但当FU个数较多,就会出现下面情况

1729822701469

而且会出现,乘法32个周期,但逻辑操作只需要1个周期,那么就是可能会在一个周期同时想用这个旁路网,导致冲突,也就是下面情况

1729822820249

这种情况该如何处理呢,最简单的方法就是按照推测唤醒讲解的思路,先假设不会冲突,然后按正常流程唤醒和仲裁,一条指令到FU,首先检查FU是否空闲,比如FU接受了latency=3的,本周期就不能接收2的,如果本周期是2的就要返回到IQ

假设一个仲裁电路对应的FU可以计算3种类型的指令,延迟分别为1,2,3

则:1729824235650

在发射队列每个表项增加两个信号,分别指示它其中的指令latency的值是1还是2

1729824249996

这种设计可以执行延迟为1,2,3的三种指令,但只需要对延迟为1,2的指令请求处理:

  1. 本周期选中延迟为3的,下周期无法选择延迟为2的,下下周期无法选择延迟为1的,所以下周期需要将仲裁电路对应的寄存器设置为10,这个寄存器每周期向右移位,下下周期为01,表示不允许执行周期为1的进入,如果本周期选择延迟3的下周起还选择延迟3的,那么

1729824793132

  1. 本周期选择的是2的,下周起不允许选择延迟为1的,在下周期设置控制寄存器为01
  2. 本周期选择的1的,只需设置控制寄存器为00

复杂设计的旁路网络

这种流水线如下图

1729825343021

旁路网络如下图

1729825375607

下图更直观

1729825603709

下图情况是两条指令相邻和相差一个周期

1729825805188

当相差两个周期时,这个阶段只能在Write back到Source Drive

1729825891282

实际上,并不需要所有FU设置旁路网络,比如load的AGU和普通指令的,AGU会使用ALU结果,但ALU不会使用AGU结果,所以旁路只需要ALU到AGU,而store都不需要旁路

操作数选择

使用scoreboard

1729838363954

记录了两个内容:

#FU:从哪个FU计算出来

R:物理寄存器已经计算出来并且写入PRF

在流水线加入这个scoreboard

1729838485740

这个是将读取scoreboard放在执行阶段,如果对频率要求高可能不太行,可以放在读寄存器阶段

1729838640102

这种方法虽然减少对于处理器时间影响,但却引入了新的问题,

1729839438577

指令C本应该从PRF得出结果,但可看出,此时scoreboard还没更新,所以会认为需要从旁路网络获得操作数,导致出错,这时就需要在scoreboard加上检测模块,主要就是当读取和写入的寄存器一样,就将控制信号设置为从PRF获得操作数,这个随着流水线复杂度增高,会越来越复杂

一种更简单的方法就是在FU计算出一条指令结果,送入旁路网络同时,对目的寄存器进行广播,此时可以在每个FU输入添加比较电路,将比较结果作为控制信号

1729839782176

Cluster

这种东西的诞生就是因为流水线越来越复杂,多端口器件导致处理器面积功耗显著增大

Cluster IQ

采用这种结构优点:

减少每个队列的端口数

每个队列的仲裁电路只需要从少量指令仲裁,仲裁电路小

而且由于容量小,所以唤醒快

但缺点就是被选中的指令唤醒其他IQ需要更长时间,需要额外的周期

1729840337794

这种数据捕捉的方式就需要研究指令分配问题

Cluster Bypass

集中式的旁路网络复杂度很高,所以需要分布式

1729840697257

但这个有些缺点:就是两条相关指令在同一个FU,就可以背靠背执行,但如果不在同一个,那么得等一个周期,由于复杂度降低,所以Source Drive和Result Drive可以去掉

1729840833894

如果两指令在同一个Cluster,那么就可以背靠背执行,如果不在同一个Cluster,那么只能通过寄存器来读,需要间隔一个周期

存储器指令加速

Memory Disambiguation

完全顺序执行

最保守,但性能不保证

部分乱序

store按照顺序执行,但处于store之间的load可以乱序,当store被仲裁选中,他后面的load就可以参与仲裁了(直到下一个store),使得load尽可能先执行

,这种方法的本质就是当store指令所携带的地址被计算出来后,之后的load就可以判断RAW了,每个load将地址与store比较,这个需要Store Buffer保存这些已经被仲裁电路选中,但没有退出流水线的store指令,如果load发现地址相等的store,直接从缓存取出数据,如果没有发现地址相等指令,load需要访问DCache

这种方法的store就像一扇门,store地址计算出或者被仲裁电路选中,就可以打开门

1729841881400

这种执行会出现当B,D被仲裁选中,store buffer除了前面的A,还有了他们后面的E,当B或D与E地址相等他们也没有RAW相关性,所以需要知道哪些store在自己前面,哪些在自己后面因此需要对load和store标号:

  1. PC值,但store之后有一个向前跳指令,这个就不好判断了

1729842269918

  1. ROB编号,这个可以,但ROB只有一部分是load,不需要全部加编号,浪费面积
  2. 解码阶段,为每个load和store分配编号,类似于分支指令

完全乱序

store按照制定顺序,但load乱序执行,只要load操作数准备好,就可以出去,这两个类型指令可以共用一个IQ,也可以分开,共用一个的话,需要相互独立的仲裁电路,store要in-order的,load要采用old-first,按照乱序方式选择

两个也可以使用不同的IQ,这样store只用FIFO结构

完全乱序执行load,load指令操作数只要准备好了,就可以参与仲裁,这样可以尽快唤醒其他指令

1729843311894

上图是发生了store/load违例,需要预测这种情况,这种算法称为LOAD/STORE相关性预测,一旦发现LOAD和之前的store存在RAW,就记录下来,之后再次遇到就不能让他提前执行

非阻塞Cache

1729843737898

要支持非阻塞Cache,需要使用一个MSHR

1729843794779

1729843819581

而load/stroe table有五项内容:

  1. V:表示一个表项是否被占用,无论首次缺失还是再次缺失

  2. MSHR entry,表示一条缺失的指令属于MSHR哪个表项,可能许多产生缺失位于同一line,他们在MSHR占用一个表项,但在这个表占用不同表项

  3. dest.reg:对于load,这部分记录目的寄存器的编号,对于store,存储store在Store Buffer的编号

    1729844117837

  4. Type:记录是SW,LW,SB等

  5. offset:在数据块位置,64位就是6位

发生缺失时,首先查找MSHR本体:

如果有相同表项,表示这个缺失正在处理,只需将这次缺失写入LOAD/store Table

如果没发现,则还需写入MSHR

如果这两个表任意一个满了,就暂停流水线

需要注意的是,在超标量中,由于乱序,导致有些load位于分支预测失败路径,这样从下级存储得出的数据就不能写入目的寄存器

关键字优先

提前开始

提交

主要就是ROB

一种一般结构

1729844989157

如果需要为分支预测设置编号,这里面还会有编号信息

这个看书上例子,大概就可以

1.介绍

The goal of this chapter is to explain enough about coherence to understand how consistency models interact with coherent caches, but not to explore specific coherence protocols or implementations, which are topics we defer until the second portion of this primer in Chapters 6–9.

MEMORY CONSISTENCY

内存一致性根据load和sw定义了正确的共享内存行为,并不涉及缓存一致性

假设一门课最初在152教室,开学前一天,教务处把他变为252,干这个事情的人收到消息,准备向学生发布信息,检查更新的时间表,比如网站管理员太忙了,没立即发布新教室,一个同学收到短信立即去检查教师,这时候仍然是152,尽管网上的时间表最终被更新到252号房间,注册员也按照正确的顺序写了“写”,但这位勤奋的学生却按照不同的顺序观察,因此走错了房间。内存一致性模型就是定义这种行为是否正确

we need to define shared memory correctness—that is, which shared memory behaviors are allowed—so that programmers know what to expect and implementors know the limits to
what they can provide

共享内存正确性由内存一致性模型指定,或者更简单地说,由内存模型指定。内存模型指定使用共享内存执行的多线程程序所允许的行为。对于使用特定输入数据执行的多线程程序,内存模型指定动态加载可能返回的值以及内存可能的最终状态。与单线程执行不同,通常允许多个正确的行为,这使得理解内存一致性模型变得微妙。

第3章介绍了内存一致性模型的概念,并介绍了最强大、最直观的一致性模型——顺序一致性(SC)。本章首先阐述了指定共享内存行为的必要性,并精确定义了什么是内存一致性模型。接下来将深入研究直观的SC模型,该模型指出,多线程执行应该看起来像每个组成线程的顺序执行的交错,就好像线程在单核处理器上进行时间复用一样。除了这种直觉之外,本章还将SC形式化,并以简单而积极的方式探索实现SC的一致性,最后以MIPS R10000案例研究告终

在第4章中,专注于x86和SPARC系统实现的内存一致性模型。这种一致性模型称为总存储顺序(TSO),其动机是希望在将结果写入缓存之前,使用先入先出的写缓冲区来保存已提交存储的结果。这种优化违反了SC,但保证了足够的性能优势,从而激发了架构来定义TSO,从而允许这种优化。在本章中,我们将展示如何从我们的SC形式化中形式化TSO, TSO如何影响实现,以及SC和TSO如何比较

最后,第5章介绍了“宽松”或“弱”内存一致性模型。如果一个线程更新10个数据项,然后更新一个同步标志,程序员通常不关心数据项是否按顺序更新,而只关心在更新标志之前更新所有数据项。宽松模型试图捕捉这种增加的排序灵活性,以获得更高的性能或更简单的实现。

CACHE COHERENCE

如果多个参与者(例如,多个核心)访问数据的多个副本(例如,在多个缓存中),并且至少有一次访问是写操作,则可能出现一致性问题。考虑一个类似于内存一致性示例的示例。学生查看在线课程表,发现计算机体系结构课程在152教室(读取数据),并将此信息复制到她的笔记本(缓存数据)。随后,大学注册官决定将班级移至252室,并更新在线时间表(写入数据)。学生的数据副本现在已经过期了。

如果她去152号房,她会找不到她的班级。不连贯的例子来自计算世界,但不包括计算机体系结构,包括陈旧的web缓存和程序员使用未更新的代码库。

使用一致性协议可以防止对陈旧数据(不一致性)的访问,一致性协议是由系统内的分布式参与者集实现的一组规则。相干协议有许多变体,但遵循几个主题

第6章介绍了缓存一致性协议的总体情况,并为后续章节的具体一致性协议奠定了基础。本章涵盖了大多数相干协议共享的问题,包括缓存控制器和内存控制器的分布式操作以及常见的MOESI相干状态:修改(M)、拥有(O)、独占(E)、共享(S)和无效(I)。重要的是,本章还介绍了我们的表驱动方法,用于呈现具有稳定(例如MOESI)和瞬态相干状态的协议

Transient states are required in real implementations because modern systems rarely permit atomic transitions from one stable state to another (e.g., a read miss in state Invalid will spend some time waiting for a data response before it can enter state Shared).

第7章涵盖snoop缓存一致性协议,直到最近才主导商业市场。在手波级别,窥探协议很简单。当缓存丢失发生时,内核的缓存控制器将为共享总线进行仲裁并广播其请求。共享总线确保所有控制器以相同的顺序观察所有请求,因此所有控制器可以协调它们各自的分布式操作,以确保它们保持全局一致的状态。

然而,窥探变得复杂,因为系统可能使用多个总线,而现代总线不能自动处理请求。现代总线具有仲裁队列,并且可以发送单播、管道延迟或乱序的响应。所有这些特征导致了更多的瞬态一致态。第七章总结了Sun UltraEnterprise E10000和IBM Power5的案例研究

第8章深入研究了目录缓存一致性协议,缓存丢失请求来自下一级缓存(或内存)控制器的内存位置,下一级缓存(或内存)控制器维护一个目录,该目录跟踪哪些缓存保存哪些位置。根据所请求内存位置的目录条目,控制器向请求者发送响应消息,或将请求消息转发给当前缓存内存位置的一个或多个参与者。每个消息通常有一个目的地(即,没有广播或多播),但是瞬态相干状态大量存在,因为从一个稳定相干状态转换到另一个稳定相干状态可以生成许多与系统中参与者数量成比例的消息。

第9章深入研究更复杂的系统模型和优化,重点关注窥探和目录协议中常见的问题。最初的主题包括处理指令缓存、多级缓存、透写缓存、(tlb)、(DMA)、虚拟缓存

Coherence Basics

本书用的模型

1730445518843

不一致什么时候发生

1730445675692

一致性协议做的就是在Time3让core2观察到的数据是core1的数据,而不是来自内存的老数据

DEFINING COHERENCE

我们首选的相干定义的基础是单写入多读取(SWMR)不变量。对于任何给定的内存位置,在任何给定的时刻,要么有一个核心可以写它(也可以读它),要么有一些核心可以读它。因此,永远不会有这样的情况:一个内核可以写入给定的内存位置,而其他内核可以同时读取或写入该位置。查看此定义的另一种方式是考虑,对于每个内存位置,内存位置的生命周期被划分为多个epoch。在每个epoch中,要么单个内核具有读写访问权限,要么若干内核(可能为零)具有只读访问权限。图2.3展示了一个示例内存位置的生命周期,它分为四个epoch,以保持SWMR不变性。

除了SWMR不变量外,一致性还要求给定内存位置的值被正确传播。为了解释为什么值很重要,让我们重新考虑图2.3中的例子。即使SWMR不变式成立,如果在第一个只读epoch, core 2和core 5可以读取不同的值,那么系统是不一致的。同样,core1没有读到core3写入的最新数据,这种情况也是不一致

1730446001109

Coherence invariants

  1. Single-Writer, Multiple-Read (SWMR) Invariant. For any memory location A, at any
    given (logical) time, there exists only a single core that may write to A (and can also read it)
    or some number of cores that may only read A.
  2. Data-Value Invariant. The value of the memory location at the start of an epoch is the same
    as the value of the memory location at the end of its last read–write epoch.

注意:第二句话的意思就是core1读的数据应该来自core3写的数据的位置,缓存一致性是架构不可见的

Memory Consistency Motivation and Sequential Consistency

PROBLEMS WITH SHARED MEMORY BEHAVIOR

首先从一个例子开始:大多数程序员会期望内核C2的寄存器r2应该得到值NEW。然而,在今天的一些计算机系统中,r2可以是0。硬件可以通过对核心C1的存储S1和S2重新排序,使r2的值为0。在本地(即,如果我们只看C1的执行而不考虑与其他线程的交互),这种重新排序似乎是正确的,因为S1和S2访问不同的地址。

1730446984705

1730447217006

还有一个例子

1730447306750

大家期待的结果:

1730447367289

实际两者都可以为0

内存一致性模型,或者更简单地说,内存模型,是对使用共享内存执行的多线程程序所允许的行为的规范。对于使用特定输入数据执行的多线程程序,它指定了动态加载可能返回的值以及内存的最终状态。与单线程执行不同,通常允许多个正确的行为,正如我们将看到的顺序一致性

CONSISTENCY VS. COHERENCE

缓存一致性不等于内存一致性。

内存一致性实现可以使用缓存一致性作为有用的“黑盒”。

BASIC IDEA OF SEQUENTIAL CONSISTENCY (SC)

Lamport首先将单处理器(核心)称为顺序处理器,如果“执行的结果与按照程序指定的顺序执行的操作相同”。然后,如果“任何执行的结果都与所有处理器(核心)的操作以某种顺序执行的结果相同,并且每个处理器(核心)的操作按照其程序指定的顺序出现在该顺序中”,他称之为多处理器顺序一致性。这种操作的总顺序称为内存顺序。在SC中,内存顺序尊重每个核心的程序顺序,但其他一致性模型可能允许内存顺序并不总是尊重程序顺序。

1730448867624

上图p表示程序顺序,m表示内存顺序,op1 <m op2表示内存顺序中OP1先于OP2,op1<p op2表示在给定的核心,op1程序顺序先于op2,在SC模型中,内存顺序尊重程序顺序,尊重”意味着op1 <p op2意味着op1 <m op2

1730449179860

A LITTLE SC FORMALISM

我们采用Weaver和Germond[17(附录D)]的形式:L(a)和S(a)分别表示加载和存储,以解决a的问题。命令<p和<m分别定义程序和全局内存顺序。程序顺序<p是每个核的总顺序,它捕获每个核在逻辑上(顺序)的顺序。全局内存顺序<m是所有内核内存操作的总顺序

An SC execution requires:All cores insert their loads and stores into the order <m respecting their program order,
regardless of whether they are to the same or different addresses (i.e., a=b or a≠b). There are four
cases:

1730449461351

Every load gets its value from the last store before it (in global memory order) to the same
address:
Value of L(a) = Value of MAX <m {S(a) | S(a) <m L(a)}, where MAX <m denotes “latest in
memory order.”

也即是每个load的值来自全局顺序最近相同地址的数据

1730449652267

“X”表示这些操作必须按程序顺序执行。

NAIVE SC IMPLEMENTATIONS

两种方法

The Multitasking Uniprocessor

首先,可以通过在单个顺序核心(单处理器)上执行所有线程来实现多线程用户级软件的SC。线程T1的指令在核心C1上执行,直到上下文切换到线程T2,等等。在上下文切换时,任何挂起的内存操作必须在切换到新线程之前完成。检查显示所有SC规则都得到遵守。

The Switch

其次,可以使用一组核心Ci、单个开关和内存来实现SC,如图3.3所示。假设每个核心按其程序顺序一次向交换机提供一个内存操作。每个核心可以使用任何不影响其向交换机呈现内存操作的顺序的优化。

1730457312942

这些实现性能随着核心增加不一定会增加

A BASIC SC IMPLEMENTATION WITH CACHE COHERENCE

缓存一致性使SC实现可以完全并行地执行无冲突的加载和存储(如果两个操作位于相同的地址,并且至少其中一个是存储,则两个操作会发生冲突)。此外,创建这样一个系统在概念上很简单。

在这里,我们将相干性主要视为实现第2章的单写多读(SWMR)不变量的黑盒。我们通过稍微打开相干块框来展示简单的一级(L1)缓存来提供一些实现直觉:

使用状态修改(M)来表示一个内核可以写入和读取的L1块,使用状态共享(S)来表示一个或多个内核只能读取的L1块,并使用GetM和GetS分别表示在M和S中获取块的一致性请求。

1730457798027

图3.4(b)稍微“打开”内存系统黑盒子,显示每个核心连接到自己的L1缓存(我们将在后面讨论多线程)。如果B具有适当的一致性权限(状态M for store或S for load),则存储系统可以响应加载或存储到块B

此外,如果相应的L1缓存具有适当的权限,则内存系统可以并行响应来自不同内核的请求。

例如,图3.5(a)描述了四个内核各自寻求执行内存操作之前的缓存状态。这四个操作不冲突,可以由它们各自的L1缓存来满足,因此可以并发执行。如图3.5(b)所示,我们可以任意排序这些操作以获得合法的SC执行模型。更一般地说,可以由L1缓存满足的操作总是可以并发地完成,因为coherence的单写多读不变性确保了它们不冲突。

1730457922381

OPTIMIZED SC IMPLEMENTATIONS WITH CACHE COHERENCE

大多数真正的核心实现都比我们的基本SC实现要复杂得多。内核采用预取、推测执行和多线程等特性来提高性能和容忍内存访问延迟。这些特性与内存接口交互,现在我们讨论这些特性如何影响SC的实现

Non-Binding Prefetching

块B的非绑定预取是向一致性内存系统请求更改块B在一个或多个缓存中的一致性状态,最常见的是,预取是由软件、核心硬件或缓存硬件请求的,以改变一级缓存中B的状态,以允许load(例如,B的状态是M或S)或通过发出一致性请求(例如GetS和GetM)来load和store(B的状态是M)

Importantly, in no case does a non-binding prefetch change the state of a register or data in block B.

非绑定预取的效果仅限于图3.4中的“cache-coherent memory system”块内,使得非绑定预取对内存产生影响等同于无操作,只要加载和存储是按程序顺序执行的,以什么顺序获得一致性权限并不重要

可以在不影响内存一致性模型的情况下进行非绑定预取。这对于内部缓存预取(例如,流缓冲区)和更激进的内核都很有用。

Non-Binding Prefetching:预取来的数据单独放一个空间,

Speculative Cores

考虑一个按程序顺序执行指令的核心,但也执行分支预测,其中后续指令(包括加载和存储)开始执行,但可能会因分支错误预测而被丢弃(即,使其效果无效)。这些丢弃的加载和存储可以看起来像非绑定预取,使这种推测是正确的,因为它对 SC 没有影响。分支预测之后的加载可以呈现给 L1 缓存,其中它要么未命中(导致非绑定 GetS 预取)要么命中,然后将值返回到寄存器。如果加载被丢弃,核心会丢弃寄存器更新,从而消除加载的任何功能影响——就好像它从未发生过一样。缓存不会撤消非绑定预取,因为这样做不是必需的,并且如果重新执行加载,预取块可以提高性能。对于store,核心可能会提前发出非绑定 GetM 预取,但它不会将store呈现给缓存,直到store被保证提交。(store buffer)

感觉说的意思就是分支预测错误路径的load发出请求,可以类似于预取

Dynamically Scheduled Cores

也就是乱序执行的CPU核,

然而,在多核处理器的上下文中,动态调度引入了一个新的问题:内存 consistency 的推测。考虑一个希望动态重新排序两个加载指令 L1 和 L2 的核心(例如,因为在计算 L1 地址之前计算 L2 地址)。许多核心将会在 L1 之前推测性地执行 L2 ,它们预测这种重新排序对其他核心不可见,这将违反 SC。

对 SC 的推测需要核心验证预测是否正确。 Gharachorloo 等人 [8] 提出了两种执行此检查的技术。首先,在核心推测性地执行 L2 之后,但在它提交 L2 之前,核心可以检查推测性访问的块是否没有离开缓存。只要块保留在缓存中,它的值就不会在加载执行和提交之间发生变化。为了执行此检查,核心跟踪 L2 加载的地址,并将其与被驱逐的块和传入的 coherence 请求进行比较。传入的 GetM 表明另一个核心可以观察 L2 乱序,并且此 GetM 将暗示错误推测并丢弃推测执行。

第二种检查技术是在核心准备好提交加载时重放每个推测性加载(注2)[2, 17]。如果提交时加载的值不等于先前推测加载的值,则预测不正确。在示例中,如果重放的 L2 加载值与 L2 的原始加载值不同,则加载-加载重新排序导致执行明显不同,推测执行必须被丢弃。

Non-Binding Prefetching in Dynamically Scheduled Cores

动态调度的核心可能会遇到程序顺序不正确的加载和存储缺失。例如,假设程序顺序是加载 A,存储 B,然后存储 C。核心可能会“乱序”启动非绑定预取,例如,首先是 GetM C,然后是并行的 GetS A 和 GetM B。 SC 不受非绑定预取顺序的影响。 SC 只要求核心的加载和存储(看起来)按程序顺序访问其一级缓存。Coherence 要求一级缓存块处于适当的状态以接收加载和存储。

重要的是,SC(或任何其他内存 consistency 模型):

  • 规定加载和存储(出现)应用于 coherent 内存的顺序,但不规定 coherence 活动的顺序。

Multithreading

SC 实现可以容纳多线程——粗粒度、细粒度或同时的。 每个多线程核心应该在逻辑上等同于多个(虚拟)核心,它们通过一个交换机共享每个一级缓存,其中缓存选择接下来要服务的虚拟核心。 此外,每个缓存实际上可以同时服务多个非冲突请求,因为它可以假装它们是按某种顺序服务的。 一个挑战是确保线程 T1 在存储对其他核心上的线程“可见”之前无法读取同一核心上另一个线程 T2 写入的值。 因此,虽然线程 T1 可以在线程 T2 以内存顺序插入存储后立即读取该值(例如,通过将其写入状态 M 的缓存块),但它无法从处理器核心中的共享加载存储队列中读取该值。

TSO

继续考虑这个例子,

1730461038218

假设多核处理器具有顺序核心,其中每个核心都有一个单入口写入缓冲区并按以下顺序执行代码。

  1. 核心 C1 执行存储 S1,但在其写缓冲区中缓冲新存储的 NEW 值。
  2. 同样,核心 C2 执行存储 S2 并将新存储的 NEW 值保存在其写缓冲区中。
  3. 接下来,两个核心执行各自的加载 L1 和 L2,并获得旧值 0。
  4. 最后,两个核心的写缓冲区使用新存储的值 NEW 更新内存。

最后的结果就是(0,0)正如我们在上一章中看到的,这是 SC 禁止的执行结果。没有写缓冲区,硬件就是 SC,但有了写缓冲区,它就不是了,这使得写缓冲区在多核处理器中在架构上是可见的。

4.2 TSO/X86 的基本思想

1730374094456

本地的store和load需要按照顺序,但全局不需要按照顺序

1730461402725

程序员(或编译器)可以通过在核心 C1 上的 S1 和 L1 之间以及核心 C2 上的 S2 和 L2 之间插入 FENCE 指令来阻止图 4.2d 中的执行。在核心 Ci 上执行 FENCE 可确保 Ci 上 FENCE 之前的内存操作(按程序顺序)按照内存顺序放置在 Ci 上 FENCE 之后的内存操作之前。使用 TSO 的程序员很少使用 FENCE(又名内存屏障),因为 TSO 对大多数程序“做正确的事”。尽管如此,FENCE 在下一章讨论的宽松模型中发挥着重要作用。

TSO 确实允许一些非直观的执行结果。表 4.3 说明了表 4.1 中程序的修改版本,其中核心 C1 和 C2 分别制作 x 和 y 的本地副本。许多程序员可能假设如果 r2 和 r4 都等于 0,那么 r1 和 r3 也应该为 0,因为存储 S1 和 S2 必须在加载 L2 和 L4 之后插入到内存顺序中。然而,图 4.3 展示了一个执行,显示 r1 和 r3 绕过每个核心写入缓冲区中的值 NEW。事实上,为了保持单线程顺序语义

,每个核心都必须按照程序顺序看到自己存储的效果,即使存储还没有被其他核心观察到。因此,在所有 TSO 执行下,本地副本 r1 和 r3 将始终设置为 NEW 值。

1730461478140

Relaxed Memory Consistency

本文介绍RVWMO模型

1730374321974

在RVWMO下,从同一hart中的其他内存指令的角度来看,在单个hart上运行的代码似乎是按顺序执行的,但是来自另一个hart的内存指令可能会观察到来自第一个hart的内存指令以不同的顺序执行。因此,多线程代码可能需要显式的同步,以保证不同内存指令之间的顺序。基本的RISC-V ISA为这个目的提供了一个FENCE指令,在2.7节中描述,而原子扩展“a”额外定义了load-reserved/storeconditional和原子读-修改-写指令。

17.1. Definition of the RVWMO Memory Model

1730471256394

17.1.1. Memory Model Primitives

注:ac表示指令顺序后的指令不能先于这条指令执行,rl表示指令顺序前的指令不能后于这条指令

如下图,注:LDAR代表load具有acquire语义,STLR代表store具有release语义。

img

img

内存操作的程序顺序反映了生成每次加载和存储的指令在该处理器的动态指令流中逻辑布局的顺序;即,一个简单的有序处理器执行该处理器指令的顺序。

不对齐的加载或存储指令可以分解为一组任意粒度的组件内存操作。XLEN<64的FLD或FSD指令也可以分解为一组任意粒度的组件内存操作。由这些指令产生的存储器操作并不按照程序顺序彼此排序,但是它们通常按照程序顺序按照前面和后面的指令产生的存储器操作进行排序。原子扩展“A”根本不需要执行环境来支持不对齐的原子指令。但是,如果通过不对齐的原子性颗粒PMA支持不对齐的原子,则原子性颗粒中的AMOs不会被分解,基本isa中也不会定义负载和存储,也不会在F、D和Q扩展中定义不超过XLEN位的负载和存储。

The decomposition of misaligned memory operations down to byte granularity facilitates emulation on implementations that do not natively support misaligned accesses. Such implementations might, for example, simply iterate over the bytes of a misaligned access one by one.

如果LR指令在程序顺序上先于SC指令,并且中间没有其他LR或SC指令,则称LR指令和SC指令成对;相应的内存操作也被称为成对的(除了SC失败的情况,没有生成存储操作)。第14.2节定义了决定SC是否必须成功、可能成功或必须失败的完整条件列表。

Load和store操作也可以携带一个或多个顺序注释,这些注释来自以下集合:“acquire-RCpc”、“acquire-RCsc”、“release-RCpc”和“release-RCsc”。带有aq集的AMO或LR指令具有“acquire-RCsc”注释。带有rl集的AMO或SC指令有一个“release-RCsc”注释。具有aq和rl集的AMO、LR或SC指令同时具有“acquire-RCsc”和“release-RCsc”注释。

For convenience, we use the term “acquire annotation” to refer to an acquire-RCpc annotation or an acquire-RCsc annotation. Likewise, a “release annotation” refers to a release-RCpc annotation or a release-RCsc annotation. An “RCpc annotation” refers to an acquire-RCpc annotation or a releaseRCpc annotation. An RCsc annotation refers to an acquire-RCsc annotation or a release-RCsc annotation.

在内存模型文献中,术语“RCpc”表示与处理器一致的同步操作的释放一致性,术语“RCsc”表示与顺序一致的同步操作的释放一致性。

尽管有许多不同的获取和发布注释的定义,RCpc注释目前只在隐式分配给每个标准扩展“Ztso”的内存访问时使用(第18章)。此外,尽管ISA目前不包含本机加载获取或存储释放指令,也不包含其RCpc变体,但RVWMO模型本身被设计为向前兼容,以便在将来的扩展中将上述任何或全部添加到ISA中。

RCpc语义”代表“Acquire-RCpc”或“Release-RCpc”。“RCsc语义”代表“Acquire-RCsc”或“Release-RCsc”。Load(store)可以携带任何一种ACQUIRE(RELEASE)语义,而RMW只能携带RCsc语义。这些语义有如下保序:

ACQUIRE -> Load,Store (ACQUIRE代表ACQUIRE-RCsc和ACQUIRE-RCpc)

Load,Store -> RELEASE (RELEASE 代表RELEASE-RCsc和RELEASE-RCpc)

RELEASE-RCsc -> ACQUIRE-RCsc (注意RELEASE-RCpc -> ACQUIRE-RCpc不成立)

从上述保序公式可以看出:

带有RELEASE-RCpc的older store指令的写数据可以直接被forward给带有ACQUIRE-RCpc的同地址younger load指令。

如果它们两个是不同地址,那么在younger load指令会先于older store指令出现在global memory order上。

上述这两点是RCsc不允许的,RCsc具有更强的保序行为。 为什么RCsc和RCpc有这两点区别 ,看它们的全称就知道了。

“RCpc”代表release consistency with processor-consistent synchronization operations。

“RCsc”代表release consistency with sequentially consistent synchronization operations。

RCpc语义有processor-consistent特性 。Processor consistency(PC)表示一个Core的store操作按顺序达到其它Core,但不一定同时达到其它Core。TSO模型是PC的特殊情况,其中每个Core都可以立即看到自己的Store操作,但是当任何其它Core看到Store操作时,所有其它Core都可以看到它,这个属性称为write atomicity。

RCsc语义有sequentially consistent特性 。Sequential consistency (SC)模型中,memory order保留了每个core的program order。也就是SC模型为同一个线程的两个指令间的所有四种load和store组合(Load -> Load, Load -> Store, Store -> Store, and Store -> Load)保留了顺序。

因此RCpc和RCsc在行为上还是有些区别,RCsc语义可以让RVWMO模型像SC(Sequential Consistency)模型行为一样,RCpc语义可以让RVWMO像TSO(Total Store Order)内存模型行为一样, 这极大方便了其它CPU内存模型的代码移植到RISC-V CPU上运行 。比如要迁移MIPS R10000的代码到RISC-V CPU上,可以使用RCsc的load和store指令。要迁移Intel/AMD的代码到RSIC-V CPU上,可以使用RCpc的load和store指令。

如下图,注:LDAR代表load具有acquire-RCsc语义,STLR代表store具有release-RCsc语义。LDAPA代表load具有acquire-RCpc语义。

1730471145989

17.1.2. Syntactic Dependencies

语法依赖关系是根据指令的源寄存器、指令的目标寄存器以及指令从源寄存器到目标寄存器携带依赖关系的方式来定义的。

源寄存器定义:一般来说,如果满足下列任何条件之一,寄存器R(除X0)就是指令A的源寄存器:

  • 在指令A的操作码中,rs1、rs2或rs3被设置为R;
  • A是CSR指令,在A的操作码中,csr被设置为R。如果A是CSRRW或CSRRWI,需要rd不是x0;
  • R是CSR,且是指令A的隐式源寄存器;
  • R是CSR,它是A的另一个源寄存器别名;

目的寄存器定义:一般来说,如果满足下列任何条件之一,寄存器R(除x0)就是指令A的目的寄存器:

  • 在指令A的操作码中,rd被设置为R;
  • A是CSR指令,在A的操作码中,CSR被设置为R。如果A为CSRRS或CSRRC,需要rs1不是x0.如果A为CSRRSI或CSRRCI,需要umm[4:0]不是0;
  • R是CSR,且是指令A的隐式目的寄存器;
  • R是CSR,它是A的另一个目的寄存器别名;

语法依赖定义:如果以下任何一个条件成立,那么指令j通过i的目的寄存器s和指令j的源寄存器r在语法上依赖于指令i。

  • s和r是同一个,且在i和j之间排序的程序指令没有r作为目的寄存器;
  • 在指令i和指令j之间有指令m,使得以下所有条件都成立:
    • 指令m的目的寄存器q和指令j的源寄存器r在语法上存在依赖;
    • 指令m的源寄存器p和指令i的目的寄存器s在语法上存在依赖;
    • 指令m的p和q存在依赖;

对于内存访问操作中, Syntactic Dependencies(语法依赖)可以分为syntactic address dependency(地址依赖),syntactic data dependency(数据依赖)和syntactic control dependency(控制依赖)

为了说明这个三个依赖的不同之处,假设有a和b两个内存操作,i和j分别是生成a和b的指令。

地址依赖:如果r是j的地址源操作数,并且j通过源寄存器r对i有语法依赖,则b有语法地址依赖于a。

指令i (操作a):lw r,0(r1)

指令j (操作b):sw r2,0( r )

数据依赖:如果b是一个store操作,r是j的数据源寄存器,j通过源寄存器r对i有语法依赖,那么b对a有语法数据依赖。

指令i (操作a):lw r,0(r1)

指令j (操作b):sw r,0(r0)

控制依赖:如果在i和j之间有一条指令m,m是一条分支或间接跳转指令,并且m在语法上依赖于i,则b在语法控制上依赖于a。

指令i (操作a):lw r,0(r0)

指令m:bne r,r1,next

指令j (操作b):sw r3,0(r4)

17.1.3. Preserved Program Order

任何给定的程序执行的全局内存顺序都尊重每个hart的部分(但不是全部)程序顺序。全局内存顺序必须遵守的程序顺序的子集称为保留程序顺序,全局必须尊重本地

保留程序顺序的完整定义如下(请注意,AMOs同时是加载和存储):内存操作a在保留程序顺序中先于内存操作b(因此也在全局内存顺序中),如果a在程序顺序中先于b,则a和b都访问常规主存(而不是I/O区域),并且以下任何一种情况都有效:

Overlapping-Address Orderings:(其实不太理解这个名词?)

a操作在程序顺序中先于b操作,a和b都访问常规主存,不包含I/O区域, 如果存在以下任何一个条件 ,那么a操作和b操作在全局内存顺序中的顺序也不会变。

  1. b是store,且a和b访问重叠的内存地址。
  2. a和b是load,x是a和b同时读取的字节,且在a和b程序顺序之间没有store操作访问x,a和b返回x的值由不同的内存操作写入。
  3. a是由AMO或SC指令生成的,b是一个load,b返回由a写入的值。

关于第一点 ,load或store操作永远不能与后面访问重叠内存位置的store操作进行重排序。从微体系架构的角度来看,一般来说,如果投机是无效的,则很难或不可能撤回投机重排序的store操作,因此模型直接不允许这种行为。不过另一方面,store可以forward数据给后面的load操作,这种情况通常发生在store的数据暂时存放在store buffer里,之后load命中store buffer,就直接load数据走了。

关于第二点 ,其实就是要求同一个hart中,younger的load返回的值不能比同地址older load返回的值更老。这通常被称为“CoRR”(Coherence for Read-Read pairs),或者SC模型(sequential consistency)的要求一部分, RVWMO需要强制执行CoRR排序。如下代码所示,不管程序如何执行,(f)返回的值肯定比(d)的新。

1730473060752

其实就是AMO或成功的SC必须要全局可见后,才能将值返回给后续的load操作。

这三个原则也适用于内存访问之间只有部分重叠的情况,而且基地址也不一定相同的。例如,当使用不同大小的操作访问同一个地址区间时,就可以发生这种情况。当使用非对齐的内存访问时,重叠地址的规则可以独立地应用于每个地址的内存访问。

Explicit Synchronization

显示同步指的是 :a操作在程序顺序中先于b操作,a和b都访问常规主存,不是I/O区域,如果存在以下任何一个条件,那么a操作和b操作在全局内存顺序中的顺序也不会变。

  1. a和b之间有FENCE指令。
  2. a拥有acquire语义。
  3. b拥有release语义。
  4. a和b都有RCsc语义。
  5. a和b是配对的。

关于第四点:如果单独使用RCpc语义,就不会强制store release到load acquire的顺序,这有助于移植在TSO或RCpc内存模型下编写的代码。为了确保store release到load acquire的顺序,代码必须使用RCsc的语义。

在全局内存顺序中,SC必须出现在与其配对的LR之后。由于固有的语法数据依赖,通常使用LR/SC来执行原子读-修改-写操作。但其实即使store的值在语法上不依赖于成对LR返回的值,这一点也适用。

Syntactic Dependencies

  1. b has a syntactic address dependency on a
  2. b has a syntactic data dependency on a
  3. b is a store, and b has a syntactic control dependency on a

Pipeline Dependencies

a操作在程序顺序中先于b操作,a和b都访问常规主存,不是I/O区域,如果存在以下任何一个条件,那么a操作和b操作在全局内存顺序中的顺序也不会变。

  1. b是load,在a和b程序顺序之间存在一个store m,m的地址或数据依赖于a,b返回的值是m写的值。
  2. b是store,在a和b程序顺序之间存在一些指令m,m的地址依赖于a。

这两点几乎在所有真实处理器pipeline上都存在的

关于第一点:是想表明如果old的store的地址或数据还未知的话,load是不能从store转发数据的。也就是必须等a确定执行完之后,得到了m的地址或数据了,才会执行b,所以a和b的全局顺序肯定是保证的。如下图所示。

1730473628480

(f)在(e)的数据确定之前是不能被执行的,因为(f)必须返回(e)写的值,并且在(d)有机会执行之前,旧的值不能被(e)的回写所破坏,因此,(f)将不会在(d)之前执行,也就是它们俩的顺序是固定的。

关于第二点:它与第一点规则有着类似的观察:在可能访问同一地址的所有older load被执行之前,store不能在memory中确定执行。因为store如果提前执行的话,那么旧的值被覆盖了,那么older的load就无法读取到了。同样的,除非知道前面的指令不会由于地址解析失败而导致异常,都则通常不能执行store操作,从这个意义上说,这个一点是之前语法依赖里的控制依赖的某种特殊情况。如下图所示。

1730473885536

在(e)的地址被解析完之前,(f)不能执行,因为结果可能是地址匹配,也就是a1等于0。因此,在(d)被执行并确认(e)地址是否确实重叠之前,(f)不能被发到内存去执行的,也就是(d)和(f)的顺序是固定的。

17.1.4. Memory Model Axioms

An execution of a RISC-V program obeys the RVWMO memory consistency model only if there exists a global memory order conforming to preserved program order and satisfying the load value axiom, the atomicity axiom, and the progress axiom.

load value公理

loadA读到的每个字节来自于store写入该字节的值,loadA可以读到store的值来自于以下两种场景:

  • 在全局内存顺序中,在loadA之前的store写该字节
  • 在program order中,在loadA之前的store写该字节 (可以forward)

全局内存顺序排在loadA之前的store,loadA肯定可以观察到。另外第二点意思是现在几乎所有的CPU实现中都存在store buffer,store操作的数据可能会暂时存放在这里面,而且还没有全局可见,后续younger的load其实就可以提前forward里面的写数据来执行。因此,对于其它hart来说,在全局内存顺序上将观察到load在store之前执行。

我们可以看下面经典的一个例子,假如这段程序是在具有store buffer的hart上运行的,那么最终结果可能是:a0=1,a1=0,a2=1,a3=0。

1730474359557

程序一种执行顺序如下:

  • 执行并进入第一个hart的store buffer;
  • 执行并转发store buffer中(a)的返回值1;
  • 执行,因为所有先前的load(即(b))已完成;
  • 执行并从内存中读取值0;
  • 执行并进入第二个hart的store buffer;
  • 执行并转发store buffer中(e)的返回值1;
  • 执行,因为所有先前的load(即(f))已经完成;
  • 执行并从内存中读取值0;
  • 从第一个hart的store buffer中写到内存;
  • (e)从第二个hart的store buffer中写到内存;

然而,即使(b)在全局内存顺序上先于(a)和,(f)先于(e),在本例中唯一合理的可能性是(b)返回由(a)写入的值,(f)和(e)也是如此,这种情况的结合促使了load value公理定义中的第二种选择。即使(b)在全局内存顺序上在(a)之前,(a)仍然对(b)可见,因为(b)执行时(a)位于store buffer中。因此,即使(b)在全局内存顺序上先于(a), (b)也应该返回由(a)写的值,因为(a)在程序顺序上先于(b)。(e)和(f)也是如此。

atomicity公理

如果r和w是分别由hart h中对齐的LR和SC指令生成的成对load和store操作,s是对字节x的store,r返回由s写的值,那么在全局内存顺序中,s必须位于w之前,并且在全局内存顺序中,除了h之外不能有其它hart的同地址store出现在s之后和w之前。

简单说,就是如果r从s读到数据了,那么s和w之间不可能穿插其它hart的store数据了,但允许本hart穿插其它同地址store数据,由此来确保一个hart使用LR和SC的原子性

1730474927618

RISC-V包含两种类型的原子操作:AMO和LR/SC对,它们的行为有所不同。LR/SC的行为就好像旧的值返回给hart,hart对它进行修改,并写回主存,这中间不被其它hart的store打断,否则就是失败的。AMO的行为就好像是在内存中直接进行的,因此AMO本质上就是原子性的。

progress公理

在全局内存顺序中,任何内存操作都不能在其他内存操作的无限序列之前进行

这个公理保证程序终究可以往前执行,确保来自一个hart的store最终在有限的时间内对系统中的其它hart可见,并且来自其它hart的load最终能够读取这些值。如果没有这个规则,例如spinlock在一个值上无限自旋是合法的,即使有来自其它一个hart的store等到解锁该自旋锁。

第六章:Coherence 协议

在本章中,我们回到了我们在第 2 章中介绍的 cache coherence 主题。我们在第 2 章中定义了 coherence,以便理解 coherence 在支持 consistency 方面的作用,但我们没有深入研究特定的 coherence 协议是如何工作的,或者它们是如何实现的。本章先一般性地讨论了 coherence 协议,然后我们将在接下来的两章中讨论特定的协议分类。我们从 6.1 节开始介绍 coherence 协议如何工作的整体情况,然后在 6.2 节展示如何指定 (specify) 协议。我们在第 6.3 节中介绍了一个简单而具体的 coherence 协议示例,并在第 6.4 节中探讨了协议设计空间。

6.1 整体情况

Coherence 协议的目标是通过强制执行 (enforce) 第 2.3 节中介绍、并在此重申的不变量来保持 coherence。

  1. 单写多读 (Single-Writer, Multiple-Reader (SWMR)) 不变量。 对于任何内存位置 A,在任何给定的(逻辑)时间,仅存在一个核心可以读写 A、或一些核心可以只读 A。
  2. 数据值 (Data-Value) 不变量。 一个时间段 (epoch) 开始时的内存位置的值,与其最后一个读写时间段 (epoch) 结束时的内存位置的值相同。(这句话的意思就是读到的数据必须是最新写入的数据的位置,比如B核写入位置0,但还内写入内存,然后A核读位置0,必须要求读的是B核的数据)

为了实现这些不变量,我们将每个存储结构(即,每个 cache 和 LLC/memory)关联到一个称为 coherence 控制器 (coherence controller) 的有限状态机。这些 coherence controllers 的集合 (collection) 构成了一个分布式系统。其中,控制器相互交换消息,以确保对于每个块,SWMR 和 Data-Value 不变量始终保持不变。这些有限状态机之间的交互由 coherence protocol 指定。

Coherence controllers 有几个职责。Cache 中的 coherence controller,我们称之为 缓存控制器 (cache controller) ,如图 6.1 所示。Cache controller 必须为来自两个来源的请求提供服务。在 “ 核心侧 (core side) ”,cache controller 与处理器核心连接。控制器接受来自核心的 loads 和 stores,并将 load values 返回给核心。一次 cache miss、会导致控制器发出一个 coherence 请求 (request) (例如,请求只读权限)、来启动一个 coherence 事务 (transaction) ,其中,这个请求包含了核心所访问位置的块。这个 coherence 请求通过互连网络发送到一个或多个 coherence controllers。一个事务由一个请求和为满足请求而交换的其他消息(例如,从另一个 coherence controller 发送到请求者的数据响应消息)组成。作为每个事务的一部分,发送的事务和消息的类型、取决于特定的 coherence 协议。

1730558186724

在 cache controller 的 “ 网络侧 (network side) ”,cache controller 通过互连网络与系统的其余部分连接。控制器接收它必须处理的 coherence 请求和 coherence 响应。与核心侧一样,如何处理传入的 coherence 消息、取决于特定的 coherence 协议。

LLC/memory 处的 coherence 控制器,我们称之为 内存控制器 (memory controller) ,如图 6.2 所示。内存控制器类似于缓存控制器,只是它通常只有一个网络侧。因此,它不会发出 coherence 请求(为了 loads 或 stores)、或接收 coherence 响应。其他代理(例如 I/O 设备)的行为,可能类似于缓存控制器、内存控制器、或两者都是,取决于它们的特定要求。

1730558199185

每个 coherence 控制器都会实现一组 有限状态机(逻辑上,每个块都有一个独立但相同的有限状态机),并根据块的状态接收和处理 事件 (events) (例如,传入的 coherence 消息)。

6.2 指定 Coherence 协议

如果想读懂之后内容,这部分必须会

我们通过指定 (specify) coherence 控制器来指定 coherence 协议。我们可以通过多种方式指定 coherence 控制器,但是 coherence 控制器的特定行为适合用表格规范来表示 [9]。如表 6.1 所示,我们可以将一个控制器指定为一张表,其中行对应于块状态 (states),列对应于事件 (events)。我们将表中的一个 state/event 条目称为一次 转换 (transition) ,与块 B 相关的事件 E 的转换包括

  • (a) E 发生时采取的动作 (actions),和
  • (b) 块 B 的下一个状态。

也就是 这种格式:做的事情/下一个状态

我们将转换格式表示为 “action/next state”,如果下一个状态是当前状态,我们可以省略 “next state” 部分。作为表 6.1 中的转换示例,如果从核心接收到块 B 的 store 请求、并且块 B 处于只读状态 (RO),则该表显示控制器的转换将是执行动作 “issue coherence request for read-write permission (to block B)”,并将块 B 的状态更改为 RW。

1730558345683

为简单起见,表 6.1 中的示例故意做得不完整,但它说明了表格规范方法捕获 coherence 控制器行为的能力。要指定 coherence 协议,我们只需要完全指定缓存控制器和内存控制器的表即可。

Coherence 协议之间的差异在于控制器规范之间的差异。这些差异包括不同的块状态 (block states)、事务 (transactions)、事件 (events) 和转换 (transitions)。在 6.4 节中,我们通过探索每个方面的选项、来描述 coherence 协议的设计空间,但我们首先来指定一个简单、具体的协议。

6.3 一个简单的 Coherence 协议示例

为了帮助理解 coherence 协议,我们现在提出一个简单的协议。我们的系统模型是第 2.1 节中的基线系统模型,但互连网络仅限于共享总线:一组共享的连线 (wires),核心可以在这些连线上发出消息并让所有核心和 LLC/memory 观察到它。

每个缓存块可以处于两种稳定 (stable) 的 coherence 状态之一:I(nvalid)V(alid) 。LLC/memory 中的每个块也可以处于两种 coherence 状态之一:I 和 V。在 LLC/memory 中,

  • 状态 I 表示所有缓存都将该 block 保持在状态 I,
  • 状态 V 表示有一个缓存将 block 保持在状态 V。

缓存块也有一个单一的瞬间 (transient) 状态,即 IV^D ,将在下面讨论。在系统启动时,所有的缓存块和 LLC/memory 块都处于状态 I。每个核都可以向其缓存控制器发出 load 和 store 请求;当缓存控制器需要为另一个块腾出空间时,它会隐式生成一个 Evict Block 事件。缓存中未命中的 loads 和 stores 会启动 coherence 事务,如下所述,以获得缓存块的 valid 拷贝。像本入门书中的所有协议一样,我们假设了一个 写回缓存 (writeback cache) ;也就是说,当 store 命中时,它将 store 值仅写入(局部)缓存、并等待将整个块写回 LLC/memory、以响应 Evict Block 事件。

我们使用三种类型的总线消息 (bus messages) 实现了两种类型的 coherence 事务 (coherence transactions)

  • Get 消息 用于请求一个 block,
  • DataResp 消息 用于传输一个 block 的数据,
  • Put 消息 用于将 block 写回内存控制器。

译者注:两种事务是指 Get 事务Put 事务

在一次 load 或 store miss 时,缓存控制器通过发送 Get 消息、并等待相应的 DataResp 消息、来启动 Get 事务。Get 事务是原子的,因为在缓存发送 Get 和该 Get 的 DataResp 出现在总线上之间,没有其他事务(Get 或 Put)可以使用总线。在 Evict Block 事件中,缓存控制器将带有整个缓存块的 Put 消息发送到内存控制器。

我们在图 6.3 中说明了稳定 coherence 状态之间的转换。我们使用前缀 “ Own ” 和 “ Other ” 来区分由给定缓存控制器发起的事务的消息、以及由其他缓存控制器发起的事务的消息。请注意,如果给定的缓存控制器具有处于状态 V 的块,并且另一个缓存使用 Get 消息(表示为 Other-Get )请求它,则 owning cache 必须用一个块去响应(使用 DataResp 消息,图中未显示)、并转换到状态 I。

1730558825446

表 6.2 和 6.3 更详细地指定了协议。表中的阴影条目表示不可能的转换。例如,缓存控制器不应该在总线上看到它自己对于一个块的 Put 请求,其中,该请求在其缓存中处于状态 V(因为它应该已经转换到了状态 I)。

1730558868504

1730558875859

瞬间状态 IV^D 对应于状态 I 中的块,该块在转换到状态 V 之前正在等待数据(通过 DataResp 消息,也就是等待数据阶段)。当稳定状态之间的转换不是原子的之时,会出现瞬间状态。在这个简单的协议中,单个消息的发送和接收是原子的,但是从内存控制器获取一个块需要发送一个 Get 消息、并接收一个 DataResp 消息,两者之间有一个中间的间隙 (gap)。IV^D 状态指示协议正在等待 DataResp。我们将在 6.4.1 节更深入地讨论瞬间状态。

这种 coherence 协议在许多方面都过于简单且效率低下,但介绍该协议的目的是了解如何指定协议。在介绍不同类型的 coherence 协议时,我们在整本书中都使用了这种规范方法。

6.4 Coherence 协议设计空间概述

如第 6.1 节所述,coherence 协议的设计者必须为系统中每种类型的 coherence 控制器选择状态 (states)、事务 (transactions)、事件 (events) 和转换 (transitions)。稳定状态的选择在很大程度上独立于 coherence 协议的其余部分。例如,有两类不同的 coherence 协议,称为 监听 (snooping) 、和 目录 (directory) ,架构师可以根据相同的稳定状态集、设计不同的监听协议或目录协议。我们将在 6.4.1 节中讨论独立于协议的稳定状态。同样,事务的选择也很大程度上独立于具体的协议,我们将在 6.4.2 节讨论事务。然而,与稳定状态和事务的选择不同,事件、转换和特定的瞬间状态,高度依赖于 coherence 协议,不能孤立地讨论。因此,在第 6.4.3 节中,我们讨论了 coherence 协议中的一些主要的设计决策。

6.4.1 状态

在只有一个参与者 (actor) 的系统中(例如,没有 coherent DMA 的单核处理器),缓存块的状态是 valid 或 invalid。如果需要区分块是 脏的 (dirty) ,则缓存块可能有两种可能的 valid 状态。脏块具有比该块的其他拷贝更近期的写入值。例如,在具有写回式 L1 缓存的两级缓存层次结构中,L1 中的块相对于 L2 缓存中的陈旧拷贝可能是脏的。

具有多个参与者的系统也可以只使用这两个或三个状态,如第 6.3 节所述,但我们经常想要区分不同类型的 valid 状态。我们希望在其状态中编码缓存块的四个特征: 有效性 (validity)脏性 (dirtiness)独占性 (exclusivity) 、和所有权 (ownership) [10]。后两个特征是具有多个参与者的系统所独有的。

  • Validity :一个有效 (valid) 的块具有该块的最新值。该块可以被读取,但只有在它同时是独占的情况下才能被写入。
  • Dirtiness :就像在单核处理器中一样,如果一个缓存块的值是最新的值、且这个值与 LLC/memory 中的值不同,那么这个缓存块就是脏 (dirty) 的,且缓存控制器负责最终使用这个新值去更新 LLC/memory。干净 (clean) 一词通常用作脏的反义词。
  • Exclusivity :如果一个缓存块是系统中该块的唯一私有缓存拷贝,则该缓存块是独占的(注1)(即,除了可能在共享 LLC 中之外,该块不缓存在其他任何地方)。
  • Ownership :如果缓存控制器(或内存控制器)负责响应对该块的 coherence 请求,那么它就是该块的 所有者 (owner) 。在大多数协议中,始终只有一个给定块的所有者。在不将块的所有权交给另一个 coherence 控制器的情况下,已拥有的块可能不会被从缓存中逐出(由于容量或冲突 miss)、以腾出空间给另一个块。在某些协议中,非拥有的块可能会被静默地驱逐(即,不发送任何消息)。

在本节中,我们首先讨论一些常用的稳定状态(当前未处于一致性事务中的块的状态),然后讨论使用瞬间状态来描述当前处于事务中的块。

原书作者注1:这里的术语可能会令人困惑,因为已经有一个称为 “Exclusive” 的缓存 coherence 状态,但还有其他缓存 coherence 状态在此处定义的意义上是 exclusive 的。

译者注:这边作者是想说,一个是协议里 “Exclusive” 状态的定义,另一个是缓存块 “exclusive” 性质的定义。

稳定状态 (Stable States)

许多 coherence 协议使用 Sweazey 和 Smith [10] 首次引入的经典五态 MOESI 模型的一个子集。这些 MOESI(通常发音为 “MO-sey” 或 “mo-EE-see”)状态指的是缓存中块的状态,最基本的三个状态是 MSI;可以使用 O 和 E 状态,但它们不是基本的。这些状态中的每一个都具有前面描述的特征的不同组合。

  • M(odified) :该块是有效的、独占的、拥有的,并且可能是脏的。该块可以被读取或写入。该缓存具有块的唯一有效拷贝,且该缓存必须响应对块的请求,并且 LLC/memory 中的该块的拷贝可能是陈旧的。
  • S(hared) :该块有效,但不独占、不脏、不拥有。该缓存具有块的只读拷贝。其他缓存可能具有该块的有效只读拷贝。
  • I(nvalid) :该块无效。该缓存要么不包含该块,要么包含可能无法读取或写入的陈旧拷贝。在本入门书中,我们不区分这两种情况,尽管有时前一种情况可以表示为 “不存在 (Not Present)” 状态。

最基本的协议仅使用 MSI 状态,但有理由添加 O 和 E 状态以优化某些情况。当我们想要讨论带有和不带有这些状态的监听和目录协议时,我们将在后面的章节中讨论这些优化。现在,这里是 MOESI 状态的完整列表:

  • M(odified)
  • O(wned) :该块是有效的、拥有的、并且可能是的,但不是独占的。该缓存具有该块的只读拷贝,并且必须响应对该块的请求。其他缓存可能具有该块的只读副本,但它们不是所有者。LLC/memory 中的该块的拷贝可能是陈旧的。
  • E(xclusive) :该块是有效的、独占的、且干净的。该缓存具有该块的只读拷贝。没有其他缓存拥有该块的有效拷贝,并且 LLC/memory 中的该块的拷贝是最新的。在本入门书中,我们认为当该块处于独占状态时,它是拥有 (owned) 的,尽管在某些协议中独占状态不被视为所有权 (ownership) 状态。当我们在后面的章节中介绍 MESI 监听和目录协议时,我们将讨论是否把独占的块视为所有者的问题。
  • S(hared)
  • I(nvalid)

我们在图 6.4 中展示了 MOESI 状态的维恩图。维恩图显示了哪些状态共享哪些特征。

  • 除了 I 之外的所有状态都是有效的。
  • M、O 和 E 是所有权 (ownership) 状态。
  • M 和 E 都表示独占性,因为没有其他缓存具有该块的有效拷贝。
  • M 和 O 都表示该块可能是脏的。

回到第 6.3 节中的简单示例,我们观察到协议有效地将 MOES 状态压缩为 V 状态。

1730559433331

MOESI 状态虽然很常见,但并不是一套详尽的稳定状态。例如,F (Forward) 状态类似于 O 状态,只是它是干净的(即 LLC/memory 中的拷贝是最新的)。有许多其他可能的 coherence 状态,但我们在本入门书中,会将注意力集中在著名的 MOESI 状态上。

瞬间状态 (Transient States)

到目前为止,我们只讨论了当块没有当前 coherence 活动时出现的稳定状态,并且在提到协议(例如,“具有 MESI 协议的系统”)时,仅使用这些稳定状态。然而,正如我们在 6.3 节的例子中看到的那样,在从一种稳定状态到另一种稳定状态的转换过程中,可能存在瞬间状态。在 6.3 节中,我们有瞬间状态 IV^D(在 I 中,正在进入 V,等待 DataResp)。在更复杂的协议中,我们可能会遇到几十种瞬间状态。我们使用符号 XY^Z 对这些状态进行编码,这表示该块正在从稳定状态 X 转换到稳定状态 Y,并且在发生 Z 类型的事件之前不会完成转换。例如,在后面章节的一个协议中,我们使用 IM^D 来表示一个块先前在 I 中,一旦 D (Data) 消息到达该块,它将变为 M。

LLC/Memory 中的块状态

到目前为止,我们讨论的状态(稳定的和瞬间的)都与缓存中的块有关。LLC 和内存中的块也有与之相关的状态,有两种通用的方法来命名 LLC 和内存中的块的状态。命名约定的选择不会影响功能或性能;这只是一个规范问题,可能会使不熟悉该约定的架构师感到困惑。

  • 以缓存为中心 (Cache-centric) :在我们认为最常见的这种方法中,LLC 和内存中的块状态是缓存中该块状态的聚合 (aggregation)。例如,如果一个块在所有缓存中都处于 I 状态,则该块的 LLC/memory 状态为 I。如果一个块在一个或多个缓存中处于 S 状态,则 LLC/memory 状态为 S。如果一个块在单个缓存中处于 M 状态,则 LLC/memory 状态为 M。
  • 以内存为中心 (Memory-centric) :在这种方法中,LLC/memory 中块的状态对应于内存控制器对该块的权限(而不是缓存的权限)。例如,如果一个块在所有缓存中都处于 I 状态,则该块的 LLC/memory 状态为 O(不是 I,如在以缓存为中心的方法中那样),因为 LLC/memory 的行为类似于该块的所有者。如果一个块在一个或多个缓存中处于 S 状态,则 LLC/memory 状态也是 O,出于同样的原因。但是,如果一个块在单个缓存中处于 M 或 O 状态,则 LLC/memory 状态为 I,因为 LLC/memory 有该块的无效拷贝。

本入门书中的所有协议都使用以缓存为中心的名称来表示 LLC 和内存中的块状态。

维护块状态

系统实现必须维护与缓存、LLC 和内存中的块相关联的状态。对于缓存和 LLC,这通常仅需要将 per-block 的缓存状态进行扩展、至多几位,因为稳定状态的数量通常很少(例如,MOESI 协议的 5 个状态需要每个块 3 位)。Coherence 协议可能有更多的瞬间状态,但只需要为那些有未决 (pending) coherence 事务的块维护这些状态。实现通常通过向未命中状态处理寄存器 (miss status handling registers, MSHR) 添加额外的位,或添加用于跟踪这些未决事务 [4] 的类似结构、来维护这些瞬间状态。

对于内存而言,更大的总容量似乎会带来重大挑战。然而,许多当前的多核系统会维护一个 inclusive LLC,这意味着 LLC 会维护缓存在系统中任何位置的每个块的拷贝(甚至是 “独占” 的块)。使用 inclusive LLC,则内存不需要 explicitly 表示 coherence 状态。如果一个块驻留在 LLC 中,则它在内存中的状态与它在 LLC 中的状态相同。如果块不在 LLC 中,则其在内存中的状态为 implicitly Invalid ,因为 inclusive LLC 的缺失意味着该块不在任何缓存中。侧边栏讨论了在具有 inclusive LLC 的多核出现之前,内存状态是如何保持的。上面对内存的讨论假设了系统具有单个多核芯片,本入门书的大部分内容也是如此。具有多个多核芯片的系统可能会受益于内存逻辑上的显式 coherence 状态。

原书侧边栏:在多核之前:在内存中保持一致性状态

传统地,pre-multicore 协议需要维护每个内存块的 coherence 状态,并且它们不能使用第 6.4.1 节中解释的 LLC。我们简要讨论了几种维持这种状态的方法以及相关的工程权衡。

用状态位扩充每个内存块。 最通用的实现是向每个内存块添加额外的位、以保持 coherence 状态。如果内存中有 N 个可能的状态,那么每个块需要 log_2(N) 个额外的位。尽管这种设计是完全通用的并且在概念上很简单,但它有几个缺点。首先,额外的位可能会以两种方式增加成本。使用现代的面向块的 DRAM 芯片很难添加两个或三个额外位,这些芯片通常至少需要 4 位宽,而且通常更宽。此外,内存中的任何变化都会妨碍使用商用 DRAM 模块(例如 DIMM),这会显著增加成本。幸运的是,对于每个块只需要几位状态的协议,可以使用修改后的 ECC 代码来存储这些状态。通过在更大的粒度上维护 ECC(例如,512 位、而不是 64 位),可以释放足够的代码空间来 “隐藏” 少量额外的位,同时,还能使用商用 DRAM 模块 [1, 5, 7]。第二个缺点是,将状态位存储在 DRAM 中、意味着获取状态会导致完整的 DRAM 延迟,即使在最新版本的、块存储在其他缓存中的情况下、也是如此。在某些情况下,这可能会增加缓存到缓存 coherence 传输的延迟。最后,将状态存储在 DRAM 中意味着所有状态更改都需要一个 DRAM read-modify-write 周期,这可能会影响功率和 DRAM 带宽。

在内存中为每个块添加单个状态位。 Synapse [3] 使用的一个设计选项是,使用与每个内存块相关联的单个位来区分两个稳定状态(I 和 V)。很少有块处于瞬间状态,并且可以通过小型专用结构来维持这些状态。该设计是更完整的第一个设计的子集,存储成本最低。

Zero-bit logical OR。 为了避免修改内存,我们可以让缓存按需重建 (reconstruct) 内存状态。一个块的内存状态、是关于每个缓存中块状态的函数,因此,如果所有缓存聚合它们的状态,它们就可以确定内存状态。系统可以通过让所有核心发送 “IsOwned?”(注a)来推断内存是否是块的所有者。信号发送到逻辑或门(或门树),其输入数量等于缓存的数量。如果此 OR 的输出为高,则表示缓存是所有者;如果输出低,那么内存就是所有者。该解决方案避免了在内存中维护任何状态的需要。然而,使用逻辑门、或 wired-OR 来实现一个快速 OR、可能很困难。

原书作者注a:不要将此 IsOwned 信号与 Owned 缓存状态相混淆。IsOwned 信号由处于所有权状态的缓存置位 (asserted),该状态包括 Owned、Modified 和 Exclusive 缓存状态。

6.4.2 TRANSACTIONS

大多数协议都有一组相似的事务,因为 coherence 控制器的基本目标是相似的。例如,几乎所有协议都有一个事务来获得对块的共享(只读)访问。在表 6.4 中,我们列出了一组常见事务,并且对于每个事务,我们描述了发起事务的请求者的目标。这些事务都是由缓存控制器发起的、以响应来自其相关核心的请求。在表 6.5 中,我们列出了核心可以向其缓存控制器发出的请求,以及这些核心请求如何引导缓存控制器启动 coherence 事务。

1730560163916

1730560177262

尽管大多数协议使用一组类似的事务,但它们在 coherence 控制器如何交互、以执行事务、这一方面存在很大差异。正如我们将在下一节中看到的,在某些协议(例如,监听协议)中,缓存控制器通过向系统中的所有 coherence 控制器广播 GetS 请求来启动 GetS 事务,并且当前该块的所有者的控制器、会用包含所需数据的消息、来响应请求者。相反,在其他协议(例如,目录协议)中,缓存控制器通过向特定的预定义 coherence 控制器发送单播 GetS 消息来发起 GetS 事务,该 coherence 控制器可以直接响应、或者可以将请求转发到将响应请求者的另一个 coherence 控制器。

6.4.3 主要协议设计选项

设计 coherence 协议有许多不同的方法。即使对于同一组状态和事务,也有许多不同的可能协议。协议的设计决定了每个 coherence 控制器上可能发生的事件和转换;与状态和事务不同,没有办法提供独立于协议的可能事件或转换的列表。

尽管 coherence 协议有巨大的设计空间,但有两个主要的设计决策会对协议的其余部分产生重大影响,我们接下来将讨论它们。

Snooping vs. Directory

Coherence 协议主要有两类:监听和目录。我们现在对这些协议进行简要概述,并将它们的深入介绍分别推迟到第 7 章和第 8 章。

  • 监听协议 :缓存控制器通过向所有其他 coherence 控制器广播请求消息来发起对块的请求。Coherence 控制器集体 “做正确的事”,例如,如果他们是所有者,则发送数据以响应另一个核心的请求。监听协议依靠互连网络以 consistent 的顺序将广播消息传递到所有核心。大多数监听协议假定请求按 total order 到达,例如,通过 shared-wire 总线。但更高级的互连网络和更宽松的顺序也是可能的。
  • 目录协议 :缓存控制器通过将块单播到作为该块所在的内存控制器来发起对块的请求。内存控制器维护一个目录,该目录保存有关 LLC/memory 中每个块的状态,例如当前所有者的身份或当前共享者的身份。当对块的请求到达主目录时,内存控制器会查找该块的目录状态。例如,如果请求是 GetS,则内存控制器查找目录状态以确定所有者。如果 LLC/memory 是所有者,则内存控制器通过向请求者发送数据响应来完成事务。如果缓存控制器是所有者,则内存控制器将请求转发给所有者缓存;当所有者缓存接收到转发的请求时,它通过向请求者发送数据响应来完成事务。

监听与目录的选择涉及权衡取舍。监听协议在逻辑上很简单,但它们无法扩展到大量核心,因为广播无法扩展。目录协议是可扩展的,因为它们是单播的,但许多事务需要更多时间,因为当 home 不是所有者时,它们需要发送额外的消息。此外,协议的选择会影响互连网络(例如,经典的监听协议需要请求消息的 total order)。

Invalidate vs. Update

Coherence 协议中的另一个主要设计决策是决定核心写入块时要做什么。这个决定与协议是监听还是目录无关。有两种选择。

  • Invalidate protocol :当一个核心希望写入一个块时,它会启动一个 coherence 事务以使所有其他缓存中的拷贝无效。一旦拷贝失效,请求者就可以写入块,而另一个核心不可能读取块的旧值。如果另一个核心希望在其副本失效后读取该块,它必须启动一个新的 coherence 事务来获取该块,并且它将从写入它的核获得一个副本,从而保持 coherence。
  • Update protocol :当一个核心希望写入一个块时,它会启动一个 coherence 事务来更新所有其他缓存中的副本,以反映它写入块的新值。

再一次,在做出这个决定时需要权衡取舍。更新协议减少了核心读取新写入块的延迟,因为核心不需要启动并等待 GetS 事务完成。但是,更新协议通常比无效协议消耗更多的带宽,因为更新消息大于无效消息(地址和新值,而不仅仅是地址)。此外,更新协议使许多 memory consistency models 的实现变得非常复杂。例如,当多个缓存必须对一个块的多个拷贝、应用多个更新时,保持写原子性(第 5.5 节)变得更加困难。由于更新协议的复杂性,它们很少被实现;在本入门书中,我们将重点介绍更为常见的无效协议。

混合设计

对于这两个主要的设计决策,一种选择是开发一种混合设计。有些协议结合了监听和目录协议 [2, 6] 的各个方面,还有一些协议结合了无效和更新协议 [8] 的各个方面。设计空间丰富,架构师不受限于遵循任何特定的设计风格。

监听一致性协议

7.1 监听简介

监听协议基于一个想法:所有一致性控制器以相同的顺序观察(监听)一致性请求,并集体“做正确的事”以保持一致性。 通过要求对给定块的所有请求按顺序到达,监听系统使分布式一致性控制器能够正确更新共同表示缓存块状态的有限状态机。

传统的监听协议将请求广播到所有一致性控制器,包括发起请求的控制器。 一致性请求通常在有序的广播网络上传播,例如总线。 有序广播确保每个一致性控制器以相同顺序观察相同系列的一致性请求,即存在一致性请求的总顺序 (total order)。 由于总顺序包含所有每个块的顺序,因此该总顺序保证所有一致性控制器都可以正确更新缓存块的状态。

为了说明以相同的每个块顺序 (per-block order) 处理一致性请求的重要性,请考虑表 7.1 和 7.2 中的示例,其中核心 C1 和核心 C2 都希望在状态 M 中获得相同的块 A。在表 7.1 中,所有三个一致性控制器观察一致性请求的相同的每个块顺序,并共同维护单写多读(SWMR)不变量。块的所有权从 LLC/内存到核心 C1 再到核心 C2。作为每个观察到的请求的结果,每个一致性控制器独立地得出关于块状态的正确结论。相反,表 7.2 说明了如果核心 C2 观察到与核心 C1 和 LLC/内存不同的每个块的请求顺序,可能会出现不一致性。首先,我们遇到核心 C1 和核心 C2 同时处于状态 M 的情况,这违反了 SWMR 不变量。接下来,我们有一种情况,没有一致性控制器认为它是所有者,因此此时的一致性请求不会收到响应(可能导致死锁)。

17307057719261730705780081

传统的监听协议在所有块中创建了一个总的一致性请求顺序,即使一致性只需要每个块的请求顺序。 具有总顺序可以更容易地实现需要内存引用的总顺序的内存连贯性模型 (memory consistency model),例如 SC 和 TSO。 考虑表 7.3 中涉及两个块 A 和 B 的示例; 每个块只被请求一次,因此系统很容易观察每个块的请求顺序。 然而,由于核心 C1 和 C2 观察到 GetM 和 GetS 请求乱序,因此该执行违反了 SC 和 TSO 内存连贯性模型。

1730705945605

原书侧边栏:监听如何依赖于一致性请求的总顺序 乍一看,读者可能会认为表 7.3 中的问题的出现是因为在周期 1 中块 A 的 SWMR 不变量被违反,因为 C1 有一个 M 副本,而 C2 仍然有一个 S 副本。 但是,表 7.4 说明了相同的示例,但强制执行一致性请求的总顺序。 此示例在第 4 周期之前是相同的,因此具有相同的明显 SWMR 违规。 然而,就像众所周知的“森林中的树”一样,这种违规行为不会引起问题,因为它没有被观察到(即“没有人听到它”)。 具体来说,因为核心以相同的顺序看到两个请求,所以 C2 在看到块 B 的新值之前使块 A 无效。因此,当 C2 读取块 A 时,它必须获取新值,因此产生正确的 SC 和 TSO 执行。 传统的监听协议使用一致性请求的总顺序来确定何时在基于监听顺序的逻辑时间内观察到特定请求。 在表 7.4 的例子中,由于总顺序,核心 C1 可以推断 C2 将在 B 的 GetS 之前看到 A 的 GetM,因此 C2 在收到一致性消息时不需要发送特定的确认消息。 这种对请求接收的隐式确认 (implicit acknowledgment) 将监听协议与我们在下一章研究的目录协议区分开来。

1730706398306

要求以总顺序观察广播一致性请求对于用于实现传统监听协议的互连网络具有重要意义。由于许多一致性控制器可能同时尝试发出一致性请求,互连网络必须将这些请求序列化为某种总顺序。然而,网络决定了这个顺序,这个机制被称为协议的序列化顺序点 (serialization ordering point)。在一般情况下,一致性控制器发出一致性请求,网络在序列化点对该请求进行排序并将其广播给所有控制器,发射控制器 (issuing controller) 通过监听从控制器接收到的请求流来了解其请求的排序位置。作为一个具体而简单的例子,考虑一个使用总线来广播一致性请求的系统。一致性控制器必须使用仲裁逻辑来确保一次在总线上只发出一个请求。该仲裁逻辑充当序列化点,因为它有效地确定了请求在总线上出现的顺序。一个微妙但重要的一点是,一致性请求在仲裁逻辑序列化它的瞬间就被排序,但控制器可能只能通过监听总线以观察在它自己的请求之前和之后出现哪些其他请求来确定这个顺序。因此,一致性控制器可以在序列化点确定之后的几个周期内观察总请求顺序。

到目前为止,我们只讨论了一致性请求,而不是对这些请求的响应。这种看似疏忽的原因是监听协议的关键方面围绕着请求。响应消息几乎没有限制。他们可以在不需要支持广播也不需要任何顺序要求的单独互连网络上 travel。由于响应消息携带数据,因此比请求长得多,因此能够在更简单、成本更低的网络上发送它们有很大的好处。值得注意的是,响应消息不会影响一致性事务的序列化。从逻辑上讲,无论响应何时到达请求者,当请求被排序时,都会发生一个由广播请求和单播响应组成的一致性事务。请求出现在总线上和响应到达请求者之间的时间间隔确实会影响协议的实现(例如,在这个间隙期间,是否允许其他控制器请求此块?如果是,请求者如何响应?),但不影响事务的序列化。(注1)

原书作者注1:这种一致性事务的逻辑序列化类似于处理器核心中指令执行的逻辑序列化。 即使核心执行乱序执行,它仍然按程序顺序提交(序列化)指令。

7.2 基准监听协议

7.2.1 高层次协议规范

基准协议只有三个稳定状态:M、S 和 I。这样的协议通常称为 MSI 协议。 与第 6.3 节中的协议一样,该协议假定一个回写缓存。 一个块由 LLC/内存拥有,除非该块在状态 M 的缓存中。在介绍详细规范之前,我们首先说明协议的更高层次的抽象,以了解其基本行为。 在图 7.1 和 7.2 中,我们分别展示了缓存和内存控制器的稳定状态之间的转换。

1730706349539

1730707063592

需要注意三个符号问题。 首先,在图 7.1 中,弧被标记为在总线上观察到的一致性请求。 我们有意省略了其他事件,包括加载、存储和一致性响应。 其次,缓存控制器上的一致性事件被标记为“Own”或“Other”,以表示观察请求的缓存控制器是否是请求者。 第三,在图 7.2 中,我们使用以缓存为中心的符号来指定内存中的块的状态(例如,内存状态 M 表示存在状态为 M 的块的缓存)。

可以看之前的LLC块状态节来了解cache-centric

7.2.2 简单的监听系统模型:原子请求,原子事务

图 7.3 说明了简单的系统模型,它几乎与图 2.1 中介绍的基准系统模型相同。唯一的区别是图 2.1 中的通用互连网络被指定为总线。每个核心都可以向其缓存控制器发出加载和存储请求;当缓存控制器需要为另一个块腾出空间时,它会选择一个块来驱逐。总线促进了被所有一致性控制器监听的一致性请求的总顺序。与前一章中的示例一样,该系统模型具有简化一致性协议的原子性属性。具体来说,该系统实现了两个原子性属性,我们将其定义为原子请求 (Atomic Request) 和原子事务 (Atomic Transaction)。 Atomic Request 属性声明一致性请求在其发出的当个周期中排序。此属性消除了在发射请求 (issue request) 和排序请求 (order request) 之间(由于另一个核心的一致性请求)而导致块状态发生变化的可能性。 Atomic Transaction 属性指出一致性事务是原子的,因为对同一块的后续请求可能不会出现在总线上,直到第一个事务完成之后(即,直到响应出现在总线上之后)。由于一致性涉及对单个块的操作,因此系统是否允许对不同块的后续请求不会影响协议。尽管比大多数当前系统更简单,但该系统模型类似于 1980 年代成功的机器 SGI Challenge [5]。

1730707253212

表 7.5 和 7.6 给出了简单系统模型的详细一致性协议。 与第 7.2.1 节中的高层次描述相比,最显着的区别是在缓存控制器中添加了两个瞬间状态 (transient state),在内存控制器中添加了一个瞬间状态。 该协议的瞬间状态很少,因为简单系统模型的原子性约束极大地限制了可能的消息交织的数量。

1730707390370

1730707400416

表中的阴影条目表示不可能的(或至少是错误的)转换。 例如,缓存控制器永远不应该收到它没有请求的块(即,在其缓存中处于状态 I 的块)的 Data 消息。 类似地,Atomic Transaction 约束阻止另一个核心在当前事务完成之前发出后续请求; 由于此约束,无法出现标记为“(A)”的表条目。 空白条目表示不需要任何操作的合法转换。 这些表省略了许多理解协议所不需要的实现细节。 此外,在本协议和本章的其余协议中,我们省略了另一个核心事务的 Data 对应的事件; 一个核心从不采取任何行动来响应在总线上观察另一个核心事务的 Data。

与所有 MSI 协议一样,可以在状态 S 和 M 中执行加载(即命中),而存储仅在状态 M 中命中。在加载和存储未命中时,缓存控制器分别通过发送 GetS 和 GetM 请求来启动一致性事务。 瞬间状态 IS^D、IM^D 和 SM^D 表示请求消息已发送,但尚未收到数据响应(Data)。 在这些瞬间状态下,因为请求已经被排序,所以事务已经被排序并且块在逻辑上分别处于状态 S、M 或 M。(注2) 但是,加载或存储必须等待数据到达。(注3) 一旦数据响应出现在总线上,缓存控制器就可以将数据块复制到缓存中,根据需要转换到稳定状态 S 或 M,并执行挂起的加载或存储。

数据响应可能来自内存控制器或具有处于状态 M 的块的另一个缓存。具有处于状态 S 的块的缓存可以忽略 GetS 请求,因为需要内存控制器响应,但必须使 GetM 请求上的块无效,以强制执行一致性不变量。 具有处于状态 M 的块的缓存必须响应 GetS 和 GetM 请求,发送数据响应并分别转换到状态 S 或状态 I。

LLC/内存有两种稳定状态,M 和 IorS,以及一种瞬间状态 IorS^D。 在状态 IorS 中,内存控制器是所有者并响应 GetS 和 GetM 请求,因为此状态表明没有缓存具有状态 M 的块。在状态 M 中,内存控制器不响应数据,因为缓存处于状态 M 是所有者并且拥有数据的最新副本。 但是,状态 M 中的 GetS 意味着缓存控制器将转换到状态 S,因此内存控制器还必须获取数据、更新内存并开始响应所有未来的请求。 它通过立即转换到瞬间状态 IorS^D 并等待直到它从拥有它的缓存中接收到数据来做到这一点。

当缓存控制器由于替换决定而驱逐一个块时,这会导致协议的两种可能的一致性降级:从 S 到 I 和从 M 到 I。在这个协议中,S-to-I 降级在该块被从缓存中逐出,而不与其他一致性控制器进行任何通信。通常,只有在所有其他一致性控制器的行为保持不变时,才有可能进行静默状态转换;例如,不允许无声地驱逐拥有的区块。 M-to-I 降级需要通信,因为块的 M 副本是系统中唯一有效的副本,不能简单地丢弃。因此,另一个一致性控制器(即内存控制器)必须改变其状态。为了替换处于状态 M 的块,缓存控制器在总线上发出 PutM 请求,然后将数据发送回内存控制器。在 LLC,当 PutM 请求到达时,块进入状态 IorS^D,然后在 Data 消息到达时转换到状态 IorS。(注4) Atomic Request 属性简化了缓存控制器,通过在 PutM 在总线上排序之前阻止可能降级状态的干预请求(例如,另一个核心的 GetM 请求)。类似地,Atomic Transaction 属性通过阻止对块的其他请求,直到 PutM 事务完成并且内存控制器准备好响应它们来简化内存控制器。

在本节中,我们将展示一个系统执行示例,以展示一致性协议在常见场景中的行为方式。 我们将在后续部分中使用此示例来理解协议并突出它们之间的差异。 该示例仅包括一个块的活动,并且最初,该块在所有缓存中处于状态 I,在 LLC/内存处处于状态 IorS。

在此示例中,如表 7.7 所示,核心 C1 和 C2 分别发出load和store指令,这些指令在同一块上未命中。 核心 C1 尝试发出 GetS,核心 C2 尝试发出 GetM。 我们假设核心 C1 的请求恰好首先被序列化,并且 Atomic Transaction 属性阻止核心 C2 的请求到达总线,直到 C1 的请求完成。 内存控制器在周期 3 响应 C1 完成事务。然后,核心 C2 的 GetM 在总线上被序列化; C1 使其副本无效,并且内存控制器响应 C2 以完成该事务。 最后,C1 发出另一个 GetS。 所有者 C2 以数据响应并将其状态更改为 S。C2 还将数据的副本发送到内存控制器,因为 LLC/内存现在是所有者并且需要块的最新副本。 在此执行结束时,C1 和 C2 处于状态 S,LLC/内存处于状态 IorS。

1730708269441

7.2.3 基准监听系统模型:非原子请求、原子事务

我们在本章其余部分中使用的基准监听系统模型与简单的监听系统模型不同,它允许非原子请求。 非原子请求来自许多实现优化,但最常见的原因是在缓存控制器和总线之间插入消息队列(甚至单个缓冲区)。 通过将发出请求的时间与发出请求的时间分开,协议必须解决简单监听系统中不存在的漏洞窗口 (window of vulnerability)。 基准监听系统模型保留了原子事务属性,直到第 7.5 节我们才放松。

我们在表 7.8 和 7.9 中介绍了详细的协议规范,包括所有瞬间状态。 与第 7.2.2 节中的简单监听系统协议相比,最显着的区别是瞬间状态的数量要多得多。 放宽 Atomic Request 属性引入了许多情况,其中缓存控制器在发射其一致性请求和在总线上观察其自己的一致性请求之间观察来自总线上另一个控制器的请求。

1730708857221

1730708866595

以 I-to-S 转换为例,缓存控制器发出 GetS 请求并将块的状态从 I 更改为 IS^AD。直到在总线上观察到请求缓存控制器自己的 GetS (Own-GetS) 并序列化,块的状态实际上是 I。也就是说,请求者的块被视为在 I 中;无法执行加载和存储,并且必须忽略来自其他节点的一致性请求。一旦请求者观察到自己的 GetS,请求是有序的,块在逻辑上是 S,但是由于数据还没有到达,所以无法进行加载。缓存控制器将块的状态更改为 IS^D 并等待前一个所有者的数据响应。由于 Atomic Transaction 属性,数据消息是下一个一致性消息(到同一个块)。一旦数据响应到达,事务就完成了,请求者将块的状态更改为稳定的 S 状态并执行加载。 I-to-M 转换与 I-to-S 转换类似。

从 S 到 M 的转变说明了在漏洞窗口期间发生状态变化的可能性。 如果一个核心试图在状态 S 中存储到一个块,缓存控制器发出一个 GetM 请求并转换到状态 SM^AD。 **该块有效地保持在状态 S,因此加载可能会继续命中,**并且控制器会忽略来自其他核心的 GetS 请求。 但是,如果另一个核心的 GetM 请求首先被排序,则缓存控制器必须将状态转换为 IM^AD 以防止进一步的加载命中。 正如我们在侧边栏中所讨论的,S 到 M 转换期间的漏洞窗口使添加升级事务变得复杂。

原书侧边栏:没有原子请求的系统中的升级事务 对于具有原子请求 (Atomic Request) 的协议,升级事务 (Upgrade transaction) 是缓存从 Shared 转换为 Modified 的有效方式。 升级请求使所有共享副本失效,并且比发出 GetM 快得多,因为请求者只需要等待升级被序列化(即总线仲裁延迟),而不是等待来自 LLC/内存的数据到达。 但是,如果没有原子请求,添加升级事务变得更加困难,因为在发出请求和请求被序列化之间存在漏洞窗口。 由于在此漏洞窗口期间序列化的 Other-GetM 或 Other-Upgrade,请求者可能会丢失其共享副本。 解决这个问题的最简单的方法是将块的状态更改为一个新状态,在该状态下它等待自己的升级被序列化。 当其 Upgrade 被序列化时,这将使其他 S 副本(如果有)无效但不会返回数据,然后核心必须发出后续 GetM 请求以转换到 M。 更有效地处理升级是困难的,因为 LLC/内存需要知道何时发送数据。 考虑核心 C0 和 C2 共享一个块 A 并寻求升级它,同时核心 C1 寻求读取它的情况。 C0 和 C2 发出升级请求,C1 发出 GetS 请求。 假设它们在总线上序列化为 C0、C1 和 C2。 C0 的升级成功,因此 LLC/内存(处于 IorS 状态)应将其状态更改为 M 但不发送任何数据,C2 应使其 S 副本无效。 C1 的 GetS 在 C0 处找到处于状态 M 的块,它以新的数据值响应并将 LLC/内存更新回状态 IorS。 C2 的 Upgrade 终于出现了,但是因为它丢失了共享副本,需要LLC/内存来响应。 不幸的是,LLC/内存处于 IorS 状态,无法判断此升级需要数据。 存在解决此问题的替代方案,但不在本入门的范围内。 漏洞窗口也以更显着的方式影响 M-to-I 一致性降级。为替换状态为 M 的块,缓存控制器发出 PutM 请求并将块状态更改为 MI^A;与第 7.2.2 节中的协议不同,它不会立即将数据发送到内存控制器。在总线上观察到 PutM 之前,块的状态实际上是 M 并且缓存控制器必须响应其他核心对该块的一致性请求。在没有干预一致性请求到达的情况下,缓存控制器通过将数据发送到内存控制器并将块状态更改为状态 I 来响应观察自己的 PutM。如果干预的 GetS 或 GetM 请求在 PutM 排序之前到达,缓存控制器必须像处于状态 M 一样做出响应,然后转换到状态 II^A 以等待其 PutM 出现在总线上。一旦它看到它的 PutM,直观地,缓存控制器应该简单地转换到状态 I,因为它已经放弃了块的所有权。不幸的是,这样做会使内存控制器卡在瞬间状态,因为它也接收到 PutM 请求。缓存控制器也不能简单地发送数据,因为这样做可能会覆盖有效数据。(注5)解决方案是,当缓存控制器在状态 II^A 中看到其 PutM 时,它会向内存控制器发送一条特殊的 NoData 消息。 NoData 消息向内存控制器表明它来自非所有者并让内存控制器退出其瞬间状态。内存控制器变得更加复杂,因为它需要知道如果它收到 NoData 消息应该返回到哪个稳定状态。我们通过添加第二个瞬态内存状态 M^D 来解决这个问题。请注意,这些瞬间状态代表了我们通常的瞬间状态命名约定的一个例外。在这种情况下,状态 X^D 表示内存控制器在收到 NoData 消息时应恢复到状态 X(如果收到数据消息则移动到状态 IorS)。

原书作者注5:考虑这样一种情况,核心 C1 在 M 中有一个块并发出一个 PutM,但核心 C2 执行 GetM,核心 C3 执行 GetS,两者都在 C1 的 PutM 之前排序。 C2 获取 M 中的块,修改块,然后响应 C3 的 GetS,用更新的块更新 LLC/内存。 当 C1 的 PutM 最终被排序时,写回数据会覆盖 C2 的更新。

7.2.4 运行示例

回到表 7.10 所示的运行示例,核心 C1 发出 GetS,核心 C2 发出 GetM。与前面的示例(在表 7.7 中)不同,消除 Atomic Request 属性意味着两个核心都会发出它们的请求并更改它们的状态。我们假设核心 C1 的请求恰好首先被序列化,并且 Atomic Transaction 属性确保 C2 的请求在 C1 的事务完成之前不会出现在总线上。在 LLC/内存响应完成 C1 的事务后,核心 C2 的 GetM 在总线上被序列化。 C1 使其副本无效,LLC/内存响应 C2 以完成该事务。最后,C1 发出另一个 GetS。当这个 GetS 到达总线时,所有者 C2 以数据响应并将其状态更改为 S。C2 还将数据的副本发送到内存控制器,因为 LLC/内存现在是所有者并且需要一个 up-to-date 的块的副本。在此执行结束时,C1 和 C2 处于状态 S,LLC/内存处于状态 IorS。

1730709616673

7.2.5 协议简化 该协议相对简单,并牺牲了性能来实现这种简单性。 最重要的简化是在总线上使用原子事务。 拥有原子事务消除了许多可能的转换,在表中用“(A)”表示。 例如,当一个核心有一个处于 IM^D 状态的缓存块时,该核心不可能观察到另一个核心对该块的一致性请求。 如果事务不是原子的,则可能会发生此类事件,并会迫使我们重新设计协议来处理它们,如第 7.5 节所示。

另一个牺牲性能的显着简化涉及对状态 S 的缓存块的存储请求事件。在此协议中,缓存控制器发出 GetM 并将块状态更改为 SM^AD。 如前面的侧边栏所述,更高性能但更复杂的解决方案将使用升级事务。

7.3 ADDING THE EXCLUSIVE STATE

TODO

第八章:目录一致性协议

在本章中,我们介绍目录一致性协议 (directory coherence protocol)。 最初开发目录协议是为了解决监听协议缺乏可扩展性的问题。 传统的监听系统在一个完全有序的互连网络上广播所有请求,并且所有请求都被所有一致性控制器监听。 相比之下,目录协议使用一定程度的间接性来避免有序广播网络和让每个缓存控制器处理每个请求。

我们首先在高层次介绍目录协议(第 8.1 节)。 然后,我们展示了一个具有完整但简单的三态 (MSI) 目录协议的系统(第 8.2 节)。 该系统和协议作为我们稍后添加系统功能和协议优化的基准。 然后我们解释如何将独占状态(第 8.3 节)和拥有状态(第 8.4 节)添加到基准 MSI 协议。 接下来我们讨论如何表示目录状态(第 8.5 节)以及如何设计和实现目录本身(第 8.6 节)。 然后,我们描述了提高性能和降低实现成本的技术(第 8.7 节)。 然后,在讨论目录协议及其未来(第 8.9 节)结束本章之前,我们将讨论具有目录协议的商用系统(第 8.8 节)。

8.1 目录协议简介

目录协议的关键创新是建立一个目录,维护每个块的一致性状态的全局视图。 目录跟踪哪些缓存保存每个块以及处于什么状态。 想要发出一致性请求(例如,GetS)的缓存控制器将其直接发送到目录(即,单播消息),并且目录查找块的状态以确定接下来要采取的操作。 例如,目录状态可能表明请求的块由核心 C2 的缓存拥有,因此应将请求转发 (forward) 到 C2(例如,使用新的 Fwd-GetS 请求)以获取块的副本。 当 C2 的缓存控制器接收到这个转发的请求时,它会向请求的缓存控制器单播响应

比较目录协议和监听协议的基本操作是有启发性的。在目录协议中,目录维护每个块的状态,缓存控制器将所有请求发送到目录。目录要么响应请求,要么将请求转发给一个或多个其他一致性控制器然后响应。一致性事务通常涉及两个步骤(单播请求,随后是单播响应)或三个步骤(单播请求,K >= 1 个转发请求和 K 个响应,其中 K 是共享者的数量)。一些协议甚至还有第四步,因为响应是通过目录间接进行的,或者因为请求者在事务完成时通知目录。相比之下,监听协议将块的状态分布在可能的所有一致性控制器上。因为没有对这种分布式状态的中央总结,所以必须将一致性请求广播到所有一致性控制器。因此,监听一致性事务总是涉及两个步骤(广播请求,随后是单播响应)。

与监听协议一样,目录协议需要定义一致性事务何时以及如何相对于其他事务进行排序。在大多数目录协议中,一致性事务是在目录中排序的。多个一致性控制器可以同时向目录发送一致性请求,事务顺序由请求在目录中的序列化顺序决定。如果两个请求竞争 (race) 到目录,互连网络有效地选择目录将首先处理哪个请求。第二个到达的请求的命运取决于目录协议和竞争的请求类型。第二个请求可能会(a)在第一个请求之后立即处理,(b)在等待第一个请求完成时保留在目录中,或者(c)否定确认 (negatively acknowledged, NACKed)。在后一种情况下,目录向请求者发送否定确认消息 (NACK),请求者必须重新发出其请求。在本章中,我们不考虑使用 NACK 的协议,但我们会在第 9.3.2 节讨论 NACK 的可能使用以及它们如何导致活锁 (livelock) 问题。

使用目录作为排序点代表了目录协议和监听协议之间的另一个关键区别。 传统的监听协议通过序列化有序广播网络上的所有事物来创建总顺序。 监听的总顺序不仅可以确保每个块的请求按块顺序处理,而且还有助于实现内存连贯性模型 (memory consistency model)。 回想一下,传统的监听协议使用完全有序的广播来序列化所有请求; 因此,当请求者观察到其自己的一致性请求时,这将作为其一致性时期可能开始的通知。 特别是,当一个监听控制器看到自己的 GetM 请求时,它可以推断出其他缓存将使其 S 块无效。 我们在表 7.4 中证明了这个序列化通知足以支持强 SC 和 TSO 内存连贯性模型。

相反,目录协议对目录中的事务进行排序,以确保所有节点按块顺序处理冲突请求。 然而,缺少总顺序意味着目录协议中的请求者需要另一种策略来确定其请求何时被序列化,从而确定其一致性时期何时可以安全开始。 因为(大多数)目录协议不使用完全有序的广播,所以没有序列化的全局概念。 相反,必须针对(可能)具有块副本的所有缓存单独序列化请求。 需要显式消息来通知请求者其请求已被每个相关缓存序列化。 特别是,在 GetM 请求中,每个具有共享 (S) 副本的缓存控制器必须在序列化失效消息后发送显式确认 (Ack) 消息。

目录和监听协议之间的这种比较突出了它们之间的基本权衡。 目录协议以间接级别(即,对于某些事务具有三个步骤,而不是两个步骤)为代价实现了更大的可扩展性(即,因为它需要更少的带宽)。 这种额外的间接级别增加了一些一致性事务的延迟。

8.2 基准目录系统

8.2.1 目录系统模型

我们在图 8.1 中说明了我们的目录系统模型。 与监听协议不同的是,互连网络的拓扑是有意模糊的。 它可以是 mesh、torus 或架构师希望使用的任何其他拓扑。 我们在本章中假设的互连网络的一个限制是它强制执行点对点排序。 也就是说,如果控制器 A 向控制器 B 发送两条消息,则这些消息以与发送它们相同的顺序到达控制器 B。(注1) 点对点排序降低了协议的复杂性,我们将没有排序的网络讨论推迟到第 8.7.3 节。

原书作者注1:严格来说,我们只需要某些类型的消息的点对点顺序,但这是我们推迟到第 8.7.3 节的细节。

1730710594962

此目录系统模型与图 2.1 中的基准系统模型之间的唯一区别是我们添加了一个目录并将内存控制器重命名为目录控制器 (directory controller)。 有很多方法来调整和组织目录的大小,现在我们假设最简单的模型:对于内存中的每个块,都有一个对应的目录条目。 在 8.6 节中,我们检查和比较了更实用的目录组织选项。 我们还假设一个具有单个目录控制器的单片 LLC; 在第 8.7.1 节中,我们解释了如何在 LLC 的多个 bank 和多个目录控制器之间分配此功能。

8.2.2 高层次协议规范

基准目录协议只有三个稳定状态:MSI。 除非块处于状态 M 的缓存中,否则块由目录控制器拥有。每个块的目录状态包括稳定的一致性状态、所有者的身份(如果块处于状态 M)和 共享者编码为 one-hot 位向量(如果块处于状态 S)。 我们在图 8.2 中说明了一个目录条目。 在 8.5 节中,我们将讨论目录条目的其他编码。

1730710668406

在介绍详细规范之前,我们首先说明协议的更高层次的抽象,以了解其基本行为。 在图 8.3 中,我们展示了缓存控制器发出一致性请求以将权限从 I 更改为 S、I 或 S 更改为 M、M 更改为 I 以及 S 更改为 I 的事务。与上一章中的监听协议一样,我们使用以缓存为中心的符号指定块的目录状态(例如,目录状态 M 表示存在一个缓存,该块处于状态 M)。 请注意,缓存控制器可能不会静默地驱逐共享块; 也就是说,有一个明确的 PutS 请求。 我们将讨论具有静默驱逐共享块的协议,以及静默与显式 PutS 请求的比较,直到第 8.7.4 节。

1730710700587

大多数事务都相当简单,但有两个事务值得在这里进一步讨论。第一个是当缓存试图将权限从 I 或 S 升级到 M 并且目录状态为 S 时发生的事务。缓存控制器向目录发送 GetM,目录执行两个操作。首先,它用包含数据和“AckCount”的消息响应请求者; AckCount 是块当前共享者的数量。目录将 AckCount 发送给请求者,以通知请求者有多少共享者必须确认已使他们的块无效以响应 GetM。其次,目录向所有当前共享者发送无效 (Inv) 消息。每个共享者在收到 Invalidation 后,会向请求者发送 Invalidation-Ack (Inv-Ack)。一旦请求者收到来自目录的消息和所有 Inv-Ack 消息,它就完成了事务。收到所有 Inv-Ack 消息的请求者知道该块不再有任何读者,因此它可以在不违反一致性的情况下写入该块。

值得进一步讨论的第二个事务发生在缓存试图驱逐处于状态 M 的块时。在此协议中,我们让缓存控制器向目录发送包含数据的 PutM 消息。 该目录以 Put-Ack 响应。 如果 PutM 没有携带数据,那么协议将需要在 PutM 事务中发送第三条消息——从缓存控制器到目录的数据消息,被逐出的块已处于状态 M。 此目录协议中的 PutM 事务与监听协议中发生的不同,其中 PutM 不携带数据。

8.2.3 避免死锁

在这个协议中,消息的接收会导致一致性控制器发送另一个消息。一般来说,如果事件 A(例如,消息接收)可以导致事件 B(例如,消息发送)并且这两个事件都需要资源分配(例如,网络链接和缓冲区),那么我们必须小心避免可能发生的死锁,如果出现循环资源依赖。例如,GetS 请求会导致目录控制器发出 Fwd-GetS 消息;如果这些消息使用相同的资源(例如,网络链接和缓冲区),那么系统可能会死锁。在图 8.4 中,我们说明了一个死锁,其中两个一致性控制器 C1 和 C2 正在响应彼此的请求,但传入队列已经充满了其他一致性请求。如果队列是 FIFO,则响应无法通过请求。由于队列已满,每个控制器都会停止尝试发送响应。因为队列是先进先出的,控制器无法切换到处理后续请求(或获取响应)。因此,系统死锁。

1730719624385

避免一致性协议中的死锁的一个众所周知的解决方案是为每类消息使用单独的网络。 网络可以在物理上分离或在逻辑上分离(称为虚拟网络),但关键是避免消息类别之间的依赖关系。 图 8.5 说明了一个系统,其中请求和响应消息在不同的物理网络上传播。 因为一个响应不能被另一个请求阻塞,它最终会被它的目的节点消费,打破了循环依赖。

1730719668923

本节中的目录协议使用三个网络来避免死锁。 因为请求可以导致转发 (forward) 请求,而转发请求可以导致响应,所以存在三个消息类别,每个类别都需要自己的网络。 请求消息是 GetS、GetM 和 PutM。 转发的请求消息是 Fwd-GetS、Fwd-GetM、Inv (Invalidation) 和 Put-Ack。 响应消息是 Data 和 Inv-Ack。 本章中的协议要求转发请求网络提供点对点排序; 其他网络没有排序约束,在不同网络上传输的消息之间也没有任何排序约束。

我们推迟到第 9.3 节对避免死锁进行更彻底的讨论,包括对虚拟网络的更多解释和避免死锁的确切要求。

8.2.4 详细的协议规范

我们在表 8.1 和 8.2 中提供了详细的协议规范,包括所有瞬间状态。与第 8.2.2 节中的高层次描述相比,最显着的区别是瞬间状态。一致性控制器必须管理处于一致性事务中的块的状态,包括缓存控制器在将其一致性请求发送到目录和接收其所有必要的响应消息之间接收来自另一个控制器的转发请求的情况,包括数据和可能的 Inv-Ack。缓存控制器可以在未命中状态处理寄存器 (miss status handling register, MSHR) 中维护此状态,核心使用该寄存器跟踪未完成的一致性请求。在符号上,我们以 XY^AD 的形式表示这些瞬间状态,其中上标 A 表示等待确认,上标 D 表示等待数据。 (这种表示法不同于监听协议,其中上标 A 表示等待请求出现在总线上。)

1730719893584

1730719903399

8.2.5 协议操作

该协议使缓存能够获取处于状态 S 和 M 的块,并将块替换到处于这两种状态中的目录中。

I to S (common case #1)

缓存控制器向目录发送 GetS 请求并将块状态从 I 更改为 IS^D。 目录接收到这个请求,如果该目录是所有者(即当前没有缓存在 M 中拥有该块),则该目录以 Data 消息响应,将块的状态更改为 S(如果它已经不是 S),并且 将请求者添加到共享者列表 (sharer list)。 当数据到达请求者时,缓存控制器将块的状态更改为 S,完成事务。

I to S (common case #2)

缓存控制器向目录发送 GetS 请求并将块状态从 I 更改为 IS^D。 如果目录不是所有者(即存在当前在 M 中具有块的缓存),则目录将请求转发给所有者并将块的状态更改为瞬态 S^D。 所有者通过向请求者发送 Data 并将块的状态更改为 S 来响应此 Fwd-GetS 消息。现在的先前所有者还必须将 Data 发送到目录,因为它正在放弃对目录的所有权,该目录必须具有块的最新副本。 当数据到达请求者时,缓存控制器将块状态更改为 S 并认为事务完成。 当 Data 到达目录时,目录将其复制到内存中,将块状态更改为 S,并认为事务完成。

I to S (race cases)

上述两种 I-to-S 场景代表了常见的情况,其中正在进行的块只有一个事务。 该协议的大部分复杂性源于必须处理一个块的多个正在进行的事务的不太常见的情况。 例如,读者可能会惊讶于缓存控制器可以接收到状态 IS^D 的块的无效 (Invalidation)。 考虑发出 GetS 并转到 IS^D 的核心 C1 和另一个核心 C2,它为在 C1 的 GetS 之后到达目录的同一块发出 GetM。 该目录首先发送 C1 Data 以响应其 GetS,然后发送 Invalidation 以响应 C2 的 GetM。 由于 Data 和 Invalidation 在不同的网络上传输,它们可能会乱序到达,因此 C1 可以在数据之前接收失效。

I or S to M

缓存控制器向目录发送 GetM 请求并将块的状态从 I 更改为 IM^AD。在这种状态下,缓存等待 Data 和(可能的)Inv-Ack,表明其他缓存已经使它们在状态 S 中的块副本无效。缓存控制器知道需要多少 Inv-Ack,因为 Data 消息包含 AckCount,可能为零。图 8.3 说明了目录响应 GetM 请求的三种常见情况。如果目录处于状态 I,它只需发送 AckCount 为零的数据并进入状态 M。如果处于状态 M,目录控制器将请求转发给所有者并更新块的所有者;现在以前的所有者通过发送 AckCount 为零的 Data 来响应 Fwd-GetM 请求。最后一种常见情况发生在目录处于状态 S 时。目录以 Data 和等于共享者数量的 AckCount 进行响应,另外它向共享者列表中的每个核心发送 Invalidation。接收 Invalidation 消息的缓存控制器使其共享副本无效并将 Inv-Ack 发送给请求者。当请求者收到最后一个 Inv-Ack 时,它转换到状态 M。注意表 8.1 中的特殊 Last-Inv-Ack 事件,它简化了协议规范。

这些常见情况忽略了一些突出目录协议并发性的可能竞争。 例如,核心 C1 的缓存块处于 IM^A 状态,并从 C2 的缓存控制器接收 Fwd-GetS。 这种情况是可能的,因为目录已经向 C1 发送了 Data,向共享者发送了 Invalidation 消息,并将其状态更改为 M。当 C2 的 GetS 到达目录时,目录将其简单地转发给所有者 C1。 这个 Fwd-GetS 可能在所有 Inv-Ack 到达 C1 之前到达 C1。 在这种情况下,我们的协议只是停止并且缓存控制器等待 Inv-Ack。 因为 Inv-Ack 在单独的网络上传输,所以保证它们不会阻塞在未处理的 Fwd-GetS 后面。

M to I

为了驱逐处于状态 M 的块,缓存控制器发送一个包含数据的 PutM 请求并将块状态更改为 MI^A。 当目录接收到这个 PutM 时,它会更新 LLC/内存,以 Put-Ack 响应,并转换到状态 I。在请求者收到 Put-Ack 之前,块的状态保持有效 M 并且缓存控制器必须响应转发块的一致性请求。 如果缓存控制器在发送 PutM 和接收 Put-Ack 之间收到转发的一致性请求(Fwd-GetS 或 Fwd-GetM),缓存控制器响应 Fwd-GetS 或 Fwd-GetM 并更改其块状态分别到 SI^A 或 II^A。 这些瞬间状态实际上分别是 S 和 I,但表示缓存控制器必须等待 Put-Ack 完成到 I 的转换。

S to I

与前一章中的监听协议不同,我们的目录协议不会静默地驱逐状态 S 中的块。相反,为了替换状态 S 中的块,缓存控制器发送 PutS 请求并将块状态更改为 SI^A。 目录接收此 PutS 并以 Put-Ack 响应。 在请求者收到 Put-Ack 之前,block 的状态实际上是 S。如果缓存控制器在发送 PutS 之后并且在收到 Put-Ack 之前收到 Invalidation 请求,它会将 block 的状态更改为 II^A。 这种瞬间状态实际上是 I,但它表示缓存控制器必须等待 Put-Ack 完成从 S 到 I 的事务。

8.2.6 协议简化

该协议相对简单,并牺牲了一些性能来实现这种简单性。 我们现在讨论两个简化:

除了只有三个稳定状态之外,最重要的简化是协议在某些情况下停止。 例如,缓存控制器在处于瞬间状态时收到转发的请求时会停止。 在第 8.7.2 节中讨论的更高性能选项是处理消息并添加更多瞬间状态。 第二个简化是目录发送 Data(和 AckCount)以响应将块状态从 S 更改为 M 的缓存。缓存已经有有效数据,因此目录只需发送 data-less AckCount 就足够了。 我们推迟添加这种新类型的消息,直到我们在第 8.4 节中介绍 MOSI 协议。

8.3 ADDING THE EXCLUSIVE STATE

正如我们之前在snoop协议的上下文中所讨论的那样,添加Exclusive (E)状态是一个重要的优化,因为它使核心能够仅通过一个coherence事务读取和写入块,而不是MSI协议所要求的两个coherence事务。在最高级别上,这种优化与缓存一致性是使用snoop还是使用目录无关。如果一个内核发出了get请求,并且该块当前没有被其他内核共享,那么请求者可以获得状态为E的块,然后内核可以静默地将块的状态从E升级到M,而无需发出另一个一致性请求

在本节中,我们将E状态添加到基线MSI目录协议中。与前一章的MESI窥探协议一样,协议的操作取决于E状态是否被视为所有权状态。而且,与MESI窥探协议一样,主要的操作差异涉及确定哪个一致性控制器应该响应对目录提供给状态E的缓存的块的请求。自从目录将块提供给状态E的缓存以来,块可能已经从E默默地升级到M。

在拥有E块的协议中,解决方案很简单。在E(或M)中包含块的缓存是所有者,因此必须响应请求。发送到目录的一致性请求将被转发到状态为E的块的缓存中,因为E状态是所有权状态,所以E块的删除不能静默执行;缓存必须向目录发出PutE请求。如果没有显式的PutE,目录将不知道该目录现在是所有者,并且应该响应传入的一致性请求。因为我们在本入门中假设E中的块是拥有的,所以这个简单的解决方案就是我们在本节的MESI协议中实现的。

在不拥有E块的协议中,E块可以被静默地驱逐,但这会增加协议的复杂性。考虑这样一种情况:核心C1获得状态E的块,然后目录从核心C2接收GetS或GetM。目录知道C1要么 i)仍然处于状态E, ii)处于状态M(如果C1执行了从E到M的静默升级),要么 iii)处于状态I(如果协议允许C1执行静默PutE)。如果C1在M中,则目录必须将请求转发给C1,以便C1可以提供最新版本的数据。如果C1在E中,则C1或目录可能会响应,因为它们都具有相同的数据。如果C1在I中,则目录必须响应。一个解决方案是让C1和目录都响应,我们将在第8.8.1节中对SGI Origin[10]的案例研究中详细描述。另一种解决方案是让目录将请求转发给C1。如果C1在I中,C1通知目录响应C2;否则,C1响应C2并通知目录它不需要响应C2。

8.3.1 High-Level Protocol Specification

我们在图8.6中指定了事务的高级视图,突出显示了与MSI协议的不同之处。只有两个显著的区别

首先,从I到E有一个过渡 如果目录接收到状态I的块的get,则会发生这种情况。其次,存在一个用于驱逐状态E的块的PutE事务。因为E是所有权状态,所以E块不能被静默地驱逐。与处于M状态的块不同,E块是干净的,因此PutE不需要携带数据;该目录已经拥有该块的最新副本

1730727710073

8.3.2 Detailed Protocol Specification

在表8.3和8.4中,我们给出了MESI协议的详细规范,包括瞬态状态。关于MSI协议的差异用黑体字突出显示。该协议向缓存状态集添加稳定E状态和瞬态状态,以处理最初处于状态E的块的事务。

这个协议比MSI协议稍微复杂一些,在目录控制器上增加了很多复杂性。除了具有更多状态之外,目录控制器还必须区分更多可能的事件。例如,当一个PutS到达时,目录必须区分这是否是“最后一次”PutS;也就是说,这个put是否来自唯一的当前共享者?如果这个PutS是最后一个PutS,那么目录的状态变为I。

1730728271136

1730728283074

8.4 ADDING THE OWNED STATE

出于同样的原因,我们在第7章中将Owned状态添加到基线MSI窥探协议中,架构师可能希望将Owned状态添加到第8.2节中介绍的基线MSI目录协议中。回顾第二章,如果一个缓存中有一个块处于Owned状态,那么这个块是有效的,只读的,脏的(也就是说,它最终必须更新内存),并且是拥有的(也就是说,缓存必须响应块的一致性请求)。与MSI相比,添加Owned状态在三个重要方面改变了协议:(1)在M中观察Fwd-GetS的块的缓存将其状态更改为O,并且不需要(立即)将数据复制回LLC/内存,(2)缓存(在O状态下)比LLC/内存满足更多的一致性请求,(3)有更多的3-hop 事务(这将由MSI协议中的LLC/内存满足)

8.4.1 High-Level Protocol Specification

我们在图8.7中指定了事务的高级视图,突出显示了与MSI协议的不同之处。最有趣的区别是,当处于状态I或S的块在所有者缓存中处于O状态,而在一个或多个共享器缓存中处于S状态时,请求者向目录发送GetM的事务。在这种情况下,目录将GetM转发给所有者,并追加AckCount。该目录还将Invalidations发送给每个共享器。所有者接收Fwd-GetM并使用Data和AckCount响应请求者。请求者使用这个接收到的AckCount来确定它何时收到了最后一个Inv-Ack。如果GetM的请求者是所有者(处于状态O),则会有类似的事务。这里的不同之处在于,目录将AckCount直接发送给请求者,因为请求者是所有者。

该协议有一个与PutM事务几乎相同的PutO事务。它包含数据的原因与PutM事务包含数据的原因相同,即,因为M和O都是脏状态

1730729332062

8.4.2 Detailed Protocol Specification

表8.5和8.6给出了MOSI协议的详细规范,包括瞬态状态。关于MSI协议的差异用黑体字突出显示。该协议向缓存状态集中添加了稳定的O状态以及瞬态OM^AC、OM^A和OI^A状态来处理最初处于状态o的块的事务。状态OM^AC表明缓存正在等待来自缓存的inv - ack (A)和来自目录的AckCount (C),但不是数据。因为这个块是从状态O开始的,所以它已经有了有效的数据。

当核心C1的缓存控制器在OM^AC或SM^AD中有一个块并从核心C2接收该块的Fwd-GetM或Invalidation时,会出现一种有趣的情况。要出现这种情况,必须在C1的GetM之前在目录中对C2的GetM进行排序。因此,在观察C1的GetM之前,目录状态更改为M(由C2拥有)。当C2的Fwd-GetM或Invalidation到达C1时,C1必须知道C2的GetM是先订购的。因此,C1的缓存状态从OM^AC或SM^AD变为IM^AD。从C2转发的GetM或Invalidation使C1的缓存块无效,现在C1必须等待Data和inv - ack。

1730729680237

1730729692527

8.5表示目录状态

在前面的小节中,我们假设了一个完整的目录;也就是说,目录维护每个块的完整状态,包括(可能)具有共享副本的完整缓存集。然而,这个假设与目录协议的主要动机相矛盾:可扩展性(scalability)。在具有大量缓存的系统中(即,一个块的大量潜在共享者),维护每个块的完整共享者集需要大量的存储空间,即使使用紧凑的位向量表示也是如此。对于具有适度数量的缓存的系统,能够维护这个完整的集合可能是有原因的,但是大型系统的架构师可能希望有更可扩展的解决方案来维护目录状态。有许多方法可以减少目录为每个块维护的状态量。这里我们讨论两种重要的技术:粗目录和有限指针(coarse directories and limited pointers.)。我们单独讨论这些技术,但注意到它们可以结合使用。我们将每个解决方案与基线设计进行对比,如图8.8顶部条目所示。

1730730454827

8.5.1 Coarse Directory

拥有完整的共享器集使目录能够将Invalidation消息准确地发送到那些处于状态S的块的缓存控制器。减少目录状态的一种方法是保守地维护一个粗略的共享器列表,该列表是实际共享器集的超集。也就是说,共享器列表中的一个给定条目对应于一组K个缓存,如图8.8中间的条目所示。如果该集合中的一个或多个缓存(可能)的块处于状态S,则设置共享器列表中的该位。GetM将导致目录控制器向该集合中的所有K个缓存发送Invalidation。因此,粗目录减少了目录状态,代价是不必要的Invalidation消息带来额外的互连网络带宽,以及处理这些额外的Invalidation消息所需的缓存控制器带宽。

8.5.2 Limited Pointer Directory

在带有C缓存的芯片中,一个完整的共享列表需要C个条目,每个条目一个位,总共C位。

然而,研究表明,许多区块没有共享者或只有一个共享者。有限指针目录通过拥有i (i< C)个条目来利用这一观察结果,其中每个条目需要log2C位,总共i * log2C位,如图8.8底部条目所示。

需要一些额外的机制来处理(希望不常见)系统试图添加第i+1个共享者的情况。有三个经过充分研究的选项可以处理这些情况,使用符号DiriX[2,8]表示,其中i指的是指向共享器的指针数量,X指的是处理系统试图添加第i+1个共享器的情况的机制。

广播(DiriB):如果已经有i个共享器,并且另一个getS到达,目录控制器将设置块的状态,以指示后续的GetM要求目录将Invalidation广播到所有缓存(即新的“太多共享器”状态)。DiriB的一个缺点是,即使只有K个共享器(i<K<C),目录也必须广播到所有C缓存,这要求目录控制器发送(以及缓存控制器处理)C-K个不必要的Invalidation消息。极限情况Dir0B将这种方法发挥到了极致,它消除了所有指针,并要求对所有一致性操作进行广播。最初的Dir0B提议在每个区块中保持两个状态位,编码三个MSI状态加上一个特殊的“Single Sharer”状态[3]。当缓存试图将其S副本升级到M副本时,这种新状态有助于消除广播(类似于Exclusive状态优化)。类似地,当内存拥有该块时,目录的I状态消除广播。AMD的Coherent HyperTransport[6]实现了一个不使用目录状态的Dir0B版本,放弃了这些优化,但消除了存储任何目录状态的需要。然后,发送到该目录的所有请求被广播到所有缓存。

无广播(DiriNB):如果已经有i个共享器,并且另一个getS到达,则目录要求当前共享器中的一个使自身无效,以便在共享器列表中为新的请求者腾出空间。对于广泛共享的块(即由超过i个节点共享的块),由于使共享器失效所花费的时间,此解决方案可能会导致显著的性能损失。

对于具有一致指令缓存的系统来说,DiriNB尤其成问题,因为代码经常被广泛共享。

软件(DiriSW):如果已经有i个共享器,并且另一个get到达,系统将trap到一个软件处理程序。trap到软件提供了极大的灵活性,例如在软件管理的数据结构中维护完整的共享者列表。然而,由于对软件的捕获会导致显著的性能成本和实现复杂性,因此这种方法在商业上的接受程度有限。

8.6目录组织

逻辑上,该目录包含每个内存块的单个条目。许多传统的基于目录的系统(其中目录控制器与内存控制器集成)通过增加内存来保存目录,直接实现了这种逻辑抽象。例如,SGI Origin在每个内存块中添加了额外的DRAM芯片来存储完整的目录状态[10]。

然而,对于今天的多核处理器和大型有限责任公司,传统的目录设计已经没有什么意义了。首先,架构师不希望目录访问片外内存的延迟和功率开销,特别是缓存在芯片上的数据。其次,当几乎所有的内存块在任何给定的时间都没有缓存时,系统设计者对大的片外目录状态犹豫不决。这些缺点促使架构师通过在芯片上仅缓存目录条目的子集来优化常见情况。在本节的其余部分,我们将讨论目录缓存设计,其中有几种是Marty和Hill先前分类的[13]。

与传统的指令和数据缓存一样,目录缓存[7]提供了对完整目录状态子集的更快访问。由于目录总结了一致性缓存的状态,因此它们表现出与指令和数据访问相似的局部性,但只需要存储每个块的一致性状态,而不需要存储其数据。因此,相对较小的目录缓存实现了高命中率。

目录缓存对一致性协议的功能没有影响;它只是减少了平均目录访问延迟。目录缓存在多核处理器时代变得更加重要。在内核位于单独的芯片和/或电路板上的旧系统中,消息延迟足够长,以至于它们倾向于摊销目录访问延迟。

在多核处理器中,消息可以在几个周期内从一个核心传递到另一个核心,而片外目录访问的延迟往往会使通信延迟相形见绌,并成为瓶颈。因此,对于多核处理器来说,实现片内目录缓存以避免代价高昂的片外访问是一种强烈的动机。

片上目录缓存包含目录条目的完整集合的子集。因此,关键的设计问题是处理目录缓存缺失,即,当一个一致性请求到达一个目录条目不在目录缓存中的块时。

我们总结表8.7中的设计选项,并在下面进行描述。

8.6.1 Directory Cache Backed by DRAM

最直接的设计是将完整的目录保存在DRAM中,就像传统的多芯片多处理器一样,并使用单独的目录缓存结构来减少平均访问延迟。一致性请求在这个目录缓存中丢失导致访问这个DRAM目录。这种设计虽然简单,但有几个重要的缺点。首先,它需要大量的DRAM来保存目录,包括当前不在芯片上缓存的绝大多数块的状态。其次,由于目录缓存与LLC解耦,因此有可能在LLC中命中,但在目录缓存中未命中,从而导致DRAM访问,即使数据在本地可用。最后,目录缓存替换必须将目录条目写回DRAM,这会导致高延迟和功耗开销。

1730731895750

8.6.2 Inclusive Directory Caches

我们可以设计更经济有效的目录缓存,因为我们只需要缓存正在芯片上缓存的块的目录状态。如果目录缓存保存了芯片上缓存的所有块的超集的目录条目,我们将目录缓存称为inclusive目录缓存。一个inclusive目录缓存作为一个“完美的”目录缓存,永远不会错过访问缓存在芯片上的块。不需要在DRAM中存储完整的目录。包含目录缓存中的缺失表明该块处于状态I;miss并不是访问某个后备目录存储的前兆。

我们现在讨论两种包含目录缓存设计,以及适用于这两种设计的优化

8.6.2.1 Inclusive directory cache embedded in inclusive LLC

最简单的目录缓存设计依赖于与上层缓存保持inclusive的LLC。缓存包含意味着如果一个块在一个高级缓存中,那么它也必须出现在一个低级缓存中。对于图8.1的系统模型,LLC包含意味着如果一个块在一个核心的L1缓存中,那么它也必须在LLC中。

inclusiveLLC的结果是,如果一个块不在LLC中,它也不在L1缓存中,因此对于芯片上的所有缓存必须处于状态I ,inclusive目录缓存通过在LLC中嵌入每个块的一致性状态来利用此属性。如果将一致性请求发送到LLC/目录控制器,并且请求的地址不存在于LLC中,则目录控制器知道所请求的块未缓存在芯片上,因此在所有l1中处于状态I。

因为目录镜像了LLC的内容,所以整个目录缓存可以简单地通过在LLC中的每个块中添加额外的位而嵌入到LLC中。这些添加的位可能导致严重的开销,这取决于内核的数量和表示目录状态的格式。我们在图8.9中说明了将这种目录状态添加到LLC缓存块中,并将其与没有嵌入LLC目录缓存的系统中的LLC块进行比较。

不幸的是,LLC inclusion 有几个重要的缺点。首先,对于私有缓存层次结构(如果底层缓存具有足够的关联性[4]),可以自动维护LLC包含,对于我们系统模型中的共享缓存,在替换LLC中的块时,通常需要发送特殊的“召回”请求来使L1缓存中的块失效(在8.6.2.3节中进一步讨论)。更重要的是,LLC包含需要维护位于上层缓存中的缓存块的冗余副本。在多核处理器中,上层缓存的总容量可能是LLC容量的很大一部分(有时甚至大于)

1730733002842

8.6.2.2 Standalone Inclusive Directory Cache

我们现在提出了一个不依赖于LLC包含的包含目录缓存设计。在这种设计中,目录缓存是一个独立的结构,它在逻辑上与目录控制器相关联,而不是嵌入在LLC本身中。为了使目录缓存具有inclusive,它必须包含所有L1缓存中块的联合目录条目,因为LLC中的块(但不包含任何L1缓存中的块)必须处于状态I。因此,在这种设计中,目录缓存由所有L1缓存中标记的重复副本组成。与之前的设计(第8.6.2.1节)相比,这种设计更灵活,因为不需要LLC包含,但它增加了重复标签的存储成本。

这个inclusive目录缓存的实现成本很高。最值得注意的是,它需要一个高度关联的目录缓存。(如果我们在包含LLC中嵌入目录缓存(章节8.6.2.1),那么LLC也必须是高度关联的。)考虑具有C个内核的芯片的情况,每个内核都有一个k路集关联L1缓存。目录缓存必须是C*K-way关联的,以保存所有L1缓存标记,不幸的是,这种关联性随着核心数线性增长。我们在图8.10中说明了K=2时的这个问题。

为了使目录缓存保持最新,包容性目录缓存设计也引入了一些复杂性。当一个块从L1缓存中被驱逐时,缓存控制器必须通过发出显式的PutS请求来通知目录缓存哪个块被替换了(例如,我们不能使用带有静默驱逐的协议,如第8.7.4节所讨论的)。一种常见的优化是在get或GetX请求上附带显式put。由于索引位必须相同,因此可以通过指定替换的方式对PutS进行编码。这有时被称为“替换提示”,尽管通常它是必需的(而不是真正的“提示”)。

1730733294157

8.6.2.3 Limiting the Associativity of Inclusive Directory Caches

为了克服先前实现中高关联目录缓存的成本,我们提出了一种限制其关联性的技术。我们不是为最坏情况(CK结合性)设计目录缓存,而是通过不允许最坏情况发生来限制结合性。也就是说,我们将目录缓存设计为A路集关联的,其中A<CK,并且我们不允许在芯片上缓存映射到给定目录缓存集的条目超过A个。当缓存控制器发出一个corherence请求,将一个块添加到它的缓存中,而目录缓存中相应的集合已经充满了有效条目时,目录控制器首先从所有缓存中取出该集合中的一个块,目录控制器执行此退出操作通过向持有该块的所有缓存发送“召回”请求,缓存将响应确认。一旦目录缓存中的条目通过此回调被释放,那么目录控制器就可以处理触发回调的原始一致性请求。

使用recall克服了在目录缓存中对高关联性的需求,但是如果不仔细设计,它可能会导致较差的性能。如果目录缓存太小,则会频繁发生召回,性能将受到影响。Conway等人[6]提出了一个经验法则,即目录缓存应该至少覆盖它所包含的聚合缓存的大小,但它也可以更大,以减少召回率。此外,为了避免不必要的回收,此方案最适合于状态s中的块的非静默回收。使用静默回收,不必要的回收将被发送到不再保存被回收块的缓存中。

8.6.3 Null Directory Cache (with no backing store)

成本最低的目录缓存是根本没有目录缓存。回想一下,目录状态有助于修剪要转发一致性请求的一致性控制器集。但是,与粗目录(第8.5.1节)一样,如果这种修剪不完全完成,协议仍然可以正常工作,但是会发送不必要的消息,并且协议的效率比它可能的要低。在极端情况下,Dir0B协议(第8.5.2节)不进行任何修剪,在这种情况下,它实际上根本不需要目录(或目录缓存)。每当一致性请求到达目录控制器时,目录控制器简单地将其转发到所有缓存(即广播转发的请求)。这种目录缓存设计(我们称之为空目录缓存)可能看起来很简单,但它在中小型系统中很流行,因为它不需要存储成本。

如果没有目录状态,可能会有人质疑目录控制器的目的,但是它有两个重要的作用。首先,与本章中的所有其他系统一样,目录控制器负责LLC;更准确地说,它是一个LLC/目录控制器。第二,目录控制器作为协议中的排序点;如果多个内核并发地请求同一个块,则在目录控制器上对请求进行排序。目录控制器解析哪个请求首先发生。

8.7 PERFORMANCE AND SCALABILITY OPTIMIZATIONS

8.7.1 Distributed Directories

1730734201922

到目前为止,我们假设有一个目录附加到单个整体 LLC。 这种设计显然有可能在这个共享的中央资源上造成性能瓶颈。 集中式瓶颈问题的典型、通用解决方案是分配资源。 给定块的目录仍然固定在一处,但不同的块可以有不同的目录。

在较旧的具有 N 个节点的多芯片多处理器中(每个节点由多个芯片组成,包括处理器核心和内存),每个节点通常具有与其关联的内存的 1/N 以及相应的目录状态的 1/N 。

我们在图 8.11 中说明了这样一个系统模型。 节点的内存地址分配通常是静态的,并且通常可以使用简单的算术轻松计算。 例如,在具有 N 个目录的系统中,块 B 的目录项可能位于目录 B 模 N 处。每个块都有一个主目录,即保存其内存和目录状态的目录。 因此,我们最终得到一个系统,其中有多个独立的目录管理不同块集的一致性。 与要求所有一致性流量通过单个中央资源相比,拥有多个目录可以提供更大的一致性事务带宽。 重要的是,分发目录对一致性协议没有影响。

在当今具有大型 LLC 和目录缓存的多核处理器世界中,分发目录的方法在逻辑上与传统多芯片多处理器中的方法相同。 我们可以分发(存储)LLC 和目录缓存。 每个块都有一个 LLC 主库及其关联的目录缓存库。

8.7.2 Non-Stalling Directory Protocols

介绍

预取要干的事情:

  1. 预测地址
  2. 预测何时预取
  3. 选择数据放在何处

覆盖率衡量预取成功的显式处理器请求的比例(即,预取消除的需求缺失的比例)。准确性衡量的是预取器发出的访问中有用的部分(即,正确预取占所有预取的比例)

目前大部分处理器将预取数据放入缓存或者额外的缓冲区,这种策略称为非绑定策略

指令预取

NEXT-LINE PREFETCHING

利用了空间局部性,每次处理器请求预取,他就从下级存储的数据转到缓存,然后预取下一个块

1729870525497

FETCH-DIRECTED PREFETCHING

1729870866685

分支预测定向与曲奇重用分支预测器探索未来控制流,这些技术使用分支预测器进行递归预测,找到地址

1729870959425

Fetch-directed instruction prefetching (FDIP):这个技术是最好的一批,FDIP将BP和IF解耦,通过fetch target queue (FTQ),预取器使用FTQ从L2获取指令块,将他们放在全相连的缓存区,该缓冲区由指令获取单元与L1 缓存并行访问,为了避免其与L1重复,可以使用空闲的L1端口探测FTQ地址,看看是否存在,如果存在,就不进行预取,

DISCONTINUITY PREFETCHING

主要针对函数调用,获取分支和陷阱中断等进行预取

观察到服务器程序具有重复函数的深度调用堆栈后,图预取预测即将到来的函数堆栈,而不仅仅只是下一个不连续行为

1729872165002

而上图这个就是相当于只预测下一个不连续

PRESCIENT FETCH

TEMPORAL INSTRUCTION FETCH STREAMING

Temporal instruction fetch streaming (TIFS):记录Cache缺失顺序,然后进行预取

1729872496166

只是适用于单个控制流

RETURN-ADDRESS STACK-DIRECTED INSTRUCTION PREFETCHING

返回地址堆栈定向指令预取(RDIP)[26]使用额外的程序上下文信息来提高预测准确性和前瞻性。RDIP基于两个观察:(1)在调用堆栈中捕获的程序上下文与L1指令缺失密切相关;(2)返回地址堆栈(RAS)已经存在于所有高性能处理器中,它简洁地总结了程序上下文。RDIP将预取操作与由RAS内容形成的签名相关联。它将签名和相关的预取地址存储在约64kb的签名表中,在每次调用和返回操作时都要查看签名表以触发预取。RDIP实现了理想L1缓存70%的潜在加速,在一组服务器工作负载上比无预取器基准高出11.5%。

PROACTIVE INSTRUCTION FETCH

主动指令获取[27]将TIFS设计修改为(1)记录提交指令序列访问的缓存块序列(而不是缓存中丢失的指令获取)和(2)分别记录在中断/trap处理程序上下文中执行的流。该设计的一个关键创新是指令序列的压缩表示,它使用位向量来有效地编码预取地址之间的空间局域性。后续工作[28]将预取元数据集中在跨核共享的结构中,这大大降低了在多核共享元数据的同构设计中的存储成本。集中化将每核元数据减少到最小容量,使预取器甚至适用于具有小核的多核设计(例如,在移动/嵌入式平台中使用的核)。

1729872855349

数据预取

数据访问模式多样,访问模式复杂,

分为四类

STRIDE AND STREAM PREFETCHERS FOR DATA

这种预取器主要针对连续布局的数据,如数组和矩阵,但对于基于指针的数据结构不太行

stride 预取器关键的挑战就是区分多个交错的跨行序列,比如矩阵向量积,下图是每次load跟踪步幅,每个表保存load的最后一个地址,其实就是最新的addr,然后stride就是和之前的相减,每当连续两次观察到相同的stride,使用最一次的地址和stride计算预取地址

1729911213595

然后第二个问题就是检查到strided stream要预取多少块,这个参数被称为预取深度

ADDRESS-CORRELATING PREFETCHERS

上一种遇到链表类似的结构就寄了,这类是针对这种情况的,基于算法倾向于以相同的方式重复遍历数据结构,从而导致反复出现的缓存缺失序列,这个利用了temporal correlation.:即最近访问的地址很可能在不久的将来再次被访问

JUMP POINTERS

关联预取器是专门针对指针跟踪访问模式的硬件和软件机制的泛化。这些早期的机制依赖于跳转指针的概念,该指针在数据结构遍历中允许大的前向跳转。例如,链表中的一个节点可以用在链表中向前10个节点的指针进行扩充;预取器可以跟随跳转指针以获得对正在执行的主遍历的前瞻性。

依赖于跳转指针的预取器通常需要软件或编译器支持来注释指针。,内容导向预取器[47,48]避免注释,而是尝试解引用和预取任何看起来形成有效虚拟地址的负载值。虽然跳转指针机制对于特定的数据结构遍历(例如,链表遍历)非常有效,但它们的主要缺点是必须仔细平衡跳转指针推进遍历的距离,以便在不跳过太多元素的情况下提供足够的前瞻性。跳转指针的距离很难调优,而且指针本身的存储成本也很高

PAIR-WISE CORRELATION

本质上,基于关联的硬件预取器是一个查找表,它将一个地址映射到另一个可能在访问序列中紧随其后的地址。虽然这种关联可以捕获顺序关系和跨步关系,但它更通用,例如,捕获指针的地址和它所指向的地址之间的关系。

然而,地址相关的预取器依赖于重复;它们无法预取以前从未引用过的地址(与跨步预取器相反)。此外,地址相关预取器需要巨大的状态,因为它们需要存储每个地址的后继地址。

MARKOV PREFETCHER

马尔可夫预取器存储了几个先前观察到的后继者,当观察到触发器地址丢失时,所有这些后继者都被预取。通过预取几个可能的后继地址,马尔可夫预取器牺牲精度(正确预取占所有预取的比例)来提高覆盖范围

1729912849204

表中的条目包含要预取的后继地址集和可选的置信度或替换策略信息

马尔可夫预取器的设计灵感来自于对片外访问序列的马尔可夫模型的概念化。模型中的每个状态对应于一个触发地址,可能的后继状态对应于随后的错过地址。一阶马尔可夫模型中的转移概率对应于每个后继者失败的可能性。查找表的目标是存储最常遇到的触发器具有最高转移概率的后继者。然而,现有的硬件建议没有明确计算触发或转移概率;每个触发地址和后继地址都使用最近最少使用(LRU)替换启发式地进行管理。

有两个因素限制了马尔可夫预取器的有效性:(1)预读和内存级并行性是有限的,因为预取器只尝试预测下一次错过;(2)覆盖范围受到片上相关表容量的限制。

IMPROVING LOOKAHEAD VIA PREFETCH DEPTH

一种直接的方法就是获取更多地址,还有就是使用预测地址递归执行表查找来发出更深的预取,

与其连续找不如折叠预取表,每个预取触发器旁边存短序列的后续程序,然而,更深层次的问题是,这样的表组织必须为每个表项中提供的存储固定最大预取深度。

Chou和合著者观察到,在乱序核中,指令窗口经常导致几个片外失误并发[56]。当这组miss被处理时,执行会停止。一旦这些错误返回,指令窗口就可以前进,并且可以计算相关的地址,从而允许并行地发出下一组错误。它们将这些组中发出的失败次数的平均值称为应用程序的内存级并行性。

IMPROVING LOOKAHEAD VIA DEAD BLOCK PREDICTION

第二个方法就是为每个预取操作选较早的触发时间,

死块预测[51,54,60,61,62]是基于一个关键的观察,即缓存块大部分时间都在缓存死区中[63,64,65],也就是说,它们仍然处于缓存中,但在失效或移除之前不会被再次访问。

死缓存块占用存储空间,但不会提供进一步的缓存命中。DBCP试图预测对缓存块最后一次访问,也就是检测死块,

他依赖两个预测,首先就是必须预测缓存块何时失效,这个事件可以通过code correlation [51, 54, 62], or time keeping发现,代码相关预测就是识别缓存快被移除之前访问缓存的最后一条指令,也就是,在该快变为死块之前访问,代码相关性依赖于每次缓存快进入缓存,他们往往会被相同的load和store序列访问,从分配块的初始丢失,到访问序列,最后到块失效时的最后一次访问。

其优点就是可以从缓存块学习到的访存序列可以应用于预测其他地址的死亡事件

另外,计时机制试图预测缓存块死亡的时间,而不是指示其死亡的特定访问[61]。这种方法的观察结果是,块的生命周期(以时钟周期衡量)在每次进入缓存时趋于相似。在这样的设计中,马尔可夫预取表增加了一个额外的字段,表明块最后一次进入缓存时的生存期。然后,在某个合适的安全范围(例如,比先前的寿命长一倍)之后,该块被预测为死块。

一旦预测到死亡事件,就必须预测一个合适的预取候选项来代替它。早期的死块预取器使用类似马尔可夫的预测表来达到这个目的[51,61]。后来的提案[54]依赖于更复杂的时间流预测器

ADDRESSING ON-CHIP STORAGE LIMITATIONS

马尔可夫预取器限制其有效性的第二个关键方面是相关表的有限片上存储容量

提高马尔可夫预取器有效性的一种方法是提高片上相关表的存储效率,标签相关预取器[66]在相关表项中只存储标签而不是完整的缓存块地址。大型程序不太行

第二种方法是将相关表重新定位到主存,消除片上相关表的容量限制,然而,将相关表移出芯片会增加预取元数据的访问延迟,从几个时钟周期增加到芯片外内存引用的延迟

为了提高效率,片外关联表必须提供足够的前瞻性,以隐藏较长的元数据访问延迟一种方法是设计预取器来记录将来并行组(epoch)中内存引用的地址,如EBCP[57]。第二种方法是增加预取深度[52];即使第一个要预取的地址块不及时,后面的地址也会预取成功。增加预取深度的一种概括是时间流[42],它通过针对任意长度的流(即实际上无限的预取深度)来分摊片外元数据引用[67]。

GLOBAL HISTORY BUFFER

Nesbit和Smith在他们的全局历史缓冲区[68]中引入了一个关键的进步,就是将关联表分成两个结构:一个历史缓冲区,它按照发生的顺序在循环缓冲区中记录缺失的序列,一个索引表,它提供从地址(或其他预取触发器)到历史缓冲区中的位置的映射。历史缓冲区允许单个预取触发器指向任意长度的流。图3.3(来自[68])说明了全局历史缓冲区的组织。

1729926738315

当miss发生时,GHB引用索引表,查看是否有任何信息与miss地址相关联。如果找到一个条目,检查历史缓冲区条目,看它是否仍然包含丢失的地址(条目可能已经被覆盖)。如果是,历史缓冲区中的下几个条目包含预测的流(也就是A,C,B,D,C)。历史缓冲区条目也可以用指向其他历史缓冲区位置的链接指针来增强,以使历史遍历能够根据多个顺序进行(例如,每个链接指针可以指示先前发生的相同的丢失地址,从而增加预取宽度和深度)。

通过改变存储在索引表中的键和历史缓冲区条目之间的链接指针,GHB设计可以利用将触发事件与预测的预取流关联起来的各种属性。Nesbit和Smith引入了GHB X/Y形式的GHB变体分类法,其中X表示流如何本地化(即链接指针如何连接应该连续预取的历史缓冲区条目),Y表示相关方法(即查找过程如何定位候选流)[68,69]。本地化可以是全局(G)或单个PC (PC)

在全局本地化下,连续记录的历史缓冲区条目形成一个流。与每个历史表项相关联的指针要么指向先前出现的相同的miss地址(如上面讨论的那样,方便更高的预取宽度),要么是未使用的。在每台PC本地化下,索引表和链接指针都基于触发访问的PC连接历史缓冲区条目;一个流是通过遵循连接由同一触发PC发出的连续未命中的链接指针形成的。相关方法可以是地址相关(AC)或增量相关(DC)

GHB组织下的一个挑战是确定流何时结束,也就是说,预取器何时不应再获取历史缓冲区中指示的额外地址。

STREAM CHAINING

流可以通过辅助查找机制将单独存储的流链接在一起来扩展,该机制可以预测从一个流到下一个流的关系[58,59]。流链接将不同pc形成的流链接在一起,以创建更长的预取序列[59]。流链依赖于连续pc本地化流之间存在时间相关性的洞察力;也就是说,相同的两个流倾向于连续地重复出现。更一般地说,可以想象流之间的有向图,指示哪个后续流最有可能跟随每个前一个流。流链使用下一个流指针扩展每个索引表项,该指针指示在此流之后使用的索引表项,以及一个置信度计数器。通过将单个pc本地化的流链接在一起,流链可以从单个触发器访问中获得更正确的预取。

TEMPORAL MEMORY STREAMING

临时内存流[42]采用GHB存储组织,但将索引表和历史缓冲区都放在芯片外的主存中,允许它记录和重播任意长度的流,即使对于大型工作集的工作负载也是如此。

然而,Wenisch和合著者表明,索引表更新带宽可以通过采样索引表更新来管理,只执行历史表写入的随机子集[67,70]。他们的研究表明,占覆盖范围大部分的流要么很长,要么频繁重复。对于长数据流,索引表项很有可能在数据流开始的几次访问中被记录下来,相对于数据流的长数据流主体,牺牲了可以忽略不计的覆盖范围。对于频繁的流,即使在第一次遍历流时可能没有记录索引表项,但随着流的重复出现,记录所需项的概率迅速接近1。图3.4(来自[67])说明了为什么抽样是有效的。

1729928033173

IRREGULAR STREAM BUFFER

虽然采样可以减少维护片外流元数据的开销,但查找延迟仍然很高,限制了短流的预取前瞻性。此外,片外存储排除了类似ghb的地址相关流的pc定位;在片外历史缓冲区中跟踪条目之间的链接指针太慢了

ISB引入了一个新的地址空间概念,即结构地址空间,它只对预取器可见。

两个片上表维护物理地址和结构地址之间的双向映射。

这些映射与TLB中的填充和替换一起从片上表转移到片外备份存储,确保片上结构包含处理器当前可以访问的地址集的元数据。

SPATIALLY CORRELATED PREFETCHING

利用了数据布局的规律性和重复性,虽然时间相关性依赖于重复出现的缺失序列,而不考虑特定的缺失地址,但空间相关性依赖于在时间上彼此相邻的内存访问的相对偏移量的模式。

空间相关性是由于数据结构布局的规律性而产生的,因为程序经常在内存中使用具有固定布局的数据结构(例如,面向对象编程语言中的对象,具有固定大小字段的数据库记录,堆栈帧)。许多高级编程语言将数据结构与缓存行和页面边界对齐,进一步提高了布局的规律性。

空间相关性的优势之一是相同的布局模式经常在内存中的许多对象中重复出现。因此,一旦学习了空间相关模式,就可以用来预取许多对象,从而使利用空间相关的预取器具有很高的存储效率(即,可以经常重用紧凑的模式来预取许多地址)。此外,与地址相关性(依赖于对特定地址的重复丢失)相反,空间模式可以应用于以前从未被引用过的地址,因此可以消除冷缓存丢失。

触发事件必须(1)提供一个键来查找描述要预取的相对位置的相关布局模式,(2)提供计算这些相对地址的基础地址。

直接读SMS那篇文章

EXECUTION-BASED PREFETCHING

我们简要介绍的最后一类数据预取器既不依赖于缺失序列中的重复,也不依赖于数据布局;相反,基于执行的预取器寻求在指令执行和退出之前探索程序的指令序列,以发现地址计算和解引用指针。这种预取器的主要目标是比指令执行本身运行得更快,在处理器内核之前运行,同时仍然使用实际的地址计算算法来识别预取候选者。因此,这些机制根本不依赖于重复。相反,它们依赖的机制要么是总结地址计算而忽略计算的其他方面,要么是直接猜测值,要么是利用暂停周期和空闲处理器资源在指令退役之前进行探索。

主要就是提前执行然后猜

建议看runahead

runahead

(又是Onur Mutlu写的文章)

要解决的问题(摘要)

现如今高性能处理器都通过OoO来中和一些高延迟操作(如cache miss,中断异常,设备访问),之前做法都是增大指令窗口(个人理解为rob中可存储指令个数),现在可以提前执行,也即是runahead,在相同的机器上,runahead结合128个Instruction Windows与在没有提前执行和384个Instruction Windows相比性能几乎没有差异

相关工作

mem access对CPU的性能影响很大,故之前有人提出了cache,no-blocking cache,硬件预取,软件预取,基于线程预取
Balasubramonian提出了长延迟指令阻塞退役时执行未来指令的机制。它们的机制动态地将寄存器文件的一部分分配给“未来线程”,该线程在“主线程”停止时启动。这种机制需要对两种不同上下文的部分硬件支持。
缺点:两模式都无法全部享用处理器资源
在运行提前执行中,普通模式和运行提前模式都可以利用机器的全部资源,这有助于机器在运行提前模式中获得进一步的领先。
Lebeck提出,将依赖于长延迟操作的指令从(相对较小的)调度窗口中移除,放入(相对较大的)等待指令缓冲区(WIB)中,直到操作完成,然后将指令移回调度窗口。这结合了大指令窗口的延迟容忍优势和小调度窗口的快速周期时间优势。(类似于超标量发射队列的推测唤醒那一节)

IW和SW(和超标量哪一本的含义不同)

An out-of-order execution machine
accomplishes this using two windows: the instruction window and the scheduling window.

指令窗口是已经解码的指令但还没退休,也即是在rob的指令,调度窗口是指令窗口的一个子集,其主要作用就是找到可以执行的指令
alt text
可以看到runahead+128表项的IW的停顿百分比与IPC(每个条顶部为IPC),其消除full window
stalls获得了20%的IPC提升

runahead

CPU任何时候都可以进入runahead,本论文在l2cache miss,并且该内存操作到达指令窗口的头部时(也就是最老的指令)进入提前执行.提前执行时的pc得存起来,以便退出runahead恢复archstate,IW中所有指令都被标记为runahead inst,在这个模式中取到的指令也是No updates to the checkpointed register file are allowed during runahead mode.runahead的执行与内存通信和无效结果的传播有关,一些规则:

  1. Invalid bits and instructions.
  2. Propagation of INV values.
    进入runahead的第一条无效指令为load或者store(引起这个问题的原因),若为load,则rf的INV拉高,若为store,则runahead cache分配一个表项,并且将dest 的byte置为无效Any invalid instruction that writes to a register marks that
    register as INV after it is scheduled or executed. Any valid
    operation that writes to a register resets the INV bit ofits
    destination register.
  3. Runahead store operations and runahead cache.之前的工作runahead load之前有runahead store(raw)被视为无效指令并被删除我们认为转发是必要的,如果两个指令都在IW中,直接通过store buffer获取指令,如果指令伪退休了,
    我们将其存入runahead cache,其目的是转发数据和状态,被替换的dirty行不会存入下一级存储器,而是会直接删除(正常模式的指令无法访问该cache)store buffer 和runahead cache的每个字节都有对应的INV,而runahead cache 中还有STO为,指示store是否写入该字节,只有访问该cache中的STO和valid 之高的line,才不会miss,规则如下:
    1. 当有效runahead store执行->拉低cache的INV,若datacache中该数据缺失,则进行预取
    2. 当无效runahead store被选中->将store buffer对应位置拉高INV
    3. 当有效runahead store退休,写入cache,重置INV,设置写入字节的STO
    4. 当无效的runahead store执行,cache的INV升高,设置STO
    5. runahead store不会将数据写入data cache
      但store地址无效时,其操作将会被视为NOP,这时与之相关的load可能无法获得正确的值,可以使用memory dependence predictors解决
  4. Runahead load operations.Runahead load操作可能因三种不同原因而无效它可能源于一个 INV 物理寄存器。(寄存器的INV位)它可能依赖于store buffer中标记为 INV 的存储。(store buffer的INV位)它可能依赖于一个已经伪退休的 INV 存储器。(runahead cache的INV位)Runahead load 执行时会并行访问data cache,store buffer和runahead cache访问store buffer和runahead cache:只有访问的line的INV置低,(STO置为高),才能获取数据,若无效,则该指令的dst reg 置为INV如果store buffer和runahead cache 都miss,但data cache hit,使用该数据,但实际这时数据可能是无效的->依赖于之前的store,但之前的store 的line是INV的或者被驱逐出cache(这种很罕见,故不会影响性能),如果三个都miss,则访问l2cache,如果访问l2cache miss,则驱除该指令,然后当作正常模式的指令运行,(个人理解这个可能会形成嵌套runahead)
  5. Execution and prediction of branches.在runahead模式的分支指令预测失败后不会恢复****训练策略runahead 时就训练BHT,这样其训练效果好不能再runahead训练BHT,这样可能导致分支预测不太行,导致runahead遇见分支性能下降只能在runahead训练BHT,正常模式通过fifo访问BHT两种模式有不同的表
  6. Instruction pseudo-retirement during runahead mode.
    runahead模式退休和正常差不多,区别在于:runahead如果检测ROB有inv指令,直接退休,两种模式均会更新RAT

Exiting runahead mode

退出也是可以随时退出,本文使用和分支预测失误后恢复的方法相似
本文使用的是当blocking的数据返回后退出runahead,

一些名词

BHT,FIFO,ROB,RAT,store buffer

0%