L1内存带宽:使用相差4096 + 64字节的地址,效率下降50%

时间:2014-09-10 20:08:14

标签: c caching memory x86 avx

我希望使用英特尔处理器实现以下操作的最大带宽。

for(int i=0; i<n; i++) z[i] = x[i] + y[i]; //n=2048

其中x,y和z是浮点数组。我在Haswell,Ivy Bridge和Westmere系统上这样做。

我最初分配了这样的内存

char *a = (char*)_mm_malloc(sizeof(float)*n, 64);
char *b = (char*)_mm_malloc(sizeof(float)*n, 64);
char *c = (char*)_mm_malloc(sizeof(float)*n, 64);
float *x = (float*)a; float *y = (float*)b; float *z = (float*)c;

当我这样做时,我获得了每个系统预期的峰值带宽的大约50%。

峰值计算为frequency * average bytes/clock_cycle。每个系统的平均字节/时钟周期为:

Core2: two 16 byte reads one 16 byte write per 2 clock cycles     -> 24 bytes/clock cycle
SB/IB: two 32 byte reads and one 32 byte write per 2 clock cycles -> 48 bytes/clock cycle
Haswell: two 32 byte reads and one 32 byte write per clock cycle  -> 96 bytes/clock cycle

这意味着,例如在Haswell I上我只观察到48个字节/时钟周期(可能是一个时钟周期内的两次读取,另一次写入下一个时钟周期)。

我打印出b-ac-b地址的差异,每个都是8256字节。值8256是8192 + 64。因此它们每个都比一个缓存行大一些(8192字节)。

一时兴起,我尝试像这样分配内存。

const int k = 0;
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float)+k*64;
char *c = b+n*sizeof(float)+k*64;
float *x = (float*)a; float *y = (float*)b; float *z = (float*)c;

这几乎使我的峰值带宽增加了一倍,因此我现在可以获得90%的峰值带宽。但是,当我尝试k=1时,它会回落到50%。我尝试了k的其他值并发现了k=2k=33k=65仅获得峰值的50%,例如k=10k=32k=63全速发挥。 我不明白这一点。

在Agner Fog的micrarchitecture手册中,他说存在与内存地址的错误依赖关系,具有相同的设置和偏移量

  

无法同时从地址读取和写入   间隔4千字节的倍数。

但这正是我看到最大利益的地方!当k=0内存地址恰好相差2*4096个字节时。 Agner还谈到了Cache bank冲突。但Haswell和Westmere并不认为存在这些银行冲突,所以不应该解释我所观察到的。 发生了什么事?!

我知道OoO执行决定了哪个地址要读写,所以即使数组的内存地址恰好相差4096字节,也不一定意味着处理器读取例如&x[0]并同时写&z[0]但是为什么会被一个缓存行关闭导致它窒息?

编辑:根据Evgeny Kluev的回答,我现在相信这就是Agner Fog所说的“虚假商店转发摊位”。在Pentium Pro,II和II的手册中,他写道:

  

有趣的是,在写作和阅读时,你可以获得一个伪造商店转发摊位   如果它们碰巧在不同的缓存中具有相同的设置值,则完全不同的地址   银行:

; Example 5.28. Bogus store-to-load forwarding stall
mov byte ptr [esi], al
mov ebx, dword ptr [esi+4092]
; No stall
mov ecx, dword ptr [esi+4096]
; Bogus stall

修改:以下是k=0k=1每个系统的效率表。

               k=0      k=1        
Westmere:      99%      66%
Ivy Bridge:    98%      44%
Haswell:       90%      49%

如果我假设k=1写入和读取不能在同一个时钟周期内发生,我想我可以解释这些数字。

       cycle     Westmere          Ivy Bridge           Haswell
           1     read  16          read  16 read  16    read  32 read 32
           2     write 16          read  16 read  16    write 32
           3                       write 16
           4                       write 16  

