为什么这个SIMD乘法没有比非SIMD乘法更快?

15

假设我们有一个函数,它可以将两个包含1000000个double元素的数组相乘。在C/C++中,该函数如下所示:

void mul_c(double* a, double* b)
{
    for (int i = 0; i != 1000000; ++i)
    {
        a[i] = a[i] * b[i];
    }
}

编译器在使用-O2时会产生以下汇编代码:
mul_c(double*, double*):
        xor     eax, eax
.L2:
        movsd   xmm0, QWORD PTR [rdi+rax]
        mulsd   xmm0, QWORD PTR [rsi+rax]
        movsd   QWORD PTR [rdi+rax], xmm0
        add     rax, 8
        cmp     rax, 8000000
        jne     .L2
        rep ret

从上面的汇编代码可以看出编译器使用了SIMD指令,但每次循环只能乘以一个双精度浮点数。因此我决定用内联汇编来编写相同的函数,在其中充分利用xmm0寄存器,一次乘以两个双精度浮点数:

void mul_asm(double* a, double* b)
{
    asm volatile
    (
        ".intel_syntax noprefix             \n\t"
        "xor    rax, rax                    \n\t"
        "0:                                 \n\t"
        "movupd xmm0, xmmword ptr [rdi+rax] \n\t"
        "mulpd  xmm0, xmmword ptr [rsi+rax] \n\t"
        "movupd xmmword ptr [rdi+rax], xmm0 \n\t"
        "add    rax, 16                     \n\t"
        "cmp    rax, 8000000                \n\t"
        "jne    0b                          \n\t"
        ".att_syntax noprefix               \n\t"

        : 
        : "D" (a), "S" (b)
        : "memory", "cc"
    );
}

在单独测量这两个函数的执行时间后,似乎它们都需要1毫秒才能完成:

> gcc -O2 main.cpp
> ./a.out < input

mul_c: 1 ms
mul_asm: 1 ms

[a lot of doubles...]

我原本期望SIMD实现至少比普通实现快两倍(0毫秒),因为它只有一半的乘法/内存指令。

所以我的问题是:当SIMD实现只执行一半的乘法/内存指令时,为什么它的速度没有比普通的C/C++实现更快?

这是完整的程序:

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

void mul_c(double* a, double* b)
{
    for (int i = 0; i != 1000000; ++i)
    {
        a[i] = a[i] * b[i];
    }
}

void mul_asm(double* a, double* b)
{
    asm volatile
    (
        ".intel_syntax noprefix             \n\t"
        "xor    rax, rax                    \n\t"
        "0:                                 \n\t"
        "movupd xmm0, xmmword ptr [rdi+rax] \n\t"
        "mulpd  xmm0, xmmword ptr [rsi+rax] \n\t"
        "movupd xmmword ptr [rdi+rax], xmm0 \n\t"
        "add    rax, 16                     \n\t"
        "cmp    rax, 8000000                \n\t"
        "jne    0b                          \n\t"
        ".att_syntax noprefix               \n\t"

        : 
        : "D" (a), "S" (b)
        : "memory", "cc"
    );
}

int main()
{
    struct timeval t1;
    struct timeval t2;
    unsigned long long time;

    double* a = (double*)malloc(sizeof(double) * 1000000);
    double* b = (double*)malloc(sizeof(double) * 1000000);
    double* c = (double*)malloc(sizeof(double) * 1000000);

    for (int i = 0; i != 1000000; ++i)
    {
        double v;
        scanf("%lf", &v);
        a[i] = v;
        b[i] = v;
        c[i] = v;
    }

    gettimeofday(&t1, NULL);
    mul_c(a, b);
    gettimeofday(&t2, NULL);
    time = 1000 * (t2.tv_sec - t1.tv_sec) + (t2.tv_usec - t1.tv_usec) / 1000;
    printf("mul_c: %llu ms\n", time);

    gettimeofday(&t1, NULL);
    mul_asm(b, c);
    gettimeofday(&t2, NULL);
    time = 1000 * (t2.tv_sec - t1.tv_sec) + (t2.tv_usec - t1.tv_usec) / 1000;
    printf("mul_asm: %llu ms\n\n", time);

    for (int i = 0; i != 1000000; ++i)
    {
        printf("%lf\t\t\t%lf\n", a[i], b[i]);
    }

    return 0;
}

