矩阵乘法速度取决于愚蠢的事物

时间:2018-06-30 05:30:51

标签: c++ optimization g++ cpu-speed

我写了一个用于快速矩阵乘法的程序。为了最大程度地使用CPU缓存,它将矩阵划分为10 * 10个单元,并分别乘以每个单元(将单元大小增加到20 * 20或50 * 50不会显着改变时间)。

事实证明,速度很大程度上取决于是否预先知道矩阵大小和单元格大小。

程序为:

#include <cmath>
#include <cstdlib>
#include <iostream>

using namespace std;

#define forall(i,n) for(int i=0; i<(int)(n); i++)

inline void Load(int N, int N2, float* x2, float* x, int iStart, int jStart) {
    int start = iStart * N + jStart;
    forall (i, N2)
        forall (j, N2)
            x2[i * N2 + j] = x[start + i * N + j];
}

inline void Add(int N, int N2, float* x, float* x2, int iStart, int jStart) {
    int start = iStart * N + jStart;
    forall (i, N2)
        forall (j, N2)
            x[start + i * N + j] += x2[i * N2 + j];
}

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;
            forall (k, N)
                sum += x[i*N+k] * y[k*N+j];
            z[i*N+j] = sum;
        }
}

inline double RandReal() {return random()/((double)RAND_MAX+1);}

int main(int argc, char** argv) {
#if VAR==3
    const int N = atoi(argv[1]), N2 = atoi(argv[2]);
#elif VAR==2
    const int N = 2000, N2 = atoi(argv[2]);
#elif VAR==1
    const int N = atoi(argv[1]), N2 = 10;
#elif VAR==0
    const int N = 2000, N2 = 10;
#else
    #error "Bad VAR"
#endif
    cout << "VAR=" << VAR << " N=" << N << " N2=" << N2 << endl;
    float x[N*N], y[N*N];
    forall (i, N)
        forall (j, N) {
            x[i*N+j] = RandReal();
            y[i*N+j] = RandReal();
        }
    float z[N*N];
    forall (i, N)
        forall (j, N)
            z[i*N+j] = 0;
    for (int i1 = 0; i1 < N; i1 += N2) {
        float x2[N2*N2], y2[N2*N2], z2[N2*N2];
        for (int j1 = 0; j1 < N; j1 += N2) {
            Load(N, N2, x2, x, i1, j1);
            for (int k1 = 0; k1 < N; k1 += N2) {
                Load(N, N2, y2, y, j1, k1);
                Mul(N2, z2, x2, y2);
                Add(N, N2, z, z2, i1, k1);
            }
        }
    }

    double val = 0, val2 = 0;
    forall (i, N)
        forall (j, N)
            val += z[i*N+j], val2 += z[i*N+j]*(i+j);
    cout << "val=" << val << " val2=" << val2 << endl;
}

现在执行时间:

$ for a in 0 1 2 3; do g++ -DVAR=$a -O3 -Wall -o mat mat.cpp; time ./mat 2000 10; done
VAR=0 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m8.127s
user    0m8.108s
sys     0m0.020s
VAR=1 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m3.304s
user    0m3.292s
sys     0m0.012s
VAR=2 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m25.395s
user    0m25.388s
sys     0m0.008s
VAR=3 N=2000 N2=10
val=2.00039e+09 val2=3.99867e+12

real    0m25.515s
user    0m25.495s
sys     0m0.016s

简单来说:

  • 不知道矩阵大小,不知道像元大小:3.3秒
  • 了解矩阵大小和单元格大小:8.1秒
  • 不知道单元格大小:25.5秒

为什么?我使用的是g ++ 5.4.0。

inline不起作用,如果我们将其删除,结果将相同。

1 个答案:

答案 0 :(得分:1)

介绍性注释::这篇文章的大部分内容已被重写,因此下面的一些评论不再有意义。如果需要,请在编辑后面查看详细信息。所以...

tl; dr

  • 用于查找热循环的配置文件
  • 尽可能使用常量计数
  • 如果没有,请尝试手动将其展开
  • OP的结果很神秘,没有人能重现如此极端的一切

