为什么我的8M L3缓存不能为大于1M的arrays带来任何好处?

我受这个问题的启发,编写了一个简单的程序来测试我的机器在每个缓存级别的内存带宽:

为什么矢量化循环没有性能改进

我的代码使用memset反复写入缓冲区(或缓冲区)并测量速度。 它还保存了最后打印的每个缓冲区的地址。 这是列表:

#include  #include  #include  #include  #define SIZE_KB {8, 16, 24, 28, 32, 36, 40, 48, 64, 128, 256, 384, 512, 768, 1024, 1025, 2048, 4096, 8192, 16384, 200000} #define TESTMEM 10000000000 // Approximate, in bytes #define BUFFERS 1 double timer(void) { struct timeval ts; double ans; gettimeofday(&ts, NULL); ans = ts.tv_sec + ts.tv_usec*1.0e-6; return ans; } int main(int argc, char **argv) { double *x[BUFFERS]; double t1, t2; int kbsizes[] = SIZE_KB; double bandwidth[sizeof(kbsizes)/sizeof(int)]; int iterations[sizeof(kbsizes)/sizeof(int)]; double *address[sizeof(kbsizes)/sizeof(int)][BUFFERS]; int i, j, k; for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++) iterations[k] = TESTMEM/(kbsizes[k]*1024); for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++) { // Allocate for (j = 0; j < BUFFERS; j++) { x[j] = (double *) malloc(kbsizes[k]*1024); address[k][j] = x[j]; memset(x[j], 0, kbsizes[k]*1024); } // Measure t1 = timer(); for (i = 0; i < iterations[k]; i++) { for (j = 0; j < BUFFERS; j++) memset(x[j], 0xff, kbsizes[k]*1024); } t2 = timer(); bandwidth[k] = (BUFFERS*kbsizes[k]*iterations[k])/1024.0/1024.0/(t2-t1); // Free for (j = 0; j < BUFFERS; j++) free(x[j]); } printf("TESTMEM = %ld\n", TESTMEM); printf("BUFFERS = %d\n", BUFFERS); printf("Size (kB)\tBandwidth (GB/s)\tIterations\tAddresses\n"); for (k = 0; k < sizeof(kbsizes)/sizeof(int); k++) { printf("%7d\t\t%.2f\t\t\t%d\t\t%x", kbsizes[k], bandwidth[k], iterations[k], address[k][0]); for (j = 1; j < BUFFERS; j++) printf(", %x", address[k][j]); printf("\n"); } return 0; } 

结果(BUFFERS = 1):

 TESTMEM = 10000000000 BUFFERS = 1 Size (kB) Bandwidth (GB/s) Iterations Addresses 8 52.79 1220703 90b010 16 56.48 610351 90b010 24 57.01 406901 90b010 28 57.13 348772 90b010 32 45.40 305175 90b010 36 38.11 271267 90b010 40 38.02 244140 90b010 48 38.12 203450 90b010 64 37.51 152587 90b010 128 36.89 76293 90b010 256 35.58 38146 d760f010 384 31.01 25431 d75ef010 512 26.79 19073 d75cf010 768 26.20 12715 d758f010 1024 26.20 9536 d754f010 1025 18.30 9527 90b010 2048 18.29 4768 d744f010 4096 18.29 2384 d724f010 8192 18.31 1192 d6e4f010 16384 18.31 596 d664f010 200000 18.32 48 cb2ff010 

我可以很容易地看到32K L1缓存和256K L2缓存的效果。 我不明白的是,在memset缓冲区的大小超过1M之后性能会突然下降。 我的L3缓存应该是8M。 它也突然发生,根本不是锥形,就像超过L1和L2缓存大小一样。

我的处理器是Intel i7 3700. / sys / devices / system / cpu / cpu0 / cache中的L3缓存的详细信息如下:

 level = 3 coherency_line_size = 64 number_of_sets = 8192 physical_line_partition = 1 shared_cpu_list = 0-7 shared_cpu_map = ff size = 8192K type = Unified ways_of_associativity = 16 

我以为我会尝试使用多个缓冲区 – 在每个1M的2个缓冲区上调用memset,看看性能是否会下降。 BUFFERS = 2,我得到:

 TESTMEM = 10000000000 BUFFERS = 2 Size (kB) Bandwidth (GB/s) Iterations Addresses 8 54.15 1220703 e59010, e5b020 16 51.52 610351 e59010, e5d020 24 38.94 406901 e59010, e5f020 28 38.53 348772 e59010, e60020 32 38.31 305175 e59010, e61020 36 38.29 271267 e59010, e62020 40 38.29 244140 e59010, e63020 48 37.46 203450 e59010, e65020 64 36.93 152587 e59010, e69020 128 35.67 76293 e59010, 63769010 256 27.21 38146 63724010, 636e3010 384 26.26 25431 63704010, 636a3010 512 26.19 19073 636e4010, 63663010 768 26.20 12715 636a4010, 635e3010 1024 26.16 9536 63664010, 63563010 1025 18.29 9527 e59010, f59420 2048 18.23 4768 63564010, 63363010 4096 18.27 2384 63364010, 62f63010 8192 18.29 1192 62f64010, 62763010 16384 18.31 596 62764010, 61763010 200000 18.31 48 57414010, 4b0c3010 