k=1/k=0 peak    16/24=66%          24/48=50%            48/96=50%

这个理论非常有效。常春藤桥比我想象的要低一点,但常春藤桥 遭遇银行缓存冲突,其他人不这样做,这可能是另一个需要考虑的效果。

以下是自行测试的工作代码。在没有AVX的系统上使用g++ -O3 sum.cpp进行编译,否则使用g++ -O3 -mavx sum.cpp进行编译。尝试更改值k

//sum.cpp
#include <x86intrin.h>
#include <stdio.h>
#include <string.h>
#include <time.h>

#define TIMER_TYPE CLOCK_REALTIME

double time_diff(timespec start, timespec end)
{
    timespec temp;
    if ((end.tv_nsec-start.tv_nsec)<0) {
        temp.tv_sec = end.tv_sec-start.tv_sec-1;
        temp.tv_nsec = 1000000000+end.tv_nsec-start.tv_nsec;
    } else {
        temp.tv_sec = end.tv_sec-start.tv_sec;
        temp.tv_nsec = end.tv_nsec-start.tv_nsec;
    }
    return (double)temp.tv_sec +  (double)temp.tv_nsec*1E-9;
}

void sum(float * __restrict x, float * __restrict y, float * __restrict z, const int n) {
    #if defined(__GNUC__)
    x = (float*)__builtin_assume_aligned (x, 64);
    y = (float*)__builtin_assume_aligned (y, 64);
    z = (float*)__builtin_assume_aligned (z, 64);
    #endif
    for(int i=0; i<n; i++) {
        z[i] = x[i] + y[i];
    }
}

#if (defined(__AVX__))
void sum_avx(float *x, float *y, float *z, const int n) {
    float *x1 = x;
    float *y1 = y;
    float *z1 = z;
    for(int i=0; i<n/64; i++) { //unroll eight times
        _mm256_store_ps(z1+64*i+  0,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 0), _mm256_load_ps(y1+64*i+  0)));
        _mm256_store_ps(z1+64*i+  8,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 8), _mm256_load_ps(y1+64*i+  8)));
        _mm256_store_ps(z1+64*i+ 16,_mm256_add_ps(_mm256_load_ps(x1+64*i+16), _mm256_load_ps(y1+64*i+ 16)));
        _mm256_store_ps(z1+64*i+ 24,_mm256_add_ps(_mm256_load_ps(x1+64*i+24), _mm256_load_ps(y1+64*i+ 24)));
        _mm256_store_ps(z1+64*i+ 32,_mm256_add_ps(_mm256_load_ps(x1+64*i+32), _mm256_load_ps(y1+64*i+ 32)));
        _mm256_store_ps(z1+64*i+ 40,_mm256_add_ps(_mm256_load_ps(x1+64*i+40), _mm256_load_ps(y1+64*i+ 40)));
        _mm256_store_ps(z1+64*i+ 48,_mm256_add_ps(_mm256_load_ps(x1+64*i+48), _mm256_load_ps(y1+64*i+ 48)));
        _mm256_store_ps(z1+64*i+ 56,_mm256_add_ps(_mm256_load_ps(x1+64*i+56), _mm256_load_ps(y1+64*i+ 56)));
    }
}
#else
void sum_sse(float *x, float *y, float *z, const int n) {
    float *x1 = x;
    float *y1 = y;
    float *z1 = z;
    for(int i=0; i<n/32; i++) { //unroll eight times
        _mm_store_ps(z1+32*i+  0,_mm_add_ps(_mm_load_ps(x1+32*i+ 0), _mm_load_ps(y1+32*i+  0)));
        _mm_store_ps(z1+32*i+  4,_mm_add_ps(_mm_load_ps(x1+32*i+ 4), _mm_load_ps(y1+32*i+  4)));
        _mm_store_ps(z1+32*i+  8,_mm_add_ps(_mm_load_ps(x1+32*i+ 8), _mm_load_ps(y1+32*i+  8)));
        _mm_store_ps(z1+32*i+ 12,_mm_add_ps(_mm_load_ps(x1+32*i+12), _mm_load_ps(y1+32*i+ 12)));
        _mm_store_ps(z1+32*i+ 16,_mm_add_ps(_mm_load_ps(x1+32*i+16), _mm_load_ps(y1+32*i+ 16)));
        _mm_store_ps(z1+32*i+ 20,_mm_add_ps(_mm_load_ps(x1+32*i+20), _mm_load_ps(y1+32*i+ 20)));
        _mm_store_ps(z1+32*i+ 24,_mm_add_ps(_mm_load_ps(x1+32*i+24), _mm_load_ps(y1+32*i+ 24)));
        _mm_store_ps(z1+32*i+ 28,_mm_add_ps(_mm_load_ps(x1+32*i+28), _mm_load_ps(y1+32*i+ 28)));
    }
}
#endif