我还尝试着充分利用所有的xmm寄存器(0-7),并消除指令之间的依赖关系,以获得更好的并行计算:

void mul_asm(double* a, double* b)
{
    asm volatile
    (
        ".intel_syntax noprefix                 \n\t"
        "xor    rax, rax                        \n\t"
        "0:                                     \n\t"
        "movupd xmm0, xmmword ptr [rdi+rax]     \n\t"
        "movupd xmm1, xmmword ptr [rdi+rax+16]  \n\t"
        "movupd xmm2, xmmword ptr [rdi+rax+32]  \n\t"
        "movupd xmm3, xmmword ptr [rdi+rax+48]  \n\t"
        "movupd xmm4, xmmword ptr [rdi+rax+64]  \n\t"
        "movupd xmm5, xmmword ptr [rdi+rax+80]  \n\t"
        "movupd xmm6, xmmword ptr [rdi+rax+96]  \n\t"
        "movupd xmm7, xmmword ptr [rdi+rax+112] \n\t"
        "mulpd  xmm0, xmmword ptr [rsi+rax]     \n\t"
        "mulpd  xmm1, xmmword ptr [rsi+rax+16]  \n\t"
        "mulpd  xmm2, xmmword ptr [rsi+rax+32]  \n\t"
        "mulpd  xmm3, xmmword ptr [rsi+rax+48]  \n\t"
        "mulpd  xmm4, xmmword ptr [rsi+rax+64]  \n\t"
        "mulpd  xmm5, xmmword ptr [rsi+rax+80]  \n\t"
        "mulpd  xmm6, xmmword ptr [rsi+rax+96]  \n\t"
        "mulpd  xmm7, xmmword ptr [rsi+rax+112] \n\t"
        "movupd xmmword ptr [rdi+rax], xmm0     \n\t"
        "movupd xmmword ptr [rdi+rax+16], xmm1  \n\t"
        "movupd xmmword ptr [rdi+rax+32], xmm2  \n\t"
        "movupd xmmword ptr [rdi+rax+48], xmm3  \n\t"
        "movupd xmmword ptr [rdi+rax+64], xmm4  \n\t"
        "movupd xmmword ptr [rdi+rax+80], xmm5  \n\t"
        "movupd xmmword ptr [rdi+rax+96], xmm6  \n\t"
        "movupd xmmword ptr [rdi+rax+112], xmm7 \n\t"
        "add    rax, 128                        \n\t"
        "cmp    rax, 8000000                    \n\t"
        "jne    0b                              \n\t"
        ".att_syntax noprefix                   \n\t"

        : 
        : "D" (a), "S" (b)
        : "memory", "cc"
    );
}

但它仍然以1毫秒的速度运行,与普通的C/C++实现相同。


更新

根据答案/评论的建议,我实现了另一种测量执行时间的方法:

#include <stdio.h>
#include <stdlib.h>

void mul_c(double* a, double* b)
{
    for (int i = 0; i != 1000000; ++i)
    {
        a[i] = a[i] * b[i];
    }
}

void mul_asm(double* a, double* b)
{
    asm volatile
    (
        ".intel_syntax noprefix             \n\t"
        "xor    rax, rax                    \n\t"
        "0:                                 \n\t"
        "movupd xmm0, xmmword ptr [rdi+rax] \n\t"
        "mulpd  xmm0, xmmword ptr [rsi+rax] \n\t"
        "movupd xmmword ptr [rdi+rax], xmm0 \n\t"
        "add    rax, 16                     \n\t"
        "cmp    rax, 8000000                \n\t"
        "jne    0b                          \n\t"
        ".att_syntax noprefix               \n\t"

        : 
        : "D" (a), "S" (b)
        : "memory", "cc"
    );
}

