【问题标题】:Last used cache line versus different cache lines最后使用的缓存线与不同的缓存线
【发布时间】:2013-12-11 09:48:43
【问题描述】:

假设高速缓存行是 64 字节宽,我有两个数组 ab 填充高速缓存行并与高速缓存行对齐。我们还假设两个数组都在 L1 缓存中,所以当我从它们中读取时,我没有缓存未命中。

float a[16];  //64 byte aligned e.g. with __attribute__((aligned (64)))
float b[16];  //64 byte aligned

我读过a[0]。我的问题是现在阅读a[1] 比阅读b[0] 更快吗? 换句话说,从最后使用的缓存行读取是否更快?

设置重要吗?现在让我们假设我有一个 32 kb L1 数据缓存,它是 4 路。因此,如果 ab 相隔 8192 个字节,它们最终会在同一个集合中。这会改变我的问题的答案吗?

另一种提问方式(这是我真正关心的问题)是关于读取矩阵。

换句话说,假设矩阵 M 适合 L1 缓存并且是 64 字节对齐的并且已经在 L1 缓存中,那么这两个代码选项中的哪一个会更有效。

float M[16][16]; //64 byte aligned

版本 1:

for(int i=0; i<16; i++) {
    for(int j=0; j<16; j++) {
        x += M[i][j];
    }
}

版本 2:

for(int i=0; i<16; i++) {
    for(int j=0; j<16; j++) {
        x += M[j][i];
    }
}

编辑:为了说明这一点,由于 SSE/AVX,假设我使用 AVX 一次从 a 读取前八个值(例如使用 _mm256_load_ps())。从a 读取接下来的八个值会比从b 读取前八个值更快吗(回想一下,a 和 b 已经在缓存中,因此不会出现 cahce 未命中)?

编辑::我最感兴趣的是自 Intel Core 2 和 Nehalem 以来的所有处理器,但我目前正在使用 Ivy Bridge 处理器并计划很快使用 Haswell。

