对于 N-Way 组关联,可能有点不好理解,这里个例子,并多说一些细节(不然后面的代码会不能理解),Intel 大多数处理器的 L1 Cache 都是 32KB,8-Way 组相联,Cache Line 是 64 Bytes。这意味着,
32KB的可以分成,32KB / 64 = 512 条 Cache Line。
因为有8 Way,于是会每一Way 有 512 / 8 = 64 条 Cache Line。
每一路就有 64 x 64 = 4096 Byts 的内存。
为了方便索引内存地址,
Tag:每条 Cache Line 前都会有一个独立分配的 24 bits来存的 tag,其就是内存地址的前24bits
Index:内存地址后续的 6 个 bits 则是在这一 Way 的是Cache Line 索引,2^6 = 64 刚好可以索引64条Cache Line
Offset:再往后的 6bits 用于表示在 Cache Line 里的偏移量
如下图所示:(图片来自《Cache: a place for concealment and safekeeping》)
当拿到一个内存地址的时候,先拿出中间的 6bits,找到是哪组。
然后,在这一个 8 组的 cache line 中,再进行 O(n) n=8 的遍历,主是要匹配前 24bits 的 tag。如果匹配中了,就算命中,如果没有匹配到,那就是 cache miss,如果是读操作,就需要进向后面的缓存进行访问了。
L2/L3 同样是这样的算法。淘汰算法有两种,一种是随机一种是 LRU。现在一般都是以 LRU 的算法(通过增加一个访问计数器实现)。
这也意味着:
L1 Cache 可映射 36bits 的内存地址,一共 2^36 = 64GB 的内存
当 CPU 要访问一个内存的时候,通过这个内存中间的 6bits 定位是哪个 set,通过前 24bits 定位相应的Cache Line。
就像一个 hash Table 的数据结构一样,先是 O(1)的索引,然后进入冲突搜索。
因为中间的 6bits 决定了一个同一个 set,所以,对于一段连续的内存来说,每隔 4096 的内存会被放在同一个组内,导致缓存冲突。
此外,当有数据没有命中缓存的时候,CPU 就会以最小为 Cache Line 的单元向内存更新数据。当然,CPU 并不一定只是更新 64Bytes,因为访问主存实在是太慢了,所以,一般都会多更新一些。好的 CPU 会有一些预测的技术,如果找到一种 pattern 的话,就会预先加载更多的内存,包括指令也可以预加载。
这叫 Prefetching 技术 (参看,Wikipedia 的 Cache Prefetching 和 纽约州立大学的 Memory Prefetching)。比如,在for-loop访问一个连续的数组,步长是一个固定的数,内存就可以做到prefetching。(注:指令也是以预加载的方式执行)
了解这些细节,会有利于知道在什么情况下有可以导致缓存的失效。
缓存的一致性对于主流的 CPU,缓存的写操作基本上是两种策略,
一种是 Write Back,写操作只要在 cache 上,然后再 flush 到内存上。
一种是 Write Through,写操作同时写到 cache 和内存上。
主流的 CPU(如:Intel Core i7/i9)采用的是 Write Back 的策略,因为直接写内存实在是太慢了。
如果有一个数据 x 在 CPU 第 0 核的缓存上被更新了,其它 CPU 核上对于这个数据 x 的值也要被更新,这就是缓存一致性的问题。(当然,对于上层的程序不用关心 CPU 多个核的缓存是怎么同步的,这对上层的代码来说都是透明的)
一般来说,在 CPU 硬件上,会有两种方法来解决这个问题。
Directory 协议。这种方法的典型实现是要设计一个集中式控制器,主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个 CPU Cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和 CPU Cache之间或在 CPU Cache自身之间进行数据同步和传输。
Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache 通过这个协议可以识别其它Cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它 CPU Cache。这个协议要求每个 CPU Cache 都可以窥探数据事件的通知并做出相应的反应。如下图所示,有一个 Snoopy Bus 的总线。