缓存十九式(6.2

305 阅读23分钟

1 缓存是分级的

缓存是分级的

  1. L1容量最小,电路规模肖,运行频率快,几个核心时钟周期
  2. 越大一级,搜索速度越慢,十几或者上百个CPU核心时钟周期

局部性的问题

  1. 时间局部性:上次访问该数据,下次继续访问
  2. 空间局部性:上次访问A地址(字节),下次访问A±1地址(字节)
    1. 采用预读的方式:一次性从下级缓存中提取比核心发出的访问请求更多的数据上来
    2. 比如,核心访问地址A,L1控制器从L2缓存中提取A、A+1、A+2等连续地址的内容
    3. 每个时钟周期能够提取的数据取决于两级缓存之间的总线位宽,比如位宽128位,每次提取16字节的数据,即使核心可能只要求访问8字节。

关于队列、缓冲buffer、缓存的区别

  1. 缓冲buffer:临时存访数据,数据很快被取走,新数据很快进来,匹配发送方和接收方的速度差异。
  2. 队列:缓冲的一种最小实现形式,是一个微型缓冲
  3. 缓存:提升命中率,有些经常访问的数据会一直呆在缓存中,和缓存配套的有预读、新老数据替换等概念。
  4. 区别对比:缓存是一个主动优化性能的手段,缓冲不是,它被动优化。

2 缓存是透明的

缓存是不可以被寻址的——无法细粒度地控制缓存

  • 核心看不到缓存,程序员也看不到,即不存在load 地址A L1缓存,stor L2缓存 地址A
  • 程序员无法细粒度地控制缓存,但是可以粗粒度地操纵缓存
    • Flush Cache:将写入到缓存但是没写入到SDRAM中数据写入SDRAM,比如intel x86 cpu地clwb指令
    • Prefetch 参数:根据参数将数据预读入缓存
    • 将缓存中某项数据写回到RAM后删除缓冲中该项数据,比如intel cpu的clflush,cache line flush指令
    • 将数据写回RAM(如果已被修改),但是不删除缓冲中该项数据,比如clwb指令(cache line write back)

3 缓存的容量、频率和延迟

L1缓存与CPU核心

  • L1缓存和CPU核心频率相同,但是核心不能在一个时钟周期就从L1获得数据或者向L1写入数据(核心向外写是先写到stor buffer中,这一步可以做到一个时钟周期结束)
  • CPU要取数据,如果刚好命中L1缓存,那么就3-4个时钟周期。如果是L2、L3、RAM内存,核心就等待更多周期
  • 但是核心不会白白地等待,乱序执行模块会调度其他满足条件的指令去执行(操作数已经准备好),同时超线程模块也会择机切换到其他线程的代码指令将它们载入流水线填充这些时间间隙。

访问时钟周期

  • 核心内部寄存器可以在1个核心时钟周期访问到
  • L1在内的各级缓存都做不到,即使L1缓存控制器和核心运行在相同的频率上
    • 原因1:核心发出来的地址请求都虚拟地址,需要由MMU(集成在CPU芯片内部)转换为物理地址,MMU查询TLB或者SDRAM中页表才能知道虚拟地址对应的物理地址,如果命中TLB可以在1个时钟周期查出,如果不命中,时间更久。
    • 原因2:查询到物理地址之后,MMU用该物理地址向L1缓存控制器发出访问请求,L1缓存需要到缓存中搜索数据所在的位置,然后读出数据,返回给取指单元或者Load/Stor单元。这段时间就是L1缓存的访问时延。

4 私有缓存private和共享shared缓存

  • 私有缓存private cache:只能被本核心访问,可以缓存任意地址上的数据,不用在乎其他核心缓存了哪些数据。
  • 共享缓存shared cache:L3 cache挂接到一个共享总线,可以供所有核心存取。

5 inclusive包含和exclusive独占

  • inclusive:同一个地址的数据可能存在多个相同的副本分别放置在L1、L2、L3缓存中
    • 缓存一致性问题的根源:多个核心缓存了同一个数据的副本,又没有相互通气。
  • exclusive:同一个地址数据在L1、L2、L3全局范围内只能存在单一副本

多级缓存引入问题——L1缓存存在某个地址的内容,是否还有必要呆在L2 cache以及L3 cahce中?

  • 毕竟L1所有的数据都是从RAM、L3、L2缓存提升上来的。
  • 如果是inclusive模式,L1数据在L2和L3以及RAM都有对应的副本,只是在L1里一定是最新的。

Intel和AMD

  • Intel主流cpu采用的是inclusive,下级缓存包含上级缓存,L2数据不一定在L2/L1存在,但是L1数据一定在L2和L3中存在副本。
  • AMD采用的exclusive模式,保证cacheline在L1、L2和L3缓存中只存在一个副本

L3/L2/L1缓存设置

  • 在L3缓存中对每条数据记录一个bitmap,每个核心对应一个,位0则表示该核心L1/L2中没有这条数据,位1表示可能会有这条数据
  • 去对应核心的L1/L2缓存请求最新的数据,接收到请求的核心的缓存控制器将L1和L2都查询一遍。

inclusive和exclusive的优缺点比较

  • inclusive:浪费空间,设置状态位表示上下级缓存不一致;不需要交换,只需要复制
  • exclusive:没有浪费空间,但是交换的数据比较多

6 Dirty标记位和Valid标记位

新数据进入缓存,缓存被占满,需要腾出空间

  • 数据已经更改:不能直接覆盖,需要flush回sdram
  • 数据没更改过:直接覆盖

如何判断哪些被更改?——dirty

  • 在每条数据的内容旁边用一位来记录这个状态,dirty位
  • dirty为1,更新过

该条目是否可以被新数据直接覆盖?——invalid

  • 1直接覆盖,0不可以被覆盖。
  • 场景:多个核心同时访问某个地址数据

7 缓存行

缓存大小内容

  • 内容:实际内容、物理地址、控制位、状态为
  • 大小:16、64、128字节
  • 主流CPU缓存行大小是64字节,因为主流DDR SDRAM一次连续数据传输通常最大64字节
  • 例子:64位地址,58位tag,6位
    • 缓存控制器拿58位与缓存invalid状态下的所有条目记录的行号/tag对比,如果命中,就再用请求地址的剩下6位来索引该行。

8 全关联/直接关联/组关联

几个术语

  • offset:cacheline的大小
  • index:缓存容量/缓存行容量,即有多少行cacheline
  • tag:访存地址除去index和offset前面几位

发展过程

  1. 阶段1:全关联方式:使用CAM保证查询速度,每次查询所有的tag对比,功耗高,虽然一个时钟周期就能出结果,但是频率上不去,拖累了CPU的运行频率

  2. 阶段2:直接关联方式:每个地址只能被放到index定位的行号。收到访存地址,直接根据index定位行,读出其tag进行对比。是则命中,使用offset寻址到字节粒度。缺点是,大量的index相同,导致冲突

  3. 阶段3:组关联方式:设立多份

缓存中条目无序乱放,核心如何加速搜索过程?——不允许缓存行随便存放,按照一定顺序和规则

多路组关联

  • 放置多份缓存存储器,每份缓存存储器被称为一路way,多路中相同行号的行逻辑上组成一个组set,一个组内的缓存行是相互竞争的。
  • 缓存控制器收到一个请求,会并行的向所有way中每个way同一个行号的tag读取出来,然后和请求的行号进行对比,看看是命中在哪个way里,通过控制对应的mux/demux将数据读出或写入。
  • 缺点:比如2个way并行查找的方式浪费50%的能源,因为其中一个way不命中,但是必须将所有的way中对应的行号的数据都选出来输送到mux从而等待被选中。→先查出地址命中在哪个way,只从该way读出数据,另一个way保持状态不变。

9 用虚拟地址查缓存

问题

  • 访问快的必须先访问一个慢的:CPU核心取指单元发出的是虚拟地址,CPU内部的MMU将其转换为物理地址,但是cache的tag是物理地址,意味着IF,L/S单元发出的请求需要先经过MMU转换为物理地址(虚实地址转换需要访问SDRAM中页表)

MMU维护一个专门用于存放页表中条目的缓存来加速地址翻译

  • TLB,页表项缓存

在物理地址还没有查到之前,使用虚拟地址匹配缓存行——VIPT

  • 在MMU翻译出物理地址的时候,缓存控制器不知道虚拟地址tag段20位对应的物理地址的20位是多少,但是缓存控制器用虚拟地址index从某个way读出对应行的tag期间,MMU也会并行执行地址翻译(如果命中TLB的话),这样下一个时钟MMU给出的20位tag就可以和读出的tag进行对比。
  • 20位的tag,6位的index(用来定位cache行),6位的offset(定位cache line中的数据)
  • 虚拟的index,物理的tag。

10 缓存的同名问题

问题

是否可以采用VIVT模式呢?——相同的虚拟地址,不同的物理地址

VIVT的问题?

  • 不同进程代码可能会发出相同的虚拟地址访存请求,而这些虚拟地址对应的物理地址是不同的。
  • 每个进程各自有一套独立的页表追踪着各自虚拟到物理的映射关系。
  • 多个进程地址空间重叠导致的问题被称为缓存的同名homonym问题,有人称之为ambiguity问题。即同一个虚拟地址可能会映射到不同的物理地址。

11 缓存的别名问题

问题

  • 同名问题:同一个虚拟地址被映射到不同的物理地址
  • 别名问题:同一个物理地址被映射到不同的虚拟地址而导致的数据一致性问题【多个不同的进程需要共享和传递数据】

VIPT缓存——4种情况

  • way容量≤页面容量:不会产生误判
  • way容量>页面容量:会产生一致性问题
    • 只有1个way时,虚拟地址的Index位数相比log页面容量超过了n位

缓存Bin容器(1个way)

  • n位将缓存分成了2^n个与页面容量同等大小的分块,每个分块称为一个缓存Bin容器

12 页面着色/缓存着色——软件方式解决缓存别名的问题

对于VIPT模式的缓存,解决别名问题的方式——同一个物理行在多个地方有多个副本

  • 硬件方式
    • 每次接收到写入缓存的请求时,缓存控制器并行地用物理tag查询所有bin对应的行,每个bin给出一个是否hit地信号,如果发生了多个bin同时给出hit信号,表明虚拟地址产生了别名问题,有多个副本存在于这些bin里。那么缓存控制器就同时更新这些bin中对应的这一行,即可保证每次写入都同时更新所有副本。
    • 缺点:将多个bin并行起来,做成4套独立的存储器分块,每个分块有对应的速写控制接口电路,成本高。
  • 软件方式
    • 保证指向同一个物理地址的多个虚拟地址的bin号都相同。人为地让这些虚拟地址都定位到同一个Bin地同一行上相互冲突挤占。

linux内核中的do_mmap()函数

if(do_align) addr=COLOUR_ALIGN(addr,pgoff);
else addr = PAGE_ALIGN(addr);
  • 变量do_align为1,判断需要按照页面着色方式映射虚拟页,调用COLOUR_ALIGN函数。【条件:使用CPUID指令读出CPU的所有属性发现,其L1缓存采用VIPT模式,而且路的容量>页面容量】
  • 不要求页面着色,调用PAGE_ALIGN函数,按照传统的方式映射,不考虑别名问题。

页面着色作用——2个不同的方面

  • 避免缓存的别名问题
    • 内存管理程序故意将指向同一个物理地址或者物理页面编号PPN的所有虚拟页的Bin号设置为相同,让他们相互挤占,从而只在缓存中保持针对该行/该页的唯一内存副本。
  • 常规的多进程轮流执行场景
    • 内存管理程序将多个不同的虚拟地址或虚拟页面编号VPN的Bin号打散,让它们不相互挤占。打乱分散的算法很多,轮流是最简单粗暴的一种。
    • 关于相互挤占的问题,也可以用多个way来解决,但是解决的不够彻底:先通过页面着色,在way内部分散存访,如果还是冲突,再利用其他的way。

14 缓存对写入操作的处理

核心对某地址写,未命中的情况

  • 【没有命中】核心发出对某地址上的字节的写操作,也就是Stor操作,该字节的地址为A(虚拟地址),其所在的缓存行并不在缓存中,或者之前曾经在但是后来被别人挤占了。
  • 缓存运行在VIPT模式,控制器拿着虚拟地址去匹配对应行的tag,发现没有任何匹配,查找哪个way中对应index行处于I状态(与tag匹配同一步完成),同时向下级缓存发起请求,读出来这一行准备填充到空行中。如果找到了一个空行,数据返回将对应的行写入到该行内,并更改对应的tag和状态,同时需要将核心发出的Stor请求要更新的字节写入到该行内对应的字节位置,并将Dirty位置1。
  • 【写分配/行填充】想写入一个或者几个字节:不是找到一个空行,只把这几个字节直接写进去就行,而是先把它填满,再把新数据覆盖进去。
    • 原因:系统刚加电时,硬件底层会自动将所有的缓存行设置为invalid状态,里面是什么值也不确定,如果不先填入而是直接更改,那么一些数据会被覆盖掉。

*

  • 写分配write allocate/行填充line fill
    • 向缓存行写入数据之前,先将其内容填充好,这个过程叫写分配write allocate/行填充line fill
  • 写命中
    • 如果某个行已经被填充上来,向其中写入一个或者多个字节就会写命中。
    • 回写write back
      • 当写命中时,如果只将数据写入L1缓存后就认为访存指令执行完毕了
    • 透写write through
      • 写到L1缓存不算完,必须同步写入到下一级缓存。
    • 回写速度>透写,一般L1缓存与核心之间采用WB模式,L1和L2之间或许采用WT模式,也就是L1缓存控制器在把脏数据写入L2缓存时,需要同步写入L3缓存。L1缓存由于追求绝对的速度,所以L1到L2之间不用WT模式。但是,在L2和L3之间或者L3到SDRAM之间,有些设计则采用了WT模式。
  • 写不命中
    • 如果对应的行内容尚未被填充,向其中写入一个或者多个字节时就会写不命中。先进行写分配再将这行内容写进去。
  • 写惩罚
    • 这种为了做一件事情而多做了其他事情的现象,被称为惩罚,上面这个例子被称为写惩罚,惩罚的结果就是多读了一整行,因为缓存必须以行为粒度来管理。
  • 写不分配write none-allocate
    • 为了避免写惩罚,有些设计在写不命中时,不分配缓存行,直接越过本机缓存,往下一级缓存中写。如果在下一级缓存命中则好,如果还不命中,就继续越过向后续存储器 (比如SDRAM)写入,在这里是一定会命中的。这种做法被称为写不分配 (writenone-allocate) 。
    • 这样写入的时候会比较快,但是后续如果要读这行数据的时候 (很有可能马上就会发生,因为程序的访存行为具有时间局部性) ,会发生不命中。早知如此当初为什么不在顶层缓存中填充好这一行呢?
  • WB和Write allocate配合使用,WT和Write None-Allocate配合使用。

15 Load/Stor Queue与Stream Buffer缓冲

Stor Queue/Stor Buffer

  • 在L1缓存命中的情况下,也需要3个左右的时钟周期完成访存。
  • 在L1缓存与L/S单元之间增加一个缓冲,Stor的数据先扔到这个缓冲中就算完成。这个缓冲只用一个周期就可以完成数据存入,可以降低核心流水线的阻塞频率。缓冲中最早的请求会被发送到连接L/S单元和L1缓存控制器的总线上,从而将请求再发送给缓存控制器处理。
  • Stor Queue/Stor Buffer
    • 用于Stor操作的缓冲,本质是一个FIFO队列,存储的就是L/S单元发出的Stor请求,包含:访存地址,长度,数据。
  • 产生代价【load请求需要查询Stor Queue】
    • 进行Load请求时,除了发送给缓存控制器去匹配查找之外,还需要并行地查询该FIFO内有没有对应地址的数据,如有则忽略缓存控制器的结果,直接从这里将数据读出并返回给核心L/S单元,因为这里才是最新的数据内容。这势必会增加硬件电路,FIFO队列外围要增加比较器。从这一点上来看,Load请求经历的第一站并非L1缓存,而是Stor Queue,当然也可以两者并行查找
  • 冲刷
    • 在Intel的CPU平台,当发生一些特殊事件时,硬件会将Stor Buffer里的请求全部写入RAM,这个过程俗称冲刷,比如执行了Sfense/Mfense指令等。
  • 增加Stor Queue/Buffer的优点——写融合write coalition
    • 写融合write coalition
      • 在这个缓冲中将多个针对同一个地址的Stor请求合并成一条,删除最早的Stor请求,保留最新的一条。这样L1缓存控制器只张罗一次就行。这个优化被称为写融合(write coalition)

写缓冲write buffer

这种思路也可以用在L1和L2等不同级的缓存之间,而且可以适当增加容量,因为L1之后的缓存层级的运行频率会降低,人们通常将位于L1缓存之后的各级缓存之间的写缓冲队列称为写缓冲 (write buffer) 。

Load Queue

  • 也是一个FIFO队列,专门缓冲那些满足了发射条件从而被发射到L/S单元来执行访存的请求。
  • load指令流程流程
    • 在保留站(等待目标地址算出来)→L/S单元

Load/Stor Queue隶属于核心L/S单元

有些设计是Load和Stor各单独设立一个FIFO队列,有些则是使用同一个FIFO队列,统称为LSQ (Load/Stor Queue)。每次Load指令执行的时候都要并行查找Stor Queue,所以这两者之间耦合得非常紧密。

读缓冲fill buffer

广泛存在于各级缓存之间,将处于L1缓存下游的各级缓存之间的读缓冲队列称为填充缓冲fill buffer

读优先——stor指令和load指令的速度比较

load需要在大量数据中查找,stor只是往stor queue一扔就行。所以stor访问比load访问时延要低。但是load比stor更重要。因为Stor是干完了活把结果送到内存,而Load则是为了干活而从内存中拿数据,拿不到数据就干不了活,也无从执行Stor指令了。所以,缓存控制器会被设计为优先执行Load请求,这被称为读优先。

加速访存的方式

  • 采用load/stor queue加速访存
  • 数据预取prefetch

数据预取prefetch——加速访存/流缓冲stream buffer

  • 缓存控制器私自将除了核心所请求的缓存行之外的接续的缓存行预先读入缓存。有些设计是将这些预读上来的数据单独放置在一个FIFO队列/缓冲中,并不往缓存中写,这个FIFO被称为流缓冲stream buffer。对于后续的访存请求,如果没有命中缓存,就去流缓冲查找一下,会有较高的命中率。

  • 由于stream buffer不会很大,所以可以快速搜索相比直接去下一级去搜索数据而言,如果命中在流缓冲中,访存时延将会有很大降低。

  • 另外,对于多核心场景,同时有多个线程在执行,而每个线程访问的地址是大相径庭的,为一个线程预读上来的数据,对其他线程几乎没有用处,为此,有些设计又设置了多个流缓冲来分摊不同线程的地址流,这也是流(stream)一词的由来了。

16 非阻塞缓存与MSHR【※】

对于CPU核心,每一条机器指令对它来讲都是一个任务;对于缓存来讲,核心的L/S单元发出的访存请求也是一个任务。核心把计算任务交给ALU来执行,把访存任务交给L/S单元来执行,L/S单元再把任务交给缓存控制器来执行。在LSQ中,可能有多个Load/Stor类指令正在等待结果返回,这些请求会按照顺序一条一条地被发送给L1缓存控制器去执行。

一种情景

  • 排在LSQ中队首的某个load指令被发往了L1缓存控制器,结果控制器查找结果为本次访存请求不命中,即发生了cache miss,L1缓存控制器等待10个左右时钟周期才能从L2缓存中拿到数据。
  • 在这10个左右的时钟周期内,L1缓存控制器无事可做吗?
  • 如果L1缓存控制器记录一个状态,比如哪个请求没有命中,该请求访问的是哪个地址等等;然后继续执行后续的load/stor请求,如果又不命中,继续记录一条追踪记录。
  • 【缓存控制器→L/S单元】当数据返回时,比较这些追踪记录,看看是哪个访存请求最终返回了数据,然后缓存控制器就在与L/S单元连接的总线上向L/S单元返回对应的数据,并对所有的返回数据加以区分,以告诉L/S单元这次返回的是哪一条访存请求的数据,比如通过ID标记机制,每个请求都有各自的ID,也可以直接在总线上通告该数据要被载入到的目标寄存器号。
  • 【L/S单元→数据寄存器】L/S单元拿到返回的数据之后,根据LSQ中记录的访存请求的目标数据寄存器号,直接将数据载入对应的核心的数据寄存器,从而完成访存操作。当然,这里其实是返回到了内部的私有寄存器中,会被流水线结果侦听单元捕获到,然后更新到保留站以及私有寄存器中的记录上,这样依赖这些数据的、依然等待在保留站中的其他指令就可以在下一个时钟周期内被发射控制单元判断为符合执行条件,从而被调度执行,这个过程建议回顾流水线那一章。

非阻塞缓存

  • 这种支持前序访存请求,即便不命中也不影响后续访存指令继续执行的缓存被称为非阻塞缓存 (none blocking cache)。上面提到过的用于追踪各个不命中的访存请求的执行状态的硬件模块被称为MSHR(Miss Status Holding/Handling Register),丢失状态保持/处理寄存器)。

MSHR寄存器(※)

  • MSHR实际上多组寄存器的组合
  • 每组寄存器内部有MSHR Header和MSHR Body
    • MSHR Header:记录缓存行地址、状态
    • MSHR Body:访问该缓存行内部数据的访存指令的类型,访问的字节offset,目标寄存器号(若干个)
  • 当访存请求miss时,缓存控制器向下一级存储器发起缓存行读请求同时,将miss对应的缓存行index以及状态记录到一组MSHR Header寄存器中,同时向Body寄存器中记录本访存请求自身的信息
    • 状态:是否属于预读、是否已经去下一级存储器读入、已经拿到了缓存行内哪些部分
    • 自身信息:访存的类型、访问的offset、数据放回时存储到的目标寄存器号、stor指令的源寄存器号;使用寄存器号可以区分每条访存指令。
  • 缓存控制器从后级缓存将缓存行数据提取上来的过程不是在一个周期内完成的——关键字优先

17 缓存行替换策列

18 i_Cache/d_Cache/TLB_cache

19 对齐与伪共享

参考

  1. 《大话计算机》卷2 6.2节