看来两个1M缓冲区都保留在L3缓存中。 但是尝试稍微增加任一缓冲区的大小并且性能下降。

我一直在用-O3编译。 它没有太大区别(除了可能在BUFFERS上展开循环)。 我尝试使用-O0,除了L1速度之外它是相同的。 gcc版本是4.9.1。

总而言之,我有一个由两部分组成的问题:

  1. 为什么我的8 MB L3缓存不会对大于1M的内存块提供任何好处?
  2. 为什么性能下降如此突然?

编辑:

正如Gabriel Southern所建议的那样,我使用BUFFERS = 1运行我的代码,一次只有一个缓冲区大小。 这是完整的命令:

 perf stat -e dTLB-loads,dTLB-load-misses,dTLB-stores,dTLB-store-misses -r 100 ./a.out 2> perfout.txt 

-r表示perf将运行a.out 100次并返回平均统计信息。

使用#define SIZE_KB {1024}输出perf

  Performance counter stats for './a.out' (100 runs): 1,508,798 dTLB-loads ( +- 0.02% ) 0 dTLB-load-misses # 0.00% of all dTLB cache hits 625,967,550 dTLB-stores ( +- 0.00% ) 1,503 dTLB-store-misses ( +- 0.79% ) 0.360471583 seconds time elapsed ( +- 0.79% ) 

并使用#define SIZE_KB {1025}

  Performance counter stats for './a.out' (100 runs): 1,670,402 dTLB-loads ( +- 0.09% ) 0 dTLB-load-misses # 0.00% of all dTLB cache hits 626,099,850 dTLB-stores ( +- 0.00% ) 2,115 dTLB-store-misses ( +- 2.19% ) 0.503913416 seconds time elapsed ( +- 0.06% ) 

因此,1025K缓冲区似乎有更多的TLB未命中。 但是,使用此大小缓冲区,程序会执行大约9500次memset调用,因此每个memset调用仍然少于1次。

简答:

初始化大于1 MB的内存区域时,您的memset版本开始使用非临时存储。 因此,即使您的L3缓存大于1 MB,CPU也不会将这些行存储在其缓存中。 因此,对于大于1 MB的缓冲区值,系统中的可用内存带宽会限制性能。

细节:

背景:

我测试了你在几个不同系统上提供的代码,最初专注于调查TLB,因为我认为在二级TLB中可能会出现颠簸。 但是,我收集的数据都没有证实这一假设。

我测试的一些系统使用的是具有最新版glibc的Arch Linux,而其他系统使用的是使用旧版eglibc的Ubuntu 10.04。 在使用多个不同的CPU架构进行测试时,我能够重现使用静态链接二进制文件时问题中描述的行为。 我关注的行为是在SIZE_KB10241025时之间的运行时间的显着差异。 性能差异可通过对慢速和快速版本执行的代码更改来解释。

汇编代码

我使用perf recordperf annotate来收集执行汇编代码的跟踪,以查看热代码路径是什么。 代码显示如下,使用以下格式:

percentage time executing instruction | address | instruction percentage time executing instruction | address | instruction

我从较短的版本中复制了热循环,该版本省略了大部分地址,并且有一条连接循环后沿和循环头的行。

对于在Arch Linux上编译的版本,热循环是(对于1024和1025大小):

  2.35 │a0:┌─+movdqa %xmm8,(%rcx) 54.90 │ │ movdqa %xmm8,0x10(%rcx) 32.85 │ │ movdqa %xmm8,0x20(%rcx) 1.73 │ │ movdqa %xmm8,0x30(%rcx) 8.11 │ │ add $0x40,%rcx 0.03 │ │ cmp %rcx,%rdx │ └──jne a0 

对于Ubuntu 10.04二进制文件,当以1024的大小运行时,热循环是:

  │a00:┌─+lea -0x80(%r8),%r8 0.01 │ │ cmp $0x80,%r8 5.33 │ │ movdqa %xmm0,(%rdi) 4.67 │ │ movdqa %xmm0,0x10(%rdi) 6.69 │ │ movdqa %xmm0,0x20(%rdi) 31.23 │ │ movdqa %xmm0,0x30(%rdi) 18.35 │ │ movdqa %xmm0,0x40(%rdi) 0.27 │ │ movdqa %xmm0,0x50(%rdi) 3.24 │ │ movdqa %xmm0,0x60(%rdi) 16.36 │ │ movdqa %xmm0,0x70(%rdi) 13.76 │ │ lea 0x80(%rdi),%rdi │ └──jge a00 

