高速缓存线是如何工作的?

我知道处理器通过缓存线将数据带入缓存,例如,在我的Atom处理器上,无论实际读取的数据大小如何,每次都会带来大约64字节的数据。

我的问题是:

想象一下,您需要从内存中读取一个字节,哪64个字节将被带入缓存?

我可以看到的两种可能性是,64字节从感兴趣的字节下面最近的64字节边界开始,或者64字节以某种预定的方式分布在字节周围(例如,一半在下面,一半在上面,或全部在上面)。

到底是哪一个?

115722 次浏览

处理器可能有多级缓存(L1, L2, L3),它们在大小和速度上有所不同。

然而,要了解每个缓存中到底有什么,你必须研究特定处理器使用的分支预测器,以及程序的指令/数据如何与之对应。

阅读有关分支预测CPU缓存替代的政策的内容。

这不是一项容易的任务。如果在一天结束的时候你想要的只是一个性能测试,你可以使用像Cachegrind这样的工具。但是,由于这是一个模拟,其结果可能在一定程度上有所不同。

我不能肯定地说,因为每个硬件都是不同的,但它通常是“64字节从下面最近的64字节边界开始”,因为这对CPU来说是一个非常快速和简单的操作。

如果包含你正在加载的字节或单词的缓存行还没有出现在缓存中,你的CPU将请求从缓存行边界开始的64个字节(你需要的64的倍数下的最大地址)。

现代PC内存模块一次传输64位(8字节),在8次转移中,因此一个命令从内存触发一个完整的缓存行读或写。(DDR1/2/3/4 SDRAM突发传输大小可配置为64B;cpu将选择突发传输大小来匹配它们的缓存线大小,但64B是常见的)

根据经验,如果处理器不能预测内存访问(并预取),检索过程可能需要~90纳秒,或~250个时钟周期(从CPU知道地址到CPU接收数据)。

相比之下,在现代x86 cpu上,L1缓存中的命中具有3或4个周期的加载-使用延迟,而存储-重新加载具有4或5个周期的存储-转发延迟。在其他体系结构上情况类似。

进一步阅读:Ulrich Drepper的关于内存,每个程序员都应该知道的事。软件预取建议有点过时:现代HW预取器更智能,超线程比P4天要好得多(所以预取线程通常是浪费)。而且,标签维基有很多关于该架构的性能链接。

如果高速缓存线是64字节宽,那么它们对应的内存块的起始地址可以被64整除。任何地址中最不有效的6位都是缓存线上的偏移量。

因此,对于任何给定的字节,必须读取的缓存行可以通过清除地址中最不重要的6位来找到,这对应于舍入到最接近的能被64整除的地址。

虽然这是由硬件完成的,但我们可以使用一些参考C宏定义来显示计算:

#define CACHE_BLOCK_BITS 6
#define CACHE_BLOCK_SIZE (1U << CACHE_BLOCK_BITS)  /* 64 */
#define CACHE_BLOCK_MASK (CACHE_BLOCK_SIZE - 1)    /* 63, 0x3F */


/* Which byte offset in its cache block does this address reference? */
#define CACHE_BLOCK_OFFSET(ADDR) ((ADDR) & CACHE_BLOCK_MASK)


/* Address of 64 byte block brought into the cache when ADDR accessed */
#define CACHE_BLOCK_ALIGNED_ADDR(ADDR) ((ADDR) & ~CACHE_BLOCK_MASK)

首先,主存访问是非常昂贵的。目前一个2GHz的CPU(最慢的一次)每秒有2G个周期。一个CPU(现在的虚拟核心)可以每tick一次从它的寄存器中获取一个值。由于虚拟核心由多个处理单元(ALU -算术逻辑单元,FPU等)组成,如果可能的话,它实际上可以并行处理某些指令。

一次主存的访问成本约为70ns到100ns (DDR4略快)。这一次基本上是查找L1、L2和L3缓存,然后敲击内存(将命令发送到内存控制器,它将其发送到内存银行),等待响应并完成。

100ns意味着大约200滴答。所以基本上,如果一个程序总是错过每个内存访问的缓存,CPU将花费大约99.5%的时间(如果它只读取内存)空闲等待内存。

