本文来源于公众号:勾勾的Java宇宙(微信号:Javagogo),莫得推广,全是干货!
原文链接:mp.weixin.qq.com/s/_j4O1aGnj… 作者:林䭽
TLB 是 CPU 的一个“零件”。
在 TLB 的设计当中不可能再去内存中创建数据结构,因此在 8 路组相联缓存设计中,我们每次只需要从 8 个缓存条目中选择 Least Recently Used 缓存。
1. 增加累计值
先说一种方法,用硬件同时比较 8 个缓存中记录的缓存使用次数。
这种方案需要做到 2 点:
-
缓存条目中需要额外的空间记录条目的使用次数(累计位)。类似我们在页表设计中讨论的基于计时器的读位操作——每过一段时间就自动将读位累计到一个累计位上。
-
硬件能够实现一个快速查询最小值的算法。
第 1 点会产生额外的空间开销,还需要定时器配合,成本较高。注意缓存是很贵的,对于缓存空间利用自然能省则省。
而第 2 点也需要额外的硬件设计。
那么,有没有更好的方案呢?
2. 1bit 模拟 LRU
一个更好的方案就是模拟 LRU。
我们可以考虑继续采用上面的方式,但是每个缓存条目只拿出一个 LRU 位(bit)来描述缓存近期有没有被使用过。缓存置换时只是查找 LRU 位等于 0 的条目置换。
更好的操作是考虑在所有 LRU 位都被置 1 的时候,清除 8 个条目中的 LRU 位(置零),这样可以节省一个计时器。相当于发生内存操作,LRU 位置 1,8 个位置都被使用,LRU 都置 0。
3. 搜索树模拟 LRU
最后我再介绍一个巧妙的方法——用搜索树模拟 LRU。
对于一个 8 路组相联缓存,这个方法需要 8-1=7bit 去构造一个树。
8 个缓存条目用 7 个节点控制,每个节点是 1 位。
0 代表节点指向左边,1 代表节点指向右边。
初始化的时候,所有节点都指向左边,如下图所示:
接下来每次写入,会从根节点开始寻找,顺着箭头方向(0 向左,1 向右),找到下一个更新方向。
比如现在图中下一个要更新的位置是 0。更新完成后,所有路径上的节点箭头都会反转,也就是 0 变成 1,1 变成 0。
上图是 read a 后的结果,之前路径上所有的箭头都被反转,现在看到下一个位置是 4,我用橘黄色进行了标记。
上图是发生操作 read b 之后的结果,现在橘黄色可以更新的位置是 2。
上图是读取 c 后的情况。
假设后面的读取顺序是 d,e,f,g,h,那么缓存会变成如下图所示的结果:
这个时候用户如果读取了已经存在的值,比如说 c,那么指向 c 那路箭头会被翻转。下图是 read c 的结果:
这个结果并没有改变下一个更新的位置,但是翻转了指向 c 的路径。
如果要读取 x,那么这个时候就会覆盖橘黄色的位置。
因此,本质上这种树状的方式,其实是在构造一种先入先出的顺序。任何一个节点箭头指向的子节点,应该被先淘汰(最早被使用)。
这是一个我个人觉得非常天才的设计,因为如果在这个地方构造一个队列,然后每次都把命中的元素的当前位置移动到队列尾部,就至少需要构造一个链表。而链表的每个节点都至少要有当前的值和
next指针,这就需要创建复杂的数据结构。在内存中创建复杂的数据结构轻而易举,但是在 CPU 中就非常困难。所以这种设计基于 bit-tree,就轻松地解决了这个问题。
当然,这是一个模拟 LRU 的情况,你还是可以构造出违反 LRU 缓存的顺序。
欢迎关注公众号 勾勾的Java宇宙(微信号:Javagogo),拒绝水文,收获干货!