由于算术强度非常低,稀疏矩阵向量积是一种存储器绑定操作。由于浮点存储格式每非零需要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(浮点数)可以看出很小的差异。
现在,由于矩阵的数据几乎比矢量的数据大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的答案。简而言之,从double到float转换的循环迭代之间的依赖性是造成开销的原因。通过展开循环(参见github上的unroll-loop分支),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
答案 0 :(得分:2)
必须快速转换的双浮动循环不会发出同样快的问题。通过一些循环展开,gcc可能会做得更好。
你的i7-2620M is a dual-core with hyperthreading。当瓶颈是CPU uop吞吐量而不是分支错误预测,缓存未命中,甚至只是长延迟链时,超线程并不会有所帮助。仅通过标量操作来饱和内存带宽并不容易。
从Godbolt Compiler Explorer上的代码的asm输出:gcc 5.3产生相同的内循环BTW,因此在这种情况下你不会因使用旧的gcc版本而丢失很多。
双倍版内循环(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个融合域uops,每两个时钟可以发出一次。它也可以快速执行:三个uop是负载,SnB每2个时钟可以执行4个负载。剩下5个ALU uops(因为SnB不能消除重命名阶段中的reg-reg移动,这是IvB引入的)。无论如何,单个执行端口没有明显的瓶颈。 SnB的三个ALU端口每两个周期最多可处理六个ALU uop。
没有微融合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
用作加载时为零,但在用作注册移动时则不行。在这种情况下,除非要合并,否则请使用movaps
。)
因此,10个融合域uops:每三个时钟可以发出一次迭代。我认为这是唯一的瓶颈,因为它只需要一个需要执行端口的额外ALU uop。 (SnB handles zeroing-idioms in the rename stage, so xorpd
doesn't need one)。 cvtss2sd
是一个2 uop指令,即使gcc使用单寄存器寻址模式,显然也不能进行微熔丝。它的吞吐量为每时钟一个。 (在Haswell上,当与寄存器src和dest一起使用时,它是一个2 uop指令,根据Agner Fog's testing,在Skylake上,吞吐量减少到每2个时钟一个。)仍然不会但是,成为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个融合域uops,并且它不是一个微小的循环,每3个循环只发出2个uop(因为10不是&ta;多个4)。
它可以通过使用置换保存lea
指令,例如[r8+rax*4 + 12]
。 IDK为什么选择不这样做。
甚至-ffast-math
都没有让它进行矢量化。可能没有意义,因为从稀疏矩阵进行聚集会超过从非稀疏向量中加载4或8个连续值的好处。 (来自存储器的insertps
是一个2-uop指令,即使使用单寄存器寻址模式也不能进行微熔丝。)
在Broadwell或Skylake上,vgatherdps
可能足够快,可以加快速度。可能是Skylake的一次大加速。 (可以收集8个单精度浮点数,每5个时钟的吞吐量为8个浮点数。或者vgatherqpd
可以收集4个双精度浮点数,每4个时钟的吞吐量为4个双精度浮点数。这为您设置256b矢量FMA。