int main () {
    const int n = 2048;
    const int k = 0;
    float *z2 = (float*)_mm_malloc(sizeof(float)*n, 64);

    char *mem = (char*)_mm_malloc(1<<18,4096);
    char *a = mem;
    char *b = a+n*sizeof(float)+k*64;
    char *c = b+n*sizeof(float)+k*64;

    float *x = (float*)a;
    float *y = (float*)b;
    float *z = (float*)c;
    printf("x %p, y %p, z %p, y-x %d, z-y %d\n", a, b, c, b-a, c-b);

    for(int i=0; i<n; i++) {
        x[i] = (1.0f*i+1.0f);
        y[i] = (1.0f*i+1.0f);
        z[i] = 0;
    }
    int repeat = 1000000;
    timespec time1, time2;

    sum(x,y,z,n);
    #if (defined(__AVX__))
    sum_avx(x,y,z2,n);
    #else
    sum_sse(x,y,z2,n);
    #endif
    printf("error: %d\n", memcmp(z,z2,sizeof(float)*n));

    while(1) {
        clock_gettime(TIMER_TYPE, &time1);
        #if (defined(__AVX__))
        for(int r=0; r<repeat; r++) sum_avx(x,y,z,n);
        #else
        for(int r=0; r<repeat; r++) sum_sse(x,y,z,n);
        #endif
        clock_gettime(TIMER_TYPE, &time2);

        double dtime = time_diff(time1,time2);
        double peak = 1.3*96; //haswell @1.3GHz
        //double peak = 3.6*48; //Ivy Bridge @ 3.6Ghz
        //double peak = 2.4*24; // Westmere @ 2.4GHz
        double rate = 3.0*1E-9*sizeof(float)*n*repeat/dtime;
        printf("dtime %f, %f GB/s, peak, %f, efficiency %f%%\n", dtime, rate, peak, 100*rate/peak);
    }
}

2 个答案:

答案 0 :(得分:9)

我认为ab之间的差距并不重要。在bc之间只留下一个差距之后,我在Haswell上得到了以下结果:

k   %
-----
1  48
2  48
3  48
4  48
5  46
6  53
7  59
8  67
9  73
10 81
11 85
12 87
13 87
...
0  86

由于众所周知Haswell没有银行冲突,唯一剩下的解释是内存地址之间的错误依赖(并且您已经在Agner Fog的微架构手册中找到了解释这个问题的适当位置)。银行冲突与虚假共享之间的区别在于银行冲突阻止在同一时钟周期内两次访问同一银行,而虚假共享则阻止在您将相同的偏移量写入相同的偏移量后读取4K内存中的某些偏移量(和不仅在相同的时钟周期内,而且在写入后的几个时钟周期内。)

