主存和cache每一块相等_Cache高速缓存简介

上次聊一个猎头的时候,问我有没有知乎分享,啊其实大佬们真的都有啊,而我只是个彩笔

可我知道,写的人的收获一定比读的人收获大, so...

非科班出身,工作中需要仿真cache性能才对此有了更深入的了解。 众所周知,现代处理器多采用了多级缓存的结构,上到手机服务器端的高性能处理器,小到ARM的M系列MCU。尽管缓存的微架构设计大有差异,但核心思想一致--存储器离CPU越近,时钟频率越高,访问的速度越快,容量越小且每字节的成本越高。如下图中的ARM A77架构,就采用了三级缓存的设计,其中64K的L1 (包含Instruction-Cache 和 Data-Cache)和256K/512K L2为每个核心独立占用,L3为一个Cluster中的核心共享,一般大小以MB为单位。

bff8205b7fae3e67668f014def872c04.png
ARM A77 的三级缓存结构

随着处理器性能的提升,对访存带宽的要求变得越来越高。然尔,一次对外部存储如DDR在总线高负载的情况下可以高达1000ns(你没看错,我建模的时候就被要求按照这个worst case仿真), 在不考虑CPU乱序执行和各种指令线程级并行的骚操作下,CPU完全处于饿死状态。相比之下,一次高速缓存的访问可能只有几个cycle, 可以最大程度匹配CPU的计算能力。当然,也没有免费的午餐,cache的增加随之而来的就是功耗的增加,甚至有可能达到CPU总功耗的25%-50%, 因此设计巧妙的多级缓存结构变为重中之重。

CACHE工作原理

我们首先考虑一个只有一级缓存的简单结构,CPU在发起每次load/store请求时,首先会去Cache中查找是否有此数据,如存在则认定为cache hit, 然后从cache中加载次数据。如不存在就记做cache miss, 然后需要通过总线发起对DDR的访问请求并把DDR中的数据加载到cache中并把数据返还给CPU。这样一次访问的最小粒度granularity为一个cacheline, 通常为64B/128B对齐. 为什么最小粒度是64/128字节呢?这主要从两个方面考量,首先DDR在返回请求的时候以burst read/write模式效率最高,通常在几拍只能可以完成对整个区域的读写请求。另一方面就是从空间时间局限性的角度来说,一个程序大概率会在之后的某一刻重复利用到邻域数据,提高cache命中率而这就是cache存在的目的啊。

那么我们如何判定cache是否命中呢?我们知道当我们用C语言访问一个普通的内存地址空间时,我们可以获得此地址对应的数据。cache中也使用了同样的方式,这样就保证了对软件的透明,但除了数据信息,cache内部显然还要添加额外的标记信息来比对此请求是否命中。接下来我们就展开看一下几种cache的设计方式。

全映射(Direct Mapping)

最简单的方式为Direct mapping, 即每个地址端直接映射到一个cache set(组)当中,一个set中只有唯一的block(块)也就是cache结构中的最小粒度。显然cache的容量比主存要小很多,因此必然有很多地址段会映射到同一个set中。比如下图中,我们假设每一个cacheline的粒度为16B且cache中一共只有4个set,红色地址段对应的0b0000 0000 和 0b0100 0000可以对应到一个相同的set中。或者说Addr[4:5]两位决定了地址段到set的映射,我们通过计算Addr[4:5]也就是index来判断一个请求落在了哪个set当中。那我们如何判断究竟是0b0000 0000还是0b0100 0000存储在set[0]中呢?我们再去比较高位addr[7:6]与Tag中的信息即可。而内存的最小访问粒度都是以字节为单位,因此Addr的低四位Addr[3:0]可以用来判断请求具体落在了16B中的哪一个字节上。最后有单比特的valid信号来标记此时cache中的信息是否有效。比如初始化的时候cache中肯定啥也没有所以valid都是无效的,或者在维护cache一致性的时候我们有时要invalidate cache中信息并要求CPU直接从DDR中读取数据。

顺着这个思路,假设CPU下发的访问请求地址为0b0101 0001, 我们首先计算addr[5:4] = 0b01发现这个地址应该落到set[1]当中。Ok这一个block的信息是有效的,然后再比对addr[7:6] = 0b01和这个set当中的Tag = 0b01, 既然相等就是命中。最后我们通过addr[3:0] = 0b0001找到offset为1的位置也就是这条cacheline中的第二个字节返还给CPU. 如果此次访问不命中,那就需要把当前的tag替换掉,然后从DDR中读取整条Cacheline并覆盖掉当前这一个block钟的cacheline

766a4952077606c3a172c19e0804769e.png
直接映射的cache结构

下面我们把刚才讨论的规律generalise一下, 对于一个m-bit位宽的地址来说,我们可以把这个地址分为:

a5a3f2dbc60b8714a8ffc0b7bbf034a9.png
  1. n-bits Block offset (2 ^ n为cacheline的最小粒度)
  2. k-bits index (2 ^ k为cache set的数量)
  3. (m-k-n)-bits tag

举例来说,如果我们假设可访问的物理地址大小为2^32bit = 4GB, 即m = 32, 同时有2^8=256组直接映射的cache set且cacheline的granularity为128B, 那么标记信息可以表示为:17-bits的tag, 8-bits的index和7-bits的offset外加1-bit的valid. 除去8-bits的index用来cache set的索引,剩余17+7+1 = 25-bits 即为每128B数据之外都需要添加的标记信息。而Cache数据的大小为256 * 128. 复杂的cache设计中也会把data和tag分两个模块存放

