为啥我的 8M L3 缓存对大于 1M 的阵列没有任何好处?

Posted

技术标签:

【中文标题】为啥我的 8M L3 缓存对大于 1M 的阵列没有任何好处?【英文标题】:Why does my 8M L3 cache not provide any benefit for arrays larger than 1M?为什么我的 8M L3 缓存对大于 1M 的阵列没有任何好处? 【发布时间】:2015-07-30 13:42:48 【问题描述】:

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

Why vectorizing the loop does not have performance improvement

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

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>

#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。

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

    为什么我的 8 MB 三级缓存对大于 1M 的内存块没有任何好处? 为什么性能下降如此突然?

编辑:

按照Gabriel Southern 的建议,我使用perf 运行我的代码,使用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 次并返回平均统计信息。

perf 的输出,与#define SIZE_KB 1024

 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 次。

【问题讨论】:

这是一个别名吗?也许地址到缓存行的映射是这样的,即连续缓冲区的每个 MB 都别名为缓存中的同一 MB,而在您的 2 缓冲区场景中,高位位可能会将其映射到其他地方。 (我不知道您的特定处理器中使用了什么映射函数......) @OliverCharlesworth 我想知道这一点。但是 L3 缓存应该是 16 路关联的,这意味着临界步长是 0.5M。因此,要在其中放置一个 1M 数组,它必须使用 2 种方式。第二个 0.5M 将被映射到第一个 0.5M 的相同位置。 当您寻址 L3 缓存时,您也在寻址 L1 和 L2 缓存。也许您看到的减速是由于 L1 缓存的抖动。 @hewy:你是对的。当然,除非映射是这样的,每个 64kB 块都被映射到相同的行(在这种情况下,我们用尽 1MB 之后的方式)。不过不太可能…… 你安装了 perf 吗?如果是这样,您可以尝试使用 1024 和 1025 测试用例运行 $perf2 stat -e dTLB-loads,dTLB-load-misses,dTLB-stores,dTLB-store-misses 并查看 TLB 未命中是否存在显着差异?我无法重现您在我的系统中描述的行为,但我认为您的 CPU 的 L2 TLB 有 512 个条目,默认页面大小为 4KB。因此,这可能可以解释您所看到的行为。如果我的理论是正确的,并且您确实注意到了差异,我将发布我认为正在发生的事情的答案。 【参考方案1】:

简答:

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

详情:

背景:

我在几个不同的系统上测试了您提供的代码,最初专注于研究 TLB,因为我认为在 2 级 TLB 中可能会出现抖动。然而,我收集的数据都没有证实这个假设。

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

汇编代码

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

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 存储库,我发现有趣的第一个提交是 b2b671b677d92429a3d41bf451668f476aa267ed

提交说明是:

x64 上更快的 memset

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

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

website referenced 有一些有趣的分析数据。

diff of the commit 表明memset 的代码被简化了很多,并且删除了非临时存储。这与来自 Arch Linux 的分析代码显示的内容相符。

查看older code,我看到是否使用非临时存储的选择似乎使用了描述为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 作为截止点可能还有其他原因。

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

【讨论】:

我喜欢这个答案,但我还没有准备好接受它。我认为您从 gcc4.4 打印的程序集显示了正在发生的事情。在 1025 版本中,movntd 是一个非临时存储,这意味着包含该内存的缓存行没有加载到缓存中,并且在下一次迭代中将无法在缓存中使用。在两个快速版本(1024 和 ArchLinux)中,都使用了 movdqa,这会导致缓存行被加载。因此,出于某种原因,在大于 1M 的数组上,memset 与非临时存储一起使用。我认为现在的问题是为什么/如何修复我的机器和类似的机器。 我认为你对非临时存储也是正确的。我一直在寻找微体系结构的解释,但并没有仔细研究组件中的差异。我明天会编辑答案。 实际上,为了猜测我自己问题的答案,我打赌memset 在 1M 之后使用 nt 存储,因为有人认为通过大量调用 memset 来杀死 1M 的缓存是不值得的。我打赌你可以通过编写自己的 memset 来修复它,可能使用内在函数。期待看到您的编辑。感谢您的帮助。 @hewy 我已经编辑了我的答案,我认为这是对正在发生的事情的更好解释(我发布了我之前的答案,因为我有一些数据,但我对我的理论并不满意)。感谢您提出一个有趣的问题,我在尝试回答的过程中学到了一些东西。【参考方案2】:

鉴于 Gabriel 对生成的汇编代码进行了反汇编,我认为这确实是问题所在 [编辑:他的答案已被编辑,现在看来是根本原因,所以我们同意]:

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

    具有弱排序语义(这使其更快)。 改善了覆盖整行时的延迟(无需获取以前的数据并合并)。 具有非临时提示,使其无法缓存。

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

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

【讨论】:

【参考方案3】:

您的基准测试只写入内存,从不读取,使用 memset 可能被巧妙地设计为不会将任何内容从缓存读取到内存中。很可能在这段代码中,您只使用了缓存内存的一半功能,与原始内存相比,性能并没有提升。写入原始内存非常接近 L2 速度这一事实可能是一个提示。如果 L2 以 26 GB/秒的速度运行,主内存以 18 GB/秒的速度运行,那么您对 ​​L3 缓存的真正期望是什么?

您测量的是吞吐量,而不是延迟。我会尝试一个基准测试,您可以在其中实际使用 L3 缓存的强度,以比主内存更低的延迟提供数据。

【讨论】:

问题是为什么 1024 KB 和 1025 KB 的缓冲区大小会有很大的性能差异。 这是我对速度的解释: 在 L1 中,速度由时钟速度决定。 CPU 每个周期可以维持 1 次 16 字节写入 L1(英特尔优化手册)。对我来说,这意味着最大写入速度介于 (3.4 GHz)*(16 字节) = 54.4 GB/s 和 (3.9 GHz)*(16 字节) = 62.4 GB/s 之间。在动态链接库中调用 memset 有一些开销,我不确定英特尔的 Turboboost 对我的时钟速度有什么作用,所以我可以接受。 L2 的速度约为 38 GB/s,L3 约为 26 GB/s,主内存约为 18 GB/s。这些速度受到内存加载到 L1 的速度的限制。

以上是关于为啥我的 8M L3 缓存对大于 1M 的阵列没有任何好处?的主要内容,如果未能解决你的问题,请参考以下文章

为啥缓存位置对阵列性能很重要?

戴尔服务器开机出错,在RAID控制器上检测到L2 / L3缓存错误。

为啥我的阵列没有消耗我发送给它的信息?斯威夫特 4

为啥我的 Apollo 缓存更新没有反映在我的查询中?

0xC0000005;Access Violation(栈区空间很宝贵, linux上栈区空间默认为8M,vc6下默认栈空间大小为1M)

是否可以检查变量是否位于 L1/L2/L3 缓存中