single/double 精度 SpMV 在 CPU 上的性能

Performance of single/double precision SpMV on CPUs

由于运算强度非常低,稀疏矩阵向量乘积是一种内存受限操作。由于浮点存储格式需要 4+4=8 个字节的每个非零,而双精度数(值和列索引)需要 4+8=12 个字节,因此当切换到浮点时,执行速度应该可以提高 33%。我构建了一个基准,它组装了一个 1000000x1000000 矩阵,每行有 200 个非零值,然后从 20 次乘法中取最小值。 github here.

上的源代码

结果和我预想的差不多。当我 运行 在我的英特尔酷睿 i7-2620M 上进行基准测试时,我发现执行速度提高了 30%。在 spec 中的 21.3 GB/s 中,可以看出带宽从大约 19.0 GB/s(双倍)下降到大约 18.0 GB/s(浮点)的微小差异。 =15=]

现在,由于矩阵的数据比向量的数据大将近 1000,因此对于只有矩阵是单精度的情况,人们会期望获得更快的性能,但是向量仍然是双打。我继续尝试这个,然后确保使用较低的精度进行计算。然而,当我 运行 它时,有效带宽使用率突然下降到大约 14.4 GB/s,执行速度只比完整的双版本快 12%。如何理解这一点?


我正在使用 Ubuntu 14.04 和 GCC 4.9.3。

Run times:

// double(mat)-double(vec)
Wall time: 0.127577 s
Bandwidth: 18.968 GB/s
Compute:   3.12736 Gflop/s

// float(mat)-float(vec)
Wall time: 0.089386 s
Bandwidth: 18.0333 GB/s
Compute:   4.46356 Gflop/s

// float(mat)-double(vec)
Wall time: 0.112134 s
Bandwidth: 14.4463 GB/s
Compute:   3.55807 Gflop/s

更新

请参阅下面 Peter Cordes 的回答。简而言之,从双精度到浮点转换的循环迭代之间的依赖关系是造成开销的原因。通过展开循环(参见 github 处的展开循环分支),float-double 和 float-float 版本都重新获得了全部带宽使用!

New run times:

// float(mat)-float(vec)
Wall time: 0.084455 s
Bandwidth: 19.0861 GB/s
Compute:   4.72417 Gflop/s

// float(mat)-double(vec)
Wall time: 0.0865598 s
Bandwidth: 18.7145 GB/s
Compute:   4.6093 Gflop/s

必须即时转换的双浮点循环不能发出那么快。随着一些循环展开,gcc 可能会做得更好。

你的i7-2620M is a dual-core with hyperthreading。当瓶颈是 CPU uop 吞吐量时,超线程无济于事,而不是分支预测错误、缓存未命中,甚至只是长延迟链。仅通过标量运算使内存带宽饱和并不容易。


从您在 Godbolt Compiler Explorer 上的代码的 asm 输出:gcc 5.3 进行了大致相同的内部循环,顺便说一句,因此在这种情况下,您不会因为使用旧的 gcc 版本而损失太多。

double-double version inner loop (gcc 4.9.3 -O3 -march=sandybridge -fopenmp):

## inner loop of <double,double>mult() with fused-domain uop counts
.L7:
    mov     edx, eax  # 1 uop
    add     eax, 1    # 1 uop
    mov     ecx, DWORD PTR [r9+rdx*4] # 1 uop
    vmovsd  xmm0, QWORD PTR [r10+rdx*8] # 1 uop
    vmulsd  xmm0, xmm0, QWORD PTR [r8+rcx*8]    # 2 uops
    vaddsd  xmm1, xmm1, xmm0    # 1 uop
    cmp     eax, esi  #  (macro-fused)
    jne     .L7       #  1 uop

总计:8 个融合域微指令,每两个时钟可以发出一个迭代器。它还可以执行得那么快:其中三个微指令是负载,SnB 每 2 个时钟可以执行 4 次负载。剩下 5 个 ALU 微指令(因为 SnB 无法在重命名阶段消除 reg-reg 移动,这是在 IvB 中引入的)。反正单个执行端口没有明显的瓶颈。 SnB 的三个 ALU 端口每两个周期最多可以处理六个 ALU 微指令。