直接映射的cache设计上很容易实现,但随之而来的缺点也十分的明显。假设一段程序不断在0b0000 0000和0b0100 0000之间跳转那么cache就会不断在这两段内存之间替换, 那么cache命中率就会很低因而失去了cache本身的价值

全相连 (fully associative)

全相连是与全映射对立的另一个极端,即一个cache只有一个set,但是一个block可以放在这个set中的任意位置。因为没有了cache set的概念,与之对应的index便不复存在,因而也不会再有0b0000 0000与0b0100 0000映射到同样一个block的情况。 如果程序程序不断在0b0000 0000和0b0100 0000之间跳转, 那么cache的访问都会命中,这就解决了直接映射中存在的问题。如果cache中所有的block都被使用了,那么可以采用Least recently used替换掉最不常用的block。

6f23bfa90ea2c1603f376f23d3d01f8e.png
全相连的cache结构

这样的设计显著的增加了硬件设计的代价和难度。首先,由于index位的减少,tag位宽就需要相应的增加,这就增加了cache本身的面积。其次,目标的block现在可能是在cache中的任意一个位置,这就需要请求地址与cache中所有的tag进行比较,由于这些比较都需要在同一拍内完成,这就大大增加资源和时序的风险。因此在实际硬件设计中多采用了两种方法的折中,即多组相联的cache设计。

组相连(Set-associative)

组相连是直接映射和全相连的融合。一个组相连的Cache通常有多个set组成,如果每个set中只有一个block,那么显然就是直接映射的情况。而一个set中有n个block,就称为n组相连 (n-way associative)的cache.

1654c06a55f8c6e0c659d8ec6e923b7f.png

如果要查找一个地址是否命中,还是用下图中同样的例子,首先用addr[4]将这个地址映射到一个set中,然后再与这个set中所有block中的tag进行比较。

34b6413a9df40d79a690103cb511501e.png
2组相连的cache结构

组相连的结构结合了直接映射和全相连各自的优(缺)点。相比于直接映射下0b00000000与0b01000000必然会产生的地址冲突,2组相连的cache允许他们进入第0组的任意位置。虽然在数据已满的情况下依然有冲突的风险,但风险得以降低。相比于全连接4-bits的tag位宽和四组同时比较的逻辑,2组相连的cache在空间和逻辑上都有降低。 实际实现中常常采用2-32组相连的cache结构。

那我们应该采用什么方法提升cache的效率呢?直观的来看这基本上是个哲学问题-取舍,实际上也实存在很多更高级的方法,我只想从几个最简单的角度分析下:

首先就是增加缓存面积。嗯?似曾相识的感觉,堆面积谁不会,无非增加功耗,成本,只要有人买单又何妨? 当然面积增大可能同时意味着访问时间的增加。 第二种方式是增加相连度,即增加每一个set中的block数。提高相连度可以减少miss率,但同样意味着降index大小而增加tag位宽,也因此占用更多的面积和功耗。再者就是采用多级缓存,每一级较上级增大容量,更高的相连度而更relex的频率,这样可以达到更高的效率但也意味着更复杂的设计。

还有一种很高级的方法就是预取。预取的性能绝对是各CPU厂商最核心的竞争力之一,其本身的算法结构估计可以写好几本书,我们在这里只当一个功能使用。预取又可以分为硬件预取和编译器优化。所谓编译器优化是指在编译的时候插入预取的指令,其核心与硬件预取一样,都是在CPU在发出请求之前提前将可能用到的数据和指令加载到cache中。当CPU进行下一个操作时便可直接从cache中取得数据和指令从而减少了坐等数据的风险。值得一提的是,当预取错误的时候,尤其是当它影响了被迫切需要的内容时,反而可能会导致性能跳水。

软件上虽然大多数时候对cache的存在不感知,但是如果了解了cache工作的原理和方式也有助于写出更高效的代码,当然如果只做上层软件的同学可能会把锅强力甩给编译器。下面介绍几种很直观的增加cache命中率和性能的方式:

最简单的方式是使得数据cacheline对齐。比如cacheline的granularity为128B,如果我有一组256B的连续数据,如果我存储的首地址是0x0000 0002,那么访问这组数据需要读取3个cacheline. 与之相比如果存放地址是0x0000 0000那么只要读2次。显然访问次数越少相应的命中概率会更高,因此数据对齐会一般会对性能产生正向收益。

还有一个很常见的方式就是改变循环嵌套的顺序。比如对一个m行n列的矩阵a[m][n]的访问:

for(int i = 0; i < n; i++)

for(int j = 0; j < m; j++)

a[m][n] = 2 * a[m][n]

由于矩阵a在内存中实际是以行为单位连续存储,因此上面代码的每次对a的访问其实都在内存中跨越了n个元素。

for(int j = 0; j < m; j++)

for(int i = 0; i < i; i++)

a[m][n] = 2 * a[m][n]

改变循环的顺序,就保证了对内存的访问是连续的。这个在图像处理中很常见。因为通常一个高分辨率的图像可以达到数MB很难完全存储在cache内,连续的内存访问增加了cache命中率可以显著提升性能。

与之相似的是矩阵的分块处理,这在矩阵乘法和图像处理中也十分常见。比如opencv中常用的映射函数remap,就会将图像图像切割成很多的小块,每个小块可以最大利用周围数据的局限性复用cache中的数据。相比于行扫描的方式,大大提升性能。具体的实现方式在这里就不展开了。

虽然这里面涉及的内容都很基础,实际写出来也参考了不少文献,在这里也不能不提及;

  • Computer Architecture A Quantiative Analysis
  • https://courses.cs.washington.edu/courses/cse378
  • smcdef:Cache的基本原理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值