void mul_asm2(double* a, double* b)
{
    asm volatile
    (
        ".intel_syntax noprefix                 \n\t"
        "xor    rax, rax                        \n\t"
        "0:                                     \n\t"
        "movupd xmm0, xmmword ptr [rdi+rax]     \n\t"
        "movupd xmm1, xmmword ptr [rdi+rax+16]  \n\t"
        "movupd xmm2, xmmword ptr [rdi+rax+32]  \n\t"
        "movupd xmm3, xmmword ptr [rdi+rax+48]  \n\t"
        "movupd xmm4, xmmword ptr [rdi+rax+64]  \n\t"
        "movupd xmm5, xmmword ptr [rdi+rax+80]  \n\t"
        "movupd xmm6, xmmword ptr [rdi+rax+96]  \n\t"
        "movupd xmm7, xmmword ptr [rdi+rax+112] \n\t"
        "mulpd  xmm0, xmmword ptr [rsi+rax]     \n\t"
        "mulpd  xmm1, xmmword ptr [rsi+rax+16]  \n\t"
        "mulpd  xmm2, xmmword ptr [rsi+rax+32]  \n\t"
        "mulpd  xmm3, xmmword ptr [rsi+rax+48]  \n\t"
        "mulpd  xmm4, xmmword ptr [rsi+rax+64]  \n\t"
        "mulpd  xmm5, xmmword ptr [rsi+rax+80]  \n\t"
        "mulpd  xmm6, xmmword ptr [rsi+rax+96]  \n\t"
        "mulpd  xmm7, xmmword ptr [rsi+rax+112] \n\t"
        "movupd xmmword ptr [rdi+rax], xmm0     \n\t"
        "movupd xmmword ptr [rdi+rax+16], xmm1  \n\t"
        "movupd xmmword ptr [rdi+rax+32], xmm2  \n\t"
        "movupd xmmword ptr [rdi+rax+48], xmm3  \n\t"
        "movupd xmmword ptr [rdi+rax+64], xmm4  \n\t"
        "movupd xmmword ptr [rdi+rax+80], xmm5  \n\t"
        "movupd xmmword ptr [rdi+rax+96], xmm6  \n\t"
        "movupd xmmword ptr [rdi+rax+112], xmm7 \n\t"
        "add    rax, 128                        \n\t"
        "cmp    rax, 8000000                    \n\t"
        "jne    0b                              \n\t"
        ".att_syntax noprefix                   \n\t"

        : 
        : "D" (a), "S" (b)
        : "memory", "cc"
    );
}

unsigned long timestamp()
{
    unsigned long a;

    asm volatile
    (
        ".intel_syntax noprefix \n\t"
        "xor   rax, rax         \n\t"
        "xor   rdx, rdx         \n\t"
        "RDTSCP                 \n\t"
        "shl   rdx, 32          \n\t"
        "or    rax, rdx         \n\t"
        ".att_syntax noprefix   \n\t"

        : "=a" (a)
        : 
        : "memory", "cc"
    );

    return a;
}

int main()
{
    unsigned long t1;
    unsigned long t2;

    double* a;
    double* b;

    a = (double*)malloc(sizeof(double) * 1000000);
    b = (double*)malloc(sizeof(double) * 1000000);

    for (int i = 0; i != 1000000; ++i)
    {
        double v;
        scanf("%lf", &v);
        a[i] = v;
        b[i] = v;
    }

    t1 = timestamp();
    mul_c(a, b);
    //mul_asm(a, b);
    //mul_asm2(a, b);
    t2 = timestamp();
    printf("mul_c: %lu cycles\n\n", t2 - t1);

    for (int i = 0; i != 1000000; ++i)
    {
        printf("%lf\t\t\t%lf\n", a[i], b[i]);
    }

    return 0;
}

当我使用这个测量值运行程序时,得到了以下结果:

mul_c:    ~2163971628 cycles
mul_asm:  ~2532045184 cycles
mul_asm2: ~5230488    cycles <-- what???

这里有两点值得注意,首先,循环计数变化非常大,我认为这是因为操作系统允许其他进程在其中运行。是否有任何方法可以防止这种情况或只在程序执行时计算周期?此外,mul_asm2与其他两个输出相同,但速度更快,原因是什么?


