第 10 章 高速缓存设计
细心的读者应该会发现,自从我们给CPU添加AXI总线接口并去除指令RAM和数据RAM之后,它的运行效率就大打折扣了,运行同样的程序需要花费更多的执行周期。那么,去除指令RAM和数据RAM是不是一种设计倒退呢?其实不然,指令RAM和数据RAM的使用要求软件人员明确掌握物理内存的容量、起始地址,增加了软件开发难度。目前,这种硬件架构仅在那些对于成本、功耗或执行延迟的确定性极为敏感的低端嵌入式领域广泛使用。这些应用领域还有一个特点是软件规模不大、程序行为相对确定,否则没有虚拟化存储管理对于应用开发来说就是“灾难”。不过,纵然指令RAM和数据RAM有这样的不足,但是性能问题也是要解决的。我们的解决思路是增加高速缓存(Cache)。
本章我们将进入最后一个设计阶段—为CPU添加Cache。这是一项很有挑战性的工作,因为围绕Cache的设计优化技术太多,导致Cache设计的复杂度的变化范围很大。在本章中,我们会把Cache的设计复杂度控制在入门级水平,兼顾性能。在Cache实现规格的细节参数上,我们也会给出一整套明确的设定。不过,读者可以放心的是,我们所选取的参数具有代表性,大多数参数即使需要调整,也只是1和2的区别,而不是从0到1的跨越。在具体实施步骤上,我们分成四个阶段:
- 阶段一 设计Cache模块。
- 阶段二 将Cache模块作为ICache(指令Cache)集成到CPU中,完成与CPU取指的配合、调整,并完成总线接口模块的设计调整。
- 阶段三 将Cache模块作为DCache(指令Cache)集成到CPU中,完成与CPU访存的配合、调整,并完成总线接口模块的设计调整。
- 阶段四 实现对Cache指令的支持。
在开始学习之前,请确保已经认真学习了《计算机体系结构基础》(第3版)中9.5.4节或其他文献中关于Cache基本概念的内容。
【本章学习目标】
- 理解Cache的组织结构和工作机理。
- 理解LoongArch架构中的Cache相关控制状态寄存器和指令。
- 掌握在流水线CPU中添加Cache支持的方法。
【本章实践目标】
本章有四个实践任务(见本章10.2节)。读者可以在学习本章内容的基础上完成这些任务,两者之间的对应关系如下:
10.1 Cache模块的设计
10.1.1 Cache的设计规格
首先我们来明确一下与Cache模块相关的主要设计规格,避免因为后续的讨论过于宏观而无法具体到细节。这些设计规格包括以下方面:
- CPU内部集成一个指令Cache和一个数据Cache。
- 指令Cache和数据Cache的容量均为8KB,均为两路组相联,Cache行大小均为16字节。
- 指令Cache和数据Cache采用Tag和Data同步访问的形式。
- 指令Cache和数据Cache均采用“虚Index实Tag”(简称VIPT)的访问形式。
- 指令Cache和数据Cache均采用伪随机替换算法。
- 数据Cache采用写回写分配的策略。
- 指令Cache和数据Cache均采用阻塞式(Blocking)设计,即一旦发生Cache Miss(未命中),则阻塞后续访问直至数据填回Cache中。
- Cache不采用“关键字优先”技术。
我们解释一下制定上述设计规格的初衷。
- 设计一个指令Cache和一个数据Cache是为了保证流水线能够满负荷运转。
- 指令Cache和数据Cache各方面规格相同,是为了确保即使不把Cache模块写成可参数化配置的,也可以通过将所定义的Cache模块实例化两份来分别用于实现指令Cache和数据Cache,减轻代码开发和调试的工作量。
- 采用两路组相联的设计规格,是因为直接映射过于简单,后期若想调整成多路组相联就需要做较大幅度的调整,而两路组相联在多路组相联结构中复杂度最低且具有代表性。
- 将每一路Cache的容量定义为4KB是为了在采用VIPT访问方式的同时规避Cache别名问题5。
- Cache行大小定为16个字节主要是为了把Cache Data部分的分体数目控制在一个适中的规模,因此并没有采用目前商用处理器中常见的64字节大小。
- Cache采用Tag和Data同步访问的形式是为了降低Cache命中情况下的执行周期数,否则在Tag和Data串行访问方式下,读一个数最快也需要3个周期。然而,现有CPU中访问指令RAM和数据RAM都只需要两个周期,3个周期的访问延迟需要对CPU流水线进行较大幅度的设计调整。
- Cache采用VIPT可以将TLB的查找与Cache的访问并行进行,从而提升CPU的频率。
- Cache采用伪随机替换算法是因为这是最简单实用的Cache替换算法。LRU算法虽然平均性能更佳,但涉及LRU信息的维护问题,会增加设计的复杂度。
- 数据Cache采用写回写分配,是因为这样写操作在发生Cache Miss时的处理流程和读操作发生Cache Miss时的处理流程几乎是一样的,从而简化控制逻辑的设计。
- Cache采用阻塞式设计,主要是因为目前我们实现的是一个静态顺序执行的流水线,即使Cache设计成非阻塞式也不会带来整体的性能提升。
- Cache不采用“关键字优先”技术,可以降低与AXI总线交互的复杂度。
根据以上设计规格,我们可以计算Cache缓存容量如下:
\[ \begin{aligned} Cache缓存容量 = 路数\times 路大小 = 2路\times 4KB = 8KB \end{aligned} \]
通过上述计算得到的容量是其可缓存数据的大小,并不是实际实现该Cache所使用的RAM的总大小。实际实现所需RAM的大小还应该考虑Cache的TAG、Dirty等域。
根据以上设计规格,我们可以计算地址相关的Tag、Index和Offset的位数。
- Offset:Cache行内偏移。宽度为log2(Cache行大小),也就是4位。
- Index:Cache组索引。宽度为(log2(路大小)-5),也就是8位。
- Tag:Cache行的Tag域。宽度为(物理地址宽度-log2(路大小)),也就是20位。
因此,对Cache进行访问时,使用虚地址[31:0]中的[11:4]作为Index索引,使用物理地址的高20位([31:12])作为TAG进行比较。地址划分形式如10.1所示。
10.1.2 Cache模块的数据通路设计
10.1.2.1 读、写操作访问Cache的执行过程
在设计Cache模块的数据通路之前,我们再回顾一下读、写操作访问Cache的执行过程。
先来看一个读操作。
第一拍:将请求中虚地址的[11:4]位作为索引值(index)送往Cache,将两路Cache 中对应同一index的两个Cache行都读出来。与此同时,将读操作的虚地址送往MMU逻辑进行虚实地址转换,此时物理地址来自虚地址的组合逻辑运算结果,需要将物理地址使用触发器(reg型变量)寄存下来共第二拍使用。
第二拍:得到Cache RAM读出的两个Cache行的Tag信息(我们要求Cache RAM是单周期返回的同步RAM),将其与锁存下来的物理地址的[31:12]进行相等比较。如果某个Cache行的Tag比较相等,且该Cache行的有效位V等于1,则表示访问命中在这个Cache 行上。在进行Tag比较的同时,可以根据锁存下来的虚地址的[3:2]位对两个Cache行的Data信息进行选择,得到访问所在的32位数据6。最终根据Tag比较结果将命中的那一路的32位数据返回。如果没有命中的Cache行,则需要通过总线接口向外发起访存请求,等访存结果返回到Cache模块后,从返回结果中取出访问所在的32位数据,将其返回。
再来看写操作。
写操作前面的操作步骤与读操作基本一致,区别仅在于写操作开始时可以不读取Cache 的Data信息。它只需要读取两个Cache行中的Tag、V信息来判断Cache是否命中。如果Cache命中,则生成要写入的Index、路号、offset、写使能(写32位数据里的哪些字节)并将写数据传入Write Buffer。在下一拍,由Write Buffer向Cache发出写请求,将Write Buffer里缓存的数据写入命中的那个Cache行的对应位置上,同时将这一Cache行的脏位D 置为1。之所以在写命中Cache和写入Cache之间引入一个Write Buffer,是出于时序方面的考虑,避免引入RAM输出端到RAM输入端的路径:Cache命中信息来自Cache RAM读出的Tag的比较结果,命中的写操作需要根据Tag的比较结果来生成写Cache里的哪个路径。如果命中时直接写,就引入了Cache RAM的Tag读出到Cache RAM的Data写使能这一路径。如果Cache缺失,由于是写回写分配的Cache,因此要像读操作发生Cache缺失那样,先通过总线向外发起访存请求,然后等访存结果返回Cache模块,最后将store要写的数据和内存重填的数据拼合在一起,一并写入Cache中。
读、写操作中都涉及Cache缺失情况下的处理。为了行文简洁,上面的描述对这个问题的处理只是做了简要说明,其实这个过程也分为多个步骤:
第一步,将Cache缺失的地址以及操作类型(如果是写操作,还要记录写数据)记录下来。
第二步,通过AXI总线接口模块向外发起对缺失Cache行的访问。这个访问的地址是缺失Cache行的起始地址,大小是一个Cache行的大小。
第三步,在等待读请求数据返回的过程中(或者在第二步的同时),根据替换算法从Cache Miss地址对应index的两个Cache行中选择一个,将其整个读出。如果发现该Cache 行的V=1且D=1,意味着这是一个有着有效脏数据的Cache行,那么需要将这个Cache行的数据通过AXI总线接口模块写出去;否则,不用做任何额外的操作,这意味着把这个Cache行的数据直接丢弃。这一步还要将选择了哪一路记录下来。
第四步,待缺失请求的数据从总线返回后,生成将要填入Cache的Cache行信息,其中Cache行的V置为1,Tag信息来自之前保存的Cache缺失的地址。如果这个Cache Miss请求是写操作引起的,那么Cache行的D置为1,Data信息是store操作待写入值部分覆盖总线返回数据之后所形成的新数据;否则D置为0,Data信息仅来自总线返回的数据。
第五步,将这个Cache行信息填入之前第三步所记录下来的那个位置。
10.1.2.2 Cache表的组织管理
从前面介绍的访问Cache的执行过程中,可以得知数据通路的主体是Cache。我们从功能逻辑角度出发,可以把每一路Cache理解为一张二维表,这方面的内容可以参考《计算机体系结构基础》(第3版)的图9.25c。虽然几乎所有组成原理、体系结构的教材和资料中都采用这种画法,但是这种画法离具体实现还有一些差距。这个差距是初学者设计实现Cache 时的第一个障碍,下面我们来捅破这层“窗户纸”。
我们先按照Cache行中的信息,把原本的一张表拆分成多张表。比如,对于我们现在所要实现的Cache规格,就有两张256项×20比特的Tag表、两张256项×1比特的V表、两张256项×1比特的D表,以及两张256×128比特的Data表。之所以所有的表都是两张,是因为每一张对应Cache的一路。Cache第0路的所有表的第0项构成了第0路Cache 的index=0的Cache行,所有表的第1项对应Cache的index=1的Cache行,……,以此类推。所有Cache模块的操作分解之后,落实到这些表上的只有读和写。我们接下来分析每张表要支持几个读、几个写,以及读和写请求的来源。
我们依据在读、写操作访问Cache执行过程中所属的不同阶段,将对Cache模块进行的访问归纳为四种:Look Up、Hit Write、Replace和Refill。下面给出四种访问的定义。
- Look Up:判断是否在Cache中,根据命中信息选取Data部分的内容7。
- Hit Write:命中的写操作会进入Write Buffer,随后将数据写入命中Cache行的对应位置。
- Replace:为了给Refill的数据空出位置而发起的读取一个Cache行的操作。
- Refill:将内存返回的数据(以及store miss待写入的数据)填入Replace空出的位置上。
我们将四种访问对于Cache中各个部分的访问行为进行分析,得到表10.1所示的结果。
...1 | Look up | Hit Write | Replace | Refill |
---|---|---|---|---|
Tag | 读所有路 | - | 读待替换路 | 更新待替换路 |
V | 读所有路 | - | 读待替换路 | 更新待替换路 |
Data | 读所有路的局部 | 更新命中路的局部 | 读待替换路的全部 | 更新待替换路的全部 |
D | - | 更新命中路 | 读待替换路 | 更新待替换路 |
从表10.1我们观察到,对于Tag和V而言,所有Cache访问对两者操作是完全一致的,于是一个很自然的想法就是将Tag表和V表横向拼接成一张表,我们称之为{Tag, V}表,其规格为256项 \(\times\) 21比特,每一项的[20:1]对应Tag信息,[0]对应V信息。
再进一步分析,我们知道Replace和Refill不会同时发生,这意味着对所有表而言,Replace的读和Refill的写不会同时发生。又因为我们设计的是阻塞式Cache,所以进行Replace和Refill操作的时候不接收新的访问请求,自然不会有Cache命中的store,这意味着对所有表而言,Replace、Refill的读、写操作不会和Look Up、Hit Write的读、写同时发生。又由于Hit Write不访问{Tag, V}表,因此对于{Tag, V}表而言,同一时刻它至多接收一个读请求或写请求;而由于Look Up不访问D表,因此对于D表而言,同一时刻它至多接收一个读请求或写请求。
Data表的情况略有些复杂。因为来自一个读操作的Look Up和来自一个写操作的Hit Write可能同时发生。一种直接的解决思路是,让Data表同时支持一个读请求和一个写请求。这种方法在设计上最简单,性能也最好,但是支持同拍一读一写的底层电路实现在面积和延迟方面都不太好。另一种直接的解决思路是,只要发生Hit Write就阻塞读操作的Look Up,这样Data表同一时刻至多接收一个读请求或一个写请求。这种方法是走向另一个极端:牺牲了可观的性能来换取电路面积和延迟的低开销。再想一想,我们会发现Look Up和Hit Write对Data表的访问都是“局部”的,就目前实现的指令而言,这个“局部”不超过一个起始地址4字节边界对齐的字。如果Hit Write更新的字和Look Up读出的字不冲突的话,其实它们是可以同时进行的。如果我们把Data表横向拆分成四等份,那么在Look Up和Hit Write不冲突的时候,每张子表在同一时刻还是至多接收一个读请求或一个写请求。我们把拆分后的子表称为Bank表,其规格为256项×32比特。当然,如果同一时刻发生的Look Up和Hit Write落在同一张Bank表的话,我们还是只能用阻塞Look Up请求的方式来解决。不过这种情况出现的概率已经比Look Up和Hit Write同时发生的概率低了不少,所以性能损失没有前面说的第二种方法大。为了支持SB、SH之类的写操作,Bank表的写粒度要精细到字节。
通过上述分析过程,我们最终将Cache从逻辑结构上组织成一个12张表的集合,如图10.2所示。
到目前为止,我们设计的是Cache的逻辑组织结构。这意味着我们还要进一步明确这些逻辑上的表与底层电路实现之间的关系。既然是存储信息的表,电路上肯定要用存储器件来实现,通常是用Regfile或RAM来实现。我们常用的设计指导思想是,容量大的表(如Bank表)用RAM实现,容量小的表(如D表)用Regfile实现。除了确定底层实现的电路形态,我们还要确定逻辑表和Regfile或RAM之间的映射关系。最简单的办法是采用一一映射关系。举例来说,Way0的Bank0表的规格是256项×32比特,支持最多一个读或一个写,写粒度精细到字节。那么,我们就实例化一个深度为256、宽度为32比特、单端口、支持字节写使能的RAM。当然,我们进一步推荐用Block RAM而非Distributed RAM,这种方案在面积、时序上的效果都更好。需要向各位读者说明的是,对于Cache而言,逻辑表和Regfile或RAM之间的映射关系并非只能是一一映射。比如,两个D表可以合并映射到一个规格为256×2的Regfile,Way0和Way1相同序号的Bank表也可以合并映射到一个规格为256×64的RAM。初次实现时,我们建议读者采用最简单的一一映射关系,若有余力,再尝试其他可行的映射方式。
最后,需要提醒的大家的是,本章的实践任务要求大家自行定制Cache需要的各个规格的RAM。根据以上分析,我们确定需要定制的RAM规格有:
- TAGV RAM:选用RAM 256×21(深度×宽度),共实例化2块。
- DATA Bank RAM:选用RAM 256×32(深度×宽度),共实例化8块。
可以参考??节内容定制以上规格的同步RAM,但是要注意DATA Bank RAM需要开启字节写使能。同时,对于所有定制的同步RAM,注意不要勾选“Primitives Output Register”和“Core Output Register”,否则该RAM不再是单周期返回了,如图10.3所示。
10.1.2.3 Cache模块功能边界划分
除了Cache表中必须要实现的数据通路,Cache模块内部还需要实现哪些数据通路取决于我们将整个读、写操作访问Cache的执行过程中余下的哪些功能放在Cache模块内实现、哪些功能放在其他模块实现。因此,我们有必要先划分出功能边界。
如前所述,我们倾向于Cache与CPU流水线之间的功能边界划分与现有“类SRAM-AXI”转接桥和CPU流水线之间的功能边界划分保持一致。简单来说,就是CPU流水线向Cache模块发送请求,Cache模块给CPU流水线返回数据或是写成功的响应。显然,这样划分之后,CPU流水线基本上不需要进行更改。此外,因为目前采用的是VIPT 的Cache访问方式,所以还需要CPU中的TLB模块将转换后的物理地址送到Cache模块中,Cache模块将该物理地址寄存一拍,然后用于Cache Tag比较。
比如,我们可以将Cache模块与CPU流水线的交互按照表10.2定义。
名称 | 位宽 | 方向 | 含义 |
---|---|---|---|
valid | 1 | IN | 表明请求有效 |
op | 1 | IN | 1:WRITE;0:READ |
index | 8 | IN | 地址的index域(addr[11:4]) |
tag | 20 | IN | 经虚实地址转换后的paddr形成的tag,由于来自组合逻辑运算,故与index是同拍信号。 |
offset | 4 | IN | 地址的offset域(addr[3:0]) |
wstrb | 4 | IN | 写字节使能信号 |
wdata | 32 | IN | 写数据 |
addr_ok | 1 | OUT | 该次请求的地址传输OK,读:地址被接收;写:地址和数据被接收 |
data_ok | 1 | OUT | 该次请求的数据传输OK,读:数据返回;写:数据写入完成 |
rdata | 32 | OUT | 读Cache的结果 |
接下来,我们要考虑Cache模块通过AXI总线接口访存的这些功能如何划分。最极端的划分方式是把所有功能都放到Cache模块中。你可以认为这是把现有“类SRAM-AXI”转接桥整个替换为新的Cache模块。不过,既然名字为Cache模块,里面又包含大量AXI 协议处理的逻辑,显然这不是一种合适的模块划分方式。就算换个名字,比如改为cache_mem_module,这个模块内部的功能也确实太多了。因此,我们建议保留一个内部访存总线到AXI总线的接口转换模块。这个转换模块对CPU内部有多个端口,对CPU外部只有一个AXI总线接口。我们把多个请求之间的仲裁、AXI协议的处理都放到这个模块中,这样Cache模块与这个AXI总线接口模块之间的功能划分仍然是简洁的。Cache模块向AXI总线接口发请求,AXI总线接口模块返回数据或响应。
基于上面的划分,Cache模块向AXI总线接口模块发送的请求中的地址、操作类型、长度等信息的交互就容易设计了,这些信息都很短,在一拍之内交互完毕即可。需要重点考虑的是读或者写的数据如何交互。因为Cache行有16个字节,我们希望在AXI总线上采用突发(Burst)访问模式来进行读写访问,这样可以尽可能减少请求通道上的交互开销。那么问题是,Cache模块和AXI总线接口模块之间的数据交互是否也需要定义类似的Burst传输模式?还是两者在一拍之内把16字节交互完毕?这里的设计取决于我们设计的出发点。我们觉得,对于本书的学习目标来说,首要任务是保证功能,其次是性能过关,有条件的话再考虑一些功耗方面的优化。因此,我们给出的设计建议是:对于读操作,AXI总线接口模块每个周期至多给Cache模块返回32位数据,Cache模块将返回的数据填入Cache的Bank RAM中或者直接将其返回给CPU流水线;对于写操作,Cache模块在一个周期内直接将一个Cache行的数据传给AXI总线接口模块,AXI总线接口模块内部设一个16字节的写缓存保存这些数,然后再慢慢地以Burst方式发出去。
于是,我们可以将Cache模块与AXI总线接口模块之间的接口按表10.3定义。
名称 | 位宽 | 方向 | 含义 |
---|---|---|---|
rd_req | 1 | OUT | 读请求有效信号。高电平有效。 |
rd_type | 3 | OUT | 读请求类型。3’b000——字节,3’b001——半字,3’b010——字,3’b100——Cache行。 |
rd_addr | 32 | OUT | 读请求起始地址. |
rd_rdy | 1 | IN | 读请求能否被接收的握手信号。高电平有效。 |
ret_valid | 1 | IN | 返回数据有效信号后。高电平有效。 |
ret_last | 2 | IN | 返回数据是一次读请求对应的最后一个返回数据。 |
ret_data | 32 | IN | 读返回数据。 |
wr_req | 1 | OUT | 写请求有效信号。高电平有效。 |
wr_type | 3 | OUT | 写请求类型。3’b000——字节,3’b001——半字,3’b010——字,3’b100——Cache行。 |
wr_addr | 32 | OUT | 写请求起始地址。 |
wr_wstrb | 4 | OUT | 写操作的字节掩码。仅在写请求类型为3’b000、3’b001、3’b010情况下才有意义。 |
wr_data | 128 | OUT | 写数据。 |
wr_rdy | 1 | IN | 写请求能否被接收的握手信号。高电平有效。此处要求wr_rdy要先于wr_req置起,wr_req看到wr_rdy后才可能置上。所以wr_rdy的生成不要组合逻辑依赖wr_req,它应该是当AXI总线接口内部的16字节写缓存为空时就置上。 |
在上面定义的信号中,之所以还要考虑字节、半字、字的访问是为了支持Uncache访问。我们会在后面??节中再具体解释这个问题。
上述接口定义好之后,大家还要对现有的类SRAM-AXI总线转接桥模块进行相应调整。请注意,此时该模块对内是两组读接口、一组写接口,第一组是指令Cache的rd_*和ret_*,第二组是数据Cache的rd_*和ret_*,第三组是数据Cache的wr_*。虽然接口看上去多了一组,但整个转接桥内部的数据通路并不需要进行大的调整。
10.1.2.4 Cache模块内除Cache表之外的数据通路
划分好Cache模块与外界的功能边界之后,我们就可以把Cache模块内部余下的数据通路的设计确定下来。在前一小节的讨论中,我们将对Cache模块的访问归纳为四种:Look Up、Hit Write、Replace和Refill。我们设计的是阻塞式Cache,所以Look Up和Repalce & Refill可以复用一些数据通路,它们的核心部分是Request Buffer、Tag Compare、Data Select、Miss Buffer和LSFR。Hit Write是游离于Look Up和Repalce & Refill之外的单独访问,其核心部分是Write Buffer。
Request Buffer负责将表10.2中定义的op、index、tag、offset、wstrb、wdata等信息锁存下来。由于RAM读访问会跨越两拍,因此Request Buffer 的输出与RAM读出的Tag、Data等信息处于同一拍。在我们设计的阻塞式Cache中,Request Buffer既维护了Tag比较时需要的信息,又维护了Miss处理时需要的信息。
Tag Compare数据通路是将每一路Cache中读出的Tag和Request Buffer寄存下来的tag(记为reg_tag)进行相等比较,生成是否命中的结果(此处未考虑Uncache情况,如果是Uncache,一定要不命中)。其Verilog代码示意如下:
assign way0_hit = way0_v && (way0_tag == reg_tag);
assign way1_hit = way1_v && (way1_tag == reg_tag);
assign cache_hit = way0_hit || way1_hit;
Data Select数据通路是对两路Cache中读出的Data信息进行选择,得到各种访问操作需要的结果。对应命中的读Load操作,首先用地址的[3:2]从每一路Cache读出的Data数据中选择一个字,然后根据Cache命中的结果从两个字中选择出Load的结果(此处未考虑Miss情况,如果Miss,Load的最终结果来自AXI接口的返回,因此这里应该是个三选一逻辑)。对应Replace操作,只需要根据替换算法决定的路信息,将读出的Data选择出来即可。其Verilog代码示意如下:
assign way0_load_word = way0_data[pa[3:2]*32 +: 32];
assign way1_load_word = way1_data[pa[3:2]*32 +: 32];
assign load_res = {32{way0_hit}} & way0_load_word
32{way1_hit}} & way1_load_word;
| {//如果考虑miss,应该是三选一
assign replace_data = replace_way ? way1_data : way0_data;
Miss Buffer用于记录缺失Cache行准备要替换的路信息,以及已经从AXI总线返回了几个32位数据。Miss处理时需要的地址、是否是Store指令等信息依然维护在Request Buffer中。
LFSR是线性反馈移位寄存器(Linear Feedback Shift Register),我们采用伪随机替换算法,LFSR会作为伪随机数源。
Write Buffer是在Hit Wire(Store操作在Look Up时发现命中Cache)时启动的,它会寄存Store要写入的way、bank、index、bank内字节写使能和写数据,然后使用寄存后的值写入Cache中。
上面五个核心部分设计完毕之后,我们回过头来看Cache表的输入生成逻辑。由于每个表都是采用单端口的Regfile或RAM实现,但是每个表的访问地址、写数据和写字节使能可能来自多个地方,因此要通过多路选择器进行选择之后,再连接到Regfile或RAM的输入端口上。表10.4简单总结了这些端口的输入来源。具体如何从这些来源生成相关的信息,可以根据前面10.1节中介绍的访问Cache的执行过程推导出来。这个推导并不复杂,请读者自行完成。
## Warning: One or more parsing issues, call `problems()` on your data frame for details,
## e.g.:
## dat <- vroom(...)
## problems(dat)
...1 | ...2 | Look Up | Hit Write | Replace | Refill |
---|---|---|---|---|---|
{Tag | V} | 地址 | 模块输入端口 | - | Request Buffer & LFSR,Request Buffer & Miss Buffer |
写数据 | - | - | - | Request Buffer | |
D | 地址 | - | Write Buffer | Request Buffer & LFSR | Request Buffer & Miss Buffer |
写数据 | - | Write Buffer | - | Request Buffer | |
Data | 地址 | 模块输入端口 | Write Buffer | Request Buffer & LFSR | Request Buffer & Miss Buffer |
字节写使能 | - | Write Buffer | - | Miss Buffer | |
写数据 | - | Write Buffer | - | 模块输入端口 |
10.1.3 Cache模块内部的控制逻辑设计
10.1.3.1 Cache模块自身的状态机设计
由于操作可能发生Cache Miss,发生之后还要对AXI发出读请求并对Cache RAM发起Replace和Refill,因此我们需要引入状态机来控制这一系列操作。由于我们实现的是一个阻塞式的Cache,Cache Miss的时候不会接收新的请求,因此Look Up和Replace & Refill处理可以共用一个状态机,(称之为主状态机)。另外,Hit Write是游离于Look Up和Replace & Refill之外的单独访问,我们单独使用一个状态机维护,称之为Write Buffer状态机。
主状态机共包括5个状态,见图10.4a。
- IDLE:Cache模块当前没有任何操作。
- LOOKUP:Cache模块当前正在执行一个操作且得到了它的查询结果。
- MISS:Cache模块当前处理的操作Cache缺失,且正在等待AXI总线的wr_rdy信号。
- REPLACE:待替换的Cache行已经从Cache中读出,且正在等待AXI总线的rd_rdy信号。
- REFILL:Cache缺失的访存请求已发出,准备/正在将缺失的Cache行数据写入Cache中。
Write Buffer状态机共包括2个状态,见图10.4b。
- IDLE:Write Buffer当前没有待写的数据。
- WRITE:将待写数据写入到Cache中。在主状态机处于LOOKUP状态且发现Store操作命中Cache时,触发Write Buffer状态机进入WRITE状态,同时Write Buffer会寄存Store要写入的Index、路号、offset、写使能(写32位数据里的哪些字节)和写数据。
主状态机里的各状态间的转换条件说明如下:
- IDLE\(\rightarrow\)IDLE:这一拍,流水线没有新的Cache访问请求,或者有请求,但因该请求与Hit Write冲突而无法被Cache接收。
- IDLE\(\rightarrow\)LOOKUP:这一拍,Cache接收了流水线发来的一个新的Cache访问请求(必定与Hit Write无冲突)。
- LOOKUP\(\rightarrow\)IDLE:当前处理的操作是Cache命中的,且这一拍流水线没有新的Cache访问请求,或者有请求但因该请求与Hit Write冲突而无法被Cache接收。
- LOOKUP\(\rightarrow\)LOOKUP:当前处理的操作是Cache命中的,且这一拍Cache接收了流水线发来的一个新的Cache访问请求(必定与Hit Write无冲突)。
- LOOKUP\(\rightarrow\)MISS:当前处理的操作是Cache缺失的。
- MISS\(\rightarrow\)MISS:AXI总线接口模块反馈回来的wr_rdy为0(注意,wr_rdy应当先于wr_req置上)。
- MISS\(\rightarrow\)REPLACE:AXI总线接口模块反馈回来的wr_rdy为1(表示AXI总线内部16字节写缓存为空,可以接收wr_req)。当看到wr_rdy为1时,会对Cache发起替换的读请求,并转到REPLACE状态。
- REPLACE\(\rightarrow\)REPLACE:AXI总线接口模块反馈回来的rd_rdy为0。刚进入REPLACE的第一拍,会得到被替换的Cache行数据,并发起wr_req送到AXI总线接口。由于wr_rdy为1,故wr_req一定会被接收。同时,对AXI总线发起缺失Cache的读请求。
- REPLACE\(\rightarrow\)REFILL:AXI总线接口模块反馈回来的rd_rdy为1,表示对AXI总线发起的缺失Cache的读请求将被接收。
- REFILL\(\rightarrow\)REFILL:缺失Cache行的最后一个32位数据(ret_valid=1&&ret_last=1)尚未返回。
- REFILL\(\rightarrow\)IDLE:缺失Cache行的最后一个32位数据(ret_valid=1&&ret_last=1)从AXI总线接口模块返回。
Write Buffer状态机里的各状态间的转换条件说明如下:
- IDLE\(\rightarrow\)IDLE:这一拍,Write Buffer没有待写的数据,并且主状态机没有新的Hit Write。
- IDLE\(\rightarrow\)WRITE:这一拍,Write Buffer没有待写的数据,并且主状态机发现新的Hit Write(主状态机处于LOOKUP状态且发现Store操作命中Cache)。
- WRITE\(\rightarrow\)WRITE:这一拍,Write Buffer有待写的数据,并且主状态机发现新的Hit Write。
- WRITE\(\rightarrow\)IDLE:这一拍,Write Buffer有待写的数据,并且主状态机没有新的Hit Write。
在主状态机的状态转换过程中,多次提到“与Hit Write有/无冲突”,这里的冲突分为两种情况:
1)主状态机处于LOOPUP状态且发现Store操作命中Cache,此时流水线发来的一个新的Load类的Cache访问请求,并且该Load请求与LOOKUP状态的Store请求地址存在写后读相关。
2)Write Buffer状态机处于WRITE状态,也就是正在写入一个待写数据到Cache中,此时流水线发来的一个新的Load类的Cache访问请求,并且该Load请求与Write Buffer里的待写请求的地址重叠。“地址重叠”是指Load请求地址的[3:2]与Store请求地址的[3:2]相等。
以上两种情况都可视为“与Hit Write冲突”。不过,第一种情况可以采用“Write Buffer前递给LOOKUP”的方法解决,而不用阻塞主状态机的转换。为简单起见,这里推荐用阻塞方式解决。但是,第二种情况只能通过阻塞的方式解决,要么阻塞主状态机的转换,要么阻塞Write Buffer状态机的转换,显然以上给出的实现方法是阻塞主状态机的转换。读者应该会发现,对于“Hit Write冲突”,我们给出的解决方法牺牲了少许性能。
另外,需要提醒大家的是,在主状态机“LOOKUP\(\rightarrow\)LOOKUP”的转换中,要注意避免引入RAM输出端到RAM输入端的路径,也就是说,主状态机发现一个命中的Cache访问,并且接收到一个新的Cache访问请求,此时要避免使用命中信息(来自RAM读出的Tag比较)控制新的RAM的读使能。我们的解决方法是:不管LOOPUP状态的请求是否命中Cache,控制“Hit Write冲突”和新的RAM使能生成时应视为命中来考虑。显然,即使最后发现不命中,也不会导致错误。
最后,从主状态机“MISS\(\rightarrow\)REPLACE”的转换过程可以看出,在MISS状态,我们是在确保AXI总线接口可以接收被替换Cache行的写出的同时对Cache RAM发起替换Cache 行的读请求。下一拍(REPLACE状态)得到被替换出的Cache行数据,发送到AXI总线接口模块(此时一定可以被接收)。同时,可以对AXI总线发起对于缺失Cache行的读请求。做出以上设置,是因为我们总是无条件地将总线接口模块返回的数据直接写入Cache中,所以只有确认被写入位置的脏数据一定能够写回内存,这种无条件的写才是安全的。这里其实是通过牺牲一点性能来降低Cache模块和AXI总线接口模块在读返回通路上握手的复杂性。
此外,提醒各位读者,对于ICache,我们可以将wr_rdy恒设为1(此时MISS状态只会持续一拍),因为ICache不会真正发出wr_req。
10.1.3.2 Cache表的片选和写使能
所有Cache表的片选和写使能生成逻辑并不难,但需要细心。建议大家使用表10.5这样的表格来分析。
## Warning: One or more parsing issues, call `problems()` on your data frame for details,
## e.g.:
## dat <- vroom(...)
## problems(dat)
...1 | ...2 | Look Up | Hit Write | Replace | Refill |
---|---|---|---|---|---|
{Tag | V} | 片选 | 2路 | - | 替换那一路,替换那一路 |
写使能 | 2路 | - | - | 替换那一路 | |
D | 片选 | - | Write Buffer记录的那一路 | 替换那一路 | 替换那一路 |
写使能 | - | Write Buffer记录的那一路 | - | 替换那一路 | |
Data | 片选 | 2路,请求所在Bank | Write Buffer记录的那一路,请求所在Bank | 替换那一路,所有Bank | 替换那一路,所有Bank |
写使能 | - | Write Buffer记录的那一路,请求所在Bank | - | 替换那一路,所有Bank |
10.1.3.3 Request/Miss Buffer各个域的写使能
Request Buffer中记录来自流水线方向的请求信息的域的写使能就是Cache模块状态机IDLE\(\rightarrow\)LOOKUP和LOOKUP\(\rightarrow\)LOOKUP两组状态转换发生条件的并集。
Miss Buffer中记录缺失Cache行准备要替换的路信息(由LFSR生成替换的路号)的域的写使能就是Cache模块状态机MISS→REPLACE状态转换发生条件。
Miss Buffer中记录已经从总线返回了几个数据的写使能,一方面来自Cache模块状态机REPLACE\(\rightarrow\)REFILL状态转换发生条件(用于清0),另一方面来自总线方向输入的ret_valid。
10.1.3.4 模块接口输出的控制相关信号
我们只分析模块接口输出的控制相关信号置1的条件。
- 流水线方向的addr_ok信号
- Cache主状态机处于IDLE。
- 或者,Cache主状态机处于LOOKUP,并将进行“LOOKUP\(\rightarrow\)LOOKUP”的转变,具体分为:LOOKUP发现Cache命中,流水线发送来的新的Cache请求是写操作;LOOKUP发现Cache命中,且新的Cache请求是读操作且无“Hit Write冲突”。
- 流水线方向的data_ok信号
- Cache当前状态为LOOKUP且Cache命中。
- 或者,Cache当前状态为LOOKUP且处理的是写操作。
- 或者,Cache当前状态为REFILL且ret_valid=1,同时Miss Buffer中记录的返回字个数与Cache缺失地址的[3:2]相等。
- AXI接口方向的rd_req信号
- 当Cache模块状态机处于REPLACE状态时,组合逻辑将rd_req置为1。在非REPLACE状态,rd_req自然为0。
- AXI接口方向的wr_req信号
- 设置一个触发器,复位期间清0。Cache模块状态机MISS\(\rightarrow\)REPLACE状态转换发生条件将其置1。随后,wr_rdy为1将其从1清为0。
10.1.4 Cache的硬件初始化问题
在LoongArch精简版指令集中,Cache可以通过软件进行初始化。处理器复位结束之后,CSR.CRMD的DATF和DMTM域都是0值,此时取指和访存都是强序非缓存,软件可以使用CACOP指令将Cache的Tag部分置为0值。
在我们的实践任务中,出于实现工作量的考虑,将CACHE指令的实现放在了Cache实验的最后一个阶段,这就引发了一个问题:在没有实现CACHE指令的时候,如何在上板验证的时候确保Cache被初始化过。于是在我们的实验场景下,要考虑Cache的硬件初始化问题。
Cache初始化至少要把Cache中每一项的Tag、V、D的状态置为确定的无效值。由于Tag、V信息都存放在RAM中,因此该问题的解决方案是设计一个小的硬件电路,将存放Cache Tag和V信息的RAM的每一行写成全0值。还有一个偷懒的方法:利用实验中采用FPGA 硬件平台这一特点来简化Cache硬件初始化的实现。读者可以在生成Cache所用的RAM的时候,选择将RAM初始化成全0。具体来说,是在RAM IP生成对话框的“Other Options”标签下,勾选“Fill Remaining Memory Locations”,同时将初始值设为0值,如图10.5所示。
至于采用Regfile实现的D表,直接采用复位信号对其复位即可。
10.2 任务与实践
完成本章的学习后,希望读者能够完成以下4个实践任务:
- 设计Cache模块,参见下面第10.2.1小节。
- 在CPU中集成ICache,参见下面第10.2.2小节。
- 在CPU中集成DCache,参见下面第10.2.3小节。
- 在CPU中添加CACOP指令,参见下面第10.2.4小节。
10.2.1 实践任务20:Cache模块设计
本实践任务的要求如下:
- 设计Cache模块。
- 利用Cache模块级验证环境对所设计的Cache进行验证,通过仿真和上板验证。
请参照第2.3.1节中介绍的方式获取本次实践任务所需的实验开发环境。具体的实验环境与之前的环境不同,是针对Cache模块的单独验证环境,位于mycpu_env/module_verify/cache_verify/ 目录下。具体目录结构及各部分功能简介如下所示:
|--cache_verify/ 目录,Cache模块级验证环境。
| |--rtl/ 目录,包含Cache模块以及验证顶层的设计源码。
| | |--cache_top.v Cache模块级验证的顶层文件。
| |--testbench/ 目录,包含功能仿真验证源码。
| | |--testbench.v 仿真顶层。
| |--run_vivado/ Vivado工程的运行目录。
| | |--constraints/ Vivado工程的设计约束。
| | |--cache_prj/ Vivado工程文件所在目录。
实验环境准备就绪后,请参考下列步骤完成本实践任务:
- 完成Cache模块的设计和RTL编写,记为cache.v,该模块名需要命名为“cache”,除时钟输入clk和低电平有效复位输入resetn以外的输入/输出端口参见本章第10.1节中表10.2和表10.3中的定义。Cache模块的设计规格要求:2路组相连,每路大小4KB,LRU或伪随机替换算法,推荐硬件初始化。将cache.v文件放入mycpu_env/myCPU/ 目录下。
- 进入 mycpu_env/module_verify/cache_verify/run_vivado/cache_prj/ 目录下启动验证cache的工程。如果该目录下尚未创建工程,请参照附录D.2节介绍的步骤,利用该目录下的 create_project.tcl 文件创建工程。如需要,请参考附录D.4节进行IP核升级。
- 在验证Cache模块的工程中运行仿真(进入仿真界面后,直接点击run all),进行功能验证与调试,直至仿真测试通过。
- 在验证Cache模块的工程中综合实现后生成bit流文件,进行上板验证。(如果无硬件实验平台,请跳过该步骤。)
10.2.1.1 仿真验证结果判断
模块级验证会从index=0的时候开始验证,针对每个index,生成四组随机的tag和data对。首先生成写请求将这四组数写进cache,然后再生成读请求读它们。如果中间没有发生错误,index递增,重新生成tag和data对进行相同的测试,直到index==ff的测试完成为止。
对于写cache请求。验证环境期望看到的结果是,写请求发出后会出现Cache miss,Cache模块会发出rd请求,验证环境返回全1值(0xFFFFFFFF)。写请求可能会引发替换操作,这时验证环境会拿wr_addr和wr_data和前述的tag/data组合做对比,如果replace的值有错,测试会中止。
写操作全部进行完之后会有读操作,验证环境会做同样的检测。当cache返回读操作的结果之后,验证环境会检测读到的结果与之前写入的结果是否相同。
在仿真时,会对每一个index生成四个cache行的先写再读的操作,所有操作都完成后会打印PASS,如下所示:
[ 2705 ns] index 00 finishd
…………
=========================================================
Test end! ----PASS!!!
如果在仿真中发现错误,请进行调试,控制台会打印出错误的原因。验证环境只会检查替换时的数据错误和Cache read的数据错误。
10.2.2 实践任务21:在CPU中集成ICache
本实践任务要求在实践任务19和实践任务20完成的基础上完成以下工作:
- 将实践任务20完成Cache模块作为ICache集成到实践任务19完成的CPU中。
- 修改CPU中的AXI转换桥,以支持Burst传输。
- 在采用AXI总线的SoC验证环境里完成exp21对应func的功能验证,要求成功通过仿真和上板验证。
请参照第2.3.1节中介绍的方式获取本次实践任务所需的实验开发环境。具体的实验环境仍位于 mycpu_env/ 目录下,且仍使用 soc_axi/ 子目录。
实验环境准备就绪后,请参考下列步骤完成本实践任务:
- 将所实现CPU的代码更新至mycpu_env/myCPU/目录中。
- 修改func配置文件——mycpu_env/func/include/test_config.h,选择exp21的配置,编译。(如果是通过压缩包exp21.zip获取实验开发环境的,请跳过该步骤。)
- 打开gettrace工程——mycpu_env/gettrace/gettrace.xpr。(该Vivado工程中的IP核是使用Vivado2019.2创建的,如果使用更高版本的Vivado打开,请参考附录D.4节进行IP核升级。)运行gettrace工程的仿真(进入仿真界面后,直接点击run all等待仿真运行完成),生成新的参考trace文件golden_trace.txt(mycpu_env/gettrace/golden_trace.txt)。要等仿真运行完成,golden_trace.txt才有完整的内容。(如果是通过压缩包exp21.zip获取实验开发环境的,请跳过该步骤。)
- 进入 mycpu_env/soc_verify/soc_axi/run_vivado/ 目录下启动验证myCPU的工程。如果该目录下尚未创建工程,请参照附录D.2节介绍的步骤,利用该目录下的 create_project.tcl 文件创建工程。如需要,请参考附录D.4节进行IP核升级。如果该目录下已有前一实践任务创建过的工程,可以在打开工程后,参照附录D.3节介绍的步骤,更新项目中CPU实现文件的列表。
- 参考第4章4.2.5.2小节,对工程中的axi_ram重新定制。(如果是通过压缩包exp21.zip获取实验开发环境的,请跳过该步骤。)
- 在验证myCPU的工程中运行仿真(进入仿真界面后,直接点击run all),进行功能验证与调试,直至仿真测试通过。
- 在验证myCPU的工程中综合实现后生成bit流文件,进行上板验证。(如果无硬件实验平台,请跳过该步骤。)
10.2.3 实践任务22:CPU中集成DCache
本实践任务要求在实践任务21完成的基础上完成以下工作:
- 将实践任务20完成Cache模块作为DCache集成到实践任务21完成的CPU中。
- 在采用AXI总线的SoC验证环境里完成exp22对应func的功能验证,要求成功通过仿真和上板验证。
请参照第2.3.1节中介绍的方式获取本次实践任务所需的实验开发环境。具体的实验环境仍位于 mycpu_env/ 目录下,且仍使用 soc_axi/ 子目录。
实验环境准备就绪后,请参考下列步骤完成本实践任务:
- 将所实现CPU的代码更新至mycpu_env/myCPU/目录中。
- 修改func配置文件——mycpu_env/func/include/test_config.h,选择exp22的配置,编译。(如果是通过压缩包exp22.zip获取实验开发环境的,请跳过该步骤。)
- 打开gettrace工程——mycpu_env/gettrace/gettrace.xpr。(该Vivado工程中的IP核是使用Vivado2019.2创建的,如果使用更高版本的Vivado打开,请参考附录D.4节进行IP核升级。)运行gettrace工程的仿真(进入仿真界面后,直接点击run all等待仿真运行完成),生成新的参考trace文件golden_trace.txt(mycpu_env/gettrace/golden_trace.txt)。要等仿真运行完成,golden_trace.txt才有完整的内容。(如果是通过压缩包exp22.zip获取实验开发环境的,请跳过该步骤。)
- 进入 mycpu_env/soc_verify/soc_axi/run_vivado/ 目录下启动验证myCPU的工程。如果该目录下尚未创建工程,请参照附录D.2节介绍的步骤,利用该目录下的 create_project.tcl 文件创建工程。如需要,请参考附录D.4节进行IP核升级。如果该目录下已有前一实践任务创建过的工程,可以在打开工程后,参照附录D.3节介绍的步骤,更新项目中CPU实现文件的列表。
- 参考第4章4.2.5.2小节,对工程中的axi_ram重新定制。(如果是通过压缩包exp22.zip获取实验开发环境的,请跳过该步骤。)
- 在验证myCPU的工程中运行仿真(进入仿真界面后,直接点击run all),进行功能验证与调试,直至仿真测试通过。
- 在验证myCPU的工程中综合实现后生成bit流文件,进行上板验证。(如果无硬件实验平台,请跳过该步骤。)
10.2.4 实践任务23:CPU中添加CACOP指令
本实践任务要求在实践任务22完成的基础上完成以下工作:
- 在实践任务22完成的CPU中增加CACOP指令实现。
- 在采用AXI总线的SoC验证环境里完成exp23对应func的功能验证,要求成功通过仿真和上板验证。
请参照第2.3.1节中介绍的方式获取本次实践任务所需的实验开发环境。具体的实验环境仍位于 mycpu_env/ 目录下,且仍使用 soc_axi/ 子目录。
实验环境准备就绪后,请参考下列步骤完成本实践任务:
- 将所实现CPU的代码更新至mycpu_env/myCPU/目录中。
- 修改func配置文件——mycpu_env/func/include/test_config.h,选择exp23的配置,编译。(如果是通过压缩包exp23.zip获取实验开发环境的,请跳过该步骤。)
- 打开gettrace工程——mycpu_env/gettrace/gettrace.xpr。(该Vivado工程中的IP核是使用Vivado2019.2创建的,如果使用更高版本的Vivado打开,请参考附录D.4节进行IP核升级。)运行gettrace工程的仿真(进入仿真界面后,直接点击run all等待仿真运行完成),生成新的参考trace文件golden_trace.txt(mycpu_env/gettrace/golden_trace.txt)。要等仿真运行完成,golden_trace.txt才有完整的内容。(如果是通过压缩包exp23.zip获取实验开发环境的,请跳过该步骤。)
- 进入 mycpu_env/soc_verify/soc_axi/run_vivado/ 目录下启动验证myCPU的工程。如果该目录下尚未创建工程,请参照附录D.2节介绍的步骤,利用该目录下的 create_project.tcl 文件创建工程。如需要,请参考附录D.4节进行IP核升级。如果该目录下已有前一实践任务创建过的工程,可以在打开工程后,参照附录D.3节介绍的步骤,更新项目中CPU实现文件的列表。
- 参考第4章4.2.5.2小节,对工程中的axi_ram重新定制。(如果是通过压缩包exp23.zip获取实验开发环境的,请跳过该步骤。)
- 在验证myCPU的工程中运行仿真(进入仿真界面后,直接点击run all),进行功能验证与调试,直至仿真测试通过。
- 在验证myCPU的工程中综合实现后生成bit流文件,进行上板验证。(如果无硬件实验平台,请跳过该步骤。)