【问题标题】:Optimized 2x2 matrix multiplication: Slow assembly versus fast SIMD优化的 2x2 矩阵乘法:慢速组装与快速 SIMD
【发布时间】:2014-07-10 13:08:22
【问题描述】:

问题

我正在研究 OpenBLAS 或 GotoBLAS 等高性能矩阵乘法算法,我正在尝试重现一些结果。这个问题涉及矩阵乘法算法的内核。具体来说,我正在研究计算C += AB,其中ABdouble 类型的2x2 矩阵,在我的CPU 的峰值速度下。有两种方法可以做到这一点。一种方法是使用 SIMD 指令。第二种方法是使用 SIMD 寄存器直接在汇编中编码。

到目前为止我所看到的

所有相关论文、课程网页、许多与该主题相关的 SO Q&A(太多无法列出),我在我的计算机上编译了 OpenBLAS,查看了 OpenBLAS、GotoBLAS 和 BLIS 源代码,Agner 的手册。

硬件

我的 CPU 是 Intel i5 - 540M。您可以在 cpu-world.com 上找到相关的 CPUID 信息。微架构是 Nehalem (westmere),因此理论上每个核心每个周期可以计算 4 个双精度触发器。我将只使用一个内核(无 OpenMP),因此在关闭超线程和 4 步 Intel Turbo Boost 的情况下,我应该会看到 ( 2.533 Ghz + 4*0.133 Ghz ) * ( 4 DP flops/core/cycle ) * ( 1 core ) = 12.27 DP Gflops 的峰值。作为参考,当两个内核都在峰值运行时,Intel Turbo Boost 提供了 2 步加速,我应该得到 22.4 DP Gflops 的理论峰值。

设置

我将我的 2x2 矩阵声明为 double 并使用随机条目初始化它们,如下面的代码 sn-p 所示。

srand(time(NULL));
const int n = 2;
double A[n*n];
double B[n*n];
double C[n*n];
double T[n*n];
for(int i = 0; i < n*n; i++){
    A[i] = (double) rand()/RAND_MAX;
    B[i] = (double) rand()/RAND_MAX;
    C[i] = 0.0;
}

我使用简单的矩阵-矩阵乘法(如下所示)计算出一个真实的答案,这使我可以通过视觉或计算所有元素的 L2 范数来检查我的结果

// "true" answer
for(int i = 0; i < n; i++)
    for(int j = 0; j < n; j++)
        for(int k = 0; k < n; k++)
            T[i*n + j] += A[i*n + k]*B[k*n + j];

为了运行代码并获得 Gflops 的估计值,我调用每个乘法函数一次以进行预热,然后在 for 循环中执行它 maxiter 次,确保每次将 C 矩阵归零我正在计算 C += AB 的时间。 for 循环放置在两个 clock() 语句中,用于估计 Gflops。代码 sn -p blow 说明了这部分。

C[0] = 0.0; C[1] = 0.0; C[2] = 0.0; C[3] = 0.0;
mult2by2(A,B,C); //warmup
time1 = clock();
for(int i = 0; i < maxiter; i++){
        mult2by2(A,B,C);
        C[0] = 0.0; C[1] = 0.0; C[2] = 0.0; C[3] = 0.0;
}
time2 = clock() - time1;
time3 = (double)(time2)/CLOCKS_PER_SEC;
gflops = (double) (2.0*n*n*n)/time3/1.0e9*maxiter;
mult2by2(A,B,C); // to compute the norm against T
norm = L2norm(n,C,T);

SIMD 代码

