什么是密集矩阵向量乘法的最有效实现?

时间:2017-11-10 20:02:15

标签: performance matrix language-agnostic linear-algebra matrix-multiplication

如果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)误判两种实现的相对效率。

1 个答案:

答案 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 fixedrestrict一起使用,但它唯一的问题是它不是侵入性的(使用显式累加器而不是求和{ {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%。