我同意@ user4581301-编译器提前知道的越多,就优化代码而言,它就可以为您做更多的事情。

但是您需要分析此代码-挂钟时间只会带您走这么远。我对gcc的探查器一无所知(我对MSVC有很好的探查器),但是您可以尝试运气here

使用Godbolt作为工具,尝试学习一些汇编程序也是值得的(正如@RetiredNinja所说的那样),特别是如果您想了解如此剧烈的减速时。

现在已经说了这么多,您的时间对我来说毫无意义,所以您的系统上正在发生一些奇怪的事情。因此,我自己进行了一些测试,结果与您的明显不同。我在MSVC上运行了其中一些测试(因为我在那里有如此出色的配置工具),而在Mac上的gcc上进行了一些测试(尽管我认为实际上实际上是在暗地里发出叮当声)。我没有linux机器,抱歉。

首先,让我们处理在堆栈上分配此类大对象的问题。这显然是不明智的,由于它不支持可变长度数组,因此我根本无法在MSVC上执行此操作,但是我在Mac上进行的测试表明,一旦我增加了{{1} }使其完全起作用(请参见here)。因此,正如您自己在评论中所说,我认为我们可以将此作为一个因素。

因此,现在让我们看看在Mac上获得的计时:

ulimit

在那儿看到不多;让我们继续我在MSVC上观察到的内容(我可以在其中进行剖析):

VAR=0 USE_STACK=0 N=2000 (known) N2=10 (known)
user    0m10.813s

VAR=1 USE_STACK=0 N=2000 (unknown) N2=10 (known)
user    0m11.008s

VAR=2 USE_STACK=0 N=2000 (known) N2=10 (unknown)
user    0m12.714s

VAR=3 USE_STACK=0 N=2000 (unknown) N2=10 (unknown)
user    0m12.778s

VAR=0 USE_STACK=1 N=2000 (known) N2=10 (known)
user    0m10.617s

VAR=1 USE_STACK=1 N=2000 (unknown) N2=10 (known)
user    0m10.987s

VAR=2 USE_STACK=1 N=2000 (known) N2=10 (unknown)
user    0m12.653s

VAR=3 USE_STACK=1 N=2000 (unknown) N2=10 (unknown)
user    0m12.673s

现在,我们有一些可以磨合的东西。就像@geza观察到的那样,在未知VAR=0 USE_STACK=0 N=2000 (known) N2=10 (known) Elapsed: 0:00:06.89 VAR=1 USE_STACK=0 N=2000 (unknown) N2=10 (known) Elapsed: 0:00:06.86 VAR=2 USE_STACK=0 N=2000 (known) N2=10 (unknown) Elapsed: 0:00:10.24 VAR=3 USE_STACK=0 N=2000 (unknown) N2=10 (unknown) Elapsed: 0:00:10.39 的情况下,代码需要花费更长的时间运行,这完全符合人们的预期,因为这是热循环所在的位置,而且编译器更有可能当知道它实际上由少量已知的迭代组成时,它将展开这样的循环。

因此,让我们从探查器中获取一些信息。它告诉我,热循环(相当长的时间)是N2中的内部循环:

Mul()

=>全部(k,N) =>和+ = x [i * N + k] * y [k N + j];                 z [i N + j] =和;            }     }

同样,我不能说这让我感到很惊讶,当我看一下代码时,我可以看到循环根本没有展开(为简洁起见,省略了循环设置代码):

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;