我的 CPU 支持 128 位向量,所以我可以在每个向量中放入 2 个doubles。这是我在内核中进行 2x2 矩阵乘法的主要原因。 SIMD 代码一次计算一整行 C

    inline void 
    __attribute__ ((gnu_inline))        
    __attribute__ ((aligned(16))) mult2by2B(        
            const double* restrict A,
            const double* restrict B,
            double* restrict C
        )

    {

    register __m128d xmm0, xmm1, xmm2, xmm3, xmm4;
    xmm0 = _mm_load_pd(C);
    xmm1 = _mm_load1_pd(A);
    xmm2 = _mm_load_pd(B);
    xmm3 = _mm_load1_pd(A + 1);
    xmm4 = _mm_load_pd(B + 2);
    xmm1 = _mm_mul_pd(xmm1,xmm2);
    xmm2 = _mm_add_pd(xmm1,xmm0);
    xmm1 = _mm_mul_pd(xmm3,xmm4);
    xmm2 = _mm_add_pd(xmm1,xmm2);
    _mm_store_pd(C,xmm2);

    xmm0 = _mm_load_pd(C + 2);
    xmm1 = _mm_load1_pd(A + 2);
    xmm2 = _mm_load_pd(B);
    xmm3 = _mm_load1_pd(A + 3);
    //xmm4 = _mm_load_pd(B + 2);
    xmm1 = _mm_mul_pd(xmm1,xmm2);
    xmm2 = _mm_add_pd(xmm1,xmm0);
    xmm1 = _mm_mul_pd(xmm3,xmm4);
    xmm2 = _mm_add_pd(xmm1,xmm2);
    _mm_store_pd(C + 2,xmm2);
}

汇编(英特尔语法)

我的第一次尝试是为此部件创建一个单独的汇编例程,并从main 例程中调用它。但是,它非常慢,因为我无法内联 extern 函数。我将程序集编写为内联程序集,如下所示。它与gcc -S -std=c99 -O3 -msse3 -ffast-math -march=nocona -mtune=nocona -funroll-all-loops -fomit-frame-pointer -masm=intel 产生的相同。根据我对 Nehalem 微架构图的了解,这款处理器可以并行执行SSE ADDSSE MULSSE MOV,这就解释了MULADDMOV 指令的交错。你会注意到上面的 SIMD 指令的顺序不同,因为我对 Agner Fog 的手册有不同的理解。不过,gcc 很聪明,上面的 SIMD 代码编译为内联版本中显示的程序集。

inline void 
__attribute__ ((gnu_inline))        
__attribute__ ((aligned(16))) mult2by2A
    (   
        const double* restrict A,
        const double* restrict B,
        double* restrict C
    )
    {
    __asm__ __volatile__
    (
    "mov        edx, %[A]                   \n\t"
    "mov        ecx, %[B]                   \n\t"
    "mov        eax, %[C]                   \n\t"
    "movapd     xmm3, XMMWORD PTR [ecx]     \n\t"
    "movapd     xmm2, XMMWORD PTR [ecx+16]  \n\t"
    "movddup    xmm1, QWORD PTR [edx]       \n\t"
    "mulpd      xmm1, xmm3                  \n\t"
    "addpd      xmm1, XMMWORD PTR [eax]     \n\t"
    "movddup    xmm0, QWORD PTR [edx+8]     \n\t"
    "mulpd      xmm0, xmm2                  \n\t"
    "addpd      xmm0, xmm1                  \n\t"
    "movapd     XMMWORD PTR [eax], xmm0     \n\t"
    "movddup    xmm4, QWORD PTR [edx+16]    \n\t"
    "mulpd      xmm4, xmm3                  \n\t"
    "addpd      xmm4, XMMWORD PTR [eax+16]  \n\t"
    "movddup    xmm5, QWORD PTR [edx+24]    \n\t"
    "mulpd      xmm5, xmm2                  \n\t"
    "addpd      xmm5, xmm4                  \n\t"
    "movapd     XMMWORD PTR [eax+16], xmm5  \n\t"
    : // no outputs 
    : // inputs
    [A] "m" (A),
    [B] "m" (B), 
    [C] "m" (C)
    : //register clobber
    "memory",
    "edx","ecx","eax",
    "xmm0","xmm1","xmm2","xmm3","xmm4","xmm5"
    );
}

