我的问题
我有两个相邻的大小相同的缓冲区(每个大约20 MB)。我只想计算它们之间的差异。
我的问题
在4.8GHz英特尔I7 9700 K和3600 MT RAM上运行此循环需要多长时间?
我们如何计算最大理论速度?
我所尝试的#
uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
uint64_t diffFound = 0;
for(uint64_t byte = 0; byte < commonSize; ++byte)
diffFound += static_cast<uint64_t>(buffer[byte] != buffer[byte + commonSize]);
return diffFound;
}
它需要11毫秒在我的个人电脑(9700 K 4. 8 Ghz RAM 3600 Windows 10叮当14. 0. 6-O3 MinGW),我觉得它太慢了,我错过了一些东西。
CPU读取40 MB应该需要不到2 ms的时间(我的RAM带宽在20到30 GB/s之间)
我不知道如何计算执行一次迭代所需的周期(特别是因为现在的CPU是超标量的)。如果我假设每次操作1个周期,并且如果我没有搞砸我的计数,那么在4.8 Ghz时,每次迭代应该是10个操作-〉2亿个操作-〉40毫秒。显然,我在如何计算每次循环的周期数上是错误的。
有趣的事实:我在Linux PopOS GCC 11. 2-O3上试过,它的运行时间为4. 5毫秒。为什么会有这样的差异呢?
下面是由clang产生的矢量化和标量化的分解:
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
lea r8, [rdi + rsi]
neg rsi
xor edx, edx
xor eax, eax
.LBB0_4: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
mov rcx, rsi
add rcx, rdx
jne .LBB0_4
ret
.LBB0_1:
xor eax, eax
ret
锵14 O3:
.LCPI0_0:
.quad 1 # 0x1
.quad 1 # 0x1
compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
test rsi, rsi
je .LBB0_1
cmp rsi, 4
jae .LBB0_4
xor r9d, r9d
xor eax, eax
jmp .LBB0_11
.LBB0_1:
xor eax, eax
ret
.LBB0_4:
mov r9, rsi
and r9, -4
lea rax, [r9 - 4]
mov r8, rax
shr r8, 2
add r8, 1
test rax, rax
je .LBB0_5
mov rdx, r8
and rdx, -2
lea r10, [rdi + 6]
lea r11, [rdi + rsi]
add r11, 6
pxor xmm0, xmm0
xor eax, eax
pcmpeqd xmm2, xmm2
movdqa xmm3, xmmword ptr [rip + .LCPI0_0] # xmm3 = [1,1]
pxor xmm1, xmm1
.LBB0_7: # =>This Inner Loop Header: Depth=1
movzx ecx, word ptr [r10 + rax - 6]
movd xmm4, ecx
movzx ecx, word ptr [r10 + rax - 4]
movd xmm5, ecx
movzx ecx, word ptr [r11 + rax - 6]
movd xmm6, ecx
pcmpeqb xmm6, xmm4
movzx ecx, word ptr [r11 + rax - 4]
movd xmm7, ecx
pcmpeqb xmm7, xmm5
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm6, 212 # xmm4 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
pand xmm4, xmm3
paddq xmm4, xmm0
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm7, 212 # xmm0 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm5, xmm0, 212 # xmm5 = xmm0[0,1,1,3]
pand xmm5, xmm3
paddq xmm5, xmm1
movzx ecx, word ptr [r10 + rax - 2]
movd xmm0, ecx
movzx ecx, word ptr [r10 + rax]
movd xmm1, ecx
movzx ecx, word ptr [r11 + rax - 2]
movd xmm6, ecx
pcmpeqb xmm6, xmm0
movzx ecx, word ptr [r11 + rax]
movd xmm7, ecx
pcmpeqb xmm7, xmm1
pxor xmm6, xmm2
punpcklbw xmm6, xmm6 # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm0, xmm6, 212 # xmm0 = xmm6[0,1,1,3,4,5,6,7]
pshufd xmm0, xmm0, 212 # xmm0 = xmm0[0,1,1,3]
pand xmm0, xmm3
paddq xmm0, xmm4
pxor xmm7, xmm2
punpcklbw xmm7, xmm7 # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm1, xmm7, 212 # xmm1 = xmm7[0,1,1,3,4,5,6,7]
pshufd xmm1, xmm1, 212 # xmm1 = xmm1[0,1,1,3]
pand xmm1, xmm3
paddq xmm1, xmm5
add rax, 8
add rdx, -2
jne .LBB0_7
test r8b, 1
je .LBB0_10
.LBB0_9:
movzx ecx, word ptr [rdi + rax]
movd xmm2, ecx
movzx ecx, word ptr [rdi + rax + 2]
movd xmm3, ecx
add rax, rsi
movzx ecx, word ptr [rdi + rax]
movd xmm4, ecx
pcmpeqb xmm4, xmm2
movzx eax, word ptr [rdi + rax + 2]
movd xmm2, eax
pcmpeqb xmm2, xmm3
pcmpeqd xmm3, xmm3
pxor xmm4, xmm3
punpcklbw xmm4, xmm4 # xmm4 = xmm4[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3,4,5,6,7]
pshufd xmm4, xmm4, 212 # xmm4 = xmm4[0,1,1,3]
movdqa xmm5, xmmword ptr [rip + .LCPI0_0] # xmm5 = [1,1]
pand xmm4, xmm5
paddq xmm0, xmm4
pxor xmm2, xmm3
punpcklbw xmm2, xmm2 # xmm2 = xmm2[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
pshuflw xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3,4,5,6,7]
pshufd xmm2, xmm2, 212 # xmm2 = xmm2[0,1,1,3]
pand xmm2, xmm5
paddq xmm1, xmm2
.LBB0_10:
paddq xmm0, xmm1
pshufd xmm1, xmm0, 238 # xmm1 = xmm0[2,3,2,3]
paddq xmm1, xmm0
movq rax, xmm1
cmp r9, rsi
je .LBB0_13
.LBB0_11:
lea r8, [r9 + rsi]
sub rsi, r9
add r8, rdi
add rdi, r9
xor edx, edx
.LBB0_12: # =>This Inner Loop Header: Depth=1
movzx r9d, byte ptr [rdi + rdx]
xor ecx, ecx
cmp r9b, byte ptr [r8 + rdx]
setne cl
add rax, rcx
add rdx, 1
cmp rsi, rdx
jne .LBB0_12
.LBB0_13:
ret
.LBB0_5:
pxor xmm0, xmm0
xor eax, eax
pxor xmm1, xmm1
test r8b, 1
jne .LBB0_9
jmp .LBB0_10
3条答案
按热度按时间bmvo0sr51#
TLDR:Clang代码如此缓慢的原因是一个糟糕的矢量化方法使端口5饱和(已知这经常是一个问题)。GCC在这方面做得更好,但仍然远远不够高效。使用AVX-2可以编写更快的基于块的代码,而不会使端口5饱和。
非矢量化Clang代码分析
要理解这是怎么回事,最好从一个简单的例子开始。确实,正如你所说,现代处理器是超标量的,所以很难理解在这样的架构上生成代码的速度。
Clang使用
-O1
优化标志生成的代码是一个很好的开始。下面是您的问题中提供的GodBold生成的热循环的代码:像Coffee Lake 9700 K这样的现代处理器由两大部分组成:前端提取/解码指令(并将它们拆分成微指令,也称为. uops),以及后端调度/执行它们。后端调度许多 * 端口 * 上的uops,每个端口都可以执行一些 * 特定的指令集 *(例如,仅内存加载,或仅算术指令)。对于每条指令,我放置了可以执行它们的端口。
p0156+p23
意味着指令被拆分为两个uop:第一个可由端口0或1或5或6执行,第二个可由端口2或3执行。请注意,前端可以通过某种方式 * 优化代码 *,从而不会为循环中的mov
等基本指令产生任何微操作(这要归功于一种称为 * 寄存器重命名 * 的机制)。对于每个循环迭代,处理器需要从内存中读取2个值。像9700 K这样的Coffee Lake处理器每个周期可以加载两个值,因此循环至少需要1个周期/迭代(假设
r9d
和r9b
中的加载不因使用同一r9
64位寄存器的不同部分而冲突)。这个处理器有一个uops缓存,循环有很多指令,所以解码部分应该不是问题。也就是说,有9个uops要执行,处理器每个周期只能执行其中的6个,所以循环不能少于1.5个周期/迭代。更准确地说,端口0、1这是一个乐观的下限执行时间,因为处理器可能不会完美地调度指令,并且有许多事情可能出错(就像一个我没有看到的鬼鬼祟祟的隐藏依赖)。频率为4. 8 GHz,最终执行时间至少为8.3ms。在3个周期/迭代的情况下,它可以达到12.5ms(注意,由于微操作到端口的调度,2.5个周期/迭代是可能的)。可以使用展开改进循环。实际上,仅执行循环而非实际计算就需要大量指令。展开有助于提高有用指令的比率,从而更好地利用可用端口。尽管如此,2次加载仍会阻止循环快于1个周期/迭代,即4.2 ms。
矢量化Clang代码分析
Clang生成的矢量化代码很复杂,你可以尝试应用与前面代码相同的分析,但这将是一项乏味的任务。
可以注意到,即使代码被矢量化,加载也没有被矢量化。这是一个问题,因为每个周期只能执行2次加载。也就是说,加载是通过两个相邻的字符值对来执行的,因此与之前生成的代码相比,加载并不那么慢。
Clang这样做是因为只有两个64位值可以放入一个128位SSE寄存器和一个64位寄存器,而且它需要这样做,因为
diffFound
是一个64位整数。8位到64位的转换是代码中最大的问题,因为它需要几个SSE指令来完成转换。此外,由于Coffee Lake上有3个SSE整数单元并且每个单元一次只能计算两个64位整数,Clang只在每个SSE寄存器中放置2个值(并且使用其中的4个,以便每次循环迭代计算8个项),因此应该期望代码运行速度快两倍以上(特别是由于SSE和循环展开),但这种情况并不多,因为SSE端口比ALU端口少,类型转换需要更多的指令。简言之,矢量化显然效率低下,但在这种情况下,Clang生成高效代码并不容易。尽管如此,如果使用28条SSE指令和3个SSE整数单元,每个循环计算8项,则代码的计算部分将花费大约28/3/8 ~= 1.2
周期/项,这与您观察到的情况相差甚远(这不是由于其他指令造成的,因为它们大多数可以并行执行,因为它们大多数可以在其他端口上调度)。实际上,性能问题肯定来自端口5的饱和。实际上,此端口是唯一可混洗SIMD寄存器的项目的端口。因此,指令
punpcklbw
、pshuflw
pshufd
甚至movd
只能在端口5上执行。这是SIMD代码的一个常见问题。这是一个大问题,因为每个循环有20条指令,处理器甚至可能无法完美地使用它。这意味着代码至少需要10.4 ms,这与观察到的执行时间(11 ms)非常接近。分析矢量化GCC代码
GCC生成的代码实际上比Clang生成的代码要好得多。首先,GCC直接使用SIMD指令加载项目,这要高效得多,因为每条指令(通过迭代)计算16个项目:它每次迭代仅需要2个加载uop,从而降低了端口2和3上的压力(对于此,1个周期/迭代,因此0.0625个周期/项)。第二,GCC仅使用14个
punpckhwd
指令,而每次迭代计算16项,从而降低了端口5上的临界压力(对于此,0.875个周期/项)。第三,SIMD寄存器几乎被完全使用,至少对于比较而言是这样,因为pcmpeqb
比较指令一次比较16个项目(与Clang比较2个项目相反)。其他指令(如paddq
)比较便宜(例如,paddq
可以在3个SSE端口上调度),并且它们应该不会对执行时间造成太大影响。最后,这个版本应该仍然受端口5的限制,但是它应该比Clang版本快得多。2实际上,应该预期执行时间达到1个周期/项(因为端口调度肯定不是完美的,并且存储器加载可能引入一些停止周期)。这意味着4.2ms的执行时间。这与观察到的结果接近。加快实施
GCC实现并不完美。
首先,它不使用处理器支持的AVX 2,因为没有提供
-mavx2
标志(或任何类似的标志,如-march=native
)。实际上,GCC和其他主流编译器一样,为了与以前的架构兼容,默认情况下只使用SSE 2:SSE 2在所有x86-64处理器上使用都是安全的,但SSE 3、SSSE 3、SSE4.1、SSE4.2、AVX、AVX 2等其他指令集则不安全。有了这样的标志,GCC应该能够生成内存受限代码。此外,编译器在理论上可以执行多级总和缩减。其思想是使用大小为1024项的块在8位宽SIMD通道中累积比较结果(即64 x16个项目)。这是安全的,因为每个通道的值不能超过64。为了避免溢出,累加值需要存储在更宽的SIMD通道中(例如64位)。使用此策略,
punpckhwd
指令的开销是原来的1/64。这是一个很大的改进,因为它消除了端口5的饱和。即使只使用SSE 2,这种策略也足以生成内存受限代码。下面是一个 * 未经测试 * 代码的示例,该代码需要标志-fopenmp-simd
才能有效。GCC和Clang生成的代码都非常高效(但对于该高速缓存中的数据适配来说,这并不是最佳的),尤其是Clang。
所有的加载都是256位SIMD的,
vpcmpeqb
的数量是最优的,vpaddb
的数量相对较好,其他指令很少,但是它们显然不应该成为瓶颈。循环每次迭代处理128个项目,我希望对于该高速缓存中已经存在的数据,每次迭代只需要不到12个循环(否则它应该是完全受存储器限制的)。这意味着〈0.1周期/项,也就是说,远小于之前的实现。实际上,uiCA工具指示大约0.055周期/项,即81 GiB/s!可以使用SIMD内部函数手动编写更好的代码,但代价是明显较差的可移植性、维护性和可读性。请注意,生成顺序内存限制并不总是意味着RAM吞吐量将饱和。事实上,在一个内核上,有时会出现not enough concurrency to hide the latency of memory operations,但在您的处理器上应该没有问题(就像在我的i5- 9600 KF上,带有2个交错的3200 MHz DDR4内存通道)。
nkoocmlb2#
是的,如果您的数据在高速缓存中不是热数据,那么即使是SSE2也应该能跟上内存带宽的速度。如果数据在L1d高速缓存中是热数据,或者是高速缓存外部级别所能提供的任何带宽,则完全可以在每个周期对32个比较结果进行比较和求和(来自两个32字节加载)。
如果没有,编译器就做得不好。不幸的是,这种情况在减少到一个更宽的变量时很常见; * * 编译器不知道如何对字节求和,尤其是必须为0/-1**的比较结果字节求和的好的矢量化策略。它们可能会立即扩展到
pmovsxbq
的64位(如果SSE4.1指令不可用,情况会更糟)。所以即使
-O3 -march=native
也没有多大帮助;这是一个很大优化缺失;希望GCC和Clang在某个时候能够学会如何对这种循环进行矢量化,对比较结果进行求和可能会在足够多代码库中找到值得识别的模式。有效的方法是使用
psadbw
进行水平求和,得到qwords。但只有在内部循环执行vsum -= cmp(p, q)
的一些迭代之后,才能减0或-1以递增计数器。8位元素可以执行255次迭代,而不会有溢出的风险。对于多个向量累加器,展开后会有许多每个32字节的向量。这样就不必经常跳出内部循环。有一个答案是,在i7 - 6700 Skylake上,AVX2的基准报告为28 GB/s,SSE2为23 GB/s(只有3.4GHz,可能他们禁用了Turbo,或者只是报告了额定速度。DRAM速度未提及)。
我希望2个输入数据流能达到与1个相同的持续带宽。
如果您在适合二级缓存的较小数组上对重复传递进行基准测试,那么优化更有趣,ALU指令的效率很重要。(该问题答案中的策略非常好,并且针对这种情况进行了很好的调整。)
psadbw
将字节相加到64位。(但没有GCC/clang那么差,当它扩展到32位时仍然是hsumming。)在一个大的至强,这将是非常不同的;您需要大部分内核来实现峰值聚合带宽,即使只对memcpy或memset也是如此,因此没有ALU工作,只有加载/存储。更高的延迟意味着单核的可用内存带宽比台式机少得多(即使是绝对意义上的,更不用说6通道而不是2通道的百分比)。另请参阅 * Enhanced REP MOVSB for memcpy * 和 * Why is Skylake so much better than Broadwell-E for single-threaded memory throughput? *
可移植的源代码,编译成不太糟糕的asm,从Jérôme的微优化:假设L1d缓存命中,每4x 32字节向量5.5个周期,从7或8个周期减少。
仍然不太好(因为它每128个字节减少一个标量,如果你想尝试的话,可以减少到192个),但是@Jérôme Richard想出了一个聪明的方法,给clang一些它可以用一个好的策略向量化一个短的东西,用一个
uint8_t sum
,用它作为一个足够短的内部循环,而不会溢出。但是clang仍然用这个循环做了一些愚蠢的事情,正如我们在他的回答中所看到的。我修改了循环控制,使用了一个指针增量,这减少了一点循环开销,只有一个指针add和compare/jcc,而不是LEA/MOV。我不知道为什么clang使用整数索引效率很低。
它还避免了
vpcmpeqb
内存源操作数letting them stay micro-fused on Intel CPUs的索引寻址模式(Clang似乎根本不知道这有什么关系!将源操作数反向为!=
就足以使它对vpcmpeqb
使用索引寻址模式,而不是对vmovdqu
进行纯加载)。这可以使用
192
而不是128
来进一步分摊循环开销,代价是需要执行%192
(不是2的幂),并且使清理循环最坏情况为191字节。我们不能使用256,或者任何高于UINT8_MAX(255)的值,并且必须坚持使用32的倍数。或者使用64作为好的度量。修正常数
set1_epi8(4)
有一个额外的vpaddb
,它将四个0/-1的和转换为来自C!=
运算符的四个1/0结果的和。我不认为有任何方法可以摆脱它或将它从循环中删除,同时仍然累积到
uint8_t
中,这是clang以这种方式进行矢量化所必需的。(非截断)字节总数,这很讽刺,因为这正是它在用于全零寄存器时的实际作用。如果你执行sum += ptr[off + commonSize] == ptr[off] ? -1 : 0
这样的操作,你可以让它直接使用vpcmpeqb
的结果,用3次加法将4个向量相加为1,最后经过一些归约步骤,将其输入到vpsadbw
中,得到matches * 0xFF
的和,对于每个128字节的块,取uint8_t,或者作为int8_t
,这是-1 * matches
的和,所以0..-128
,它不会溢出一个有符号的字节。这很有趣。但是在64位计数器中添加零扩展可能会破坏信息,并且在外部循环中进行符号扩展将花费另一条指令。这将是一条标量movsx
指令,而不是vpaddb
,但这对Skylake来说并不重要,可能只有在使用AVX-512和512位向量时才重要(clang和GCC都做得不好,不使用掩码加法)。我们能在循环后执行128*n_chunks - count
以从匹配之和恢复差异吗?不,我不这么认为。uiCA static analysis预测天空湖(例如您的CPU)将以**5.51周期/iter的速度运行主循环(4个矢量)**如果数据在一级缓存中是热数据,则为5.05(Ice Lake/Rocket Lake)。(我不得不手动调整asm以模拟
-mbranches-within-32B-boundaries
的填充效果,对于uiCA的默认假设,即循环顶部相对于32字节对齐边界的位置。我本可以在uiCA中更改该设置。:/)在实施此次优策略时,唯一遗漏的微优化是它使用了
vpextrb
(因为它不能证明不需要截断到uint8_t
?),而不是vmovd
或vmovq
。因此,前端需要额外的uop,后端的端口5需要额外的uop。(链接中的comment + uncomment),Skylake上为5.25c/iter,Ice Lake上为4.81,非常接近2负载/时钟瓶颈。(每个iter执行6个向量,192个字节,预计SKL上每个iter为7个周期,或每个向量为1.166个周期,低于5.5/iter = 1.375个周期。或ICL/RKL上大约为6.5个周期= 1.08 c/vec,会遇到后端ALU端口瓶颈。)
这对于我们能够从可移植C++源代码中生成clang代码来说并不坏,而对于高效的手动矢量化来说,每4个32字节的矢量需要4个周期的比较。这很可能会跟上内存或缓存带宽,即使是二级缓存也是如此,所以它非常可用,而且对于L1d中的热数据也不会慢多少。多执行几个uop确实会影响无序执行,并且使用共享物理核的另一个逻辑核可以使用的更多执行资源(超线程)。
vpaddb zmm0{k1}, zmm0, zmm1
合并掩码之类的操作,有条件地递增矢量,其中zmm1 =set1_epi8( 1 )
。(或者将-1
常量与sub
组合使用。)如果处理得当,每个向量的指令和微操作计数应与AVX2大致相同,但gcc/clang使用的数量大约是AVX2的两倍,因此,唯一的节省是在减少到标量,这似乎是代价,让任何东西在所有可用的。这个版本还避免了清理循环的展开,只是使用每个iter 4个字节的策略进行矢量化,这个策略非常适合清理
size%128
字节。它同时使用vpxor
进行翻转,并使用vpand
将0xff转换为0x01,这非常愚蠢。当它可以使用vpandn
在一条指令中完成这两件事时,这将使清除循环减少到8个微操作,仅仅是Haswell/Skylake上流水线宽度的两倍,因此它将更有效地从循环缓冲区发出,除了Skylake在微码更新中禁用了它。这对Haswell会有一点帮助vcirk6k63#
如果我错了请纠正我,但答案似乎是
我将尝试编写自己的AVX代码进行比较。
详细数据
在花了一个下午的时间对这些建议进行修改之后,我发现了以下几点:
-O3启用SSE 2,速度与O 1一样慢,可能是汇编代码不正确
-O3 -march=韦斯特米尔也支持SSE 2,但速度更快(7毫秒)
-O3 -march=native启用AVX -〉2.5ms,我们可能受到RAM带宽限制(接近理论速度)
标量10毫秒现在是有意义的,因为根据这个了不起的工具uica.uops.info,它需要
g++在没有添加标志时似乎生成了更好的默认代码