现在看来,通过展开该循环似乎不会有任何节省,因为与执行其中的所有其余代码相比,循环将是便宜的,但是如果您查看反汇编的话知道$1: movss xmm0,dword ptr [r9+rsi*4] mulss xmm0,dword ptr [r8+4] movss xmm1,dword ptr [r9+r15*4] mulss xmm1,dword ptr [r8] cvtps2pd xmm2,xmm0 cvtps2pd xmm0,xmm1 movss xmm1,dword ptr [r8+8] mulss xmm1,dword ptr [r9] addsd xmm0,xmm3 addsd xmm2,xmm0 cvtps2pd xmm0,xmm1 movss xmm1,dword ptr [r9+r14*4] movaps xmm3,xmm2 mulss xmm1,dword ptr [r8+0Ch] add r9,rbp add r8,10h addsd xmm3,xmm0 cvtps2pd xmm0,xmm1 addsd xmm3,xmm0 sub rcx,1 jne $1 时发生的同一循环,您会感到惊讶:

N2

现在,没有 循环,并且将明显减少将整体执行的指令数量。也许毕竟,MS并不是那么愚蠢的家伙。

最后,作为练习,让我们快速手动展开该循环并查看获得的时间(我没有看生成的代码):

    movss       xmm0,dword ptr [rax-8]  
    mulss       xmm0,dword ptr [rcx-50h]  
    cvtps2pd    xmm2,xmm0  
    movss       xmm0,dword ptr [rcx-28h]  
    mulss       xmm0,dword ptr [rax-4]  
    addsd       xmm2,xmm7  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx]  
    mulss       xmm0,dword ptr [rax]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+28h]  
    mulss       xmm0,dword ptr [rax+4]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+50h]  
    mulss       xmm0,dword ptr [rax+8]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+78h]  
    mulss       xmm0,dword ptr [rax+0Ch]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0A0h]  
    mulss       xmm0,dword ptr [rax+10h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0C8h]  
    mulss       xmm0,dword ptr [rax+14h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+0F0h]  
    mulss       xmm0,dword ptr [rax+18h]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    movss       xmm0,dword ptr [rcx+118h]  
    mulss       xmm0,dword ptr [rax+1Ch]  
    addsd       xmm2,xmm1  
    cvtps2pd    xmm1,xmm0  
    addsd       xmm2,xmm1  
    cvtpd2ps    xmm0,xmm2  
    movss       dword ptr [rdx+rcx],xmm0  

当我这样做的时候,我得到了:

#define UNROLL_LOOP 1

inline void Mul(int N, float* z, float* x, float* y) {
    forall (i, N)
        forall (j, N) {
            double sum = 0;
#if UNROLL_LOOP
            assert (N == 10);
            sum += x[i*N] * y[0*N+j];
            sum += x[i*N+1] * y[1*N+j];
            sum += x[i*N+2] * y[2*N+j];
            sum += x[i*N+3] * y[3*N+j];
            sum += x[i*N+4] * y[4*N+j];
            sum += x[i*N+5] * y[5*N+j];
            sum += x[i*N+6] * y[6*N+j];
            sum += x[i*N+7] * y[7*N+j];
            sum += x[i*N+8] * y[8*N+j];
            sum += x[i*N+9] * y[9*N+j];
#else
            forall (k, N)
                sum += x[i*N+k] * y[k*N+j];
#endif
            z[i*N+j] = sum;
        }
}

所以那是您需要经历的过程来分析这样的性能问题,并且您需要好的工具。我不知道您的情况如何,因为我无法重现此问题,但是当(少量)循环计数未知时,循环展开(按预期)是MSVC的主要因素。

我使用的测试代码为here,以防有人参考。我想你欠我一票,OP。

编辑:

使用gcc 9.0.0在Wandbox上玩了一点。时间(由于我们是在共享盒上运行,或者更有可能在虚拟机中运行,因此速度较慢且不精确):

VAR = 0 USE_STACK = 0 N = 2000(已知)N2 = 10(已知) 经过的时间=〜8sec

VAR = 3 USE_STACK = 0 N = 2000(未知)N2 = 10(未知) 经过的时间=〜15.5sec

VAR = 3 USE_STACK = 0 N = 2000(未知)N2 = 10(未知),循环展开 经过的时间=〜13.5sec

因此,需要使用探查器并查看生成的代码进行更多的调查,并且距OP所获得的内容还有100万英里。

Live demo-如果您想尝试其他操作,OP可以自己玩。