【问题标题】:CPU spatial cache locality in array iteration数组迭代中的 CPU 空间缓存局部性
【发布时间】:2018-07-24 16:22:14
【问题描述】:

我对 L1 缓存的理解是内存提取会加载缓存行。假设高速缓存行大小为 64 字节,如果我访问地址 p 的内存,它将把从 pp + 64 的整个块加载到高速缓存中。因此,最好从左到右(而不是从右到左)遍历数组以最大化缓存局部性。

但是,我编写了示例 C 代码,该代码分配了一个包含 1 亿个字符的数组,将随机值写入其中并求和(复制如下以供参考)。一个版本的代码从左到右求和,另一个从右到左求和。当我对它进行基准测试时,我得到了非常相似的结果(其中“时钟周期”是根据 clock 来衡量的。代码是在没有优化的情况下编译的。

所以我的问题是:现代处理器除了“缓存读取 + 64 字节”之外还做其他事情吗?他们是否向前和向后缓存?编译器能否“告诉”处理器代码正在向后迭代?

作为参考,我在 Mac OS X 10.13.3 上运行,使用 gcc-7 (Homebrew GCC 7.2.0_1) 7.2.0 和具有 64 字节高速缓存行的 x86-64 Intel 处理器。

基准测试:

$ ./a.out
Backward Iterating...took 150101 clock cycles

$ ./a.out
Forward Iterating...took 146545 clock cycles

我预计前向迭代会快 64 倍左右,因为每 64 个元素应该是缓存命中,而在向后迭代中,每个元素都应该是缓存未命中。

所以,我在上面调用了 cachegrind。两者的缓存命中未命中率几乎相同:

# Left to right iteration
==21773==
==21773== I   refs:      4,006,996,067
==21773== I1  misses:            5,183
==21773== LLi misses:            3,019
==21773== I1  miss rate:          0.00%
==21773== LLi miss rate:          0.00%
==21773==
==21773== D   refs:      1,802,393,260  (1,401,627,925 rd   + 400,765,335 wr)
==21773== D1  misses:        3,153,101  (    1,588,104 rd   +   1,564,997 wr)
==21773== LLd misses:        3,004,885  (    1,440,660 rd   +   1,564,225 wr)
==21773== D1  miss rate:           0.2% (          0.1%     +         0.4%  )
==21773== LLd miss rate:           0.2% (          0.1%     +         0.4%  )
==21773==
==21773== LL refs:           3,158,284  (    1,593,287 rd   +   1,564,997 wr)
==21773== LL misses:         3,007,904  (    1,443,679 rd   +   1,564,225 wr)
==21773== LL miss rate:            0.1% (          0.0%     +         0.4%  )

# Right to left iteration
==21931==
==21931== I   refs:      4,006,996,453
==21931== I1  misses:            5,198
==21931== LLi misses:            3,045
==21931== I1  miss rate:          0.00%
==21931== LLi miss rate:          0.00%
==21931==
==21931== D   refs:      1,802,393,428  (1,401,628,038 rd   + 400,765,390 wr)
==21931== D1  misses:        3,153,113  (    1,588,079 rd   +   1,565,034 wr)
==21931== LLd misses:        3,135,505  (    1,571,219 rd   +   1,564,286 wr)
==21931== D1  miss rate:           0.2% (          0.1%     +         0.4%  )
==21931== LLd miss rate:           0.2% (          0.1%     +         0.4%  )
==21931==
==21931== LL refs:           3,158,311  (    1,593,277 rd   +   1,565,034 wr)
==21931== LL misses:         3,138,550  (    1,574,264 rd   +   1,564,286 wr)
==21931== LL miss rate:            0.1% (          0.0%     +         0.4%  )

代码:

#include <stdint.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

#define BUF_SIZE 100000000

int main() {
  srand(time(NULL));
  uint8_t *buf1 = (uint8_t *)malloc(BUF_SIZE);
  // Fill the buf with random data
  for (size_t i = 0; i < BUF_SIZE; ++i) {
    buf1[i] = rand();
  }

#ifdef BACKWARDS
  printf("Backward Iterating...");
#else
  printf("Forward Iterating...");
#endif

  uint64_t sum = 0;
  clock_t start = clock();
#ifdef BACKWARDS
  for (size_t i = BUF_SIZE - 1; i != ~0; --i) {
#else
  for (size_t i = 0; i < BUF_SIZE; ++i) {
#endif
    sum += buf1[i];
  }
  clock_t end = clock();
  printf("took %lu clock cycles\n", end - start);
  printf("sum: %llu\n", sum);
  free(buf1);
}

【问题讨论】:

  • 你的计算中存在一个不合的情况;如果 p 在 64 字节边界上对齐,它将 p+0 到 p+63(而不是 p+64)。否则,它是(p &amp; 0x3F)(p &amp; 0x3F) + 0x3F,假设p 被视为char *(您不能对void * 进行算术运算,除非在GCC 和模拟GCC 的编译器中)。这意味着当您从最后开始并向后工作时,向后读取会加载一个缓存行,因此性能与向前工作不会有太大不同。二维数组中的坏处是向下读取列而不是跨行读取;这真的会严重影响缓存。
  • 一个循环完成100000000 圈,另一个从100000000 到0-环绕,再到UINT_MAX。这不一定与SIZE_MAX 相同。不要试图聪明地使用按位运算符。此外,SIZE_MAX 不一定是~(size_t)0,所以所有假设一开始都是错误的。

标签: c x86 cpu-cache gcc7 cache-locality


【解决方案1】:

如果我访问地址 p 的内存,它将从 pp + 64 进入缓存。

不完全是。处理器加载的是包含p 的高速缓存行。例如,如果p 是 0x1234,则加载缓存行 0x1200 到 0x123F。因此,向后扫描数组与向前扫描没有太大区别。

【讨论】:

  • 推论是:顺序(在任何方向)=好,保持“在同一区域”=好,在内存中跳跃=坏。
【解决方案2】:

扩展上一个答案:

加载完整的缓存行粒度意味着前进或后退都无关紧要,一旦您击中了行的一侧,您就会得到全部。这当然只适用于可缓存的负载和内存类型(+流式传输的情况,可能会在缓冲区中被命中)。

然而,这还不是全部。现代 CPU 使用非常擅长处理空间局部性的硬件预取器 - 这些将通过在您正在进行的相同方向上预取额外的缓存线来增加粒度。退出预取器取决于您使用的确切架构,但常见的包括下一行、相邻行(+/- 1 行)、下一行流或基于 IP 的步幅。更多信息here

这些预取器应该是对称的,但我们不确定(具体细节未披露),它们可能对不同方向有不同的机会或阈值。

还有一点,cachegrind 只是一个缓存模拟,它不包括预取之类的效果,甚至没有对准确的缓存进行建模(大小应该没问题,但替换策略和其他微架构细节不是) t 保证相同),因此您不会看到完整的效果。使用性能计数器查看实际的硬件行为可能会更好。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-10-12
    • 1970-01-01
    • 2013-04-23
    • 1970-01-01
    • 2013-05-21
    • 2015-10-31
    • 1970-01-01
    相关资源
    最近更新 更多