-
CPU缓存概述
- 为什么需要CPU缓存
- cpu缓存访问速度
- cpu缓存介绍
-
CPU Cache
- Cache 结构
- cache寻址方式
- Cache Entry & Cache line
- L3 Cache
-
CPU高速缓存读操作
- 直接映射寻址过程
- 组关联寻址过程
- Cache Miss
-
CPU高速缓存写操作
- 缓存写入策略
- 写直达
- 写回
-
缓存一致性协议 MESI
- MESI协议缓存状态
- MESI状态转换
- 多核缓存协同操作
-
读写屏障
- MESI优化和他们引入的问题
- CPU切换状态阻塞解决-存储缓存(Store Bufferes)
- Store Bufferes
- Store Bufferes的风险
- 硬件内存模型
-
问题
- L1为什么比L2快?
- 为什么不把L2 Cache也放在离CPU很近的地方?
- CPU Cache为什么需要set?
-
参考
CPU缓存概述
为什么需要CPU缓存
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
- 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
比如循环、递归、方法的反复调用等。
- 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
比如顺序执行的代码、连续创建的两个对象、数组等。
cpu缓存访问速度
cpu缓存介绍
程序运行的时间主要花在将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作缓存行(Cache Line)。在我们日常使用的 Intel 服务器或者 PC 里,Cache Line 的大小通常是 64 字节。
现代的个人PC或者Server的CPU至少有三种独立的cache,分别是:Data Cache, Instruction Cache和TLB。Data Cache用于加速数据的读取和存储,Instruction Cache用于加速指令的读取,而TLB用于加速指令或者数据虚拟地址到物理地址的转换。
CPU Cache
Cache 结构
cache由set组成,set由Cache line(cache entry)组成,cache line由valid bit,tag和data组成。其中data是真正要缓存的内存地址中的数据,而tag是用来搜索cache line的标签。
cache寻址方式
内存地址被分成了3部分,tag,set index和block offset,这三部分分别用来在cache中定位数据。
cache的结构为Set结构,分成多个组,每个组存在多个cache entry(cache line)
所谓8路组相连( 8-way set associative)的含义是指,每个set index里面有8个cache line。
举例来说,data cache: 32-KB, 8-way set associative, 64-byte line size
Cache总大小为32KB,8路组相连(每个set有8个cache line),每个line的大小linesize为64Byte,可以算出一共有32K/8/64=64 个组。
对于32位的内存地址,每个line有2^6 = 64Byte,所以地址的【0,5】区分line中的那个字节。一共有64个组。我们取内存地址中间6为来hash查找地址属于那个组。即内存地址的【6,11】位来确定属于64组的哪一个组。组确定了之后,【12,31】的内存地址与组中8个cache line中的tag挨个比对,如果【12,31】为与某个line的tag一致,并且这个line为有效,那么缓存命中。
cache寻址方式可以分为3类:
- 直接映射(direct mapped cache):相当于每个set只有1个cache line。
- 组关联(set associative cache):多个set,每个set多个cache line。一般每个set有n个cache line,就说n-ways associative cache。
- 全相联(fully associative cache):相当于只有1个set,需要遍历所有的cache line,会导致缓存性能变差
Cache Entry & Cache line
cache line是cache的基本单位,从主存向cache迁移数据都是按照cache line为单位传送的。假如cache line为32Byte,那么数据迁移必须一次迁移32Byte到cache。
当cache line从main memory拷贝到cache时,cache entry被创建。cache entry包括传送的数据和所请求的memorylocation(现在成为tag)。
当CPU需要从main memory中特定位置(memory location)读取或者写入数据时,它会首先检查cache中相应的cache entry。cache此时会检查所有cache entry的memory location。如果此时所要查找的memory location在cache中,则CPU从cache中读取或者写入数据,这就是我们常说的Cacae Hit;如果所要查找的memory location不在cache中,则cache会分配新的cache entry,并且将数据从main memory拷贝到cache entry中,这就是我们常说的Cache Miss。
cache entry通常的结构如下所示:
- Tag:保存着从main memory中取出数据的地址的一部分。
- 一个有效的物理内存地址(Memory Address)被包括
Tag、Index及Block Offset
- Index表示该cacheline在cache set中的位置
- Block Offset表示数据在cache line中的偏移量。
- 因此Tag的长度是
Memory_Address_len - Index_len - Block_Offset_len
- 例如:Pentium 4有一个4-way set associative L1 data cache, 该cache的大小为8KB,cache block(cache line)的大小为64Bytes,因此cache block的数量为:8 * 1024 / 64 = 128块,因为是4-way,所以each way包含的块的数量是128 / 4 = 32块,所以index的位数为5 bits (2^5 = 32),而block offset的位数为7 bits (2^7 = 128),最后得出的Tag的位数为 32 - 5 - 7 = 20。
- 一个有效的物理内存地址(Memory Address)被包括
- flag bits
- 指令cache每个cache entry仅仅需要一个flag bit:a valid bit,用来指示该entry是否加载了有效的数据
- 数据cache每个cache entry需要两个flag bit:a valid bit and a dirty bit。Dirty Bit用来说明读进cache的数据是否发生过改变。
- Data Block(Cache Line):存放着从main memory中取出的数据
- Cache的大小(Size)可以如下计算得出,即Data Block中存放字节的数量和Cache中Data Block数量的乘积。
- 尽管Tag和Flag占有一部分的空间,但是我们在计算cache大小的时候并没有考虑。
cpu cache大小只计算了data block的总和的大小,tag 及valid不在cache size中
L3 Cache
L1/L2 Cache通常都是每个CPU核心一个(x86而言,ARM一般L2是为一个簇即4个核心共享的),这意味着每增加一个CPU核心都要增加相同大小的面积,即使各个CPU核心的L2 Cache有很多相同的数据也只能各保存一份,因而一个所有核心共享的L3 Cache也就有必要了。
L3 Cache通常都是各个核心共享的,而且DMA之类的设备也可以用。
CPU高速缓存读操作
CPU Cache 和 Redis 缓存访问类似,都是先访问缓存,如果数据不存在再访问内存。在各类基准测试(Benchmark) 和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。
直接映射(Direct Mapped Cache)寻址过程
CPU 访问内存数据时按缓存行(cache line)大小读写,通常是 64 byte。我们将内存将缓存行大小切分,每个内存地址必定会落到某个内存块上,数据在这个内存块的偏移量也是确定的。问题是如何将这个 "内存块 block" 和 "缓存行 Cache Line" 索引号映射关系确定下来。
- 索引号:CPU 直接映射是通过求余运算来实现,并且要求缓存行的个数必须是 2的n次方。这样可以直接使用低位来表示索引号。
- 偏移量:可以根据块大小确定偏移量。
现在,我们知道了 Cache Line 的索引号和在这个 Cache Line 的偏移量,CPU 就可以在缓存行中读出这个数据了。通过索引号和偏移量建立内存映射关系,这在软件工程中很常见,比如内存的虚拟地址和物理地址之间的映射关系,再比如 innodb 中数据地址定位(页号 + 偏移量)。
比如说,我们的主内存被分成 0~31 号这样 32 个块。我们一共有 8 个缓存块。用户想要访问第 21 号内存块。如果 21 号内存块内容在缓存块中的话,它一定在 5 号缓存块(21 % 8 = 5)中。
了解 HashMap 的都知道,通过求余算法一定会出现哈希碰撞。同样的道理,此时也会出现多个内存块映射同一个缓存行的情况,CPU 如何判断这是不是我们想要访问的数据呢?
-
组标记(Tag):最简单的办法,当然是在缓存行中存储完整真实的物理内存地址,但有点浪费空间。前面已经说了缓存行的个数是 2的n次方,可以直接使用低位表示索引号,也就是每个缓存行对应的低位地址是固定的,缓存行中只需要保存高位地址即可。
如 21 的低 3 位 101,缓存块本身的地址已经涵盖了对应的信息、对应的组标记,我们只需要记录 21 剩余的高 2 位的信息,也就是 10 就可以了。 -
有效位(valid bit):标记对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。
而内存地址对应到 Cache 里的数据结构,则多了一个有效位和对应的数据,由 "索引 + 有效位 + 组标记 + 数据" 组成。如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 5 个步骤:
- 根据内存地址的低位,计算在 Cache 中的索引;
- 判断有效位,确认 Cache 中的数据是有效的;
- 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
- 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。
- 如果在 2、3 这两个步骤中,CPU 发现,Cache Line 并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新该 Cache Line 对应的有效位和组标记的数据。
组关联寻址过程
- 根据内存物理地址找到set index
- 根据内存物理地址找到tag,在set中比较物理地址搜索到cache line
如果找到了,且valid bit为1则为命中,如果没有命中,就要根据置换策略找一个cache line把内存的数据加载到cache line。
- 根据内存物理地址找到block offset,即在line的data中找到对应的值
Cache Miss
如果发生了Cache Miss,就需要从Memory中取数据,这个取数据的过程中,CPU可以执行几十上百条指令的,如果等待数据时什么也不做时间就浪费了。可以在这个时候提高CPU使用效率的有两种方法
- 一个是乱序执行(out of order execution),即把当前线程中后面的、不依赖于当前指令执行结果的指令拿过来提前执行
- 另一个是超线程技术,即把另一个线程的指令拿过来执行。
- 当发生cache miss时(特别是read cache miss,在cpu运行命令时,read只能是同步的,write可以是异步的),cpu在等待数据从内存读进cache期间,没事可做,CPU在硬件层面,把一个CPU模拟成两个CPU,在上层看来是两个CPU,并发的执行两个线程
- 这样当一个线程因cache miss在等待时,另一个线程可以执行
在64位数据总线的系统中,只有操作的数据小于64bit并且在内存中是64bit对齐且cache在同一个cache line中才能保证是原子操作,如果cache line大于等于64bit并且是64bit的整数倍,那么基本可以认为在内存中是64bit对齐的数据也会保存在同一个cache line中,可以认为是原子的。
Cache淘汰策略
cache中包含的cache entry的数目是有限的,并且远小于内存,当cache entry全部都被占用的时候,就需要一个淘汰策略将部分cache entry淘汰掉,一般使用LRU策略。
CPU高速缓存写操作
缓存写入策略
- 写直达(Write-Through):每一次数据都要写入到主内存里面。
- 写回(Write-Back):数据写到 CPU Cache 就结束。只有当 CPU Cache 是脏数据时,才把数据写入主内存。
写直达
写直达策略中,每一次数据都要写入到主内存里面。写入前,先判断数据是否已经在 Cache 里面了。
- 命中缓存。如果数据已经在 Cache 里,先把数据写入更新到 Cache 里面,再写入到主内存里面;
- 未命中缓存。如果数据不在 Cache 里,只需要更新主内存。
写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。
写回
写回策略不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。只有当 CPU Cache 里面的数据要被“替换”的时候,我们才把数据写入到主内存里面去。
- 命中缓存。如果要写入的数据,就在 CPU Cache 里面,那么只是更新 CPU Cache 里面的数据。同时标记 CPU Cache 里的这个 Block 是脏(Dirty)的。所谓脏的,就是指这个时候,我们的 CPU Cache 里面的这个 Block 的数据,和主内存是不一致的。
- 未命中缓存。如果要写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,需要判断 Cache Block 里面的数据有没有被标记成脏的。
- 如果是脏数据,我们要先把这个 Cache Block 里面的数据,写入到主内存里面。然后,再把当前要写入的数据,写入到 Cache 里,同时把 Cache Block 标记成脏的。
- 如果没有被标记成脏数据,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标记成脏的就好了。
- 加载缓存。加载内存数据到 Cache 里面的时候,也要多出一步同步脏 Cache 的动作。如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。
可以看到,在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。
数据缓存一致性协议 MESI
多核CPU的情况下有多个一级数据缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
只有数据缓存有该问题,一级指定缓存不存在该问题
MESI协议缓存状态
MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态 | 描述 | 监听任务 |
---|---|---|
M-修改(Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对应主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E-独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S-共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I-无效 (Invalid) | 该Cache line无效。 | 无 |
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
MESI状态转换
- 触发事件
- 本地读取(Local read)| 本核CPU读取本地cache数据
- 本地写入(Local write)| 本核CPU写入本地cache数据
- 远端读取(Remote read)| 其它CPU读取该CPU cache数据
- 远端写入(Remote write)| 其它CPU写入该CPU cache数据
- cache分类-前提是所有的cache共同缓存了主内存中的某一条数据。
- 本地cache:指当前cpu的cache。
- 触发cache:触发读写事件的cache。
- 其它cache(远端Cache):指其它CPU的Cache,既除了以上两种之外的cache。
本地的事件触发 本地cache和触发cache为相同。
状态 | 触发本地读取 | 触发本地写入 | 触发远端读取 | 触发远端写入 |
---|---|---|---|---|
M状态(修改) | 本地cache:M 触发cache:M 其他cache:I |
本地cache:M 触发cache:M 其他cache:I |
本地cache:M→E→S 触发cache:I→S 其他cache:I→S 同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享 |
本地cache:M→E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I |
E状态(独享) | 本地cache:E 触发cache:E 其他cache:I |
本地cache:E→M 触发cache:E→M 其他cache:I 本地cache变更为M,其他cache状态应当是I(无效) |
本地cache:E→S 触发cache:I→S 其他cache:I→S 当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享) |
本地cache:E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M |
S状态(共享) | 本地cache:S 触发cache:S 其他cache:S |
本地cache:S→E→M 触发cache:S→E→M 其他cache:S→I 当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态 |
本地cache:S 触发cache:S 其他cache:S |
本地cache:S→I 触发cache:S→E→M 其他cache:S→I 当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改) |
I状态(无效) | 本地cache:I→S或者I→E 触发cache:I→S或者I →E 其他cache:E、M、I→S、I 本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I |
本地cache:I→S→E→M 触发cache:I→S→E→M 其他cache:M、E、S→S→I |
既然是本cache是I,其他cache操作与它无关 | 既然是本cache是I,其他cache操作与它无关 |
多核缓存协同操作
假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
-
单核读取
- CPU A发出了一条指令,从主内存中读取x。
- 从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享).
-
双核读取
- CPU A发出了一条指令,从主内存中读取x。
- CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。
- CPU B发出了一条指令,从主内存中读取x。
- CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。
-
修改数据
- CPU A 计算完成后发指令需要修改x.
- CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
- CPU A 对x进行赋值。
- 同步数据
- CPU B 发出了要读取x的指令。
- CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)
- CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。
读写屏障
MESI优化和他们引入的问题
缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。
CPU切换状态阻塞解决-存储缓存(Store Bufferes)
比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。
Store Bufferes
为了避免这种CPU运算能力的浪费,只要让CPU发送通知之后不需要同步等待通知即可,然后就有了Store Bufferes,CPU对某个共享变量修改时,向其他CPU发出Invalid指令后不同步等待其他CPU指令的响应了,而是直接把最新值写入到一个缓冲区也就是Store Bufferes里,然后直接可以去干别的事情了,直到所有的CPU都对Invalid响应后,再把共享变量的值从Store Bufferes里拿出来,写入到自己的缓存里同时同步到主存里面去。
Store Bufferes的风险
第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果store buffer中存在,则直接返回,如果没有才能读取自己缓存里面的数据。
第二、保存什么时候会完成,这个并没有任何保证。
value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}
试想一下开始执行时,CPU A保存着isFinsh在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,由于isFinsh不需要同步其它cpu,而value需要加载到cpu缓存,并同步其它cpu,所以value需要在同步其它cpu后放入store buffer中,故value会比isFinsh更迟地同步至内存。所以完全有可能CPU B读取isFinsh的值为true,而value的值不等于10。
即isFinsh的在内存中的赋值在value赋值之前。
这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。
它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。
硬件内存模型
执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:
- 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
- Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
- 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。
干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。
-
写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
-
读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
void executedOnCpu0() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
finished = true;
}
void executedOnCpu1() {
while(!finished);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
assert value == 10;
}
问题
L1为什么比L2快?
L1/L2 Cache都是用SRAM做为存储介质,为什么说L1比L2快呢?这里面有三方面的原因:
- 存储容量不同导致的速度差异
L1的容量通常比L2小,容量大的SRAM访问时间就越长,同样制程和设计的情况下,访问延时与容量的开方大致是成正比的。
- 离CPU远近导致的速度差异
通常L1 Cache离CPU核心需要数据的地方更近,而L2 Cache则处于边缓位置,访问数据时,L2 Cache需要通过更远的铜线,甚至更多的电路,从而增加了延时。
L1 Cache分为ICache(指令缓存)和DCache(数据缓存),指令缓存ICache通常是放在CPU核心的指令预取单远附近的,数据缓存DCache通常是放在CPU核心的load/store单元附近。而L2 Cache是放在CPU pipeline之外的。
- 制程不同的造成的速度差异
首先, L1 Cache都是N路组相联的,N路组相联的意思时,给定一个地址,N个Cache单元同时工作,取出N份tag和N份数据,然后再比较tag,从中选出hit的那一个采用,其它的丢弃不用。这种方式一听就很浪费,很不节能。
另外,L2 Cache即便也是N路组相联的,但它是先取N个tag,然后比对tag后发现cache hit之后再把对应的数据取出来。由于L2是在L1 miss之后才会访问,所以L2 cache hit的概率并不高,访问的频率也不高,而且有前面L1抵挡一下,所以它的延迟高点也无所谓,L2容量比较大,如果数据和tag一起取出来,也比较耗能。
通常专家都将L1称为latency filter, L2称为bandwidth filter。
为什么不把L2 Cache也放在离CPU很近的地方?
由于Cache的容量越大,面积越大,相应的边长的就越长(假设是正方形的话),总有离核远的。
为什么需要set,而不能根据tag直接找到cache line,再根据offset找到值?
因为硬件必须要在几个纳秒内完成这些工作,这就要求搜索是并行进行的,如果cache中包括100 cache line,硬件就要并行搜索100个 cache line的tag,所以进行100个并行搜索非常困难,而且很昂贵。
这样,就引入了set的层级,先根据set index找出一个set,再在set中并行搜索少量cache line就容易多了。
至于set index为什么取内存地址的中间几位,而不是头几位,这是为了照顾空间局部性下cache的利用率而进行的优化。这样的话,某个内存块,只会保存至某个set中,而不会出现某内存块中的地址1保存到set1,地址2保存到set2中的情况。
为什么Cache不能做成Direct Mapped
和Fully Associative完全相反,使用Direct Mapped模式的Cache给定一个内存地址,就唯一确定了一条Cache Line。
设计复杂度低且速度快。那么为什么Cache不使用这种模式呢?
让我们来想象这么一种情况:一个拥有 1M L2 Cache的32位CPU,每条Cache Line的大小为 64Bytes。
那么整个L2Cache被划为了 1M/64=16384 条Cache Line。
我们为每条Cache Line从0开始编上号。同时32位CPU所能管理的内存地址范围是 2^32=4G,那么Direct Mapped模式下,内存也被划为 4G/16384=256K 的小份。也就是说每256K的内存地址共享一条Cache Line。
被映射到同一 Cache Line 上的两个内存块是不能同时换入缓存的。
但是,这种模式下每条Cache Line的使用率如果要做到接近100%,就需要操作系统对于内存的分配和访问在地址上也是近乎平均的。而与我们的意愿相反,为了减少内存碎片和实现便捷,操作系统更多的是连续集中的使用内存。这样会出现的情况就是0-1000号这样的低编号Cache Line由于内存经常被分配并使用,而16000号以上的Cache Line由于内存鲜有进程访问,几乎一直处于空闲状态。这种情况下,本来就宝贵的1M二级CPU缓存,使用率也许50%都无法达到。
cpu载入内存数据的最小单元为cache line即64byte,但是32位系统总线数据最多一次响应32位,即4个字节,那cpu是如何一次载入64byte的?
cpu总线分为数据总线及地址总线,其中数据总线决定一次能够传输的数据大小,地址总线决定最多寻址大小
当前CPU访问RAM采用的是Burst transfer(突发式数据传输模式),如果数据总线为32位,即4byte,而cache line为64byte,则cpu通过地址总线访问内存时,通知内存获取的数据范围为64byte,将该64byte数据分16次通过数据总线传输至cpu 缓存中,该过程中burst size为4byte,burst len为16
如果是对地址连续的读取,burst效率高得多,但如果地址是跳跃的,则无法采用burst操作
burst transfer是总线支持的传输方式,详见AXI (总线协议)