由于您的代码(对于k=0)写入任何偏移量只是 从同一偏移量执行两次读取并且在很长时间内不会从中读取,因此这种情况应该是被视为&#34;最好&#34;,所以我将k=0放在表的末尾。对于k=1,您总是从最近被覆盖的偏移量中读取,这意味着错误共享,从而降低性能。写入和读取之间的k时间增加,CPU内核有更多机会将写入数据传递到所有内存层次结构(这意味着两个地址转换用于读取和写入,更新缓存数据和标记以及从缓存中获取数据,数据核心之间的同步,可能还有更多东西)。 k=12或24个时钟(在我的CPU上)足以让每个写入的数据准备好进行后续读取操作,因此从这个值开始,性能将恢复正常。看起来与AMD的20多个时钟没有太大区别(正如@Mysticial所说)。

答案 1 :(得分:4)

TL; DR :对于k的某些值,发生了太多的4K混叠情况,这是带宽降低的主要原因。在4K别名中,负载不必要地停止,从而增加了有效的负载等待时间,并使所有以后的依赖指令停止。这进而导致L1带宽利用率降低。对于k的这些值,可以通过如下拆分循环来消除大多数4K混叠条件:

for(int i=0; i<n/64; i++) {
    _mm256_store_ps(z1+64*i+  0,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 0), _mm256_load_ps(y1+64*i+  0)));
    _mm256_store_ps(z1+64*i+  8,_mm256_add_ps(_mm256_load_ps(x1+64*i+ 8), _mm256_load_ps(y1+64*i+  8)));
}
for(int i=0; i<n/64; i++) {
    _mm256_store_ps(z1+64*i+ 16,_mm256_add_ps(_mm256_load_ps(x1+64*i+16), _mm256_load_ps(y1+64*i+ 16)));
    _mm256_store_ps(z1+64*i+ 24,_mm256_add_ps(_mm256_load_ps(x1+64*i+24), _mm256_load_ps(y1+64*i+ 24)));
}
for(int i=0; i<n/64; i++) {
    _mm256_store_ps(z1+64*i+ 32,_mm256_add_ps(_mm256_load_ps(x1+64*i+32), _mm256_load_ps(y1+64*i+ 32)));
    _mm256_store_ps(z1+64*i+ 40,_mm256_add_ps(_mm256_load_ps(x1+64*i+40), _mm256_load_ps(y1+64*i+ 40)));
}
for(int i=0; i<n/64; i++) {
    _mm256_store_ps(z1+64*i+ 48,_mm256_add_ps(_mm256_load_ps(x1+64*i+48), _mm256_load_ps(y1+64*i+ 48)));
    _mm256_store_ps(z1+64*i+ 56,_mm256_add_ps(_mm256_load_ps(x1+64*i+56), _mm256_load_ps(y1+64*i+ 56)));
}

对于k是奇数正整数(例如1)的情况,此拆分消除了大多数4K别名。 Haswell上实现的L1带宽提高了约50%。仍有改进的空间,例如,通过展开循环并找出不对装载和存储使用索引寻址模式的方法。

但是,对于k的偶数,此拆分不能消除4K别名。因此,k的偶数值需要使用不同的拆分。但是,当k为0时,可以在不拆分循环的情况下获得最佳性能。在这种情况下,性能将同时限制在端口1、2、3、4和7上。

在某些情况下,同时执行加载和存储可能会有几个周期的损失,但是在这种情况下,基本上不存在这种损失,因为基本上没有这种冲突(例如,地址并发负载和存储之间的距离足够远)。此外,总的工作集大小适合L1,因此在第一次执行循环后就没有L1-L2流量了。

此答案的其余部分包括此摘要的详细说明。


首先,请注意,这三个数组的总大小为24KB。此外,由于您是在执行主循环之前初始化阵列,因此主循环中的大多数访问都将进入L1D,L1D的大小为32KB,在现代Intel处理器上是8关联的。因此,我们不必担心丢失或硬件预取。在这种情况下,最重要的性能事件是LD_BLOCKS_PARTIAL.ADDRESS_ALIAS,当涉及较晚负载的部分地址比较导致与较早商店匹配并且满足所有商店转发条件,但目标位置满足时,发生此事件。实际上不同。英特尔将这种情况称为4K别名或错误的存储转发。可观察到的4K别名性能损失取决于周围的代码。

