缓存行是如何工作的?

224

我了解处理器通过缓存行将数据带入缓存,例如,在我的Atom处理器上,每次带入大约64个字节的数据,无论实际读取的数据大小如何。

我的问题是:

如果您需要从内存中读取一个字节,那么哪些64个字节会被带入缓存?

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

它是哪一种情况?


34
请阅读:程序员应知道的有关内存的所有内容,然后再次阅读。更好的(pdf)源文件在此 - andersoj
5个回答

188
如果包含所需字节或单词的缓存行不在缓存中,您的CPU将请求从缓存行边界开始的64个字节(低于所需地址且是64的倍数的最大地址)。
现代PC内存模块每次传输64位(8个字节),以8个传输为一组连续传输,因此一个命令会触发对一个完整缓存行的读取或写入。(DDR1/2/3/4 SDRAM的突发传输大小可配置达到64B;CPU将选择与其缓存行大小相匹配的突发传输大小,但64B很常见)
作为经验法则,如果处理器无法预测内存访问(并进行预取),检索过程可能需要约90纳秒或约250个时钟周期(从CPU知道地址到CPU接收数据)。
相比之下,在L1缓存中的命中具有3或4个周期的加载使用延迟,并且在现代x86 CPU上,存储-重新加载具有4或5个周期的存储转发延迟。其他架构类似。

更多阅读:Ulrich Drepper的程序员应该了解的有关内存的一切。软件预取建议有点过时:现代硬件预取器更加智能,而超线程比P4时期要好得多(因此预取线程通常是浪费)。此外,标签的维基页面中有许多有关该架构性能的链接。


2
这个答案完全没有意义。64位内存带宽(在这方面也是错误的)与64字节(不是64位)有什么关系?而且如果你击中RAM,10到30纳秒也是完全错误的。这可能对L3或L2缓存是正确的,但对于RAM来说,更像是90ns。你的意思是突发时间-访问下一个四字节的突发模式的时间(这实际上是正确的答案)。 - Martin Kersten
6
一条DDR1/2/3/4 SDRAM通道使用64位数据总线宽度。整个高速缓存行的突发传输需要8个8字节的传输,并且实际上就是这样发生的。优化该过程的方法可能仍然是正确的,即首先传输包含所需字节的8字节对齐块,即从那里开始突发传输(如果不是突发传输大小的第一个8字节,则绕回)。然而,具有多级缓存的现代CPU可能不再这样做,因为这意味着提前将突发传输的第一个块(或几个块)中继到L1缓存。 - Peter Cordes
2
Haswell在L2和L1D缓存之间有一个64B的路径(即完整的缓存行宽度),因此传输包含所请求字节的8B将导致该总线的低效使用。@Martin关于必须访问主内存的负载的访问时间也是正确的。 - Peter Cordes
3
关于数据是否一次性全部发送到存储器层次结构的顶部,还是L3在从存储器接收完整行之前等待它,这是一个好问题。不同级别缓存之间有传输缓冲区,并且每个未处理的缺失都会占用其中一个。因此(纯属猜测),可能L3将字节从内存控制器放入自己的接收缓冲区,同时将它们放入所需的L2缓存的加载缓冲区中。当该行从内存完全传输时,L3会通知L2该行已经准备好,并将其复制到自己的数组中。 - Peter Cordes
2
@Martin:我决定继续编辑这个答案。我认为现在更准确,而且仍然简单易懂。未来的读者们:还可以看看Mike76的问题和我的回答:https://dev59.com/4VkT5IYBdhLWcg3wALDw - Peter Cordes
显示剩余5条评论