我在我的系统上尝试了Z玻色子的程序以及我的两个实现,并获得了以下结果:

> g++ -O2 -fopenmp main.cpp
> ./a.out
mul         time 1.33, 18.08 GB/s
mul_SSE     time 1.13, 21.24 GB/s
mul_SSE_NT  time 1.51, 15.88 GB/s
mul_SSE_OMP time 0.79, 30.28 GB/s
mul_SSE_v2  time 1.12, 21.49 GB/s
mul_v2      time 1.26, 18.99 GB/s
mul_asm     time 1.12, 21.50 GB/s
mul_asm2    time 1.09, 22.08 GB/s

1
你的时间计算不够精确,无法进行这种基准测试。尝试使用Google Benchmark库运行代码,看看会发现什么。 - Daniel Kamil Kozar
2
你需要更多的循环迭代来更好地测量它,使用高分辨率计时器或使用RDTSC / RDTSCP。 1毫秒是噪音。 - Anty
6
例如,你可能会受到内存瓶颈的影响。 - Jester
4
另外使用 -O3,你将得到C版本的 mulpd xmm0, XMMWORD PTR [rcx+rax] - Anty
4
你在这里受到内存的绝对瓶颈制约。 - Cory Nelson
显示剩余11条评论
3个回答

11

以前的基准测试中,我使用的计时函数存在一个严重的bug。这导致没有矢量化时带宽被严重低估,还有其他测量也存在问题。此外,还存在另一个问题,即读取但未写入的数组上的COW导致带宽被高估。最后,我使用的最大带宽是不正确的。我已经进行了更正并将旧答案保留在本答案末尾。


您的操作受内存带宽限制。这意味着CPU大部分时间都在等待缓慢的内存读写操作。这里有一个很好的解释:为什么向量化循环不会提高性能
然而,我必须稍微反驳该回答中的一点。

所以无论如何优化(向量化、展开等),速度都不会快多少。

事实上,向量化、展开和多线程可以显著增加内存带宽限制下的带宽。原因是很难获得最大的内存带宽。这里有一个很好的解释:https://dev59.com/9F8e5IYBdhLWcg3w9-Rs#25187492
我的回答的其余部分将展示如何通过向量化和多线程接近最大内存带宽。

我的测试系统:Ubuntu 16.10,Skylake(i7-6700HQ@2.60GHz),32GB RAM,双通道DDR4 @ 2400 GHz。 我的系统最大带宽为38.4 GB/s。

从下面的代码中,我生成以下表格。 我使用OMP_NUM_THREADS设置线程数,例如export OMP_NUM_THREADS=4。 效率为bandwidth/max_bandwidth

-O2 -march=native -fopenmp
Threads Efficiency
1       59.2%
2       76.6%
4       74.3%
8       70.7%

-O2 -march=native -fopenmp -funroll-loops
1       55.8%
2       76.5%
4       72.1%
8       72.2%

-O3 -march=native -fopenmp
1       63.9%
2       74.6%
4       63.9%
8       63.2%

-O3 -march=native -fopenmp -mprefer-avx128
1       67.8%
2       76.0%
4       63.9%
8       63.2%

-O3 -march=native -fopenmp -mprefer-avx128 -funroll-loops
1       68.8%
2       73.9%
4       69.0%
8       66.8%

由于测量中的不确定性,经过多次迭代,我得出了以下结论:

  • 单线程标量操作获得超过50%的带宽。
  • 两个线程标量操作获得最高带宽。
  • 单线程向量操作比单线程标量操作更快。
  • 单线程SSE操作比单线程AVX操作更快。
  • 展开无益。
  • 展开单线程操作比不展开慢。
  • 超线程(Hyper-Threading)产生较低的带宽。

提供最佳带宽的解决方案是使用两个线程的标量操作。

我用于基准测试的代码:

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <omp.h>

#define N 10000000
#define R 100

void mul(double *a, double *b) {
  #pragma omp parallel for
  for (int i = 0; i<N; i++) a[i] *= b[i];
}