没有微融合because of using two-register addressing modes


双浮点版本内循环:

## inner loop of <double,float>mult() with fused-domain uop counts
.L7:
    mov     edx, eax  # 1 uop
    vxorpd  xmm0, xmm0, xmm0    # 1 uop (no execution unit needed).
    add     eax, 1    # 1 uop
    vcvtss2sd       xmm0, xmm0, DWORD PTR [r9+rdx*4]      # 2 uops
    mov     edx, DWORD PTR [r8+rdx*4] # 1 uop
    vmulsd  xmm0, xmm0, QWORD PTR [rsi+rdx*8]   # 2 uops
    vaddsd  xmm1, xmm1, xmm0    # 1 uop
    cmp     eax, ecx  # (macro-fused)
    jne     .L7       # 1 uop

gcc 使用 xorpd 来打破循环携带的依赖链。 cvtss2sd 错误地依赖于 xmm0 的旧值,因为它设计不当并且不会将寄存器的上半部分清零。 (movsd 用作负载时为零,但用作 reg-reg 移动时则不会。在这种情况下,除非你想合并,否则请使用 movaps。)

因此,10 个融合域微指令:可以每三个时钟迭代一次。我认为这是唯一的瓶颈,因为它只是一个需要执行端口的额外 ALU uop。 (). cvtss2sd is a 2 uop instruction that apparently can't micro-fuse even if gcc used a one-register addressing mode. It has a throughput of one per clock. (On Haswell, it's a 2 uop instruction when used with a register src and dest, and on Skylake the throughput is reduced to one per 2 clocks, according to Agner Fog's testing.) 不过,这仍然不是 Skylake 上此循环的瓶颈。 Haswell / Skylake 上仍然是 10 个融合域 uops,这仍然是瓶颈。


-funroll-loops 应该有助于 gcc 4.9.3

gcc 做得还不错,代码像

    mov     edx, DWORD PTR [rsi+r14*4]        # D.56355, *_40
    lea     r14d, [rax+2]     # D.56355,
    vcvtss2sd       xmm6, xmm4, DWORD PTR [r8+r14*4]      # D.56358, D.56358, *_36
    vmulsd  xmm2, xmm1, QWORD PTR [rcx+rdx*8]   # D.56358, D.56358, *_45
    vaddsd  xmm14, xmm0, xmm13  # tmp, tmp, D.56358
    vxorpd  xmm1, xmm1, xmm1    # D.56358

    mov     edx, DWORD PTR [rsi+r14*4]        # D.56355, *_40
    lea     r14d, [rax+3]     # D.56355,
    vcvtss2sd       xmm10, xmm9, DWORD PTR [r8+r14*4]     # D.56358, D.56358, *_36
    vmulsd  xmm7, xmm6, QWORD PTR [rcx+rdx*8]   # D.56358, D.56358, *_45
    vaddsd  xmm3, xmm14, xmm2   # tmp, tmp, D.56358
    vxorpd  xmm6, xmm6, xmm6    # D.56358

没有循环开销,每个元素的工作减少到 8 个融合域微指令,并且它不是一个每 3 个周期只发出 2 微指令的小循环(因为 10 不是 4 的倍数).

它可以通过使用位移来节省lea指令,例如[r8+rax*4 + 12]。 IDK 为什么 gcc 选择不这样做。

甚至 -ffast-math 都无法对其进行矢量化。这可能没有意义,因为从稀疏矩阵进行收集会超过从非稀疏向量加载 4 或 8 个连续值的好处。 (内存中的 insertps 是一条 2-uop 指令,即使使用单寄存器寻址模式也无法微融合。)

在 Broadwell 或 Skylake 上,vgatherdps 可能足够快以提供加速。可能是 Skylake 的一大加速。 (可以收集 8 个单精度浮点数,吞吐量为每 5 个时钟 8 个浮点数。或者 vgatherqpd 可以收集 4 个双精度浮点数,吞吐量为每 4 个时钟 4 个双精度浮点数)。这将为您设置 256b 矢量 FMA。