对于运行缓冲区大小为1025的Ubuntu 10.04版本,热循环是:

  │a60:┌─+lea -0x80(%r8),%r8 0.15 │ │ cmp $0x80,%r8 1.36 │ │ movntd %xmm0,(%rdi) 0.24 │ │ movntd %xmm0,0x10(%rdi) 1.49 │ │ movntd %xmm0,0x20(%rdi) 44.89 │ │ movntd %xmm0,0x30(%rdi) 5.46 │ │ movntd %xmm0,0x40(%rdi) 0.02 │ │ movntd %xmm0,0x50(%rdi) 0.74 │ │ movntd %xmm0,0x60(%rdi) 40.14 │ │ movntd %xmm0,0x70(%rdi) 5.50 │ │ lea 0x80(%rdi),%rdi │ └──jge a60 

这里的关键区别是较慢的版本使用movntd指令,而较快的版本使用movdqa指令。 英特尔软件开发人员手册中介绍了以下非临时存储:

特别是对于WC内存类型,处理器似乎永远不会将数据读入缓存层次结构。 相反,可以通过加载具有等效的对齐的高速缓存行的临时内部缓冲器而不将该数据填充到高速缓存来实现非时间提示。

所以这似乎解释了使用大于1 MB的值的memset不适合缓存的行为。 接下来的问题是为什么Ubuntu 10.04系统和Arch Linux系统之间存在差异,以及为什么选择1 MB作为截止点。 为了调查这个问题,我查看了glibc源代码:

memset源代码

sysdeps/x86_64/memset.S看glibc git repo我发现有趣的第一个提交是b2b671b677d92429a3d41bf451668f476aa267ed

提交描述是:

x64上更快的memset

这种实现以多种方式加速了memset。 首先是避免昂贵的计算跳跃。 其次是使用memset的参数大部分时间与8个字节对齐的事实。

基准测试结果:kam.mff.cuni.cz/~ondra/benchmark_string/memset_profile_result27_04_13.tar.bz2

引用的网站有一些有趣的分析数据。

提交的差异表明memset的代码被大量简化,非时间存储被删除。 这与Arch Linux的配置代码相同。

看看旧代码,我看到是否使用非临时存储的选择似乎使用了描述为The largest cache size

 L(byte32sse2_pre): mov __x86_shared_cache_size(%rip),%r9d # The largest cache size cmp %r9,%r8 ja L(sse2_nt_move_pre) 

计算它的代码在: sysdeps / x86_64 / cacheinfo.c

虽然看起来有计算实际共享缓存大小的代码,但默认值也是1 MB :

 long int __x86_64_shared_cache_size attribute_hidden = 1024 * 1024; 

所以我怀疑是否使用了默认值,但可能还有其他原因导致代码选择1MB作为截止点。

在任何一种情况下,您的问题的总体答案似乎是在设置大于1 MB的内存区域时,系统上的memset版本使用非临时存储。

鉴于Gabriel对生成的汇编代码的反汇编,我认为这确实是问题[ 编辑:他的答案已被编辑,现在它似乎是根本原因所以我们达成了协议 ]:

请注意, movnt是一个流媒体商店,它可能具有(取决于确切的微架构实现)几个影响:

  1. 具有弱排序语义(允许它更快)。
  2. 如果覆盖整行(无需获取以前的数据并合并),则延迟有所改善。
  3. 有一个非暂时的暗示,使其无法缓存。

#1和#2可以改善这些操作的延迟和带宽,如果它们受内存限制,但#3基本上强制它们受内存限制,即使它们可以适合某些缓存级别。 这可能超过了好处,因为内存延迟/ BW开始时显着更差。

因此,你的memset库实现可能使用了错误的阈值来切换到流存储版本(我想它不会检查你的LLC大小,但假设1M是内存驻留是非常奇怪的)。 我建议尝试替代库,或禁用编译器生成它们的能力(如果它支持)。

你的基准测试只是写入内存,从不读取,使用memset,它可能设计得巧妙,无法从缓存到内存中读取任何内容。 很可能,使用此代码,您只使用缓存内存的一半function,与原始内存相比,没有性能提升。 写入原始内存非常接近L2速度这一事实可能是一个暗示。 如果L2以26 GB /秒的速度运行,主内存以18 GB /秒的速度运行,您对L3缓存的期望是什么?

您正在测量吞吐量,而不是延迟。 我尝试了一个基准测试,你实际上使用L3缓存的强度,提供比主存储器更低延迟的数据。