在计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。
缓存的存储结构
结构上,一个直接映射(Direct Mapped)缓存由若干缓存块(Cache Block,或Cache Line)构成。每个缓存块存储具有连续内存地址的若干个存储单元。在32位计算机上这通常是一个双字(dword),即四个字节。因此,每个双字具有唯一的块内偏移量。每个缓存块有一个索引(Index),它一般是内存地址的低端部分,但不含块内偏移和字节偏移所占的最低若干位。一个数据总量为4KB、缓存块大小为16B的直接映射缓存一共有256个缓存块,其索引范围为0到255。使用一个简单的移位函数,就可以求得任意内存地址对应的缓存块的索引。由于这是一种多对一映射,必须在存储一段数据的同时标示出这些数据在内存中的确切位置。所以每个缓存块都配有一个标签(Tag)。拼接标签值和此缓存块的索引,即可求得缓存块的内存地址。如果再加上块内偏移,就能得出任意一块数据的对应内存地址。因此,在缓存大小不变的情况下,缓存块大小和缓存块总数成反比关系。下图中所示的缓存块来自一个数据总量为4KB、每个缓存块大小为16B的直接映射缓存,其标签长度为20bits
此外,每个缓存块还可对应若干标志位,包括有效位(valid bit)、脏位(dirty bit)、使用位(use bit)等。这些位在保证正确性、排除冲突、优化性能等方面起着重要作用。
运作流程
假象直接映射缓存的工作流程。这个缓存共有四个缓存块,每个块16个字节,即4个字,因此共有64字节存储空间。使用写回(Write back)策略以保证数据一致性。系统启动时,缓存内没有任何数据。之后,数据逐渐被载入或换出缓存。假设在此后某一时间点, 缓存和内存布局如右图所示。此时,若处理器执行数据读取指令,控制逻辑依如下流程:
1、(将地址由高至低划分为四个部分:标签、索引、块内偏移、字节偏移。其中块内偏移和字节偏移各占两位,后者在以下操作中不使用。)
2、用索引定位到相应的缓存块。
3、用标签尝试匹配该缓存块的对应标签值。如果存在这样的匹配,称为命中(Hit) ;否则称为未命中(Miss) 。
4、如命中,用块内偏移将已定位缓存块内的特定数据段取出,送回处理器。
5、如未命中,先用此块地址(标签+索引)从内存读取数据并载入到当前缓存块,再用块内偏移将位于此内的特定数据单元取出,送回处理器。这里要注意的是,(1) 读入的数据会冲掉之前的内容。为保证数据一致性, 必须先将数据块内的现有内容写回内存。(2) 尽管处理器请求的只是一个字, 缓存仍必须在读取的时候把整个数据块都填充满。(3) 缓存的读取是按缓存块大小为边界对齐的。对于大小为16字节的缓存块,任何因为0000、或0x0001、 或0x0002、 或0x0003造 成的未命中,都会导致位于内存0x0000- -0x0003的全部四个字被读 入块中。在下图中,如此时处理器请求的地址在0x0020到0x0023之间,或在0x0004到0x0007之间, 或在0x0528到0x052B之间, 或在0x05EC到0x05EF之间, 均会命中。其余地址则全部未命中。而处理器执行数据写入指令时,控制逻辑依如下流程:CPU缓存的运作流程如下图:(注意内存左侧给出的地址是字地址而不是字节地址)
组相联
直接映射
为了便于数据查找,一般规定内存数据只能置于缓存的特定区域。对于直接映射缓存,每一个内存块地址都可通过模运算对应到一个唯一缓存块上。注意这是一种多对一映射:多个内存块地址须共享一个缓存区域。
其中I为缓存索引,Amb为内存块地址,N为缓存块总数。使用内存块地址而不是内存地址是因为缓存块通常包含一组连续的内存单元数据。以缓存块为32字节的直接映射缓存为例,内存地址Am到缓存索引计算为:
由于缓存字节数和缓存块数均为2的幂,上述运算可以由硬件通过移位极快地完成。
N路组相联
直接匹配缓存尽管在电路逻辑上十分简单,但是存在显著的冲突问题。由于多个不同的内存块仅共享一个缓存块,一旦发生缓存失效就必须将缓存块的当前内容清除出去。这种做法不但因为频繁的更换缓存内容造成了大量延迟,而且未能有效利用程序运行期所具有的时间局部性。
组相联(Set Associativity)是解决这一问题的主要办法。使用组相联的缓存把存储空间组织成多个组,每个组有若干数据块。通过建立内存数据和组索引的对应关系,一个内存块可以被载入到对应组内的任一数据块上。以下图为例, 如使用2路组相联,内存地址为0、8、16、24的数据均可被置于缓存第0组中两个数据块的任意一个;如果使用4路组相联,内存地址为0、8、16、24的数据均可被置于缓存第0组中四个数据块的任意一个。一般地:
其中,I为缓存索引,Am为内存地址,Nw为缓存块内字数, Na为相联路数, N为组数。当使用组相联时,在通过索引定位到对应组之后,必须进一步地与所有缓存块的标签值进行匹配,以确定查找是否命中。这在一定程度上增加了电路复杂性,因此会导致查找速度有所降低。
此外,在不增大缓存大小的前提下单纯地增加组相联的路数,将不会改变缓存和内存的对应比例。再以右图为例,对于2路组相联,尽管第0组内有两个缓存块,但是该组现在也是内存块1、9、17、25的目标块。直接匹配可以被认为是单路组相联。经验规则表明,在缓存小于128KB时,欲达到相同失效率,一个双路组相联缓存仅需相当于直接匹配缓存一半的存储空间[1]。一、二、四、八路组相联缓存的较:
全相联
组相联的一个极端是全相联。这种缓存意味着内存中的数据块可以被放置到缓存的任意区域。这种相联完全免去了索引的使用,而直接通过在整个缓存空间上匹配标签进行查找。 由于这样的查找造成的电路延迟最长,因此仅在特殊场合,如缓存极小时,才会使用。
置换策略
对于组相联缓存,当一个组的全部缓存块都被占满后,如果再次发生缓存失效,就必须选择一个缓存块来替换掉。存在多种策略决定哪个块被替换。显然,最理想的替换块应当是距下一次被访问最晚的那个。这种理想策略无法真正实现,但它为设计其他策略提供了方向。先进先出算法(FIFO)替换掉进入组内时间最长的缓存块。最久未使用算法(LRU)则跟踪各个缓存块的使用状况,并根据统计比较出哪个块已经最长时间未被访问。对于2路以上相联,这个算法的时间代价会非常高。对最久未使用算法的一个近似是非最近使用(NMRU)。这个算法仅记录哪一个缓存块是最近被使用的。在替换时,会随机替换掉任何一个其他的块。故称非最近使用。相比于LRU,这种算法仅需硬件为每一个缓存块增加一个使用位(use bit)即可。
此外,也可使用纯粹的随机替换法。测试表明完全随机替换的性能近似于LRU[2]。
回写策略
为了和下级存储(如内存)保持数据一致性,就必须把数据更新适时传播下去。这种传播通过回写来完成。一般有两种回写策略:写回(Write back)和写通(Write through)。写回是指,仅当一个缓存块需要被替换回内存时,才将其内容写入内存。如果快取命中,则总是不用更新内存。为了减少内存写操作,缓存块通常还设有一个脏位(dirty bit),用以标识该块在被载入之后是否发生过更新。如果一个缓存块在被置换回内存之前从未被写入过,则可以免去回写操作。
写回的优点是节省了大量的写操作。这主要是因为,对一个数据块内不同单元的更新仅需一次写操作即可完成。这种内存带宽上的节省进一步降低了能耗,因此颇适用于嵌入式系统。写通是指,每当缓存接收到写数据指令,都直接将数据写回到内存。如果此数据地址也在缓存中,则必须同时更新缓存。由于这种设计会引发造成大量写内存操作,有必要设置一个缓冲来减少硬件冲突。这个缓冲称作写缓冲器(Write buffer),通常不超过4个快取Block大小。不过,出于同样的目的,写缓冲器也可以用于写回型缓存。写通较写回易于实现,并且能更简单地维持数据一致性。
按写分配与不按写分配
当发生写失效时,缓存可有两种处理策略,分别称为按写分配(Write allocate)和不按写分配(No-write allocate)。按写分配是指,先如处理读失效一样,将所需数据读入缓存,然后再将数据写到被读入的单元。不按写分配则总是直接将数据写回内存。
设计缓存时可以使用回写策略和分配策略的任意组合。对于不同组合,发生数据写操作时的行为也有所不同。如右表所示。
由于计算机程序一般使用虚拟地址,一个必须决定的设计策略是缓存的地址标签及索引是使用虚拟地址还是物理地址。
虚缓存
一个简单的方案就是缓存的标签和索引均使用虚拟地址。这种缓存称为虚缓存(virtual cache)。这种缓存的优点是仅在缓存失效时才需要进行页面翻译。由于缓存命中率很高,需要翻译的次数也相对较少。但是这种技术也存在严重的问题。
第一,引入虚拟地址的一个重要原因是在软件(操作系统)级进行页面保护,以防止进程间相互侵犯地址空间。由于这种保护是通过页表和翻译旁视缓冲器(TLB)中的保护位(protection bit)实现的,直接使用虚拟地址来访问数据等于绕过了页面保护。一个解决办法是在缓存失效时查看TLB对应表项的保护位以确定是否可以加载缺失的数据。
第二,由于不同进程使用相同的虚拟地址空间,在切换进程后会出现整个缓存都不再对应新进程的有效数据。如果前后两个进程使用了相同的地址区间,就可能会造成缓存命中,却访问了错误的地址,导致程序错误。有两个解决办法:(1)进程切换后清空缓存。代价过高。(2)使用进程标识符(PID)作为缓存标签的一部分,以区分不同进程的地址空间。
第三,别名问题(Alias)。由于操作系统可能允许页面别名,即多个虚拟页面映射至同一物理页面,使用虚拟地址做标签将可能导致一份数据在缓存中出现多份拷贝的情形。这种情况下如果对其中一份拷贝作出修改,而其他拷贝没有同步更新,则数据丧失整合性,导致程序错误。有两个解决办法:(1)硬件级反别名。当缓存载入目标数据时,确认缓存内没有缓存块的标签是此地址的别名。如果有则不载入,而直接返回别名缓存块内的数据。(2)页面着色(Page Coloring)。这种技术是由操作系统对页面别名作出限制,使指向同一页面的别名页面具有相同的低端地址。这样,只要缓存的索引范围足够小,就能保证在缓存中决不会出现来自不同别名页面的数据。
第四,输入输出问题。由于输入输出系统通常只使用物理地址,虚缓存必须引入一种逆映射技术来实现虚拟地址到物理地址的转换。
实缓存
实缓存(physical cache)完全使用物理地址做缓存块的标签和索引,故地址翻译必须在访问缓存之前进行。这种传统方法所以可行的一个重要原因是TLB的访问周期非常短(因为本质上TLB也是一个缓存),因而可以被纳入流水线。但是,由于地址翻译发生在缓存访问之前,会比虚缓存更加频繁地造成TLB。(相比之下,虚缓存仅在本身失效的前提下才会访问TLB,进而有可能引发TLB失效)实缓存在运行中存在这样一种可能:首先触发了一个TLB失效,然后从页表中更换TLB表项(假定页表中能找到)。然后再重新访问TLB,翻译地址,最后发现数据不在缓存中。如下图:
虚索引、实标签缓存
一个折中方案是同时使用虚索引和实标签(virtually indexed, physically tagged)。这种缓存利用了页面技术的一个特征,即虚拟地址和物理地址享有相同的页内偏移值(page offset)。这样,可以使用页内偏移作为缓存索引,同时使用物理页面号作为标签。这种混合方式的好处在于,其既能有效消除诸如别名引用等纯虚缓存的固有问题,又可以通过对TLB和缓存的并行访问来缩短流水线延迟。
这种技术的一个缺点是,在使用直接匹配缓存的前提下,缓存大小不能超过页面大小,否则页面偏移范围就不足以覆盖缓存索引范围。这个弊端可以通过提高组相联路数来改善。
多级缓存
引入动机
介于处理器和内存二者之间的缓存有两个天然冲突的性能指标:速度和容积。如果只向处理器看齐而追求速度,则必然要靠减少容积来换取访问时间;如果只向内存看齐而追求容积,则必然以增加处理器的访问时间为牺牲。这种矛盾促使人们考虑使用多级缓存。
在一个两级缓存体系中,一级缓存靠近处理器一侧,二级缓存靠近内存一侧。当一级缓存发生失效时,它向二级缓存发出请求。如果请求在二级缓存上命中,则数据交还给一级缓存;如失效,二级缓存进一步向内存发出请求。对于三级缓存可依此类推。通常,更接近内存的缓存有着更大容积,但是速度也更慢。以AMD Opteron X4处理器为例,见右表比较[4]。值得注意的是,无论如何,低级缓存的局部命中率总是低于高级缓存。这是因为数据的时空局部性在一级缓存上基本上已经利用殆尽。
设计考虑
虽然功能类似,但不同级别的缓存在设计和实现上也有不同之处。尽管一般而言,在存储体系结构中低级存储总是包含高级存储的全部数据,但对于多级缓存则未必。相反地,存在一种多级排他性(Multilevel exclusion)的设计。此种设计意指高级缓存中的内容和低级缓存的内容完全不相交。这样,如果一个高级缓存请求失效,并在次级缓存中命中的话,次级缓存会将命中数据和高级缓存中的一项进行交换,以保证排他性。
多级排他性的好处是在存储预算有限的前提下可以让低级缓存更多地存储数据。否则低级缓存的大量空间将不得不用于覆盖高级缓存中的数据,这无益于提高低级缓存的命中率。
当然,也可以如内存对缓存般,使用多级包容性(Multilevel inclusion)设计。这种设计的优点是比较容易方便查看缓存和内存间的数据一致性,因为仅检查最低一级缓存即可。对于多级排他性缓存这种检查必须在各级上分别进行。这种设计的一个主要缺点是,一旦低级缓存由于失效而被更新,就必须相应更新在高级缓存上所有对应的数据。因此,通常令各级缓存的缓存块大小一致,从而减少低级对高级的不必要更新。
此外,各级缓存的写策略也不相同。对于一个两级缓存系统,一级缓存可能会使用写通来简化实现,而二级缓存使用写回确保数据一致性。