我手动矢量化循环并一次处理4个项目。项目总数可能不是4的倍数,所以我在主循环结束时留下了一些项目。我认为如果计数大于4并且重做某些项目是安全的,我可以使用相同的主矢量化循环执行剩余项目。例如,如果我需要处理10个项目,我可以在三次迭代中处理0123,4567,6789。我找不到任何对这种技术的引用。它是愚蠢但我不知道怎么样?
assessment
答案 0 :(得分:5)
当您的输入和输出不重叠,并且可以安全地多次重新处理同一元素时,这个一般的想法很棒。当输出是只写时,通常就是这种情况。例如out[i] = pure_func(in[i])
是幂等的,但out[i] += func(in[i])
不是。像sum += in[i]
这样的缩减也不太合适。
当它可用时,它通常比标量清理循环更好。
如果不是这么简单,请参阅@Paul R的评论和相关问题:Vectorizing with unaligned buffers: using VMASKMOVPS: generating a mask from a misalignment count? Or not using that insn at all(TL:DR:实际上使用vmaskmovps
isn'通常很好,但其他掩蔽和未对齐加载技巧。)
你的具体实现(使重复的最后一个向量重复使用相同的循环)最终使clang内部循环变得非常糟糕,在每个内循环迭代中执行i+8
和i+4
。
gcc管理内部循环稍差一点,但它的效率仍然低于gcc7.2 -O3 -mtune=haswell
(在Godbolt上输出asm)。内部循环有额外的开销,因为它每次i
都会保存旧的i += 4
,因为它与循环条件中的i+4
之间的CSE和/或循环外的i = count - 4
。 (gcc有时候非常愚蠢地将额外的工作放在内部循环中,而不是在之后重新计算或撤消操作。)
Source + asm on the Godbolt compiler explorer (原始版本和改进版本(见下文))。
# Your original source, built with gcc7.2 -O3
# we get here with some registers already set up when count >= 4
.L2: # top of outer "loop"
lea rcx, [rax+4]
cmp rcx, rdx
ja .L4
.L17: # Inner loop
movdqu xmm0, XMMWORD PTR [rdi+rax*4]
paddd xmm0, xmm1
movups XMMWORD PTR [rsi+rax*4], xmm0
mov rax, rcx # save RAX for use outside the loop!
lea rcx, [rax+4] # 3 uops of loop overhead
cmp rcx, rdx
jbe .L17
.L4:
# missed optimization: do rcx-4 here instead of extra work in every inner-loop iteration
cmp rax, rdx
je .L1 # ret if we're done (whole number of vectors)
mov rax, r8
jmp .L2 # else back into the loop for the last vector
使用索引寻址模式并不会对SSE2造成特别的伤害,但AVX并不是一件好事。 AVX允许未对齐的内存操作数到任何指令(vmovdqa
除外),因此如果使用vpaddd xmm0, xmm1, [rdi+rax*4]
构建,编译器可以将负载折叠为-march=haswell
。 But that can't micro-fuse even on Haswell,所以前端仍然是2 uops。
我们可以使用i <= count - 4
来修复clang和gcc的内部循环。我们知道count >= 4
此时count - 4
永远不会包含大量数字。 (注意i + 4
可以换行,因此如果count
在类型的最大值的4之内,则会创建一个无限循环。这可能是因为clang如此困难并导致错过优化
现在我们从gcc7.2和clang5.0(都使用-O3 -march=haswell
)得到一个相同的内循环。 (字面上相同,即使使用相同的寄存器,只是一个不同的标签名称。)
.L16:
vpaddd xmm0, xmm1, XMMWORD PTR [rdi+rax*4]
vmovups XMMWORD PTR [rsi+rax*4], xmm0
add rax, 4
cmp rax, rcx
jbe .L16
这是Haswell上的5个融合域uop,因此每1.25个时钟最多可以运行一次,前端瓶颈,而不是加载,存储或SIMD paddd
吞吐量。 (它可能会在大输入上对内存带宽产生瓶颈,但即使在这种情况下,至少展开一点也是一件好事。)
clang在自动矢量化时会为你展开,并使用AVX2,因此在编译器可以轻松完成的情况下,通过手动矢量化你实际上会变得更糟。 (除非您使用gcc -O2
构建,但不启用自动向量化。)
你实际上可以看到clang自动向量化的一个例子,因为它会对清理循环进行向量化,因为某些原因没有意识到它无法与count >= 4
一起运行。是的,它检查是否count > 3
并跳转到手动矢量化循环,然后检查它是否0
,然后检查它是否> 32
并跳转到清理循环的自动矢量化版本... / facepalm。)
实际上跳回主循环与在C源中展开大多不兼容,并且可能会使编译器展开失败。
如上所述,展开内循环通常是一种胜利。
在asm中,你当然可以进行设置,这样你就可以在展开循环之后跳回到最后1或2个向量的内循环,然后可能再次为未对齐的最终向量。
但这对分支预测可能不利。可能会强烈预测循环分支,因此每次跳回循环时都可能会错误预测。单独的清理代码不会有这个问题,所以如果它是一个真正的问题,那么单独的清理代码(复制内部循环体)会更好。
您通常可以将内部循环逻辑包装在一个内联函数中,该函数可以在展开的循环中多次使用,一次在清理/最后未对齐的向量块中。
虽然你的想法在这种情况下不会出现内部循环受到伤害但是如果你仔细地做,内部循环之前/之后的额外指令和额外分支的数量可能比你得到的要多。使用更简单的清理方法。同样,如果循环体非常大,这可能是有用的,但在这种情况下,它只是一些指令,复制比分支更便宜。
解决相同问题的一种有效方法是使用可能重叠的最后一个向量,因此没有条件分支取决于元素计数是否是整数个完整向量或不。最终的向量必须使用未对齐的加载/存储指令,因为您不知道它的对齐方式。
在现代x86上(英特尔自Nehalem以来,AMD自Bulldozer以来,请参阅Agner Fog's guides),在运行时实际对齐的指针上的未对齐加载/存储指令不会受到惩罚。 (与Core2不同,即使数据实际对齐,movdqu
总是较慢。)IDK ARM,MIPS或其他SIMD架构的情况如何。
你可以使用同样的想法来处理一个可能未对齐的第一个向量(如果它实际上是对齐的,它不会重叠),然后使主循环使用对齐的向量。但是,有两个指针,一个可能相对于另一个指针未对齐。通常的建议(来自英特尔的优化手册)是对齐输出指针(您通过它存储的。)
您可以而且应该在两个指针上使用__restrict
。没有理由不去,除非它实际上可以别名别的东西。如果我只打算做一个,我会在输出指针上使用它,但这两个都很重要。