装配与内在(2014)

2021-04-22 11:28:23

组装v。每次偶尔固定内在,我听到了金属内部的改善了,它可以使用它们来用于高性能代码。那样就好了。内在机构的承诺是您可以通过呼叫对应于特定装配说明的功能(内在)来编写优化的代码。由于内在函数就像正常功能,它们可以是跨平台。由于您的编译器可以访问比大脑的更多计算能力,以及每个CPU的详细模型,编译器应该能够做出更好的微优化工作。尽管有十年的旧声称,固有内部可以让你的生活更轻松,但似乎永远不会锻炼身体。

我最后一次尝试了内在的内在是2007年的;有关它们为什么他们无望的原因(请参阅VirtualDub作者的探索)。我最近给了他们另一个镜头,而他们改善了,他们仍然不值得努力。问题是,内在函数是如此不可靠,您必须手动检查每个平台和每个编译器的结果,您希望将代码运行,然后在获得合理的结果之前调整内在函数。那个'比只需用手写作大会的工作。如果你不要用手检查结果,它'很容易得到糟糕的结果。

例如,如本撰写,前两个Google for for popcnt基准(以及前3名Bing Hits中的2个)声明了英特尔' s硬件popcnt指令比计算位数数量的软件实现慢在缓冲区中,使用SSSE3 PSHUFB指令通过表查找。这结果是不真实的,但它绝不能是明显的,或者这一索赔不会如此持久。让'为什么有人可能得出结论,如果他们使用内在机构编码解决方案,Popcnt指令很慢。

其中一个顶级搜索命中率为使用PSHUFB的本机POPCNT以及软件版本的示例代码和基准。他们的代码需要msvc,我没有访问,但他们的第一个popcnt实现只需在循环中调用popcnt内部,这在gcc和clang将接受的形式中相当容易再现。时间它也很简单,因为我们'重新定时一个函数(它发生在一些固定大小的缓冲区中设置的位数)。

UINT32_T构建_popcnt(const uint64_t * buf,int len){int cnt = 0; for(int i = 0; i< len; ++ i){cnt + = __builtin_popcountll(buf [i]); }返回cnt;}

这与上面链接的代码略有不同,因为它们使用Popcnt的DWORD(32位)版本,以及使用QWORD(64位)版本。由于我们的版本获得了两倍于每循环迭代完成的两倍,我' D期望我们的版本比他们的版本更快。

运行clang -o3 -mpopcnt -funroll-loops会产生我们可以检查的二进制文件。在Mac上,我们可以使用Otool -TV来获得拆卸。在Linux上,有#39; s Objdump -d。

_builtin_popcnt :;地址指令0000000100000B30 PUSPQ%RBP0000000100000B31 MOVQ%RSP,%RBP0000000100000B34 MOVQ%RDI,-0x8(%RBP)0000000100000B38 MOVL%ESI,-0xc(%RBP)000000000100000B3B MOVL $ 0x0,-0x10(%RBP)0000000100000B42 MOVL $ 0x0,-0x14 (%RBP)0000000100000B49 MOVL -0x14(%RBP),%EAX0000000100000B4C CMPL -0xC(%RBP),%EAX0000000100000B4F JGE 0x100000B55 MOVSLQ -0X14(%RBP),%RAX0000000100000B59 MOVIQ -0x8(%RBP),%RCX0000000100000B5D MOVED(% RCX,%RAX,8),%rax0000000100000b61 MOVQ%RAX,%rcx0000000100000b64 SHRQ%rcx0000000100000b67 movabsq $ 0x5555555555555555,%rdx0000000100000b71和Q%的RDX,%rcx0000000100000b74 SUBQ%RCX,%rax0000000100000b77 movabsq $ 0x3333333333333333,%rcx0000000100000b81 MOVQ%RAX,%rdx0000000100000b84和Q %RCX,%rdx0000000100000b87 SHRQ $ 0X2,%rax0000000100000b8b和Q%RCX,%rax0000000100000b8e addq%RAX,%rdx0000000100000b91 MOVQ%的RDX,%rax0000000100000b94 SHRQ $为0x4,%rax0000000100000b98 addq%RAX,%rdx0000000100000b9b movabsq $ 0xf0f0f0f0f0f0f0f,%rax000000010000 0BA5和Q%RAX,%rdx0000000100000ba8 movabsq $ 0x101010101010101,%rax0000000100000bb2 imulq%RAX,%rdx0000000100000bb6 SHRQ $ 0x38,%rdx0000000100000bba MOVL%EDX,%esi0000000100000bbc MOVL -0x10(%RBP),%edi0000000100000bbf ADDL%ESI,%edi0000000100000bc1 MOVL%EDI ,-0x10(%RBP)0000000100000BC4 MOVL -0x14(%RBP),%eax0000000100000bc7 addl $ 0x1,%eax0000000100000bcc modl%eax,-0x14(%rbp)000000000100000bcf jmpq 0x100000b490000000100 000bd4 mov1 -0x10(%rbp),%eax0000000100000bd7 popq%rbp00000001000bd8雷

