如果M
是密集的m x n矩阵且v
是n分量向量,则乘积u = Mv
是由u[i] = sum(M[i,j] * v[j], 1 <= j <= n)
给出的m分量向量。这种乘法的一个简单实现是
allocate m-component vector u of zeroes
for i = 1:m
for j = 1:n
u[i] += M[i,j] * v[j]
end
end
一次构建向量u
一个元素。另一个实现是交换循环:
allocate m-component vector u of zeroes
for j = 1:n
for i = 1:m
u[i] += M[i,j] * v[j]
end
end
整个矢量一起构建。
这些实现中的哪一个(如果有的话)通常用于像C和Fortran这样专为高效数值计算而设计的语言?我的猜测是,像C这样的内部以行主顺序存储矩阵的语言使用前一种实现,而使用列主要顺序的Fortran等语言使用后者,因此内部循环访问矩阵M的连续内存站点。这是正确的吗?
前一个实现似乎更有效,因为写入的内存位置只会更改m
次,而在后一个实现中,写入的内存位置会更改m*n
次,即使只有{{} 1}}唯一的位置被写入。 (当然,通过相同的逻辑,后一种实现对于行向量矩阵乘法会更有效,但这种情况要少得多。)另一方面,我认为Fortran在密集矩阵向量时通常更快。乘法比C,所以也许我要么(a)猜测它们的实现是错误的,要么(b)误判两种实现的相对效率。
答案 0 :(得分:4)
可能使用已建立的BLAS实施是最常见的。除此之外,还有一些简单实现的问题可能会让人感兴趣。例如,在C(或C ++)中,指针别名通常会阻止大量优化,因此for example
void matvec(double *M, size_t n, size_t m, double *v, double * u)
{
for (size_t i = 0; i < m; i++) {
for (size_t j = 0; j < n; j++) {
u[i] += M[i * n + j] * v[j];
}
}
}
由Clang 5(内循环摘录)
转变为此.LBB0_4: # Parent Loop BB0_3 Depth=1
vmovsd xmm1, qword ptr [rcx + 8*rax] # xmm1 = mem[0],zero
vfmadd132sd xmm1, xmm0, qword ptr [r13 + 8*rax - 24]
vmovsd qword ptr [r8 + 8*rbx], xmm1
vmovsd xmm0, qword ptr [rcx + 8*rax + 8] # xmm0 = mem[0],zero
vfmadd132sd xmm0, xmm1, qword ptr [r13 + 8*rax - 16]
vmovsd qword ptr [r8 + 8*rbx], xmm0
vmovsd xmm1, qword ptr [rcx + 8*rax + 16] # xmm1 = mem[0],zero
vfmadd132sd xmm1, xmm0, qword ptr [r13 + 8*rax - 8]
vmovsd qword ptr [r8 + 8*rbx], xmm1
vmovsd xmm0, qword ptr [rcx + 8*rax + 24] # xmm0 = mem[0],zero
vfmadd132sd xmm0, xmm1, qword ptr [r13 + 8*rax]
vmovsd qword ptr [r8 + 8*rbx], xmm0
add rax, 4
cmp r11, rax
jne .LBB0_4
看起来非常痛苦,执行会伤害更多。编译器&#34;必须&#34;这样做是因为u
可能与M
和/或v
混淆,因此对u
的商店会受到极大的怀疑(&#34;必须&#34;在引号,因为编译器可以插入一个别名测试,并为好的情况提供快速路径)。在Fortran中,默认情况下的过程参数不能别名,因此这个问题不存在。这是一个典型的原因,为什么在没有特殊技巧的情况下随机输入的代码在Fortran中比在C中更快 - 我的其余答案不会是这样,我只是要制作C代码更快一点(最后我回到了专栏M
)。在C语言中,别名问题可以be fixed与restrict
一起使用,但它唯一的问题是它不是侵入性的(使用显式累加器而不是求和{ {1}}也可以,但不依赖于魔术关键字)
u[i]
现在发生这种情况:
void matvec(double *M, size_t n, size_t m, double *v, double * restrict u)
{
for (size_t i = 0; i < m; i++) {
for (size_t j = 0; j < n; j++) {
u[i] += M[i * n + j] * v[j];
}
}
}
它不再是标量,所以这很好。但不理想。虽然这里有8个FMA,但它们被安排在四对依赖的FMA中。在整个循环中,实际上只有4个独立的FMA依赖链。 FMA通常具有长延迟和良好的吞吐量,例如在Skylake上它具有4的延迟和2 /周期的吞吐量,因此需要8个独立的FMA链来利用所有的计算吞吐量。 Haswell更糟糕的是,FMA的延迟为5,吞吐量为2 /周期,因此它需要10个独立链。另一个问题是很难实际提供所有这些FMA,上面的结构甚至没有真正尝试:它每个FMA使用2个负载,而负载实际上具有与FMA相同的吞吐量,因此它们的比率应该是1:1。
提高负载:FMA比率可以通过展开外部循环来完成,这样我们就可以重复使用来自.LBB0_8: # Parent Loop BB0_3 Depth=1
vmovupd ymm5, ymmword ptr [rcx + 8*rbx]
vmovupd ymm6, ymmword ptr [rcx + 8*rbx + 32]
vmovupd ymm7, ymmword ptr [rcx + 8*rbx + 64]
vmovupd ymm8, ymmword ptr [rcx + 8*rbx + 96]
vfmadd132pd ymm5, ymm1, ymmword ptr [rax + 8*rbx - 224]
vfmadd132pd ymm6, ymm2, ymmword ptr [rax + 8*rbx - 192]
vfmadd132pd ymm7, ymm3, ymmword ptr [rax + 8*rbx - 160]
vfmadd132pd ymm8, ymm4, ymmword ptr [rax + 8*rbx - 128]
vmovupd ymm1, ymmword ptr [rcx + 8*rbx + 128]
vmovupd ymm2, ymmword ptr [rcx + 8*rbx + 160]
vmovupd ymm3, ymmword ptr [rcx + 8*rbx + 192]
vmovupd ymm4, ymmword ptr [rcx + 8*rbx + 224]
vfmadd132pd ymm1, ymm5, ymmword ptr [rax + 8*rbx - 96]
vfmadd132pd ymm2, ymm6, ymmword ptr [rax + 8*rbx - 64]
vfmadd132pd ymm3, ymm7, ymmword ptr [rax + 8*rbx - 32]
vfmadd132pd ymm4, ymm8, ymmword ptr [rax + 8*rbx]
add rbx, 32
add rbp, 2
jne .LBB0_8
的{{1}}行的负载(这个甚至不是一个缓存考虑因素,但它也有助于此。展开外环也有助于实现更多独立的FMA链。编译器通常不想展开除内循环之外的任何内容,因此需要一些manual work。 &#34;尾部&#34;省略迭代(或:假设v
是4的倍数。)
M
不幸的是,Clang仍然决定将内循环展开错误,错误&#34;是那种天真的连续展开。只要仍然只有4个独立链,就没有多大意义了:
m
如果我们停止懒惰并最终制作一些explicit accumulators:
,这个问题就会消失void matvec(double *M, size_t n, size_t m, double *v, double * restrict u)
{
size_t i;
for (i = 0; i + 3 < m; i += 4) {
for (size_t j = 0; j < n; j++) {
size_t it = i;
u[it] += M[it * n + j] * v[j];
it++;
u[it] += M[it * n + j] * v[j];
it++;
u[it] += M[it * n + j] * v[j];
it++;
u[it] += M[it * n + j] * v[j];
}
}
}
现在Clang这样做了:
.LBB0_8: # Parent Loop BB0_3 Depth=1
vmovupd ymm5, ymmword ptr [rcx + 8*rdx]
vmovupd ymm6, ymmword ptr [rcx + 8*rdx + 32]
vfmadd231pd ymm4, ymm5, ymmword ptr [r12 + 8*rdx - 32]
vfmadd231pd ymm3, ymm5, ymmword ptr [r13 + 8*rdx - 32]
vfmadd231pd ymm2, ymm5, ymmword ptr [rax + 8*rdx - 32]
vfmadd231pd ymm1, ymm5, ymmword ptr [rbx + 8*rdx - 32]
vfmadd231pd ymm4, ymm6, ymmword ptr [r12 + 8*rdx]
vfmadd231pd ymm3, ymm6, ymmword ptr [r13 + 8*rdx]
vfmadd231pd ymm2, ymm6, ymmword ptr [rax + 8*rdx]
vfmadd231pd ymm1, ymm6, ymmword ptr [rbx + 8*rdx]
add rdx, 8
add rdi, 2
jne .LBB0_8
哪个好看。负载:FMA比率为10:8,Haswell的蓄电池太少,因此仍有可能进行一些改进。其他一些有趣的展开组合是(外部x内部)4x3(12个累加器,3个临时,5/4负载:FMA),5x2(10,2,6 / 5),7x2(14,2,8 / 7),15x1 (15,1,16 / 15)。这使得它看起来通过展开外循环更好,但是有太多不同的流(即使不是&#34;流式传输&#34;在#34;流式负载&#34;意义上)对于自动预取是不利的并且当实际流式传输时,超过填充缓冲区的数量可能是不好的(实际细节很少)。手动预取也是一种选择。实现一个真正好的MVM程序需要做更多的工作,尝试很多这些东西。
将商店保存到内部循环外的void matvec(double *M, size_t n, size_t m, double *v, double *u)
{
size_t i;
for (i = 0; i + 3 < m; i += 4) {
double t0 = 0, t1 = 0, t2 = 0, t3 = 0;
for (size_t j = 0; j < n; j++) {
size_t it = i;
t0 += M[it * n + j] * v[j];
it++;
t1 += M[it * n + j] * v[j];
it++;
t2 += M[it * n + j] * v[j];
it++;
t3 += M[it * n + j] * v[j];
}
u[i] += t0;
u[i + 1] += t1;
u[i + 2] += t2;
u[i + 3] += t3;
}
}
意味着不再需要.LBB0_6: # Parent Loop BB0_3 Depth=1
vmovupd ymm8, ymmword ptr [r10 - 32]
vmovupd ymm9, ymmword ptr [r10]
vfmadd231pd ymm6, ymm8, ymmword ptr [rdi]
vfmadd231pd ymm7, ymm9, ymmword ptr [rdi + 32]
lea rax, [rdi + r14]
vfmadd231pd ymm4, ymm8, ymmword ptr [rdi + 8*rsi]
vfmadd231pd ymm5, ymm9, ymmword ptr [rdi + 8*rsi + 32]
vfmadd231pd ymm1, ymm8, ymmword ptr [rax + 8*rsi]
vfmadd231pd ymm3, ymm9, ymmword ptr [rax + 8*rsi + 32]
lea rax, [rax + r14]
vfmadd231pd ymm0, ymm8, ymmword ptr [rax + 8*rsi]
vfmadd231pd ymm2, ymm9, ymmword ptr [rax + 8*rsi + 32]
add rdi, 64
add r10, 64
add rbp, -8
jne .LBB0_6
。最令人印象深刻的是,我认为,没有必要使用SIMD内在函数来实现这一目标 - 如果没有可怕的潜在别名,Clang相当不错。海湾合作委员会和国际刑事法院确实尝试过,但是没有足够的展开,但更多的人工展开可能会成功。
循环平铺也是一种选择,但这是MVM。对于MMM来说,平铺是非常必要的,但是MMM具有几乎无限量的数据重用,MVM没有。只重复使用矢量,矩阵只传输一次。流式传输巨大矩阵的可能内存吞吐量将比不适合缓存的向量更大。
对于列主要的M,它是不同的,没有明显的循环携带依赖性。通过内存存在依赖性,但它有很多时间。负载:FMA比率仍然需要降低,所以它仍然需要一些外环的展开,但总体来说它似乎更容易处理。它可以重新排列以主要使用添加物,但无论如何FMA都具有高吞吐量(在HSW上,高于添加!)。它不需要水平总和,这很烦人,但它们无论如何发生在内环之外。作为回报,内循环中有商店。没有尝试,我不希望这些方法之间存在很大的固有差异,似乎两种方式都应该可调节到计算吞吐量的80%到90%之间(对于可缓存的大小)。 &#34;烦人的额外负担&#34;无论如何都无法避免任意接近100%。