17

我使用 SSE 4.2 和 AVX 2 对 2 个向量之间的点积进行了向量化,如下所示。该代码是使用带有 -O2 优化标志的 GCC 4.8.4 编译的。正如预期的那样,两者的性能都变得更好(并且 AVX 2 比 SSE 4.2 更快),但是当我使用 PAPI 分析代码时,我发现未命中的总数(主要是 L1 和 L2)增加了很多:

没有矢量化:

PAPI_L1_TCM: 784,112,091
PAPI_L2_TCM: 195,315,365
PAPI_L3_TCM: 79,362

使用 SSE 4.2:

PAPI_L1_TCM: 1,024,234,171
PAPI_L2_TCM: 311,541,918
PAPI_L3_TCM: 68,842

使用 AVX 2:

PAPI_L1_TCM: 2,719,959,741
PAPI_L2_TCM: 1,459,375,105
PAPI_L3_TCM: 108,140

我的代码可能有问题还是这种行为正常?

AVX 2 代码:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;
    const int loopBound = n-3;

    __m256d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm256_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=4){
        vecPi  = _mm256_loadu_pd(&(pA)[i]);
        vecCi  = _mm256_loadu_pd(&(pB)[i]);
        vecQCi = _mm256_mul_pd(vecPi,vecCi);
        vsum   = _mm256_add_pd(vsum,vecQCi);
    }

    vsum = _mm256_hadd_pd(vsum, vsum);

    dot = ((double*)&vsum)[0] + ((double*)&vsum)[2];

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

SSE 4.2 代码:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    const int loopBound = n-1;

    __m128d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=2){
        vecPi  = _mm_load_pd(&(pA)[i]);
        vecCi  = _mm_load_pd(&(pB)[i]);
        vecQCi = _mm_mul_pd(vecPi,vecCi);
        vsum   = _mm_add_pd(vsum,vecQCi);
    }

    vsum = _mm_hadd_pd(vsum, vsum);

    _mm_storeh_pd(&dot, vsum);

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

非向量化代码:

double dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    for (i = 0; i < n; ++i)
    {
        dot += vecs.x[start_a+i] * vecs.x[start_b+i];
    }
    return dot;
}

编辑:非矢量化代码的组装:

   0x000000000040f9e0 <+0>:     mov    (%rcx),%r8d
   0x000000000040f9e3 <+3>:     test   %r8d,%r8d
   0x000000000040f9e6 <+6>:     jle    0x40fa1d <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+61>
   0x000000000040f9e8 <+8>:     mov    (%rsi),%eax
   0x000000000040f9ea <+10>:    mov    (%rdi),%rcx
   0x000000000040f9ed <+13>:    mov    (%rdx),%edi
   0x000000000040f9ef <+15>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040f9f3 <+19>:    add    %eax,%r8d
   0x000000000040f9f6 <+22>:    sub    %eax,%edi
   0x000000000040f9f8 <+24>:    nopl   0x0(%rax,%rax,1)
   0x000000000040fa00 <+32>:    mov    %eax,%esi
   0x000000000040fa02 <+34>:    lea    (%rdi,%rax,1),%edx
   0x000000000040fa05 <+37>:    add    $0x1,%eax
   0x000000000040fa08 <+40>:    vmovsd (%rcx,%rsi,8),%xmm1
   0x000000000040fa0d <+45>:    cmp    %r8d,%eax
   0x000000000040fa10 <+48>:    vmulsd (%rcx,%rdx,8),%xmm1,%xmm1
   0x000000000040fa15 <+53>:    vaddsd %xmm1,%xmm0,%xmm0
   0x000000000040fa19 <+57>:    jne    0x40fa00 <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+32>
   0x000000000040fa1b <+59>:    repz retq 
   0x000000000040fa1d <+61>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040fa21 <+65>:    retq   

Edit2:您可以在下面找到较大 N 的向量化代码和非向量化代码之间的 L1 缓存未命中比较(x 标签上的 N 和 y 标签上的 L1 缓存未命中)。基本上,对于较大的 N,矢量化版本中的未命中率仍然比非矢量化版本中的多。

在此处输入图像描述

4

2 回答 2

2

正如您在一些评论中看到的,缓存未命中来自于性能的提高。

例如,对于最近的 CPU,您将能够在每个周期执行 2 个 AVX2 add 或 mul,因此每个周期有 512 位。您需要加载数据的时间会更长,因为它需要多个缓存行。

此外,根据您的系统配置方式、超线程、亲和性等,您的调度程序可以同时做其他事情,用其他线程/进程污染您的缓存。

最后一件事。CPU 现在非常有效地将简单模式识别为具有非常小的循环的模式,然后在几次迭代后自动使用预取。无论如何,解决缓存大小问题是不够的。

尝试使用不同大小的 N,您应该会看到有趣的结果。此外,首先对齐您的数据并确保如果您使用 2 个变量,则不会共享相同的缓存行。

于 2016-01-28T21:52:14.067 回答
1

Rostislav 是正确的,编译器是自动矢量化的,并且来自 -O2 的 GCC 文档:

“-O2 优化更多。GCC 执行几乎所有支持的优化,不涉及空间速度折衷。” (从这里:https ://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html )

带有 -O2 标志的 GCC 试图生成最有效的代码,而不偏爱代码大小或速度。

因此,就 CPU 周期而言,-O2 自动矢量化代码将需要最少的功率来运行,但不会是最快或最小的代码。这是在移动设备和多用户系统上运行的代码的最佳案例,这些往往是 C++ 的首选用途。如果您想要绝对最大速度而不管它使用多少瓦,如果您的 GCC 版本支持它们,请尝试 -O3 或 -Ofast,或者使用您手动优化的更快解决方案。

造成这种情况的原因可能是两个因素的组合。

首先,更快的代码在相同的时间内对内存/缓存产生更多的请求,这给预取预测算法带来了压力。L1 缓存不是很大,通常为 1MB - 3MB,并且在该 CPU Core 上所有正在运行的进程之间共享,因此 CPU Core 无法预取,直到之前预取的块不再使用。如果代码运行得更快,那么在块之间进行预取的时间就会减少,并且在有效流水线的代码中,在 CPU 内核完全停止之前会执行更多的缓存未命中,直到完成挂起的取指。

其次,现代操作系统通常通过动态调整线程亲和性来在多个核心之间划分单线程进程,以便在多个核心之间使用额外的缓存,即使它不能并行运行任何代码 - 例如填充核心 0缓存你的数据,然后在填充核心 1 的缓存时运行它,然后在核心 1 上运行,同时重新填充核心 0 的缓存,循环直到完成。这种伪并行性提高了单线程进程的整体速度,并应大大减少缓存未命中,但只能在非常特定的情况下完成……好的编译器将尽可能生成代码的特定情况。

于 2016-01-28T05:39:08.230 回答