好吧,那个'有趣。 Clang似乎是手动计算事物而不是使用popcnt。它似乎正在使用这里描述的方法,这是类似的

X = X - ((X>>为0x1)及0x5555555555555555); X =(X安培; 0x3333333333333333)+((X>> 0X2)及0x3333333333333333); X =(X +(X GT; > 0x4))& 0xf0f0f0f0f0f0f0f0f; ans =(x * 0x101010101010101101)>> 0x38;

这与一个不符合任何类型的专业硬件的简单实现,而不是依赖于任何一种专业化的硬件,而是比单个Popcnt指令更长的时间更长。

我有一个漂亮的旧版克朗(3.0),所以让我在升级到3.4后再次尝试,以防他们添加了“最近”的硬件popcnt支持。

0000000100001340 PUSHQ%RBP;保存帧Pointer0000000100001341 MOVQ%RSP%RBP;新帧指针10000000100001344 XORL%ECX,%ECX; CNT = 00000000100001346 Testl%ESI,%ESI0000000100001348 JLE 0x10000136300000000010000134A NOPW(%rax,%rax)0000000100001350 popcntq(%rdi),%r​​ax; “eax”= popcnt [rdi] 0000000100001355 addl%ECX,%EAX; EAX + = CNT0000000100001357 ADDQ $ 0x8,%RDI;递增地址64位(8字节)000000010000135B DELL%ESI;减量回路计数器; SET FLAGS000000010000135D MOVL%EAX,%ECX; cnt = eax;没有设置FLAGS000000010000135F JNE 0x100001350;检查标志。如果是ESI!= 0,GOTO POPCNT0000000100001361 JMP 0x100001365;转到“恢复帧指针”0000000100001363 MOVL%ECX,%eax0000000100001365 popq%rbp;恢复框架pointer0000000100001366 RET

那个更好的'我们得到一个硬件popcnt!让' s将此与此处呈现的SSSE3 PSHUFB实现进行比较,作为执行POPCNT的最快方法。我们' ll使用一个表链接中的一个表来显示速度,除了我们'重新显示一个速率,而不是原始循环计数,使不同大小之间的相对速度很清楚。该速率是GB / s,即,我们每秒可以处理多少次缓冲区。我们在块中提供功能数据(从1KB到16MB而变化);每列是不同块大小的速率。如果我们查看每种算法用于各种缓冲区大小的速度,我们都会得到以下内容。

那个'不是那么伟大。相对于上面链接的基准,我们'重新做得更好,因为我们使用64位popcnt而不是32位popcnt,但PSHUFB版本仍然是快速的两倍。

一个奇怪的是CNT累积的方式。 CNT存储在ECX中。但是,而不是将Popcnt的结果添加到ECX,Clang已决定将ECX添加到Popcnt的结果。要解决此问题,则CLANG必须在每个循环迭代结束时将该总和移动到ECX中。

另一个明显的问题是,我们只获得循环的迭代只能获得一个popcnt,这意味着循环isn' t ungered,我们'重新支付每个popcnt的循环开销的整个成本。展开循环也可以让CPU从代码中提取更多的指令级并行性,虽然这是一个超出这个博客文章的范围。

使用CLANG,即使与-O3 -FUNROLL-LOOPS也会发生。使用GCC,我们得到一个正确的展开循环,但GCC还有其他问题,就像我们' LL见稍后。目前,让' s尝试通过在循环的每个迭代期间多次调用__builtin_popcountll来展开循环。为简单起见,请尝试在每次迭代中执行四项POPCNT操作。我不声明它的最佳状态,但它应该是一个改进。

Uint32_t build_popcnt_unrolled(const uint64_t * buf,int len){sssert(len%4 == 0); int cnt = 0; for(int i = 0; i< len; i + = 4){cnt + = __builtin_popcountll(buf [i]); cnt + = __builtin_popcountll(buf [i + 1]); cnt + = __builtin_popcountll(buf [i + 2]); cnt + = __builtin_popcountll(buf [i + 3]); }返回cnt;}

