【问题标题】:When is a cache line first fetched in a program?什么时候第一次在程序中获取高速缓存行?
【发布时间】:2023-09-12 23:36:01
【问题描述】:

假设我有一个像这样的普通 C 程序:

void print_character(char);

int main(int argc, char* argv[]){

  char loads_of_text[1024];
  int count = strlen(argv[1]);
  memcpy(loads_of_text, argv[1], count);

  for(int i = 0; i < count; ++i)
    print_character(loads_of_text[i]);

  return 0;
}

据我了解缓存的概念,由于在获取内存时的延迟,处理器会在请求提高性能时获取比必要更多的数据。并且这仅在我顺序使用内存直到读取整个缓存行然后它会获取另一个时才有效。

但是我很难准确地看到处理器何时获取和处理缓存行,这在像上面的代码这样的示例中到底发生在哪里?什么时候处理缓存行?

【问题讨论】:

  • 可能的策略有很多,实际使用的也很多。所以一般来说,这并不是真正可以回答的,除非说这取决于。
  • 请注意缓存不是一个概念,它是内置在 CPU 芯片中的物理硬件。而且“超出必要的数据”也不是严格正确的,它总是会获取缓存行,并且您的数据可能会或可能不会小于获取的数量。

标签: c arrays performance caching processor


【解决方案1】:

我很难理解这是做什么的:

int count = strlen(argv);

您应该收到警告。顺便说一句,argv[0] 是你的可执行文件的路径。

首先确保了解您的代码是如何工作的,然后,然后,请转到更高级的主题,例如了解缓存。


给你直觉:

缓存将尽可能多地加载数据(例如数组),希望您的程序稍后会请求它(因为从主内存加载某些东西会很昂贵)。

如果缓存已满,则选择一个受害者(使用任何实现缓存的策略,例如 LRU)并被新请求的数据替换。

【讨论】:

  • 这只是草草写成的伪代码,而不是实际的工作代码示例。
  • 没有提到vlind,反正希望我的回答有帮助! :)
  • 缓存无法知道负载来自数组,直到您已经完成了几次顺序访问。只有在那个时候,硬件预取才开始。尽管 IIRC,英特尔 CPU 中的空间预取器会尝试获取对齐的 128B 中的其他 64B,其中包含刚刚加载的缓存行(如果有空闲带宽)。
  • 这听起来不错@PeterCordes,但我只是给出直觉,任何未来的用户都应该阅读您的答案以获取更多信息!感谢您的支持!你的回答也不错! :)
【解决方案2】:

进一步阅读:请参阅此答案的末尾以获取链接。

缓存行大小为 64B,并在 64B 边界上对齐。 (有些 CPU 可能使用不同大小的缓存线,但 64B 是很常见的选择)。

缓存加载数据有两个原因:

  • 需求未命中:加载或存储指令访问的字节不在任何当前热缓存行中。

    对同一字节(或 int 或其他任何内容)的近期访问将在缓存中命中(时间局部性)。近期访问同一高速缓存行中的附近字节 也将命中(空间局部性)。以错误的顺序循环遍历多维数组,或者循环遍历仅访问一个成员的结构数组,这非常糟糕,因为必须加载整个缓存行,但您只使用其中的一小部分。

  • 预取:在几次顺序访问之后,硬件预取器会注意到这种模式并开始加载尚未访问的缓存行,因此希望程序不会出现缓存未命中确实访问它。对于硬件预取器行为的具体示例,英特尔的优化手册描述了各种特定 CPU 中的硬件预取器(有关链接,请参阅 标签 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 在加载新行时如何选择丢弃哪一行)。

【讨论】:

    最近更新 更多