通过测量cyclesLD_BLOCKS_PARTIAL.ADDRESS_ALIASMEM_UOPS_RETIRED.ALL_LOADS,我们可以看到,对于k的所有值,其中获得的带宽远小于峰值带宽{{ 1}}和LD_BLOCKS_PARTIAL.ADDRESS_ALIAS几乎相等。同样,对于MEM_UOPS_RETIRED.ALL_LOADS的所有已达到带宽接近峰值带宽的值,kLD_BLOCKS_PARTIAL.ADDRESS_ALIAS相比非常小。这证实了带宽下降是由于大多数负载遭受4K混叠而造成的。

Intel优化手册第12.8节说:

  

当代码存储到一个内存时,发生4 KB内存别名   位置,然后不久便从另一个内存加载   它们之间的偏移量为4 KB。例如,   线性地址0x400020跟随存储到线性地址0x401020。

     

加载和存储的第5-11位具有相同的值   地址和访问的字节偏移应具有部分或   完全重叠。

也就是说,有两个必要条件,以便以后使用较早的商店加载别名:

  • 两个线性地址的5-11位必须相等。
  • 所访问的位置必须重叠(以便可以转发一些数据)。

在支持AVX-512的处理器上,单加载uop最多可以加载64个字节。所以我认为第一个条件的范围应该是6-11,而不是5-11。