int main() {
  double maxbw = 2.4*2*8; // 2.4GHz * 2-channels * 64-bits * 1-byte/8-bits 
  double mem = 3*sizeof(double)*N*R*1E-9; // GB

  double *a = (double*)malloc(sizeof *a * N);
  double *b = (double*)malloc(sizeof *b * N);

  //due to copy-on-write b must be initialized to get the correct bandwidth
  //also, GCC will convert malloc + memset(0) to calloc so use memset(1)
  memset(b, 1, sizeof *b * N);

  double dtime = -omp_get_wtime();
  for(int i=0; i<R; i++) mul(a,b);
  dtime += omp_get_wtime();
  printf("%.2f s, %.1f GB/s, %.1f%%\n", dtime, mem/dtime, 100*mem/dtime/maxbw);

  free(a), free(b);
}

存在时间漏洞的旧解决方案

现代内联汇编的解决方案是使用内置函数。虽然仍有一些情况需要使用内联汇编,但这不是其中之一。

对于您的内联汇编方法,可以使用一个内置函数解决:

void mul_SSE(double*  a, double*  b) {
  for (int i = 0; i<N/2; i++) 
      _mm_store_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}

让我定义一些测试代码

#include <x86intrin.h>
#include <string.h>
#include <stdio.h>
#include <x86intrin.h>
#include <omp.h>

#define N 1000000
#define R 1000

typedef __attribute__(( aligned(32)))  double aligned_double;
void  (*fp)(aligned_double *a, aligned_double *b);

void mul(aligned_double* __restrict a, aligned_double* __restrict b) {
  for (int i = 0; i<N; i++) a[i] *= b[i];
}

void mul_SSE(double*  a, double*  b) {
  for (int i = 0; i<N/2; i++) _mm_store_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}

void mul_SSE_NT(double*  a, double*  b) {
  for (int i = 0; i<N/2; i++) _mm_stream_pd(&a[2*i], _mm_mul_pd(_mm_load_pd(&a[2*i]),_mm_load_pd(&b[2*i])));
}

void mul_SSE_OMP(double*  a, double*  b) {
  #pragma omp parallel for
  for (int i = 0; i<N; i++) a[i] *= b[i];
}

void test(aligned_double *a, aligned_double *b, const char *name) {
  double dtime;
  const double mem = 3*sizeof(double)*N*R/1024/1024/1024;
  const double maxbw = 34.1;
  dtime = -omp_get_wtime();
  for(int i=0; i<R; i++) fp(a,b);
  dtime += omp_get_wtime();
  printf("%s \t time %.2f s, %.1f GB/s, efficency %.1f%%\n", name, dtime, mem/dtime, 100*mem/dtime/maxbw);
}

int main() {
  double *a = (double*)_mm_malloc(sizeof *a * N, 32);
  double *b = (double*)_mm_malloc(sizeof *b * N, 32);

  //b must be initialized to get the correct bandwidth!!!
  memset(a, 1, sizeof *a * N);
  memset(b, 1, sizeof *a * N);

  fp = mul,         test(a,b, "mul        ");
  fp = mul_SSE,     test(a,b, "mul_SSE    ");
  fp = mul_SSE_NT,  test(a,b, "mul_SSE_NT ");
  fp = mul_SSE_OMP, test(a,b, "mul_SSE_OMP");

  _mm_free(a), _mm_free(b);
}

现在是第一个测试

g++ -O2 -fopenmp test.cpp
./a.out
mul              time 1.67 s, 13.1 GB/s, efficiency 38.5%
mul_SSE          time 1.00 s, 21.9 GB/s, efficiency 64.3%
mul_SSE_NT       time 1.05 s, 20.9 GB/s, efficiency 61.4%
mul_SSE_OMP      time 0.74 s, 29.7 GB/s, efficiency 87.0%

使用不进行循环向量化的-O2参数,我们可以看到内部SSE版本比普通C解决方案mul快得多。efficiency = bandwith_measured/max_bandwidth,其中最大值为我的系统的34.1 GB/s。
第二个测试。
g++ -O3 -fopenmp test.cpp
./a.out
mul              time 1.05 s, 20.9 GB/s, efficiency 61.2%
mul_SSE          time 0.99 s, 22.3 GB/s, efficiency 65.3%
mul_SSE_NT       time 1.01 s, 21.7 GB/s, efficiency 63.7%
mul_SSE_OMP      time 0.68 s, 32.5 GB/s, efficiency 95.2%

