CPU设计之Cache

此篇是根据之前文章整理而成,稍添加了一些新内容。主要是以介绍cache相关的一些概念为主,很多内容都没有展开,深入,尤其是多核处理器cache设计,比如cache stashing等话题完全没有涉及。
Cache的基本原理
Cache存储器也被称为高速缓冲存储器,位于CPU和主存储器(内存)之间。之所以在CPU和内存之间要加入高速缓存是因为现代的CPU频率大大提高,内存的发展已经跟不上CPU访问的速度。在2001 – 2005年间,处理器时钟频率以每年55%的速度增长,而内存的增长速度只是7%。在现在的系统中,处理器需要上百个时钟周期才能从内存中取到数据。如果没有cache,处理器在等待数据的大部分时间内将会停滞不动。
图片
在复杂一些的CPU设计中cache一般会分为若干级,后面会讲原因。一级(level 1)cache简称L1,往下类推,最后一级通常也会称为LLC(last level cache)。L1高速缓存通常还分为指令缓存(I-cache)和数据缓存(D-cache)。在L2和L3中往往不区分指令和数据了。
Cache的容量跟主存比起来要小得多,尤其是离CPU最近的L1,通常是几十KB大小。一般L3也就是几十MB大小,跟现在以GB为单位的内存比起来差了好几个数量级。那为什么加入cache还能提高性能呢?
设想一下,如果提前把CPU接下来最有可能用到的数据存放在cache中,那么CPU就可以在很短的时间内得到数据了,而不用再去访问内存。一般如果L1命中的话,CPU在2-3个时钟周期内就会得到想要的数据。那么CPU是如何预测到接下来将要用到的数据的呢?其实这种预测是基于程序代码和数据在时间和空间上的局部性原理(locality)。
时间局部性(temporal locality):如果一个数据现在被访问了,那么以后很有可能也会被访问
空间局部性(spatial locally):如果一个数据现在被访问了,那么它周围的数据在以后可能也会被访问
这里要提到一些概念。当CPU在cache中找到需要的数据,我们称之为“命中(hit)”。反之没有找到数据,我们称之为“缺失(miss)”,这时候就要去外层存储(下一级缓存或者内存)中寻找所需数据。如果是多级cache设计,那么对于L1来讲L2就是它的外层存储。
缓存缺失的类型有很多,常见的有以下三种,可以用3C表示:
强制缺失(Compulsory miss),第一次将数据块读入cache所产生的缺失,也成为冷缺失(cold miss)。
冲突缺失(Conflict miss),由于cache相联度有限导致的缺失。
容量缺失(Capacity miss),由于cache大小有限导致的缺失。
高速缓存的管理需要考虑多个方面。首先是数据放置策略;其次是数据替换策略;最后是数据写策略,接下来会逐一介绍。
Cache数据放置策略
在讲cache的构成前,先要讲几个概念。首先,缓存的大小称之为cache size,其中每一个缓存行称之为cache line(下文中会混称缓存行和cache line)。CPU访问内存的最小单位就是cache line。Cache主要由两部分组成,Tag部分和Data部分。因为cache是利用了程序中的相关性,一个被访问的数据,它本身和它周围的数据在最近都有可能被访问,因此Data部分就是用来保存一片连续地址的数据,而Tag部分则是存储着这片连续地址的公共地址,一个Tag和它对应的所有数据Data组成一行称为cache line,而Cache line中的数据部分成为数据块(cache data block,也称做cache block或data block)。如果一个数据可以存储在Cache的多个地方,这些被同一个地址找到的多个cache line称为cache set。当CPU在读取缓存数据时,一个cache line的多字节会被同时读出。
假设我们现在的cache size是32KB,一个cache line是64Bytes。通过简单的除法我们就知道在cache中有512条cache line。假设我们的系统中地址宽度是32 bits,当一个地址发下来,会用最低的6 bits作为块内的偏移地址(offset),用较高的9bits作为cache索引地址(index),将其余的17 bits地址作为标志位(tag)作为比对。
使用Index来从Cache中找到一个对应的cache line,但是所有Index相同的地址都会寻址到这个cache line,因此在cache line中还有Tag部分,用来和地址中的Tag进行比较,只有它们相等才表明这个cache line就是想要的那个。在一个cache line中有很多数据,通过存储器地址中的Offset部分可以找到真正想要的数据,它可以定位到每个字节。在cache line中还有一个有效位(Valid),用来标记这个cache line是否保存着有效的数据,只有在之前被访问过的存储器地址,它的数据才会存在于对应的cache line中,相应的有效位也会被置为1。每个cache line中会有一个bit位记录数据是否被修改过,称之为dirty bit。
图片
上面的地址对应关系被称为“直接映射(direct-mapped)”。直接映射缓存在硬件设计上会更加简单,因此成本上也会较低。根据直接映射,我们可以画出主存地址与cache的对应关系如下图:
问题来了,如果CPU需要连续访问0x0000_0000,0x0001_0000,0x0002_0000地址,会发生什么呢?这三个地址的index位是一样的,tag位不同,因此对应的cache line是同一个。所以当访问0x0000_0000时,cache缺失,需要从主存中搬入数据(假设只有一级cache);当访问0x0001_0000时,同样是cache缺失,需要从主存中搬入数据,替换掉cache中的上一条数据;当访问0x0002_0000时,依然cache缺失,需要从主存中搬入数据。这就相当于每次访问数据都要从主存中读取,所以cache的存在并没有对性能有什么提升。这种现象叫做“cache颠簸(cache thrashing)”。
“组相连”的方式是为了解决直接映射结构cache的不足而提出的,存储器中的一个数据不单单只能放在一个cache line中,而是可以放在多个cache line中,对于一个组相连结构的cache来说,如果一个数据可以放在n个位置,则称这个cache是n路组相连的cache(n way set-associative Cache)。下图为一个两路组相连cache的原理图。
图片
这种结构仍旧使用存储器地址的Index部分对cache进行寻址,此时可以得到两个cache line,这两个cache line称为一个cache set,究竟哪个cache line才是最终需要的,是根据Tag比较的结果来确定的,如果两个cache line的Tag比较结果都不相等,那么就说明这个存储器地址对应的数据不在cache中,也就是发生了cache缺失。上图所示为并行访问,如果先访问Tag SRAM部分,根据Tag比较的结果再去访问Data SRAM部分,就称为串行访问。
两路组相连缓存的硬件成本相对于直接映射缓存更高。因为其每次比较tag的时候需要比较多个cache line对应的tag(某些硬件可能还会做并行比较,增加比较速度,这就增加了硬件设计复杂度)。为什么我们还需要两路组相连缓存呢?因为其可以有助于降低cache颠簸可能性。
既然组相连缓存那么好,如果所有的cache line都在一个组内。岂不是性能更好?由于所有的cache line都在一个组内,因此地址中不需要set index部分。因为,只有一个组让你选择,间接来说就是你没得选。我们根据地址中的tag部分和所有的cache line对应的tag进行比较(硬件上可能并行比较也可能串行比较)。哪个tag比较相等,就意味着命中某个cache line。因此,在全相连缓存中,任意地址的数据可以缓存在任意的cache line中。所以,这可以最大程度的降低cache颠簸的频率。但是硬件成本上也是更高。
Cache写策略
上面讲的是cache的放置策略。对于指令而言,CPU不会去修改其内容。但是对数据,CPU会去修改。这就涉及到了cache的写策略问题。
第一个写策略问题是,当处理器修改了高速缓存中的数据后,这些修改什么时候会被传播到外层的存储层次。对于D-cache来说,当执行一条store指令时,如果只是向D-Cache中写入数据,而并不改变它的下级存储器中的数据,这样就会导致D-cache和下级存储器中,对于这一个地址有着不同的数据,这称作不一致(non-consistent)。对应的两种策略是:
写直达(write through),缓存中任何一个字节的修改都会被立即传播到外层的存储层次,也有人译为“写透”。
写回(write back),只有当缓存块被替换的时候,被修改的数据块会写回并覆盖外层存储层次中的过时数据。
采用“写直达(写透)”还是“写回”策略,首先要考虑的系统带宽。对于处理器芯片最外层的高速缓存,由于片外带宽有限,往往采用“写回”策略;而对于内层高速缓存,由于片上带宽较大,因此往往采用“写直达”策略。
一个影响是要考虑两种策略在硬件故障下的容错。例如,当遇到阿尔法粒子或者宇宙射线时存储在高速缓存中的数据会反转其存储的值。在“写直达”策略中,当检测到故障时,可以安全的丢弃出故障的数据块并从外层存储中重新读取该数据块。但在“写回”策略中,仅仅有故障检测并不够,。为了增加纠错功能,需要添加冗余的数据位ECC。但由于ECC计算开销大,因此ECC会增加高速缓存的访问时延。
另一个影响是要考虑两种策略中外层高速存储的功耗。在“写直达”策略中,外层高速缓存会被频繁写入,导致外层高速缓存较高的功耗。解决办法之一是在内层缓存和外层缓存中间增加一个写缓冲(wirte buffer),用于临时保存对内层缓存的最近若干更新。当写缓冲满的时候,将存储最久的或最近最少使用的数据块写入外层缓存。当内层缓存缺失时,首先检查写缓冲区。
第二个问题是,如果要写入字节的数据块不在高速缓存中时,是否将其读入高速缓存中。上面所讲述的情况都是假设在写D-cache时,要写入的地址总是D-cache中存在的,而实际当中,有可能发现这个地址并不在D-cache中,这就发生了“写缺失(write miss)”,此时最简单的处理方法就是将数据直接写到下级存储器中,而并不写到D-cache中,这种方式称为“写不分配(Write non-Allocate)”。与之相对应的方法就是“写分配(Write Allocate)”,在这种方法中,如果写cache时发生了缺失,会首先从下级存储器中将这个发生缺失的地址对应的整个数据块(data block)取出来,将要写入到D-cache中的数据合并到这个数据块中,然后将这个被修改过的数据块写到D-cache中。如果为了保持存储器的一致性,将这个数据块也写到下级存储器中,这种方法就是刚才提到的“写直达(Write Through)”。如果只是将D-cache中对应的line标记为“脏”(Dirty)的状态,只有等到这个line要被替换时,才将其写回到下级存储器中,则这种方法就是前面提到的“写回(WriteBack)”。“写分配”为什么在写缺失时,要先将缺失地址对应的数据块从下级存储器中读取出来,然后在合并后写到cache中?因为通常对于写D-cache来说,最多也就是写入一个字,直接写入cache的话,会造成数据块中的其它部分和下级存储器中对应的数据不一致,且是无效的,如果这个cache line由于被替换而写回到下级存储器中时,就会使下级存储器中的正确数据被篡改。
“写直达”策略可能会搭配使用“写分配”或者“写不分配”策略。然而,一个“写回”策略通常会使用“写分配”策略,否则如果使用“写不分配”策略,“写缺失”会被直接传播到外层存储层次,从而变的与“写直达”策略相似。
对于多核处理器设计来说,往往最后一级cache(last level cache,LLC)是所有处理器共享,而其它级cache是某处理器独享,因此还有一个写操作如何传播的问题。有两种实现方式:“写更新(write update)”和“写无效(write invalidate)”。区别是对某个处理器的缓存中的某个值执行写操作时,对于保有该数据副本的其他所有缓存的值是全部更新还是全部被置为无效。后面讲缓存一致性的时候会设计这两个策略。
Cache替换策略
在一个cache set内的所有cache line都已经被占用的情况下,如果需要存放从下层存储中读过来的其它地址的数据,那么就需要从其中替换一个,如何从这些有效的cache line找到一个并替换之,这就是缓存替换(cachereplacement)策略。常见的替换算法有以下几种:
先进先出(FIFO)算法
最不经常使用(LFU)算法
近期最少使用(LRU)算法
随机替换算法
评价cache数据替换策略的标准是,被替换出的数据块应该是将来最晚被访问的数据块。这也好理解,就是要尽量降低随后访问的缓存缺失。目前,大部分高速缓存采用LRU或者近似的替换策略。但是LRU的性能也不是完美的,特别是当程序的工作集远大于缓存大小时,LRU的性能会出现断崖式下跌。
Cache中的buffer
在实际的cache设计中会包含很多的buffer,下面简单介绍一下。
当发生了cache miss时,需要从主存或者外层cache读取一条缓存行到当前缓存中。也就意味着,当前缓存中的一条缓存行会被替换,数据被移出,这个过程称为eviction。下面是从ARMv8-A文档中截取的关于eviction的解释。
不管我们采用什么替换算法,都不能保证CPU短时间内不会再次访问被移出的数据。一旦这种情况发生,cache中的数据可能会被频繁的移进移出,极端情况下会影响缓存性能。聪明的前辈们想到了一个办法,在cache中加上一个eviction buffer,用于暂时保存被移出的缓存行,这样当CPU再次需要这条缓存行的时候,就可以在eviction buffer中找到了。而且,eviction buffer还有一个用处,就是被移出的缓存行如果是“dirty”状态,需要把数据更新到外层存储中,这是eviction buffer可以起到写缓冲的目的。也就是类似接下来讲的write buffer,在前面探讨缓存写策略的时候也有提及。
Write buffer也是高速缓存中的一个很小的存储缓冲器,用来临时存放处理器将要写入到主存中的数据。如果没有writer buffer,写到外层存储的数据将被直接发送给外层存储,并等待写操作完成;如果有了write buffer,写数据将会被放到这个buffer中,随后写往外层存储,这样CPU和当前缓存将从低速读写操作中脱离出来。尤其是当CPU对连续地址进行写的时候,会大大提高cache的效率。
此外,高速缓存中还有很多种buffer,不一一列举了。贴一张ARM的cache控制器IP(L210)中对各种buffer的支持。
图片
Cache寻址方式
在前面讲缓存数据放置时讲到,缓存控制器通过地址的高位来做标签(Tag)和索引(Index)判断是否命中。但是我们并没有说这个地址是虚拟地址(virtual address)还是物理地址(physical address)。
现代的处理器系统都支持虚拟存储。在虚拟存储系统中,程序可以假设整个地址空间都是可用的。系统通过两种类型的地址:虚拟地址和物理地址,营造了程序拥有较大地址空间的抽象。操作系统以页为粒度(通常4KB为一页),维护地址页表,将虚拟地址翻译为物理地址。负责地址翻译的组件被称作内存管理单元(Memory Management Unit,MMU)。
页表由操作系统维护,放在主存中。由于虚拟空间较大,页表往往采用层次结构,需要多次访问才能一个虚拟页地址转换为一个物理页地址。如果每次存储访问都要访问页表,那么cache的访问时延将很大。为了加速页表访问,大多数的处理器都会采用旁路转换缓冲(Translation Lookaside Buffer,TLB),作为一个高速缓存保存最近最常用的页表项。因为一个页比一个高速缓存块大很多,所以TLB只需要很少的表项就能覆盖很大的空间。可以把TLB理解为专门做地址转换用的一小块cache。
在高速缓存中,虚拟地址和物理地址都可以用于索引和标签比较。根据标签和索引是虚拟地址还是物理地址,缓存寻址方式分为以下几类:
第一种是虚拟索引虚拟标签(Virtual Index Virtual Tag,VIVT),即通过虚拟地址的索引位进行缓存寻址,并将虚拟地址中的标签位保存在缓存的标签阵列。通过虚拟寻址,在访问cache时不需要物理地址,因此TLB和L1的访问可以并行执行,访问TLB的延迟可以被隐藏掉。但是这种方式的缺点也很明显,首先每个进程都有自己的页表,也就是说相同的虚拟页地址在不同进程中其对应的物理地址很可能不同。这就是所说的“歧义(ambiguity)”问题。举个例子,假设A进程虚拟地址0x1000映射物理地址0x2000。B进程虚拟地址0x1000映射物理地址0x3000。当A进程运行时,访问0x1000地址会将物理地址0x2000的数据加载到cache中。随后切换到B进程的时候,B进程访问0x1000会cache hit,此时B进程就访问了错误的数据。想要避免“歧义”的发生,当切换进程的时候,可以选择flush所有的cache。因此,虚拟寻址的cache无法在多个进程间共享数据。切换后的进程刚开始执行的时候,将会由于大量的cachemiss导致性能损失。VIVT还要面临另一个问题:“别名(alias)”。当不同的虚拟地址映射相同的物理地址,而这些虚拟地址的index不同,此时就发生了别名现象。一个例子。虚拟地址0x1000和0x4000都映射到相同的物理地址0x8000。这意味着进程既可以从0x1000读取数据,也能从地址0x4000读取数据。那么有可能同一个物理地址的数据会加载到不同的cache line。如果进程先访问0x1000把数据加载进一条cache line,接着访问0x4000把相同物理地址的数据加载进另一条cache line,然后将0x1000地址数据修改,当再次访问0x4000的时候由于cache hit导致读取到旧的数据。这就造成了数据不一致现象。想解决这个问题,可以把0x1000和0x4000地址设成uncacheable,对这些地址的访问不通过cache,但是这样就损失了cache带来的性能好处。
图片
第二种是物理索引物理标签(Physical Index Physical Tag,PIPT),即通过物理地址的索引位进行缓存寻址,并将物理地址中的标签位保存在缓存的标签阵列中。这种方式的缺点是在生成cache所需的物理地址时,需要访问TLB进行地址翻译,因此访问TLB造成的延迟会加在cache的访问时间上。这就相当于L1的访问时间延长了一倍,显著影响性能。PIPT的优点是软件层面基本不需要任何的维护就可以避免“歧义”和“别名”问题。在Linux内核中,可以看到针对PIPT高速缓存的管理函数都是空函数,无需任何的管理。
图片
第三种是虚拟索引物理标签(Virtual Index Physical Tag,VIPT),索引位不需要通过TLB,可以立即对缓存进行访问,标签位经过TLB,物理地址标签与缓存中的标签进行对比,从而决定是hit还是miss。这种寻址方式的缺点是L1的大小受限,目前看这种缺陷并不严重,因为从性能角度考虑,L1的大小已经受限了。VIPT以物理地址部分位作为标签位,因此不会存在“歧义”问题。但是有可能会存在“别名”问题,取决于cache的数据放置策略和操作系统的页表管理,具体就不分析了。
图片
可能有细心的同学要问了,“根据排列组合来说,还应该有PIVT啊”。之所以没有文献提,主要是因为PIVT在速度上很慢,还有“歧义”和“别名”问题,完全没有优势。
最后总结一下,VIVT软件维护成本过高,是最难管理的高速缓存。目前主要使用PIPT和VIPT。
为什么cache要分级
我们经常会看到cache分为L1,L2,L3甚至L4等多级。为什么不能把L1的容量做大,不要其它的cache了?原因在于性能/功耗/面积(PPA)权衡考虑。L1 cache一般工作在CPU的时钟频率,要求的就是够快,可以在2-4时钟周期内取到数据。L2 cache相对来说是为提供更大的容量而优化的。虽然L1和L2往往都是SRAM,但构成存储单元的晶体管并不一样。L1是为了更快的速度访问而优化过的,它用了更多/更复杂/更大的晶体管,从而更加昂贵和更加耗电;L2相对来说是为提供更大的容量优化的,用了更少/更简单的晶体管,从而相对便宜和省电。在有一些CPU设计中,会用DRAM实现大容量的L3 cache(一个DRAM的存储单元要比SRAM小)。现在也有一些设计会带L4 cache,有时放在片外或者和CPU封装在一起。
图片
再回到L1 cache,如果容量做大,那么存储单元的“选通”逻辑将会复杂。从而很难满足高时钟频率的要求。另外,当Cache容量很小时增加容量,命中率增加的比较明显;当容量达到一定程度,提高cache容量对于提高cache命中率的贡献就很有限了。简单说就是大容量L1很难做,即使做出来用处不明显。与其这样,还不如把节约下来的晶体管用来做其它的用途。
图片
因此出于PPA的权衡,我们先看到的cache系统一般是这样的:32-64KB的指令cache和数据cache(一般L1的指令和数据cache是分开的),2-4个时钟周期访问时间;256KB-2MB的L2 cache(一般从L2开始指令和数据就不分开了),10-20个时钟周期的访问时间;8-80MB的L3 cache,20-50个时钟周期的访问时间。注意,这里所说的时钟周期都是指的CPU的时钟周期。一般L2和L3的工作时钟频率要比CPU的低,这个时钟周期是折算后的数值。
如果是多核设计,LLC可能会采用“分布式”结构,访问时间往往会更长。
多级cache的包含策略
在多级高速缓存的设计中,另一个相关的问题是内层高速缓存的内容是否包含在外层高速缓存中。如果外层高速缓存包含了内层高速缓存的内容,则称外层高速缓存为“包含的(inclusive)”,相反如果外层高速缓存只包含不在内层高速缓存中的数据块,则称外层高速缓存是“排他的(exclusive)”。包含性和排他性需要特殊的协议才能实现,否则无法保证包含性或者排他性,这种情况称之为“不包含又不排他(non-inclusive non-exclusive,NINE)”。
包含策略的优点是,前处理器缓存缺失的时候想看看所需的块是不是在其他处理器的私有cache中,不需要再去一个个查其他处理器的cache了,只需要看看共享的外层cache中有没有即可,对于实现cache一致性非常方便,也有效降低了缓存缺失时的总线负载和miss penalty;缺点是整体cache的容量变小。
包含策略的特性会产生两个影响。一是在采用包含策略的高速缓存中,缓存缺失的时延较短,而采用排他和NINE策略则较长。二是对对所有内层高速缓存检查访问的数据块是否存在意味着增加对高速缓存控制器和内层高速缓存标签阵列的占用。
排他策略正好相反,其优点是,可以最大限度的存储不同的数据块,相当于增大整体了cache的容量;其缺点是需要频繁填充新的数据块,会消耗更多的内外层间缓存带宽,并且对标签和数据阵列产生更高的占用率。
Cache物理组成
在单核CPU中,cache是一个CPU核独占。但是到了多核处理器中,往往L1是独占的,其它的cache是多核之间共享的。这就涉及到了一个cache物理构成的问题。
仅以L2为例,多核可以通过交叉开关来连接L2的多个bank。这时每个CPU有自己独享的L1,每个L1通过交叉开关可以访问全部的L2 bank。这么做的好处是设计相对简单,L2 bank集中在一起,而且只有多核需要访问同一个bank时才有冲突。但是,交叉开关的复杂度随着处理器核数目及L2 bank数目的增加而加大,因此,这种设计仅限于处理器核数目及L2 bank数目不多的情况。
图片
对于处理器核数目多的设计,分布式高速缓存设计更加适用。下图以2x2的网格互连为例,图中的R表示互连路由器,L2与处理器相关联的区域被称为高速缓存片(cache tile)。这种设计适用于处理器核很多的情况,想象一下,如果网格是8x8的场景。现在ARM的CMN就支持很大的网格互连。其实,如果处理器数目不是特别多,还有另外一个选择,就是环形互连,比如用ARM的CCN。发现没,下图也可以算是一个环形互连,哈哈。
图片
在实际的多核处理器设计中,尤其是基于ARM架构,很可能高速缓存是混合式的。这时,多个处理器分别有自己的L1,多个处理器共享一个L2,从而构成一个处理器簇(cluster)。每个处理器簇挂在互连路由器上。L3是所有处理器核共享,采取分布式设计,也是连接在互连路由器上。
Cache预取
随着工艺的提升,高速缓存的大小虽然不断在增加,但是远远不及内存的容量。众所周知,一旦发生缓存缺失,其代价(penalty)很大,要等待很多时钟周期把数据从内存搬移到缓存中。这期间CPU的流水线很可能要停下来。为了解决这个问题,行业大牛们想了很多办法,Cache预取(prefetch)技术就是其中之一。现在的高速缓存设计通常采用了预取机制。
顾名思义,预取就是预先把程序需要的数据搬移到缓存中,而不必等到缓存缺失时再去搬运。预取分为软件预取和硬件预取。有的处理器提供专门用于预取的指令,但是怎么使用和何时使用指令进行预取是由软件决定的。因此这种方式被称为软件预取(software prefetching)。首先实现预取指令的处理器是Motorola的88110处理器。软件预取指令可以由编译器自动加入,但是在很多场景,更加有效的方式是由程序员主动加入预取指令。这些预取指令在进行大规模向量运算时,可以发挥巨大的作用。在这一场景中,通常含有大规模的有规律的循环迭代(loop iteration)。这类程序通常需要访问处理较大规模的数据,从而在一定程度上破坏了程序的时间和空间的局部性(temporal and spatial locality),这使得数据预取成为提高系统效率的有效手段。
预取的另一种设计方式是设计对软件透明的硬件结构,动态观测程序的行为并产生相应的预取请求,这种方式称为硬件预取(hardware prefetching)。说简单些,就是用硬件根据某些规律去猜测处理器未来将要访问的数据地址。由于硬件预取技术是与高速缓存设计直接相关,我们不妨多看一些。根据可以应对的数据访问模式类型,顺序预取(sequential prefetching)检测并预取对连续区域进行访问的数据。顺序预取是最简单的预取机制,因为总是预取当前cache line的下一条line,硬件开销小,但是对访存带宽的需求高。步长预取(stride prefetching)检测并预取连续访问之间相隔s个缓存数据块的数据,其中s即是步长的大小。硬件实现需要使用访问预测表,记录访问的地址,步长以及访存指令的pc值。流预取(stream prefetching)对流访问特征进行预取,流访问特征是指一段时间内程序访问的cache行地址呈现的规律,这种访问规律在科学计算和工程应用中广泛存在。硬件实现时,需要使用流识别缓冲记录一段时间内访存的cache行地址。预取引擎识别到流访问则进行预取。关联预取(association based prefetching)利用访存地址之间存在的关联性进行预取。
采用硬件预取的优点是不需要软件进行干预,不会扩大代码的尺寸,不需要浪费一条预取指令来进行预取,而且可以利用任务实际运行时的信息(run time information)进行预测,这些是硬件预取的优点。硬件预取的缺点是预取结果有时并不准确,有时预取的数据并不是程序执行所需要的,比较容易出现缓存污染(cache pollution)的问题。更重要的是,采用硬件预取机制需要使用较多的系统资源。在很多情况下,耗费的这些资源与取得的效果并不成比例。
必须强调的是,预取对程序肯定是会有影响的。但并非所有程序在开启预取时都是性能增益的,预取对那些有规律的数据采集和指令执行过程中才会发挥最大功效,而在随机访问事务中,预取反而是有害的,因为随机事务中的数据都是随机的,预取进来的数据在接下去的执行中并不能有效利用,只是白白浪费了cache空间和存储带宽。
通常有几个指标来评价预取机制的有效性:覆盖率(coverage),准确率(accuracy),及时性(timeliness)。覆盖率被定义为原始缓存缺失中被预取的比例,由于预取,缓存缺失变为缓存命中或部分缓存命中。准确率被定义为预取数据中有效的比例,也就是导致缓存命中的比例。及时性描述了预取的数据可以提前多久到达,进而决定了缓存缺失时时延是否可以被完全隐藏。
一个理想的预取机制应该是高覆盖率,最大程度消除缓存缺失;高准确率,不会增加过多的存储带宽消耗;及时性,完全隐藏缓存缺失时延。我们可以看出来,这三个指标在某种程度上是对立的。如果采取激进的预取,可以提高覆盖率,但是准确率就会下降;如果采取保守的预取策略,只对准确预测的数据进行预期,可以提高准确率,但是覆盖率会降低。对于及时性,如果预取启动过早,可能在处理器使用该数据前就被替换出缓存或者预取缓冲区,“污染”了高速缓存;如果启动过晚,可能就无法完全隐藏缓存缺失时延。
需要指出的是,预取可以在不同的地方启用并将预取数据放置到目的地址。预取可以在L1/L2/L3高速缓存,内存控制器,甚至是存储芯片中预加载DRAM行缓存时被启用。预取的数据通常被保存在启用预取的那一层,比如启用预取的高速缓存,或者在一个独立的预取缓冲区,从而避免预取数据“污染”缓存。
在多核处理器设计中,预取设计尤为困难。如果预取过早,那么在一个处理器在访问某数据块之前,可能会因为另一个处理器对该数据块的访问导致数据块无效。同时,被预取的数据块可能替换了更有用的数据块。即使加大高速缓存的容量也不能像单处理器那样解决这些问题。过早的预取会导致某处理器从其它处理器中“窃取”缓存块,而这些缓存块很可能又被其它处理器再“窃取”回去。极端情况下,不合适的预取会给多处理器设计带来灾难,缓存缺失更加严重,进而大大降低性能。
缓存一致性(cache coherency)
我们知道cache只是一块缓存,暂时备份数据以加速CPU运行。既然是临时的存储空间,那么就涉及到了一致性问题。通常涉及两个方面,一个是空间上,需要缓存一致性协议来保证;另一个是时间上,需要一些同步机制。今天先说一致性协议。
Cache一致性一般涉及几个问题,首先是cache与内存之间的一致性。如果有其他的模块更改了内存中的数据,那么cache中缓存的数据就与内存中的不一致了。比如DMA进行数据搬移时,那么后续CPU读取该内存数据时候,若cache命中,则可能读取到的数据不是DMA搬移后的数据。所以在进行DMA搬移之前,先进行cache Invalidate操作,这样CPU下次读取这条cache line里的数据的时候,才能知道这些数据不是最新的,需要从内存更新。保证后续CPU读取到的数据是DMA真正搬移的数据。如下图,红色代表数据块被更新过。
图片
对于采用“写回”策略的cache,当CPU更改了某条cache line中的数据,则该cache line中的数据比对应内存中的数据新,此时需要将这条cache line标记为modified。一般只有在该cache line被替换时才会把新数据更新到内存。但在某些时候,CPU可能需要清除cache,这时候需要执行flush操作,把cache的新数据写回到内存。
图片
上面提到的cache与内存之间的一致性问题不管是单核还是多核系统都会存在。而比较麻烦的是多核系统中的cache之间的一致性问题。我们常说的cache一致性协议通常就是解决多核处理器设计中的一致性问题。
通常在多核处理器系统中,每个处理器核有自己独享的cache,只有LLC是所有处理器核共享。如果多个处理器核同时在自己的私有cache缓存了同一段内存的数据,这时候某一个处理器核修改了其中的数据,那么就与其它处理器核的cache数据不一致了。其它的处理器核需要知道自己cache中的数据已经不是最新的了。
图片
缓存一致性协议的具体作用就是把某个处理器核新写的数据传播给其他处理器核,以确保所有处理器核看到一致的共享存储内容。缓存一致性协议需要解决两个问题,一是如何传播新值,二是传播给谁。从如何传播的角度看,缓存一致性协议可以分为“写无效(Write Invalidate)”协议和“写更新(Write Update)”协议,前面略有提及。“写无效”协议就是当一个处理器核要把对某一单元新写的值传播给其它处理器核时,使其它处理器核中该单元的备份无效,这样当其它处理器核使用该备份时需要重新获得新值。“写更新”协议就是当一个处理器核要把对某一单元新写的值传播给其它处理器核时,把拥有该备份的其它处理器核中的数据直接更新为新值。“写无效”的优点是,一旦某处理器核使某一变量在所有拥有该备份的缓存中无效后,它就取得了对此变量的独占权,随后对此变量的更新不必再通知其它处理器核,直到其它处理器核请求访问此变量而导致独占权被取消;缺点是,当某变量在一个处理器核中的备份无效后,此处理器核再访问该变量会引起cache miss,当一个共享块被多个处理器核频繁访问时会导致“乒乓”效应,导致性能严重下降。“写更新”的优点是,一旦某cache缓存了某一个变量,它就会一直拥有此变量的最新备份;缺点是,当某一共享变量被更新时,所有拥有该变量的处理器核的备份都会被更新,哪怕这些处理器已经不需要这个变量了。“写无效”协议适用于顺序共享(Sequential Sharing)的程序;“写更新”协议适用于紧密共享(Tight Sharing)的程序。
从传播给谁的角度看,在缓存一致性协议中,一致性消息可以被发送到所有缓存,也可以被发送到特定缓存。根据这种区别可以把一致性协议分为广播/侦听式和基于目录式。总的说来,协议设计的越复杂,消耗的带宽越低,实现起来也越困难。
在多核SoC设计中,所有处理器核与内存之间是通过总线连接的。基于RISC的处理器读写内存都是通过load/store指令来完成的,不管是load还是store都需要经过总线的传输,总线的一次传输被称为传输事务(transcation)。
广播/侦听式的原理就是当一个处理器核修改了cache line之后,将广播通知到总线上其他所有的处理器核,而所有处理器核需要一个硬件单元来负责侦听总线上的事务广播。但是要时刻监听总线上的一切活动没有必要,所有处理器核之间共享的内存数据毕竟只占少数,大部分监听可以说是无用的,所以又引入了一个用于侦听过滤器(snoop filter)。过滤的标准就是看自家的处理器核有没有缓存这个传输事务涉及到的内存位置,或者说有没有对应的cache line。
总线方式天然的支持广播式协议,而基于总线的缓存一致性协议是所有广播/侦听式协议中较为容易实现的,因为总线为所有的一致性事务提供了串行化点(point ofSerialization)。举个例子,假设有四个CPU处理器核P0-3。它们从内存中读了一个共享数据到各自的cache中,然后P0修改了这个数据,随后P1也修改了同样的数据,接下来对于P2和P3来说看到的应该是P0修改后的数据还是P1修改后的数据呢?如果P2看到是P0,而P3看到的是P1,那就产生了不一致。这时必须要保证各个处理器核对内存同一位置的store和load的操作是序列化的(sequenced),也就是store和load的顺序应该和线程执行的顺序一致。广播/侦听方式会占用比较大的总线带宽,尤其是处理器核数目很多的时候。
基于目录式的原理是点对点的方式进行传播,每个总线事务只会发给相关的处理器核。所谓相关,就是拥有这个事务所涉及内存位置对应的cache line。那怎么知道哪些核是相关的呢?基于目录的主要思想是,为每一个cacheline维护一个目录项,该目录项记录所有当前持有此备份的处理器核号以及此行是否被改写等信息。当一个处理器核想写某一个cache line且可能引起数据不一致时,它就根据目录内容只向持有此备份的那些核发出写使无效/写更新信号,从而避免了广播。典型的目录组织方式是位向量目录。位向量目录中的每一个目录项都有一个N位的向量,N是系统中的处理器核数。位向量中的第i位为1表示第i个处理器核持有该备份。每一目录项还有一个改写位表示某处理器核独占并改写此行。相比广播/侦听,目录式占用较少的总线带宽。但是目录式的缺点很明显,如果处理器核多且共享存储容量大的时候,目录的存储开销非常大。另外,每次传输事务都要查询目录表项,也就造成了一些延迟。
“写直达”策略的一致性协议
最简单的一致性协议是基于写直达(Write Through)策略的。假设一个单级缓存系统,处理器缓存的请求包含:
PrRd:处理器请求从缓存中读出;
PrWr:处理器请求向缓存中写入。
总线侦听的请求包括:
BusRd:总线侦听到一个来自另一个处理器的读出缓存请求;
BusWr:总线侦听到一个来自另一个处理器的写入缓存请求。
再来复习一下写直达策略,也就是缓存中任何一个字节的修改都会被立即传播到外层的存储层次。所以BusWr也意味着另一个处理器向主存中写入数据。而且,在写直达里是没有dirty状态的,因为所有对于缓存的写操作都被直接写入到内存,所有缓存的值都是干净的。那么缓存块的状态包括以下几种:
Valid(V):缓存块有效且干净,与内存中的相同;
Invalid(I):缓存块无效或尚未使用,访问该缓存块会导致miss。
再假设使用写不分配(Write Non-allocate)和写无效(Write Invalidate)两种策略,接下来看一下缓存状态是如何切换的。
首先考虑缓存块为“I”时,当处理器发出读请求,会导致缓存缺失。这就需要把数据从主存加载进缓存,总线上产生了一个BusRd的请求。当数据被加载进缓存的同时,缓存块状态变为“V”。当处理器发出写请求,因为采用写不分配(不需要先将数据从主存加载进缓存),写操作触发BusWr,此时缓存块状态仍为“I”。
接下来考虑缓存块状态为“V”,当处理器发出读请求,缓存命中,数据直接返回给处理器,不触发总线事务,缓存块状态仍是“V”。当处理器发出写请求,该缓存块被更新,并且触发BusWr,缓存块状态仍为“V”。
对于侦听器过滤器(Snoop Filter)来说,如果缓存块状态是“I”,那么BusRd和BusWr不会产生影响,缓存块状态还是“I”。如果缓存块状态是“V”,BusRd意味着其它的处理器遇到了缺失,需要从主存中读取数据,该缓存块的状态保持“V”不变;但是一旦侦听到BusWr,那就意味着其他的处理器要写入新数据,该缓存块的状态需要变为“I”。
图片
上图左边是处理器的请求响应,右侧是侦听器的请求响应。如果是具体实现,两边可以合并在一起做成一个有限状态机。
还记得吗?写直达策略的最大缺点是占用很大的带宽。对于多级缓存系统,处理器芯片最外层的高速缓存,由于片外带宽有限,往往采用写回策略;而对于内层高速缓存,由于片上带宽较大,因此往往采用写直达策略。
“写回”策略的一致性MSI协议
写回策略与写直达策略相比,占用的带宽小。由于修改的数据不会马上更新到内存,所以缓存块要增加一个dirty位。
在MSI协议中,处理器的缓存请求包含:
PrRd,处理器请求从缓存块中读出;
PrWr,处理器请求像缓存块中写入。
总线侦听的请求包含:
BusRd,总线侦听到一个来自另一个处理器的读出缓存请求;
BusRdX,总线侦听到一个来自另一个处理器的“读独占”(或者是写)缓存请求;
Flush,总线侦听到一个缓存块被另一个处理器写回到主存的请求。
每一个缓存块的状态包含:
Modified(M):缓存块有效,并且其数据与主存中的原始数据不同,这个缓存块是“dirty”的。
Shared(S):缓存块是有效的且有可能被其他处理器共享,这个缓存块是“clean”的。
Invalid(I):缓存块无效。
假设使用写分配和写无效缓存一致性策略。写回缓存的MSI一致性策略有限状态机如图所示。处理器方面的请求响应在左侧,总线侦听的请求响应在右侧。
图片
先看处理器请求端,如果缓存块状态是“I”,当处理器发出读请求时,发生cache miss,需要把数据加载进缓存,总线上产生了一个BusRd事务。当数据被加载完,缓存块的状态变为“S”,这里S状态不区分该缓存块是不是唯一的。当处理器发出写请求时,由于是写分配,缓存块要加载,故触发BusRdX,其他缓存响应后将它们的拷贝变成“I”。发出写请求的处理器得到该缓存块后将状态变为“M”。
接下来再看如果缓存块的状态是“S”时,处理器发出读请求,cache hit,不会触发总线事务。如果处理器发出写请求,由于不知道是不是还存在其它的副本,所以必须要触发BusRdX通知其它处理器。
最后来看如果缓存块的状态是“M”,不管处理器发出读或写请求都不会触发总线事务。
再看右边的总线侦听。对于其它的缓存来说,如果缓存块的状态是“I”,侦听到BusRd和BusRdX也无所谓。如果状态是“S”,侦听到BusRd说明其它的处理器遇到了读缺失,需要加载数据,对自己没有影响;如果是侦听到BusRdX,说明有其它处理器要修改这个缓存块,自己的拷贝不是最新的数据,因此要把状态变为“I”。如果当前状态是“M”,侦听到BusRd,说明其它处理器需要加载该缓存块的最新数据,所以要把自己的拷贝全部清空(Flush)出去并把状态变为“S”;侦听到BusRdX,说明其它处理器要修改该块,所以把自己的拷贝清空,并把状态改为“I”,这个flush动作是必须的,因为其它处理器要修改的和自己修改的不一定是同一个字节。
还记得吗?S状态不区分该缓存块是不是唯一的。所以MSI协议有一个严重的缺陷,假设有一个处理器想要读一些块并对他们进行写入,这里没有其他处理器共享的块。这种情况下,对于每一个读-写操作序列,会触发两个总线事务:一个BusRd将块变为S状态,以及一个BusRdX以无效化其它缓存拷贝。该BusRdX是没有用的,因为没有其它拷贝,但是一致性控制器不知道这一点。大多数BusRdX请求是没有必要的。这个缺陷会影响诸如顺序执行程序等几乎没有数据共享的程序在执行时的性能。
要克服这个缺陷,需要增加一个新的状态来区分缓存块是干净且唯一还是存在多个拷贝。
“写回”策略的一致性MESI协议
由于MSI协议里面的S状态不区分拷贝是否唯一,所以MSI协议有一个天生的缺陷,会影响诸如顺序执行程序等几乎没有数据共享的程序在执行时的性能。要解决这个问题,需要引入另一个状态。
与MSI协议相同,处理器的缓存请求包含:
PrRd,处理器请求从缓存块中读出;
PrWr,处理器请求像缓存块中写入。
总线侦听的请求包含:
BusRd,总线侦听到一个来自另一个处理器的读出缓存请求;
BusRdX,总线侦听到一个来自另一个处理器的“读独占”(或者是写)缓存请求;
BusUpgr,总线侦听到一个要向其它处理器缓存已经拥有的缓存块上写入的请求;
Flush,总线侦听到一个缓存块被另一个处理器写回到主存的请求。
FlushOpt,总线侦听到一整块缓存块被放至总线已提供给另一个处理器。
每一个缓存块的状态包含:
Modified(M),缓存块有效,并且其数据与主存中的原始数据不同,这个缓存块是“dirty”的;
Eclusive(E),缓存块是干净有效且唯一的;
Shared(S),缓存块是有效干净的,但在多个缓存有拷贝;
Invalid(I),缓存块无效。
缓存一致性控制器是如何知道加载进缓存的块应该是E还是S状态。我们需要引入一条新的总线,C总线。当存在至少一份缓存拷贝时,C总线为高电平,否则缓存块唯一,C总线为低电平。
假设使用写分配和写无效的缓存一致性策略。MESI一致性协议的状态转换如下图。
图片
当缓存块处于I状态时,处理器发出读请求,在总线上产生BusRd请求。内存控制器响应BusRd,将所需要的块从内存中取出。其它的侦听器会侦听到该请求并检查它们的缓存来判断是否拥有该拷贝。如果发现拷贝,即C总线为高,取回的块放在请求者的缓存上并置为S状态;否则被置为E状态。处理器发出写请求,触发BusRdX,其它缓存无效自己的拷贝,请求者获得该块后状态变为M。
如果缓存块处于E状态,处理器发出读/写请求都不会触发总线事务,因为E状态表示缓存块是干净有效且唯一的,这是与MSI协议最大的不同。
如果缓存块处于S状态,当处理器发出读请求后会马上得到数据,并不触发总线事务。当处理器发出写请求,由于该块还在其它地方有拷贝,因此必须通知拥有拷贝的缓存,也就是触发了BusUpgr。
如果缓存块处于M状态,说明该缓存块有效,并且没有其它的拷贝。因此处理器的读/写请求都不会产生总线事务,也不会改变缓存块状态。
再来看侦听端的情况。如果缓存块处于I状态,说明缓存块无效。即使侦听到总线上的事务也不会影响它的状态。
如果缓存块处于E状态,当侦听到其它处理器发出的BusRd时,说明其它处理器遇到了读缺失并且需要加载该缓存块,因此自己拥有的拷贝就不唯一了,需要改变状态到S,同时还要产生FlushOpt来把自己的拷贝分享给请求者。这种缓存到缓存的传输大大降低了请求者从主存读取数据的延迟。如果侦听到BusRdX,说明其它处理器要求独占这个缓存块,因此自己的状态需要变成I。
如果缓存块处于S状态,当侦听到BusRd,说明其它处理器遇到了读缺失,自己的状态不需要改变,但是拥有拷贝的某一个处理器需要提供缓存到缓存的传输。当侦听到BusRdX,需要清空块并无效自己的拷贝。当侦听到BusUpgr,直接无效拷贝。
如果缓存块处于M状态,说明该缓存块在整个系统里面是唯一有效的,当侦听到BusRd或BusRdX时都需要清空块,区别是其它处理器的请求是独占时要无效自己的拷贝,状态变为I,否则只是变为S。
由于引入了E状态,MESI协议可以消除MSI协议的读-写次序操作引发的的两次总线事务,避免了性能损耗。但是MESI仍然有一个潜在的问题。当一个缓存块被多个处理器连续读写时,每一个操作都会触发干预,需要拥有者清空缓存块。也就说S状态需要缓存块是“干净”的,这样就需要频繁更新主存,从而消耗很大的带宽。
如果能允许多个缓存之间共享“脏”块而不再频繁更新主存,那么就能解决这个问题了。
“写回”策略的一致性MOESI协议
与MSI协议相同,处理器的缓存请求包含:
PrRd,处理器请求从缓存块中读出。
PrWr,处理器请求像缓存块中写入。
总线侦听的请求包含:
BusRd,总线侦听到一个来自另一个处理器的读出缓存请求。
BusRdX,总线侦听到一个来自另一个处理器的“读独占”(或者是写)缓存请求。
BusUpgr,总线侦听到一个要向其它处理器缓存已经拥有的缓存块上写入的请求。
Flush,总线侦听到一个缓存块被另一个处理器写回到内存的请求。
FlushOpt,总线侦听到一整块缓存块被放至总线已提供给另一个处理器。
FlushWB,侦听到一整块缓存被另一个处理器写回内存,并且这里不是缓存到缓存之间的传输。
每一个缓存块的状态包含:
Modified(M),缓存块有效,并且其数据与主存中的原始数据不同,这个缓存块是“脏”的。
Eclusive(E),缓存块是干净有效且唯一的。
Owned(O),缓存块是有效的,可能是“dirty”,也可能有多份拷贝。但是,当存在多份拷贝时,只能有一个是O状态,其他拷贝都是S状态。
Shared(S),缓存块是有效干净的,但在多个缓存有拷贝
Invalid(I),缓存块无效。
提出O状态的一个想法是,当一个缓存块被多个处理器缓存共享,其值被允许与主存中的对应值不同。其中一个缓存块被设定为块的所有者并将状态置为O,其它备份则为S。O的出现简化了缓存到缓存的数据传输。比如,当侦听到一个BusRd时,可以让所有者通过FlushOpt来提供数据而其它控制器没有任何操作。
依然假设是使用写分配和写无效的缓存一致性策略。MOESI一致性协议的状态转换如下图。
图片
基于“写更新”/“写回”策略的缓存一致性协议
基于写无效策略的一个弊端在于会造成大量的一致性缺失,每一次读取被无效的块都会遇到缓存缺失,从而导致处理缺失的延迟会很高。
基于写更新的dragon协议,处理器端的请求包括:
PrRd:处理器请求从缓存块中读出;
PrRdMiss:处理器请求读的块不在缓存中;
PrWr:处理器请求向缓存块中写入;
PrWrMiss:处理器请求写的块不在缓存中。
总线请求包括:
BusRd:总线侦听到一个来自另一个处理器的读出缓存请求;
Flush:总线侦听到整个缓存块被另一个处理器放上总线的请求;
BusUpd:总线侦听到一个写入字操作导致的写入值在总线上传播的请求,这里只有一个字的数据被放上总线,而不是整个缓存块;
每一个缓存块的状态包含:
Modified:缓存块唯一有效,可能与内存的原始数据不一致,该状态意味着对该块的唯一所有权;
Exclusive:缓存块有效,干净并且唯一;
Shared Modified:缓存块有效,可能是dirty的,也可能有多份拷贝。但是,当存在多份拷贝时,只有一份拷贝处于Sm状态,其它拷贝必须是Sc状态;
Shared Clean:缓存块有效,可能不干净,也可能有多份拷贝。
Dragon协议允许“dirty”共享,此时所有者的状态为Sm,其他拷贝的状态为Sc。假设缓存使用写分配的策略,状态转换如下图。因为dragon协议中没有I状态,图中没有出处的箭头指向一个状态代表新加载的缓存块。先看处理器端的请求,首先是读缺失,总线上产生一个BusRd,如果其它缓存没有相应的拷贝,那么该块应该标记成E状态。相反,如果其它的缓存中有拷贝,则该块置为Sc状态。如果是写缺失情况,由于是写分配策略,因此总线上产生一个BusRd,如果不存在其它拷贝,则该块置为M。相反如果存在其它拷贝,该块置为Sm,并且总线上还需要产生一个BusUpd通知其它缓存更新。
如果一个块处于E状态,PrRd和PrWr请求都不会产生总线事务,不同的是PrWr请求会改变块状态为M。
如果一个块处于M状态,PrRd和PrWr请求也都不会产生总线事务,因为没有其它的拷贝存在。
如果一个块处于Sc状态,处理器写请求会检查是否存在其它拷贝,如果存在,需要把状态置为Sm,成为该块的所有者。相反如果不存在其它拷贝,状态置为M。
如果缓存块处于Sm,处理器读请求不会改变其状态,也不会触发总线事务。处理器写请求可能不改变Sm状态,因为它还是该块的所有者;也可能会改变状态为M,如果这是它已经是唯一的拷贝。
图片
对于侦听端,如果缓存块处于E状态,当侦听到BusRd时,块状态需要变成Sc。
如果是M状态,说明自己拥有的块是整个系统中唯一有效的,当侦听到BusRd,块必须被清空来保证写传播。
如果是Sc状态,BusRd意味着另外的处理器遇到了读缺失,由于不是所有者,所以不需要触发总线事务。侦听到BusUpd,则该字的更新被获取并用于更新当前的缓存块。
如果是处于Sm状态,说明本地缓存是所有者,侦听到BusRd需要清空该块;侦听到BusUpd,即一个请求者试图写入该块,因此请求者会成为新的所有者,本地的状态需要变为Sc。
后记
关于cache的内容其实有很多,本文只是做了一些概念性的介绍,大部分细节都没有展开。希望本文起到抛砖引玉的作用,可以给感兴趣或者刚入行的朋友提供一些参考。
至于有些朋友关心的具体实现,只能说那些国际大厂各有各的方法,各有各的窍门。毕竟指令集不同,微架构不同,对高速缓存的设计影响非常大。我仅仅是一个普通工程师,视野有限,如果大家有好的经验,还望不吝赐教,在此提前谢过。