下面的清单显示了基于AVX的(32字节)内存访问序列及其地址的最低有效12位(对于两个MEM_UOPS_RETIRED.ALL_LOADS的不同值。

k

请注意,当k = 0时,似乎没有负载满足4K混叠的两个条件。另一方面,当k = 1时,所有负载似乎都满足条件。但是,对于所有迭代和====== k=0 ====== load x+(0*64+0)*4 = x+0 where x is 4k aligned 0000 000|0 0000 load y+(0*64+0)*4 = y+0 where y is 4k aligned 0000 000|0 0000 store z+(0*64+0)*4 = z+0 where z is 4k aligned 0000 000|0 0000 load x+(0*64+8)*4 = x+32 where x is 4k aligned 0000 001|0 0000 load y+(0*64+8)*4 = y+32 where y is 4k aligned 0000 001|0 0000 store z+(0*64+8)*4 = z+32 where z is 4k aligned 0000 001|0 0000 load x+(0*64+16)*4 = x+64 where x is 4k aligned 0000 010|0 0000 load y+(0*64+16)*4 = y+64 where y is 4k aligned 0000 010|0 0000 store z+(0*64+16)*4= z+64 where z is 4k aligned 0000 010|0 0000 load x+(0*64+24)*4 = x+96 where x is 4k aligned 0000 011|0 0000 load y+(0*64+24)*4 = y+96 where y is 4k aligned 0000 011|0 0000 store z+(0*64+24)*4 = z+96 where z is 4k aligned 0000 011|0 0000 load x+(0*64+32)*4 = x+128 where x is 4k aligned 0000 100|0 0000 load y+(0*64+32)*4 = y+128 where y is 4k aligned 0000 100|0 0000 store z+(0*64+32)*4= z+128 where z is 4k aligned 0000 100|0 0000 . . . ====== k=1 ====== load x+(0*64+0)*4 = x+0 where x is 4k aligned 0000 000|0 0000 load y+(0*64+0)*4 = y+0 where y is 4k+64 aligned 0000 010|0 0000 store z+(0*64+0)*4 = z+0 where z is 4k+128 aligned 0000 100|0 0000 load x+(0*64+8)*4 = x+32 where x is 4k aligned 0000 001|0 0000 load y+(0*64+8)*4 = y+32 where y is 4k+64 aligned 0000 011|0 0000 store z+(0*64+8)*4 = z+32 where z is 4k+128 aligned 0000 101|0 0000 load x+(0*64+16)*4 = x+64 where x is 4k aligned 0000 010|0 0000 load y+(0*64+16)*4 = y+64 where y is 4k+64 aligned 0000 100|0 0000 store z+(0*64+16)*4= z+64 where z is 4k+128 aligned 0000 110|0 0000 load x+(0*64+24)*4 = x+96 where x is 4k aligned 0000 011|0 0000 load y+(0*64+24)*4 = y+96 where y is 4k+64 aligned 0000 101|0 0000 store z+(0*64+24)*4 = z+96 where z is 4k+128 aligned 0000 111|0 0000 load x+(0*64+32)*4 = x+128 where x is 4k aligned 0000 100|0 0000 load y+(0*64+32)*4 = y+128 where y is 4k+64 aligned 0000 110|0 0000 store z+(0*64+32)*4= z+128 where z is 4k+128 aligned 0001 000|0 0000 . . . 的所有值手动执行此操作很麻烦。因此,我编写了一个程序,该程序基本上会生成内存访问的地址,并计算针对k的不同值遭受4K别名的负载总数。我面临的一个问题是,对于任何给定的负载,我们都不知道仍在存储缓冲区中的存储数量(尚未提交)。因此,我设计了模拟器,以便它可以对不同的k值使用不同的存储吞吐量,这似乎可以更好地反映实际处理器上实际发生的情况。可以找到代码here

下图显示了与在Haswell上使用k测得的数量相比,模拟器产生的4K混叠情况的数量。我已经针对每个LD_BLOCKS_PARTIAL.ADDRESS_ALIAS的值对模拟器中使用的商店吞吐量进行了调整,以使两条曲线尽可能相似。第二个图显示了模拟器中使用的并在Haswell上测量的反向存储吞吐量(总周期除以存储总数)。请注意,k = 0时的存储吞吐量无关紧要,因为无论如何都没有4K别名。由于每个商店有两个负载,因此反向负载吞吐量是反向商店吞吐量的一半。

enter image description here

enter image description here

很明显,在Haswell和模拟器上,每个存储区保留在存储缓冲区中的时间是不同的,因此我需要使用不同的吞吐量来使两条曲线相似。该模拟器可用于显示商店吞吐量如何影响4K别名的数量。如果商店吞吐量非常接近1c / store,则4K混叠情况的数量会少得多。 4K别名条件不会导致流水线刷新,但可能会导致RS重播uop。在这种情况下,我没有观察到任何重播。

  

如果我假设对于k = 1,我可以解释这些数字   写入和读取不能在同一时钟周期内发生。

在同时执行加载和存储时实际上要付出几个周期的代价,但是只有在Haswell上加载或存储的地址在64字节(但不相等)或32字节之内时,它们才会发生在常春藤桥和桑迪桥上。 Weird performance effects from nearby dependent stores in a pointer-chasing loop on IvyBridge. Adding an extra load speeds it up?。在这种情况下,所有访问的地址都是32字节对齐的,但是在IvB上,L1端口的大小均为16字节,因此,在Haswell和IvB上可能会产生代价。实际上,由于加载和存储可能需要更多的时间才能退休,并且由于加载缓冲区比存储缓冲区更多,因此,较晚的加载将对早期的存储进行假别名。但是,这提出了一个问题,即4K别名损失和L1访问损失如何相互影响并有助于整体性能。使用k事件和负载延迟性能监视工具CYCLE_ACTIVITY.STALLS_LDM_PENDING,在我看来,没有可观察到的L1访问损失。这意味着大多数时候并发加载和存储的地址不会引起代价。因此,4K混叠损失是带宽降低的主要原因。

我使用以下代码在Haswell上进行测量。这基本上与MEM_TRANS_RETIRED.LOAD_LATENCY_GT_*发出的代码相同。

g++ -O3 -mavx