使用-O3会向量化循环,而内置函数基本没有优势。
第三个测试。
g++ -O3 -fopenmp -funroll-loops test.cpp
./a.out
mul              time 0.85 s, 25.9 GB/s, efficency 76.1%
mul_SSE          time 0.84 s, 26.2 GB/s, efficency 76.7%
mul_SSE_NT       time 1.06 s, 20.8 GB/s, efficency 61.0%
mul_SSE_OMP      time 0.76 s, 29.0 GB/s, efficency 85.0%

使用-funroll-loops选项,GCC将循环展开八次,我们看到了显著的改进,但对于非暂态存储解决方案没有实际优势,OpenMP解决方案也没有真正的优势。在展开循环之前,mul的汇编代码是-O3
    xor     eax, eax
.L2:
    movupd  xmm0, XMMWORD PTR [rsi+rax]
    mulpd   xmm0, XMMWORD PTR [rdi+rax]
    movaps  XMMWORD PTR [rdi+rax], xmm0
    add     rax, 16
    cmp     rax, 8000000
    jne     .L2
    rep ret

使用 -O3 -funroll-loops 编译选项,mul 的汇编代码为:

   xor     eax, eax
.L2:
    movupd  xmm0, XMMWORD PTR [rsi+rax]
    movupd  xmm1, XMMWORD PTR [rsi+16+rax]
    mulpd   xmm0, XMMWORD PTR [rdi+rax]
    movupd  xmm2, XMMWORD PTR [rsi+32+rax]
    mulpd   xmm1, XMMWORD PTR [rdi+16+rax]
    movupd  xmm3, XMMWORD PTR [rsi+48+rax]
    mulpd   xmm2, XMMWORD PTR [rdi+32+rax]
    movupd  xmm4, XMMWORD PTR [rsi+64+rax]
    mulpd   xmm3, XMMWORD PTR [rdi+48+rax]
    movupd  xmm5, XMMWORD PTR [rsi+80+rax]
    mulpd   xmm4, XMMWORD PTR [rdi+64+rax]
    movupd  xmm6, XMMWORD PTR [rsi+96+rax]
    mulpd   xmm5, XMMWORD PTR [rdi+80+rax]
    movupd  xmm7, XMMWORD PTR [rsi+112+rax]
    mulpd   xmm6, XMMWORD PTR [rdi+96+rax]
    movaps  XMMWORD PTR [rdi+rax], xmm0
    mulpd   xmm7, XMMWORD PTR [rdi+112+rax]
    movaps  XMMWORD PTR [rdi+16+rax], xmm1
    movaps  XMMWORD PTR [rdi+32+rax], xmm2
    movaps  XMMWORD PTR [rdi+48+rax], xmm3
    movaps  XMMWORD PTR [rdi+64+rax], xmm4
    movaps  XMMWORD PTR [rdi+80+rax], xmm5
    movaps  XMMWORD PTR [rdi+96+rax], xmm6
    movaps  XMMWORD PTR [rdi+112+rax], xmm7
    sub     rax, -128
    cmp     rax, 8000000
    jne     .L2
    rep ret

第四个测试。
g++ -O3 -fopenmp -mavx test.cpp
./a.out
mul              time 0.87 s, 25.3 GB/s, efficiency 74.3%
mul_SSE          time 0.88 s, 24.9 GB/s, efficiency 73.0%
mul_SSE_NT       time 1.07 s, 20.6 GB/s, efficiency 60.5%
mul_SSE_OMP      time 0.76 s, 29.0 GB/s, efficiency 85.2%

现在非内在函数是最快的(不包括OpenMP版本)。
因此,在这种情况下,没有理由使用内在函数或内联汇编,因为我们可以通过适当的编译器选项(例如-O3-funroll-loops-mavx)获得最佳性能。
测试系统:Ubuntu 16.10,Skylake(i7-6700HQ@2.60GHz),32GB RAM。最大内存带宽(34.1 GB/s)https://ark.intel.com/products/88967/Intel-Core-i7-6700HQ-Processor-6M-Cache-up-to-3_50-GHz

