进一步阅读:请参阅此答案的末尾以获取链接。
缓存行大小为 64B,并在 64B 边界上对齐。 (有些 CPU 可能使用不同大小的缓存线,但 64B 是很常见的选择)。
缓存加载数据有两个原因:
-
需求未命中:加载或存储指令访问的字节不在任何当前热缓存行中。
对同一字节(或 int 或其他任何内容)的近期访问将在缓存中命中(时间局部性)。近期访问同一高速缓存行中的附近字节 也将命中(空间局部性)。以错误的顺序循环遍历多维数组,或者循环遍历仅访问一个成员的结构数组,这非常糟糕,因为必须加载整个缓存行,但您只使用其中的一小部分。
-
预取:在几次顺序访问之后,硬件预取器会注意到这种模式并开始加载尚未访问的缓存行,因此希望程序不会出现缓存未命中确实访问它。对于硬件预取器行为的具体示例,英特尔的优化手册描述了各种特定 CPU 中的硬件预取器(有关链接,请参阅x86 标签 wiki),并指出它们仅在内存系统尚未被需求淹没时运行错过了。
还有软件预取:软件可以运行一条指令,告诉 CPU 它将很快访问某些内容。但是即使内存还没有准备好,程序也会继续运行,因为这只是一个提示。该程序不会因为等待缓存未命中而陷入困境。现代硬件预取非常好,而软件预取通常是浪费时间。它对于像二分搜索这样的东西很有用,您可以在查看新的中间位置之前预取 1/4 和 3/4 位置。
不要在未测试它是否真正加速您的真实代码的情况下添加软件预取。即使硬件预取效果不佳,软件预取也可能无济于事,或者可能会造成伤害。乱序执行通常可以很好地隐藏缓存未命中延迟。
通常缓存是“满的”,加载新行需要丢弃旧行。缓存通常通过对集合中的标签进行排序来实现LRU replacement policy,因此每次访问缓存行都会将其移动到最近使用的位置。
以 8 路关联缓存为例:
一个 64B 的内存块可以通过它映射到的集合中的 8 种“方式”中的任何一种来缓存。部分地址位用作“索引”来选择一组标签。 (有关将地址拆分为 tag | index | offset-within-cache-line 的示例,请参阅 this question。OP 对其工作方式感到困惑,但有一个有用的 ASCII 图。)
命中/未命中判定不依赖于顺序。快速缓存(如 L1 缓存)通常会并行检查所有 8 个标签,以找到与地址高位匹配的标签(如果有)。
当我们需要一个新行的空间时,我们需要选择 8 个当前标签中的一个来替换(并将数据放入其关联的 64B 存储阵列中)。如果有任何当前处于无效状态(不缓存任何内容),那么选择是显而易见的。正常情况下,8个标签都已经有效。
但是我们可以使用标签存储额外的数据,足以存储订单。每次缓存命中发生时,集合中标签的顺序都会更新,以将命中的行放在 MRU 位置。
当需要分配新行时,驱逐LRU标签,在MRU位置插入新行。
标准的 LRU 策略意味着循环遍历一个稍微太大而无法放入缓存的数组意味着您永远不会看到任何缓存命中,因为当您返回相同的地址时,它已被逐出。一些 CPU 使用复杂的替换策略来避免这种情况:例如Intel IvyBridge 的大型共享 L3 缓存使用an adaptive replacement policy that decides on the fly when to allocate new lines in the LRU position,因此新分配会驱逐其他最近分配的行,保留确实具有未来价值的行。这需要额外的逻辑,所以只在大/慢缓存中完成,而不是在更快的 L2 和 L1 缓存中。
(为什么所有 8 个标签通常都是有效的,即使在程序开始时也是如此:
当执行到达程序的开头时,内核已经在运行程序的同一个 CPU 上运行了一堆代码。典型的现代缓存是物理索引和标记的 (the VIPT L1 speed trick avoids aliasing, and is really equivalent to index translation being free, not really to using virtual indexing),因此不必在上下文切换时刷新缓存。即它们缓存物理内存,而不管页表引起的虚拟到物理转换的变化。)
您应该阅读 Ulrich Drepper 的 What Every Programmer Should Know About Memory 文章。 IIRC,他介绍了触发负载的基础知识。
Ulrich 的一些具体建议现在有点过时了;预取线程在 Pentium 4 上很有用,但通常不再有用了。硬件预取器现在更智能了,超线程足以运行两个完整的线程。
*的CPU Cache article 还解释了有关缓存的一些细节,包括驱逐策略(CPU 在加载新行时如何选择丢弃哪一行)。