结果

我使用以下标志编译我的代码:

gcc -std=c99 -O3 -msse3 -ffast-math -march=nocona -mtune=nocona -funroll-all-loops -fomit-frame-pointer -masm=intel

maxiter = 1000000000 的结果如下:

********** Inline ASM
L2 norm: 0.000000e+000, Avg. CPU time: 9.563000, Avg. Gflops: 1.673115

********** SIMD Version
L2 norm: 0.000000e+000, Avg. CPU time: 0.359000, Avg. Gflops: 44.568245

如果我强制 SIMD 版本不与__attribute__ ((noinline)) 内联,结果是:

********** Inline ASM
L2 norm: 0.000000e+000, Avg. CPU time: 11.155000, Avg. Gflops: 1.434334

********** SIMD Version
L2 norm: 0.000000e+000, Avg. CPU time: 11.264000, Avg. Gflops: 1.420455

问题

  1. 如果内联 ASM 和 SIMD 实现都产生相同的程序集输出,为什么程序集版本要慢得多?就好像内联程序集没有内联,第二组结果显示“内联”ASM 与“非内联”SIMD 的性能相同,这一点很明显。我能找到的唯一解释是在Agner Fog Volume 2 page 6

    编译代码可能比汇编代码更快,因为编译器可以使 程序间优化和整个程序优化。大会 程序员通常必须使用定义明确的调用来制作定义明确的函数 遵循所有调用约定的接口,以使代码可测试和 可验证的。这会阻止编译器使用的许多优化方法,例如 作为函数内联、寄存器分配、常量传播、公共子表达式 跨职能消除、跨职能调度等。这些 可以通过使用带有内部函数的 C++ 代码而不是 汇编代码。

    但两个版本的汇编输出完全相同。

  2. 为什么我在第一组结果中看到 44 Gflops?这远高于我计算的 12 Gflops 峰值,如果我以单精度计算运行两个内核,这也是我所期望的。

编辑 1 评论说可能有死代码消除我可以确认 SIMd 指令正在发生这种情况。 -S 输出显示 SIMD 的 for 循环仅将 C 矩阵归零。我可以通过使用-O0 关闭编译器优化来禁用它。在这种情况下,SIMD 的运行速度是 ASM 的 3 倍,但 ASM 仍然以完全相同的速度运行。现在标准也是非零的,但在 10^-16 时仍然可以。我还看到内联 ASM 版本与 APPNO_APP 标记内联,但它也在 for 循环中展开了 8 次。我认为展开多次会严重影响性能,因为我通常展开循环 4 次。根据我的经验,再多的东西似乎会降低性能。

【问题讨论】:

  • 当你内联函数时,GCC 可能会跳过循环中的函数,因为 `C[0] = 0.0; C[1] = 0.0; C[2] = 0.0; C[3] = 0.0;` 您仍然可以在程序集中看到该函数,因为您在规范之前调用它。
  • 使用 2x2 矩阵无法接近峰值性能。您需要每个时钟周期进行一次乘法、一次加法和一次加载。但是由于您还必须从 A 矩阵中读取数据,所以它更像是两次加载、一次乘法和一次加法。在我的代码中,我使用 AVX 执行 64x64 块。我做了 9 次加载(1 次来自 A,8 次来自 B)、8 次乘法和每行 8 次加法,因此我更接近每个时钟周期的一次乘法、一次加法和一次加载。 n 越大越好,除了 nxn 矩阵也需要适合 L1 缓存。
  • 不知道你是否也有机会使用FORTRAN
  • 我也有一个 4x4 版本,但在整个矩阵乘法框架中,它的运行速度比使用 2x2 时慢很多。不知道为什么...ja72 我不知道这有多大帮助,gcc 可能会将其编译为具有正确优化的相同机器代码。
  • @matmul,4x4 矩阵的失败次数是 2x2 的 8 倍,所以它应该花费更多时间,但也许你的意思是 GFLOPS 较低。那会很奇怪。您将读取 A 一次和 B 两次的每一行,然后执行 2 add 和 2 mult:3 加载,4 计算。其中 2x2 是 2 负载,2 计算,因此负载与计算的比率对于 2x2 更高,但我承认我还没有研究过这么小的矩阵的性能。

