我写了一个用于快速矩阵乘法的程序。为了最大程度地使用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
简单来说:
为什么?我使用的是g ++ 5.4.0。
inline
不起作用,如果我们将其删除,结果将相同。
答案 0 :(得分:1)
介绍性注释::这篇文章的大部分内容已被重写,因此下面的一些评论不再有意义。如果需要,请在编辑后面查看详细信息。所以...
tl; dr
我同意@ 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可以自己玩。