这里有另一个值得考虑的解决方案。如果我们从-N计数到零,并使用N+i访问数组,则不需要cmp指令。GCC应该早就修复了这个问题。它消除了一条指令(尽管由于宏操作融合,cmp和jmp通常被视为一个微操作)。

void mul_SSE_v2(double*  a, double*  b) {
  for (ptrdiff_t i = -N; i<0; i+=2)
    _mm_store_pd(&a[N + i], _mm_mul_pd(_mm_load_pd(&a[N + i]),_mm_load_pd(&b[N + i])));

使用-O3进行汇编

mul_SSE_v2(double*, double*):
    mov     rax, -1000000
.L9:
    movapd  xmm0, XMMWORD PTR [rdi+8000000+rax*8]
    mulpd   xmm0, XMMWORD PTR [rsi+8000000+rax*8]
    movaps  XMMWORD PTR [rdi+8000000+rax*8], xmm0
    add     rax, 2
    jne     .L9
    rep ret
}

这种优化只有在数组适合L1缓存,即不需要从主存中读取时才有可能有帮助。

我终于找到了一种方法,使得纯C解决方案不会生成cmp指令。

void mul_v2(aligned_double* __restrict a, aligned_double* __restrict b) {
  for (int i = -N; i<0; i++) a[i] *= b[i];
}

然后,像这样从单独的对象文件中调用函数:mul_v2(&a[N],&b[N]),这可能是最好的解决方案。但是,如果您从与定义它的对象文件(翻译单元)相同的文件中调用该函数,则GCC会再次生成cmp指令。

另外,

void mul_v3(aligned_double* __restrict a, aligned_double* __restrict b) {
  for (int i = -N; i<0; i++) a[N+i] *= b[N+i];
}

仍会生成cmp指令,并产生与mul函数相同的汇编代码。

mul_SSE_NT 函数很傻。它使用非临时存储器,而这种存储器只在仅写入存储器时有用,但由于该函数读取和写入同一地址,因此非临时存储器不仅无用,而且会给出劣质的结果。


之前的答案在带宽方面存在误差。原因是数组未初始化。

1
@fighting_falcon93,因为你的操作是内存带宽受限的,所以它不会随着SIMD通道数或线程数的增加而扩展。然而,它仍然可以从多线程、展开和SIMD中受益。这是大多数人不太欣赏的部分。我从一开始就更新了我的答案,并提供了更多细节。 - Z boson
1
@fighting_falcon93,我忘记回答你关于OpenMP的问题了。如果你使用-fopenmp编译,你会看到call GOMP_parallel和其他代码,因此OpenMP汇编与没有使用OpenMP时不同。https://godbolt.org/g/yZkH23 - Z boson
@fighting_falcon93,好的,我想我找到了问题所在。我必须在每个测试之间初始化数组。当我这样做时,memcpy会给出正确的带宽。我明天会更新这个。 - Z boson
1
@fighting_falcon93,我修复了我的答案。问题是我使用了未初始化的数组。memset(b, 1, sizeof *a * N)解决了它!我重写了代码。现在只有一个文件,而且更加简洁。我还清理了我的答案。现在我很满意。 - Z boson
1
@fighting_falcon93,好的,我已经根据时间进行了答案更新。让我知道你的想法。我从这个问题中学到了很多。 - Z boson
显示剩余10条评论

9

您的汇编代码确实没问题,问题在于您测量方式不正确。正如我在评论中指出,您应该:

a)使用更多迭代次数——对于现代CPU来说,100万次迭代根本不算什么

b)使用HPT进行测量

c)使用RDTSC或RDTSCP计算实际CPU时钟

另外,为什么您害怕使用-O3优化?不要忘记为您的平台构建代码,所以请使用-march=native。如果您的CPU支持AVX或AVX2,编译器将利用这个机会生成更好的代码。

接下来的事情是,给编译器一些关于别名和对齐的提示,如果您知道自己的代码。