标签: c assembly matrix


【解决方案1】:

GCC 正在使用内部函数 mult2by2B 优化您的内联函数,因为

C[0] = 0.0; C[1] = 0.0; C[2] = 0.0; C[3] = 0.0;

如果没有这条线,Coliru 在计算机上需要 2.9 秒 http://coliru.stacked-crooked.com/a/992304f5f672e257

而这条线只需要 0.000001 http://coliru.stacked-crooked.com/a/9722c39bb6b8590a

您也可以在程序集中看到这一点。如果你将下面的代码放到http://gcc.godbolt.org/ 中,你会看到那行代码完全跳过了这个函数。

但是,当您内联程序集时,GCC 并未优化函数 mult2by2A(即使它内联了它)。您也可以在程序集中看到这一点。

#include <stdio.h>
#include <emmintrin.h>                 // SSE2
#include <omp.h>

inline void 
    __attribute__ ((gnu_inline))        
    __attribute__ ((aligned(16))) mult2by2B(        
            const double* __restrict A,
            const double* __restrict B,
            double* __restrict C
        )

    {

    register __m128d xmm0, xmm1, xmm2, xmm3, xmm4;
    xmm0 = _mm_load_pd(C);
    xmm1 = _mm_load1_pd(A);
    xmm2 = _mm_load_pd(B);
    xmm3 = _mm_load1_pd(A + 1);
    xmm4 = _mm_load_pd(B + 2);
    xmm1 = _mm_mul_pd(xmm1,xmm2);
    xmm2 = _mm_add_pd(xmm1,xmm0);
    xmm1 = _mm_mul_pd(xmm3,xmm4);
    xmm2 = _mm_add_pd(xmm1,xmm2);
    _mm_store_pd(C,xmm2);

    xmm0 = _mm_load_pd(C + 2);
    xmm1 = _mm_load1_pd(A + 2);
    xmm2 = _mm_load_pd(B);
    xmm3 = _mm_load1_pd(A + 3);
    //xmm4 = _mm_load_pd(B + 2);
    xmm1 = _mm_mul_pd(xmm1,xmm2);
    xmm2 = _mm_add_pd(xmm1,xmm0);
    xmm1 = _mm_mul_pd(xmm3,xmm4);
    xmm2 = _mm_add_pd(xmm1,xmm2);
    _mm_store_pd(C + 2,xmm2);
}

int main() {
  double A[4], B[4], C[4];
  int maxiter = 10000000;
  //int maxiter = 1000000000;
  double dtime;
  dtime = omp_get_wtime();
  for(int i = 0; i < maxiter; i++){
        mult2by2B(A,B,C);
        C[0] = 0.0; C[1] = 0.0; C[2] = 0.0; C[3] = 0.0;
  }
  dtime = omp_get_wtime() - dtime;
  printf("%f %f %f %f\n", C[0], C[1], C[2], C[3]);
  //gflops = (double) (2.0*n*n*n)/time3/1.0e9*maxiter;
  printf("time %f\n", dtime);
}

【讨论】:

  • OK 我可以看到删除C 零行并不能消除死代码,但它会完全编写不同的程序集。由于循环展开,我看到了整个系列的addpd,因为现在C 只是+=' 每次迭代。你能建议另一种方法来阻止死代码消除,这样我就可以对苹果进行比较?
猜你喜欢
  • 1970-01-01
  • 2017-01-25
  • 1970-01-01
  • 2021-02-28
  • 2011-11-30
  • 2015-08-14
  • 2015-04-07
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多