【问题讨论】:

    标签: c performance optimization x86 cpu-cache


    【解决方案1】:

    对于当前的 Intel 处理器,在加载两个不同的缓存行之间没有性能差异,这两个缓存行都在 L1 缓存中,其他条件相同。给定 float a[16], b[16]; 和最近加载的 a[0]a[1]a[0] 在同一缓存行中,以及 b[1] 最近未加载但仍在 L1 缓存中,那么加载 a[1]b[0] 在没有其他因素的情况下。

    可能导致差异的一件事是,如果最近有一个存储到某个地址,该地址与正在加载的值之一共享一些位,尽管整个地址是不同的。英特尔处理器比较地址的一些位以确定它们是否可能与当前正在进行的存储相匹配。如果位匹配,某些 Intel 处理器会延迟加载指令,以便处理器有时间解析完整的虚拟地址并将其与存储的地址进行比较。然而,这只是a[1]b[0] 所特有的偶然效应。

    理论上也有可能看到您的代码的编译器在短时间内同时加载a[0]a[1] 可能会进行一些优化,例如使用一条指令同时加载它们。我上面的 cmets 适用于硬件行为,而不是 C 实现行为。

    对于二维数组场景,只要整个数组M在一级缓存中,应该还是没有区别的。但是,当数组超过 L1 缓存时,数组的列遍历会因性能问题而臭名昭著。由于地址通过地址中的固定位映射到缓存中的集合,并且每个缓存集合只能容纳有限数量的缓存行,例如四个,因此出现问题。这是一个问题场景:

    • 数组M 的行长度是导致地址映射到相同缓存集的距离的倍数,例如4096 字节。例如,在数组 float M[1024][1024];M[0][0]M[1][0] 中,相隔 4096 个字节并映射到同一个缓存集。
    • 当您遍历数组的一列时,您会访问M[0][0]M[1][0]M[2][0]M[3][0],等等。每个元素的缓存行都加载到缓存中。
    • 沿着列继续,您可以访问M[8][0]M[9][0],等等。由于它们中的每一个都使用与前一个相同的缓存集,并且缓存集只能容纳四行,因此包含 M[0][0] 等的较早行将被从缓存中逐出。
    • 当您通过阅读M[0][1] 完成该列并开始下一列时,数据不再在 L1 缓存中,并且您的所有负载都必须从 L2 缓存中获取数据(或者更糟糕的是,如果您还破坏了 L2 缓存)同样的方式)。

    【讨论】:

    • 这是一个很好的答案。我知道关键步的问题。我唯一缺少的部分(我应该在我的问题中更清楚地说明)是 L2 中的效果。我使用的瓷砖尺寸形成我的 GEMM 代码适合 L2 而不是 L1(我可以将它们制作成任何尺寸,但我发现让它们适合 L2 会产生最佳结果)。所以我想我仍然对当矩阵适合 L2 而不是 L1 时会发生什么感到困惑。多级缓存很复杂。
    • 大多数现代 CPU 还具有内存消歧功能,因此即使存在部分匹配,完全存储/加载别名也不会成为瓶颈,您可以推测重新排序是安全的。
    【解决方案2】:

    获取a[0],然后获取a[1]b[0] 在任何一种情况下都应该相当于2 个访问L1 的缓存。您没有说您使用的是哪个 uArch,但我不熟悉任何可以进一步“缓存”L1 上方(内存单元中的任何位置)的完整缓存线的机制,我不认为这样的机制可能是可行的(至少不是以任何合理的价格)。

    假设您阅读了a[0],然后阅读了a[1],并且希望节省再次访问该行的 L1 的工作 - 您的硬件不仅必须将完整的高速缓存行保留在内存单元中的某个位置,以防万一它将再次被访问(不确定这是一种常见的情况,所以这个功能可能不是努力),但也要让它作为缓存的逻辑扩展保持可窥探,以防其他核心尝试修改 a[1] 之间这两个读取(x86 允许 wb 内存)。事实上,它甚至可以是同一个线程上下文中的存储,您必须防止这种情况发生(因为当今最常见的 x86 CPU 正在执行无序加载)。如果你不同时维护这两个(可能还有其他保护措施)——你打破了一致性,如果你这样做了——你已经创建了一个怪物逻辑,它和你的 L1 已经做的一样,只是为了节省微薄的 1-2 个周期访问权限。

    然而,即使这两个选项都需要相同数量的缓存访问,也可能存在影响其效率的其他考虑因素,例如 L1 银行业务、同集访问限制、延迟 LRU 更新等。所有这些都取决于您的确切机器实现。

    如果您不只关注内存/缓存访问效率,您的编译器应该能够向量化对连续内存位置的访问,这仍然会产生相同的访问,但执行 BW 会更轻松。我认为任何体面的编译器都应该能够以这种大小展开你的循环,并将连续访问组合成一个向量,但你可以通过使用选项 1 来帮助它(特别是如果还有写入或其他有问题的指令中间会复杂化编译器的工作)

    编辑

    由于您还询问在 L2 中拟合矩阵 - 这简化了问题 - 在这种情况下,与选项 1 一样多次使用相同的行会更好,因为它允许您击中 L1,而另一种方法是不断从 L2 获取,这可以降低延迟和带宽。这就是loop tiling / blocking背后的基本原理

    【讨论】:

    • 关于为什么平铺有用的要点。这就是我使用它的原因。我想知道我是否应该使用两层平铺? L1 一个,L2 一个。现在我只使用适合 L2 的图块,然后我重新排列矩阵 (something like the transpose),以便我可以使用选项 1。但如果我使用两层平铺,我就不必重新排列。
    • 我在这里看不到 2 级平铺的实际意义,重要的是您的代码运行在什么上,其余的可以根据需要保留,只要您有效地获取它(提前如果可能,时间)。如果访问模式更复杂,并且您必须多次加载图块,那么这可能会有意义。
    【解决方案3】:

    空间位置为王,所以版本 #1 更快。一个好的编译器甚至可以使用 SSE/AVX 对读取进行矢量化。

    CPU 会重新排列读取,因此无论哪个是第一个都没有关系。在乱序的 CPU 中,如果两条高速缓存线在同一条路上,则无关紧要。

    对于大型矩阵,保持局部性更为重要,这样 L1 缓存保持热状态(更少的缓存未命中)。

    【讨论】:

    • 也许我不应该给出矩阵建议。我正在编写自己的 GEMM 代码,所以我自己做 SSE/AVX。我使用平铺,所以我对适合缓存的平铺的答案感兴趣。我做例如通过从每行读取八个值并向下移动行来一次八个点积。所以我想知道我从下一行读取接下来的八个值是否会产生影响,或者我是否应该重新排序矩阵,以便接下来的八个值位于同一缓存行中。也许我应该在问题中解释这一点。
    • 跨行做点积会更快。这样,CPU 将在没有任何代码提示的情况下预取数据。
    • 但是如果数据已经在缓存中,就没有什么可以预取的了。这就是为什么我说矩阵适合缓存并加载到缓存中。也许我不明白预取是什么意思。我唯一能看到的可能会有所不同的是,访问最新的缓存行是否比访问新的缓存行更快。
    • 是的,但您也谈到了适合 L2 缓存的大型矩阵。就我个人而言,我会远离通过提示进行预取。如果您希望特定 CPU 型号的最大值,您应该尝试不同的磁贴大小。请注意,不同的 CPU 型号可能会有不同的行为。
    • L2 和 L1 之间的行为是否存在差异(除了它们的大小)?我读到 L2 缓存一次不能预取多于一行。我不确定这意味着什么,但这确实意味着 L1 一次可以预取多行,或者为什么这很重要。您能否在答案中添加一些关于 L1 和 L2 之间差异的信息?
    【解决方案4】:

    虽然我不直接知道您问题的答案(其他人可能对处理器架构有更多的了解),但您是否尝试过/是​​否可以通过某种形式的benchmarking自己找到答案?

    您可以通过 QueryPerformanceCounter(假设您在 Windows 上)或等效操作系统等功能获得高分辨率计时器,然后通过 x 次数迭代您想要测试的读取,然后获得高分辨率再次解析计时器以获取读取的平均时间。

    对不同的读取再次执行此过程,您应该能够比较不同类型读取的平均读取时间,这应该可以回答您的问题。这并不是说答案在不同的处理器上保持不变。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-03-28
      • 2010-11-15
      • 2018-08-06
      • 2015-05-05
      相关资源
      最近更新 更多