0000000100001390 popcntq(%rdi,%rcx,8),%rdx%eax,%edx0000000100001398 popcntq 0x8(%rdi,%rcx,8),%rax000000010000139f addl%edx,%eax00000001000013A1 popcntq 0x10(%rdi,%rcx,8 ),%RDX00000001000013A8 ADDL%EAX,%EDX00000001000013AA POPCNTQ 0x18(%RDI,%RCX,8),%RAX00000001000013B1 ADDL%EDX,%EAX

围绕循环体围绕相同的代码。我们每次都通过循环执行四个popcnt操作,这导致以下性能:

在使用64位popcnt和展开循环之间,我们已经击败了据称更快的pshufb代码!但它与另一个编译器或其他芯片相比,我们可能会获得不同的结果。让'我们看看我们是否可以做得更好。

那么,与这个popcnt虚假依赖虫子的交易是什么,&#39最近得到了很多宣传?结果,popcnt对其目的地寄存器具有错误的依赖性,这意味着即使popcnt的结果也不依赖于其目标寄存器,CPU认为它确实并等到目标寄存器在开始之前准备好了popcnt指令。

X86通常具有两个操作数操作,例如Addl%EAX,%EDX添加EAX和EDX,然后将结果放在EDX中,因此它'对于操作寄存器的依赖性,&#39。在这种情况下,有一个依赖关系,因为结果不依赖于输出寄存器的内容,但是'是介绍的一个容易的错误,以及一个难以捕获的错误。

在这种特殊情况下,POPCNT具有3个周期延迟,但它' s流水线,使得POPCNT操作可以执行每个循环。如果我们忽略其他开销,这意味着单个popcnt将需要3个周期,2个将需要4个周期,3个将需要5个周期,并且n将采用n + 2个周期,只要操作是独立的。但是,如果CPU不正确地认为它们之间的依赖关系,我们有效地失去了管道指令的能力,并且N + 2变成了3N。

我们可以通过从AMD或VIA中购买CPU或将POPCNT结果放在不同的寄存器中来解决此问题。让'制作一系列目的地,这将让我们将结果从每个popcnt放入不同的地方。

