CPU 数组迭代中的空间缓存位置
CPU spatial cache locality in array iteration
我对L1缓存的理解是内存获取加载缓存行。假设缓存行大小为 64 字节,如果我访问地址 p
处的内存,它将把从 p
到 p + 64
的整个块加载到缓存中。因此,最好从左到右(而不是从右到左)遍历数组以最大化缓存局部性。
但是,我编写了示例 C 代码,分配了一个 1 亿个字符的数组,将随机值写入其中并对其求和(复制在下面以供参考)。一个版本的代码从左到右求和,另一个版本从右到左求和。当我对其进行基准测试时,我得到了非常相似的结果(其中 "clock cycles" 是根据 clock
衡量的。代码是在没有优化的情况下编译的。
所以我的问题是:除了 "cache the read + 64 bytes" 之外,现代处理器是否还能做其他事情?他们是否向前和向后缓存?编译器 "tell" 处理器可以向后迭代代码吗?
作为参考,我在 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);
}
If I access memory at address p
, it will load the entire block from
p
to p + 64
into the cache.
不完全是。处理器加载的是包含p
的缓存行。例如,如果 p
是 0x1234,则加载缓存行 0x1200 到 0x123F。因此,通过数组向后扫描与向前扫描没有太大区别。
扩展上一个答案:
加载完整的缓存行粒度意味着向前或向后都无关紧要,一旦您到达行的一侧,您就会获得所有内容。这当然只适用于可缓存的加载和内存类型(+在缓冲区中时可能被命中的流式传输情况)。
然而,这还不是全部。现代 CPU 使用非常擅长处理空间局部性的硬件预取器 - 这些将通过在您前进的相同方向上预取额外的缓存行来增加粒度。退出预取器取决于您使用的确切架构,但常见的包括下一行、相邻行(+/- 1 行)、下一行流或基于 IP 的步幅。更多信息 here .
这些prefetchers应该是对称的,但我们不确定(具体细节未透露),它们可能对不同的方向有不同的机会或阈值。
还有一点,cachegrind只是一个缓存模拟,它不包括预取之类的效果,甚至没有模拟准确的缓存(大小应该没问题,但替换策略和其他微架构细节不保证相同),所以您看不到完整的效果。使用性能计数器查看实际硬件行为可能更好。
我对L1缓存的理解是内存获取加载缓存行。假设缓存行大小为 64 字节,如果我访问地址 p
处的内存,它将把从 p
到 p + 64
的整个块加载到缓存中。因此,最好从左到右(而不是从右到左)遍历数组以最大化缓存局部性。
但是,我编写了示例 C 代码,分配了一个 1 亿个字符的数组,将随机值写入其中并对其求和(复制在下面以供参考)。一个版本的代码从左到右求和,另一个版本从右到左求和。当我对其进行基准测试时,我得到了非常相似的结果(其中 "clock cycles" 是根据 clock
衡量的。代码是在没有优化的情况下编译的。
所以我的问题是:除了 "cache the read + 64 bytes" 之外,现代处理器是否还能做其他事情?他们是否向前和向后缓存?编译器 "tell" 处理器可以向后迭代代码吗?
作为参考,我在 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);
}
If I access memory at address
p
, it will load the entire block fromp
top + 64
into the cache.
不完全是。处理器加载的是包含p
的缓存行。例如,如果 p
是 0x1234,则加载缓存行 0x1200 到 0x123F。因此,通过数组向后扫描与向前扫描没有太大区别。
扩展上一个答案:
加载完整的缓存行粒度意味着向前或向后都无关紧要,一旦您到达行的一侧,您就会获得所有内容。这当然只适用于可缓存的加载和内存类型(+在缓冲区中时可能被命中的流式传输情况)。
然而,这还不是全部。现代 CPU 使用非常擅长处理空间局部性的硬件预取器 - 这些将通过在您前进的相同方向上预取额外的缓存行来增加粒度。退出预取器取决于您使用的确切架构,但常见的包括下一行、相邻行(+/- 1 行)、下一行流或基于 IP 的步幅。更多信息 here .
这些prefetchers应该是对称的,但我们不确定(具体细节未透露),它们可能对不同的方向有不同的机会或阈值。
还有一点,cachegrind只是一个缓存模拟,它不包括预取之类的效果,甚至没有模拟准确的缓存(大小应该没问题,但替换策略和其他微架构细节不保证相同),所以您看不到完整的效果。使用性能计数器查看实际硬件行为可能更好。