这是我的版本mul_c - 是GCC特定的,但您已经表明您使用了GCC。

void mul_c(double* restrict a, double* restrict b)
{
   a = __builtin_assume_aligned (a, 16);
   b = __builtin_assume_aligned (b, 16);

    for (int i = 0; i != 1000000; ++i)
    {
        a[i] = a[i] * b[i];
    }
}

它将产生:
mul_c(double*, double*):
        xor     eax, eax
.L2:
        movapd  xmm0, XMMWORD PTR [rdi+rax]
        mulpd   xmm0, XMMWORD PTR [rsi+rax]
        movaps  XMMWORD PTR [rdi+rax], xmm0
        add     rax, 16
        cmp     rax, 8000000
        jne     .L2
        rep ret

如果您拥有AVX2并确保数据对齐为32字节,它将变得更快。
mul_c(double*, double*):
        xor     eax, eax
.L2:
        vmovapd ymm0, YMMWORD PTR [rdi+rax]
        vmulpd  ymm0, ymm0, YMMWORD PTR [rsi+rax]
        vmovapd YMMWORD PTR [rdi+rax], ymm0
        add     rax, 32
        cmp     rax, 8000000
        jne     .L2
        vzeroupper
        ret

所以如果编译器可以为您完成,就不需要手工制作汇编语言了;)

我尝试使用RDTSCP来测量运行时间,我已经用新代码和结果更新了我的问题。正如我在更新中所写的那样,循环次数变化很大,可能是因为操作系统在其中运行其他进程。有没有办法只计算我的程序期间的循环次数?此外,为什么mul_asm2在计算周期时如此快?我不使用-O3的原因是因为稍后运行代码的系统不允许我指定标志,并且它使用-O2,否则我会使用-O3 ;) 另外,感谢您的提示,我不知道这样的提示是可能的。 - fighting_falcon93
此外,我将来要运行的系统支持AVX2,但我现在正在使用的系统不支持,所以我现在只使用128位(XMM)寄存器。稍后我会改为256位寄存器(YMM)。如果能使用512位寄存器(ZMM)的AVX-512就太酷了,但两个系统都不支持 :'( - fighting_falcon93
@fighting_falcon93 修复C源代码而不是编写汇编的目的在于,您可以在不更改源代码的情况下为两个系统编译(在您的系统上,它将在没有AVX2的情况下编译,在目标系统上,它将在使用AVX2编译(如果使用适当的编译时开关))。那么,如果C足以生成最佳矢量化代码,为什么您还要修复汇编呢? - Ped7g
1
主要是因为我想学习。我认为编写汇编语言并击败编译器很有趣,而且经常会发现编译器做了一些不完全优化的愚蠢事情。我做了很多需要非常重视性能的编程工作,在这些工作中,每少一毫秒都更好,你希望你的代码尽可能地运行得更快,例如在游戏中以及在像CodeChef等网站上与其他人竞争谁的代码更快。因此,我正在尝试寻找新的方法来将我的实现性能推向极限 :) - fighting_falcon93

4
我希望为这个问题添加另一个观点。 如果没有内存限制,SIMD指令可以显著提高性能。但是在当前的示例中,存在过多的内存加载和存储操作以及过少的CPU计算。因此,CPU在不使用SIMD的情况下处理传入数据的时间非常充分。 如果您使用另一种类型的数据(例如32位浮点数)或更复杂的算法,则内存吞吐量不会限制CPU性能,并且使用SIMD将带来更多优势。

这是我的最初想法:内存带宽受限。但在我的测试中,我仍然看到了向量化对于N=1000000(2个双精度数组,因此为16 MB)的显着改进。 - Z boson
考虑到 OP(最后一个实验)中的循环展开实验,我认为我们可以得出结论:CPU 无法并行执行所有物理上可能的内存获取。因此,OP 已经遇到了内存屏障,只是在延迟方面而不是吞吐量方面。 - cmaster - reinstate monica
@Ermlg 很好的观点。有没有确切的方法可以知道实现是否受到内存限制?或者其他类型的限制,例如分支预测错误限制或输入/输出限制? - fighting_falcon93

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接