40
首先,主内存访问非常昂贵。目前,2GHz的CPU(最慢的CPU)每秒有2G个时钟周期。CPU(现在的虚拟核心)可以每个时钟周期从其寄存器中获取一个值。由于虚拟核心包括多个处理单元(ALU-算术逻辑单元、FPU等),如果可能的话,它实际上可以并行处理某些指令。
访问主内存的成本约为70ns到100ns(DDR4速度略快)。这段时间基本上是查找L1、L2和L3缓存,然后命中内存(发送命令给内存控制器,然后将其发送到内存银行),等待响应并完成。
100ns意味着大约需要200个时钟周期。因此,如果一个程序每次访问内存都会错过缓存,那么CPU将花费约99.5%的时间(如果它只读取内存)处于空闲状态,等待内存。
为了加快速度,有L1、L2、L3缓存。它们使用直接放置在芯片上的存储器,并使用不同类型的晶体管电路来存储给定的位。这需要更多的空间、更多的能量和比主内存更昂贵,因为CPU通常使用更先进的技术进行生产,而L1、L2、L3存储器的生产故障有可能使CPU变得毫无价值(缺陷),因此大型的L1、L2、L3缓存增加了错误率,从而降低了产量,直接降低了投资回报率。因此,在可用的缓存大小方面存在巨大的权衡。
(目前,创建更多的L1、L2、L3缓存是为了能够停用某些部分,以减少实际生产缺陷是缓存内存区域导致CPU整体缺陷的机会)。
为了给出时间估计(来源:访问缓存和内存的成本
  • L1缓存:1ns至2ns(2-4个周期)
  • L2缓存:3ns至5ns(6-10个周期)
  • L3缓存:12ns至20ns(24-40个周期)
  • RAM:60ns(120个周期)
由于我们混合使用不同的CPU类型,这些仅是估计,但可以很好地说明在获取内存值时发生的情况,我们可能会在某些缓存层中命中或错过。
因此,缓存基本上可以大大加快内存访问速度(60ns与1ns相比)。
获取一个值,将其存储在缓存中以便可能重新读取它对于经常访问的变量是有好处的,但对于内存复制操作来说仍然太慢,因为我们只是读取一个值,写入一个值,并且永远不会再次读取该值...没有缓存命中,速度非常慢(除此之外,这可以并行处理,因为我们有乱序执行)。这个内存复制如此重要,以至于有不同的方式加速它。在早期,内存通常能够在CPU之外复制内存。它由内存控制器直接处理,因此内存复制操作不会污染缓存。
除了简单的内存复制,其他串行访问内存也很常见。例如,分析一系列信息。拥有一个整数数组并计算总和、平均值、均值或甚至更简单的查找某个值(过滤/搜索)是任何通用CPU上每次运行的另一个非常重要的算法类别。
因此,通过分析内存访问模式,显然数据经常被顺序读取。如果程序读取索引i处的值,那么该程序还将读取i+1处的值的概率很高。这个概率略高于同一程序还将读取i+2等值的概率。
因此,给定内存地址后,预读和获取其他值是一个好主意(现在仍然是)。这就是为什么有增强模式的原因。
增强模式下的内存访问意味着发送一个地址和多个值。每个额外发送的值只需要额外约10ns(甚至更少)。
另一个问题是地址。发送地址需要时间。为了寻址大量内存,必须发送大型地址。在早期,这意味着地址总线不足以在单个周期(时钟)中发送地址,需要多个周期来发送地址,增加了更多的延迟。
例如64字节的缓存线表示内存被划分为大小为64字节的不同(不重叠)内存块。64字节意味着每个块的起始地址具有最低六位地址位始终为零。因此,无需每次发送这些六个零位,即可增加地址空间64倍,适用于任何地址总线宽度(欢迎效果)。
除了预读和在地址总线上保存/释放六个位之外,缓存线还解决了另一个问题,即缓存的组织方式。例如,如果将高速缓存分成8字节(64位)块(单元),则需要将内存单元的地址存储到该缓存单元中。如果地址也是64位,则一半的缓存大小会被地址消耗,导致100%的开销。
由于缓存行是64字节,CPU可能使用64位-6位=58位(不需要存储零位),这意味着我们可以使用58位(11%的开销)的开销缓存64字节或512位。实际上存储的地址甚至比这个小,但是有状态信息(例如缓存线是否有效和准确,脏数据需要写回到RAM等)。另一个方面是我们设置了关联缓存。不是每个缓存单元都能够存储某个地址,而只能存储其中的一部分。这使得必要的存储地址位变得更小,并允许缓存的并行访问(每个子集可以被访问一次但与其他子集无关)。
在不同虚拟核心之间同步缓存/内存访问时,它们独立的多个处理单元以及最终一个主板上的多个处理器之间还有更多需要考虑的因素(某些主板可容纳48个或更多处理器)。
这基本上是目前使用缓存线的原因。预读所带来的好处非常大,仅读取缓存行中的一个字节且不再读取剩余部分的最坏情况的概率非常小,因为这种情况发生的可能性很小。
缓存行的大小(64)是明智选择的折衷方案,因为较大的缓存行使得最后一个字节在不久的将来也被读取的可能性变小,而同时需要考虑从内存中获取完整缓存行(以及将其写回)所需的时间、缓存组织开销以及缓存和内存访问的并行化方面的问题。

1
一个集合关联缓存使用一些地址位来选择一个集合,因此标记可以比您的示例更短。当然,缓存还需要跟踪哪个标记与集合中的哪个数据数组相对应,但通常集合比集合内的路数多。(例如,在Intel x86 CPU中,32kB 8路关联L1D缓存,具有64B行,偏移6位,索引6位。标记只需要48-12位宽,因为x86-64(目前)仅具有48位物理地址。正如您所知,低12位不是巧合,因此L1可以是VIPT而不会发生别名。) - Peter Cordes
厉害的回答,伙计...有没有什么“赞”按钮? - Edgard Lima

25
如果缓存行宽为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)

1
我很难理解这个。我知道现在已经过去了两年,但你能给我一个示例代码吗?只需要一两行就可以了。 - Nick
2
@Nick 这个方法有效的原因在于二进制数系统。2的任何次幂都只有一个位被设置,其余位都被清除,所以对于64来说,你有0b1000000,注意到最后6位是零,所以即使你有一些数字中有这6个中的任意一个被设置(代表数字%64),清除它们将会给你最接近的64字节对齐内存地址。 - legends2k

7
处理器可能具有多级缓存(L1、L2、L3),它们在大小和速度上有所不同。然而,要理解每个缓存中究竟包含了什么,您需要研究该特定处理器使用的分支预测器以及程序指令/数据与其之间的行为。阅读关于分支预测器CPU缓存替换策略的内容。这不是一项容易的任务。如果您最终想要的只是性能测试,可以使用像Cachegrind这样的工具。然而,由于这是一种模拟,其结果可能会有所不同。

4

我不能确定每个硬件都是如此,但通常情况下,“64字节从最接近的64字节边界开始”是一种对于CPU来说非常快速简单的操作。


3
我可以肯定地说,任何合理的高速缓存设计都会采用大小为2的幂次方且自然对齐的行。例如,对齐到64B字节边界。这不仅快速简单,而且几乎是免费的:例如,只需忽略地址的低6位即可。高速缓存通常会针对不同范围的地址执行不同的操作。(例如,高速缓存通过标记和索引来检测命中与未命中,然后仅使用缓存行内的偏移量来插入/提取数据) - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接