UINT32_T构建_popcnt_unrolled_errate(const uint64_t * buf,int len){sssert(len%4 == 0); int cnt [4]; for(int i = 0; i< 4; ++ i){cnt [i] = 0; for(int i = 0; i< len; i + = 4){cnt [0] + = __builtin_popcountll(buf [i]); cnt [1] + = __builtin_popcountll(buf [i + 1]); cnt [2] + = __builtin_popcountll(buf [i + 2]); cnt [3] + = __builtin_popcountll(buf [i + 3]); }返回CNT [0] + CNT [1] + CNT [2] + CNT [3];}

0000000100001420 popcntq(%rdi,%r9,8),%r80000000100001426 addl%ebx,%r8d0000000100001429 popcntq 0x8(%rdi,%r9,8),%rax0000000100001430 addl%r14d,%eax0000000100001433 popcntq 0x10(%rdi,%r9,8 ),%RDX000000010000143A ADDL%R11D,%EDX000000010000143D POPCNTQ 0x18(%RDI,%R9,8),%RCX

' s更好 - 我们可以看到第一个popcnt输出到r8,第二个进入rax,第三到rdx,以及第四到RCX。但是,这与原始累积相同,而不是将popcnt的结果添加到cnt [i],它与之相反,这需要将结果移动回之后的cnt [i]。

嗯,至少在铿cl(3.4)。 GCC(4.8.2)太智能了,无法为此单独的目的地进行,并且“优化”代码回到像我们原始版本的内容。

要获得与GCC和CLANG合作的版本,并且没有这些额外的MOV,我们' LL必须用手写组装3:

UINT32_T构建_popcnt_unrolled_errata_manual(const uint64_t * buf,int len){sssert(len%4 == 0); UINT64_T CNT [4]; for(int i = 0; i< 4; ++ i){cnt [i] = 0; for(int i = 0; i< len; i + = 4){__asm __(" popcnt%4,%4 \ n \"加%4,%0 \ n \ t" " popcnt%5,%5 \ n \ t""加%5,%1 \ n \ t"" popcnt%6,%6 \ n \ t&#34 ;"加%6,%2 \ n \ t"" popcnt%7,%7 \ n \ t""添加%7,%3 \ n \ t&#t&#34 34; // + R表示输入/输出,R表示intput:" + R"(CNT [0])," + R"(CNT [1]),&#34 ; + R"(CNT [2])," + R"(CNT [3]):" R"(Buf [i])," r&# 34;(Buf [i + 1])," R"(Buf [i + 2])," R"(Buf [i + 3])); }返回CNT [0] + CNT [1] + CNT [2] + CNT [3];}

00000001000013C3 POPCNTQ%R10,%R1000000001000013C8 ADDQ%R10,%RCX00000001000013CB POPCNTQ%R11,%R1100000001000013D0 ADDQ%R11,%R900000001000013D3 POPCNTQ%R14,%R1400000001000013D8 ADDQ%R14,%R800000001000013DB POPCNTQ%RBX,%RBX

伟大的!此添加现在正在进行正确的方向,因为我们确切的是他们应该做的事情。

最后!吹掉PSHUFB实现的版本。我们如何知道这应该是最终版本?我们可以从Agner'我们最多可以执行的指令表中看到一个popcnt。我碰巧在3.4GHz的桑迪桥上跑了这个,所以我们' ve有8个字节/循环的上限* 3.4g cycles / sec = 27.2 gb / s。那个'非常接近26.3 GB / s的我们'重复实际上,这是我们可以' t的标志更快4。

在这种情况下,手编码的装配版本比原始内在循环快3倍(不计算来自&#39的克朗版本的版本,响起了一个popcnt)。正碰巧,对于我们使用的编译器,使用Popcnt内在的展开循环比Pshufb版本更快,但是当我尝试使用GCC时,这是两个展开版本中的一个。

它'很容易看出为什么有人可能有基准相同的代码,并决定非常快地' t非常快。它' S也很容易看出为什么使用内在的临界代码可以是一个巨大的时间汇5。

如果你喜欢这一点,你' ll可能享受这篇文章,因为它是如何从80年代开始改变的CPU。

看到实际的基准测试代码。在第二次思想中,它'是一个尴尬的可怕的黑客,我更喜欢你的看起来。 [返回]

如果它是另一种方式,硬件没有意识到应该有一个依赖性,这很容易捕获 - 任何依赖的指令序列都可能产生不正确的结果。在这种情况下,一些指令序列比应慢慢慢,这是检查的差异。 [返回]

那个' S不是很好的,因为CPU具有TBATOBOOST,但它非常接近。放在旁边,这个例子很简单,但用手计算这个东西可以令人疑惑,因为更复杂的代码。幸运的是,英特尔架构代码分析可以为我们解决这个问题。它找到了代码中的瓶颈(假设零延迟处的无限内存带宽),并显示处理器是瓶颈的方式和原因,这通常足以确定是否有更多优化的房间。

您可能已经注意到,随着缓冲区大小的大于我们的缓存,性能会降低。它可能会做一下信封计算的背面,以找到因内存限制和缓存性能而强制的上限,但通过计算工作会花费更多的空间,这脚注可用。您可以看到一个很好的例子,其中一个简单的案例如何在这里。 Nathan Kurz和John McCaplin的评论特别好。

[返回]

在运行这些基准的过程中,我也注意到_mm_cvtsi128_si64在gcc上产生奇异的坏代码(虽然它'铿fine有良好)。 _MM_CVTSI128_SI64是将SSE(SIMD)寄存器移动到通用寄存器(GPR)的内在。编译器是否有很多纬度,而不是变量是否应该生活在寄存器中或内存中。 Clang意识到它'如果结果即将使用,则可能更快地将来自SSE寄存器的值移动到GPR。 GCC决定保存寄存器并将数据从SSE寄存器移动到内存,然后将下一个指令运行在内存上,如果是,如果是的话,则可以进行操作。在我们的popcnt示例中,clang使用大约2x以便不展开循环,其余的来自于CPU错误的最新,这是可理解的。它很难想象为什么编译器会在它'关于数据上运行时,编译器会对内存进行注册,除非它没有做到的' t' t做优化,否则它有一些错误不知道注册到注册版本的指令。但至少它得到了正确的结果,与此版本的MSVC不同。

ICC和ARMCC被誉为在处理内在机构时更好地更好地处理,但它们'重新启动最开源项目的非启动器。下载ICC'免费的非商业版已被禁用为一年的更好的部分,即使它回来,谁将相信它赢得了' T再次消失?至于ARMCC,I'不确定它有没有免费版本?

[返回]