为了加快速度,有L1 L2 L3缓存。他们使用直接放置在芯片上的存储器,并使用一种不同的晶体管电路来存储给定的比特。这需要更多的空间,更多的能量,比主存更昂贵,因为CPU通常使用更先进的技术生产,L1, L2, L3内存中的生产故障有可能使CPU变得毫无价值(缺陷),所以L1, L2, L3缓存会增加错误率,从而降低产量,直接降低ROI。因此,当涉及到可用缓存大小时,有一个巨大的权衡。

(目前创建更多的L1, L2, L3缓存,以便能够禁用某些部分,以减少实际生产缺陷是缓存内存区域呈现整体CPU缺陷的机会)。

给出一个时间概念(来源:访问缓存和内存的成本)

  • L1缓存:1ns到2ns(2-4个周期)
  • L2缓存:3ns到5ns(6-10个周期)
  • L3缓存:12ns到20ns(24-40个周期)
  • RAM: 60ns(120循环)

由于我们混合了不同的CPU类型,这些只是估计值,但当获取内存值时,我们可能会在某些缓存层中命中或错过,这些都是一个很好的想法。

因此缓存基本上大大加快了内存访问速度(60ns vs. 1ns)。

获取一个值,将其存储在缓存中,以便有机会重新读取它对于经常访问的变量是有好处的,但对于内存复制操作,它仍然会很慢,因为它只是读取一个值,将值写入某个地方,并且永远不会再次读取该值……没有缓存命中,速度极慢(除此之外,由于我们有乱序执行,这可能会并行发生)。

这个内存副本是如此重要,有不同的方法来加快它。在早期,内存通常能够在CPU外部复制内存。它是由内存控制器直接处理的,因此内存复制操作不会污染缓存。

但是除了普通内存复制之外,其它串行内存访问是相当常见的。一个例子是分析一系列信息。拥有一个整数数组并计算总和、平均值、平均值或更简单的查找某个值(过滤器/搜索)是每次在任何通用CPU上运行的另一类非常重要的算法。

因此,通过分析内存访问模式,很明显,数据是经常按顺序读取的。如果程序读取到 索引i的值,程序也会读取i+1的值。这个概率比同样的程序读取i+2等值的概率略高。< / p >

因此,给定一个内存地址,提前读取并获取额外的值是一个好主意(现在仍然是)。这就是为什么会有一个加速模式。

boost模式中的内存访问意味着,发送一个地址和多个值依次发送。每个额外的值发送只需要大约额外的10ns(甚至更少)。

另一个问题是地址。发送地址需要时间。为了对较大的内存进行寻址,必须发送较大的地址。在早期,这意味着地址总线不够大,不能在一个周期(tick)内发送地址,并且需要多个周期来发送地址,从而增加了延迟。

例如,64字节的缓存行意味着内存被划分为不同的(不重叠的)64字节大小的内存块。64字节意味着每个块的起始地址的最低6个地址位始终为零。因此,每次发送这6个零位并不需要为任意数量的地址总线宽度增加64倍的地址空间(欢迎效应)。

缓存线解决的另一个问题(除了提前读取和在地址总线上保存/释放6位)是缓存的组织方式。例如,如果缓存将被划分为8字节(64bit)块(单元),则需要存储存储单元的地址,该缓存单元同时保存值。如果地址也是64位的,这意味着缓存大小的一半被地址消耗,导致开销为100%。

由于缓存线是64字节,而CPU可能使用64位- 6bit = 58bit(不需要存储零位),这意味着我们可以缓存64字节或512bit,开销为58bit(11%开销)。实际上,存储的地址甚至比这个还要小,但有状态信息(比如缓存线是否有效和准确,是否脏,是否需要写回ram中等)。

另一个方面是我们有集关联缓存。不是每个缓存单元都能存储某个地址,而只能存储这些地址的一个子集。这使得必要的存储地址位更小,允许并行访问缓存(每个子集可以访问一次,但独立于其他子集)。

特别是当涉及到在不同的虚拟核心之间同步缓存/内存访问时,它们每个核心独立的多个处理单元,最后一个主板上的多个处理器(主板上有多达48个处理器)。

这就是为什么我们要有高速缓存线。提前读取的好处是非常高的,最坏的情况是从缓存线中读取一个字节,然后不再读取其余的字节,因为这种可能性非常小。

cache-line的大小(64)是一个明智的选择,在更大的cache-line之间进行权衡,使得它的最后一个字节在不久的将来也不太可能被读取,从内存中获取完整的cache line所需的时间(并将其写回来),以及缓存组织的开销以及缓存和内存访问的并行化。