看LLVM:比较钳位实现

2021-04-10 04:40:01

请注意,这不是对这些语言中的任何一种的认可或批评。它只是我发现有趣的东西,LLVM如何处理两者之间的代码。这是一个实现QUIRK,而不是语言问题。

LLVM项目是一种模块化的工具集,使得编译器更容易设计和实现编译器。 LLVM最着名的部分是他们的中间代表性; IR短暂。 LLVM的IR是一个非常强大的工具,旨在使优化和定位许多架构尽可能简单。许多工具使用LLVM IR; Clang C ++编译器和Rust编译器(Rustc)都是值得注意的例子。但是,尽管有这种统一的架构,但代码生成仍然可以在实现之间疯狂地变化,以及如何使用IR。在前段时间,我偶然发现了这款Tweet讨论了与C ++相比钳位的钳位的实现:

生锈1.50出来并具有f32.clamp。我对基于C ++体验的性能的预期极低了,但通常的RUDE被证明是" C ++完成右和#34;当然,Zig已经有了钳位,也得到了Codegen。 pic.twitter.com/0wi1flrqab.

- Arseny Kapoulkine(@Zeuxcg)2月11日,2021年2月11日

与使用STD :: CLAMP的等效Clang版本相比,RUST的代码生成了远远优越,即使他们使用相同的底层IR:

相应的组件如下所示。这是短暂的,简洁的,以及你将成为最好的。我们可以看到两个内存访问来获取钳位界限和有效地使用X86指令。

.lcpi0_0:。 long 0xbf800000.lcpi0_1:。 long 0x3f800000实施例:: clamp:movss xmm1,dword ptr [rip + .lcpi0_0] maxss xmm1,xmm0 movss xmm0,dword ptr [rip + .lcpi0_1] mins xmm0,xmm1 ret

相应的组件如下所示。有条件的移动,有条件的移动,并且在普通丑陋的情况下,它有很多数据访问。

.lcpi1_0:。 long 0x3f800000 float 1.lcpi1_1:。 LONG 0xBF800000 FLOAT - 1CLAMP2(FLOAT):@ CLAMP2(FLOAT)MOVS DWORD PTR [RSP-4],XMM0 MOV DWORD PTR [RSP-8], - 1082130432 MOV DWORD PTR [RSP-12],1065353216 Ucomens XMM0,DWORD PTR [rip + .lcpi1_0] Lea Rax,[RSP - 12] Lea RCX,[RSP-4] CMOVA RCX,RAX MOVSS XMM1,DWORD PTR [RIP + .LCPI1_1]#XMM1 = MEM [0],零,零,零ucomiss XMM1,XMM0 Lea Rax,[RSP-8] CMOVBE RAX,RCX MOVSS XMM0,DWORD PTR [RAX]#XMM0 = MEM [0],零,零,零RET

浮子夹(浮法V,浮法,浮法Hi){V =(V< lo)? LO:v; v =(v>嗨)?艾滋病病毒;返回v;}填充钳位1(浮动V){返回钳位(v, - 1.f,1.f);}

.lcpi0_0:。 long 0xbf800000#float - 1.lcpi0_1:。 long 0x3f800000#float 1clamp1(float):#@ clamp1(float)movss xmm1,dword ptr [rip + .lcpi0_0]#xmm1 = mem [0],零,零,零maxss xmm1,xmm0 movss xmm0,dword ptr [RIP + .lcpi0_1]#xmm0 = mem [0],零,零,零分钟xmm0,xmm1 ret

显然,STD :: Clamp与我们的实施之间的东西是脱离的。根据C ++参考,STD :: Clamp乘坐两个引用以及谓词(默认为std :: less)并返回引用。在功能上,我们的代码和std :: clamp之间的唯一区别是我们不使用引用类型。知道这一点,我们可以重现问题。

const float& bad_clamp(const float& v,const float& lo,const float& hi){返回(v< lo)? LO :( v>嗨)?嗨:v;}浮动钳位2(float v){return bad_clamp(v, - 1.f,1.f);}

.lcpi1_0:。 long 0x3f800000#float 1.lcpi1_1:。 long 0xbf800000#float - 1clamp2(float):#@ clamp2(float)movs dword ptr [rsp-4],xmm0 mov dword ptr [RSP-8], - 1082130432 Mov DWORD PTR [RSP - 12],1065353216 Ucomiss XMM0,1065353216 DWORD PTR [RIP + .LCPI1_0] LEA RAX,[RSP-12] LEA RCX,[RSP-4] CMOVA RCX,RAX MOVSS XMM1,DWORD PTR [RIP + .LCPI1_1]#XMM1 = MEM [0],零,零,零Ucomiss XMM1,XMM0 Lea Rax,[RSP-8] CMOVBE RAX,RCX MOVSS XMM0,DWORD PTR [RAX]#XMM0 = MEM [0],零,零,零RET

LLVM IR是静态单分配(SSA)中间表示。这意味着每个变量只分配一次。为了代表条件分配,SSA表单使用称为“PHI”节点的特殊类型的指令,其基于先前运行的块选择值。但是,CLANG不会最初使用PHI节点。相反,为了使初始代码生成更容易,使用函数中的变量使用AlloCA指令在堆栈上分配。对变量的读取和分配分别为AlloCA的加载和存储指令:

在这个未优化的IR中,我们可以看到一个Alloca指令,然后将浮动值0存储给它:

define dso_local i32 @main()#0 {%1 = AlloCa Float,对齐4 Store Float 0.000000E + 00,Float *%1,对齐4 RET I32 0}

然后,LLVM(希望)将优化具有相关通行证的AlloCA指令,如SROA。

在此优化的IR中,我们可以看到参考已被转换为具有特定属性的指针。

定义dso_local void @ _z4testrf(float * nocapture nonnull align 4 dereferencable(4)%0)local_unnamed_addr#0 {store float 1.000000e + 00,float *%0,对齐4,!tbaa!2 ret void}

当函数被赋予引用类型作为参数时,它将其传递基础对象的地址而不是对象本身。还传递了一些关于参考类型的元数据。例如,Nonnull和Dereferecable被设置为参数的属性,因为C ++标准指示引用始终必须绑定到有效对象。对我们来说,这意味着AlloCa指令直接传递给钳位功能:

__Attribute __((noinline))const float& bad_clamp(const float& v,const float& lo,const float& hi){返回(v< lo)? LO :( v>嗨)?嗨:v;}浮动钳位2(float v){return bad_clamp(v, - 1.f,1.f);}

在此优化的IR中,我们可以将传递给Bad_Clamp的Alloca指令对应于传递作为引用的变量。

定义linkonce_odr dso_local nonnull align 4 dereferenceable(4)float * @ _z9bad_clamprkfs0_s0_(float * nonnull对准4取消义(4)%0,float * nonnull对齐4取消转移(4)%1,float * nonur align 4解除难以(4)%2 )local_unnamed_addr#2 comdat {%4 = load float,float *%0,对齐4%5 =负载浮动,浮子*%1,对准4%6 = fcmp Olt浮法%4,%5%7 =负载浮动,浮动*%2,对齐4%8 = FCMP OGT浮法%4,%7%9 =选择I1%8,Float *%2,Float *%0%10 =选择I1%6,Float *%1,float *% 9 ret float *%10}定义dso_local float @ _z6clamp2f(float%0)local_unnamed_addr#1 {%2 = AlloCa float,对齐4%3 = AlloCa Float,对齐4%4 = AlloCa Float,对齐4储存浮子%0, float *%2,对齐4储存float -1.000000e + 00,float *%3,对齐4储存浮子1.000000e + 00,float *%4,对齐4%6 =呼叫非ennull align 4取消遗留(4)float * @ _z9bad_clam. PRKFS0_S0_(Float * nonnull对齐4取消转移(4)%2,Float * nonnull对齐4取消转移(4)%3,Float * nonur align 4取消遗留(4)%4)%7 =负载浮动,浮动*%7,对齐4 Ret Float%7}

在此示例中,noinline属性用于演示传递给函数的引用。如果我们删除该属性,则调用函数内容:

const float& bad_clamp(const float& v,const float& lo,const float& hi){返回(v< lo)? LO :( v>嗨)?嗨:v;}浮动钳位2(float v){return bad_clamp(v, - 1.f,1.f);}

然而,即使优化后,AlloCa指令仍然没有好理由。这些Alloca指令应该被LLVM的通行证优化;他们没有任何其他地方使用,没有棘手的商店或终身问题。

定义dso_local float @ _z6clamp2f(float%0)local_unnamed_addr#0 {%2 = AlloCa Float,对齐4%3 = AlloCa Float,对齐4%4 = AlloCa Float,对齐4储存浮子%0,浮子*%2,对齐4 ,!2 store float -1.000000e + 00,float *%3,对齐4,!tbaa!2商店浮子1.000000e + 00,float *%4,对齐4,!tbaa!2%5 = fcmp olt float %0,-1.000000e + 00%6 = fcmp ogt float%0,1.000000e + 00%7 =选择I1%8,float *%4,float *%2%9 =选择I1%7,float *%3 ,Float *%9%9 =负载浮动,浮子*%10,对齐4,!TBAA!2 RET FLOAT%9}

这里唯一的候选者是两个顺序选择指令,因为它们在由AlloCA指令而不是底层值创建的指针上运行。但是,LLVM也有一个通行证;如果可能,LLVM将尝试“推测”在加载结果的选择指令上“推测”。

选择说明基本上是三元运算符,基于第一个操作数的值选择最后两个操作数(浮点指针)。

距离链条的几个呼叫,此函数调用isDereferenceAbleanDalignedPointer,这是确定是否可以解除指针的原因。这里的代码公开了主要问题:选择指令永远不会被视为“取消转移”。因此,当序列有两个选择时(与我们的STD :: Clamp看到),它不会尝试推测选择指令,并且不会删除AlloCA。

潜在的修复程序正在修改原始代码以以相同的方式产生选择指令。例如,我们可以模仿我们原始的恳求用指针而不是值类型。虽然IR输出变化相对较小,但这为我们提供了我们想要的代码生成,而无需修改LLVM CodeBase:

const float& every_ref_clamp(const float& v,const float& lo,const float& hi){const float * Out;出=(v< lo)? & lo:& v;出局=(* OUT>嗨)? &嗨:out;返回* out;} float clamp3(float v){return leken_ref_clamp(v, - 1.f,1.f);}

正如您所看到的,呼叫之后生成的IR显着比以前更短,更有效:

定义dso_local float @ _z6clamp3f(float%0)local_unnamed_addr#1 {%2 = fcmp olt float%0,-1.000000e + 00%3 =选择I1%2,float -1.000000e + 00,float%0%4 = fcmp OGT Float%3,1.000000E + 00%5 =选择I1%4,Float 1.000000E + 00,Float%3 Ret Float%5}

.lcpi1_0:。 long 0xbf800000#float - 1.lcpi1_1:。 long 0x3f800000#float 1clamp3(float):#@ clamp3(float)movs xmm1,dword ptr [rip + .lcpi1_0]#xmm1 = mem [0],零,零,零maxss xmm1,xmm0 movss xmm0,dword ptr [RIP + .lcpi1_1]#xmm0 = mem [0],零,零,零分钟xmm0,xmm1 ret

更常见的方法是在LLVM本身中修复代码生成问题,这可能是如此简单:

Diff --git A / LLVM / lib / Analysis / Loads.cpp B / LLVM / lib / Analysis / Loads.cppPindex D8F954F575838D9886FCE0DF2D40407B194E7580..AFFB55C7867F48866045534D383B4D7BA19773A3 100644 --- A / LLVM / LIB / Analysis / Loads.cpp +++ B / llvm / lib / sanges / loads.cpp @@ -103,6 +103,14 @静态BOOL ISDEREFERENCEABLEANGALIGNEDPOINTER(CTXI,DT,TLI,访问,MAXDEPTH); } + //对于选择说明,两个操作数都需要取消转移。+ if(const selectinst * selinst = dyn_cast< selectinst>(v))+返回isdereferenceableandalignedpointer(selinst-> getoperand(1),对齐,+大小,dl ,CTXI,DT,TLI,+访问,MaxDepth)&& + isDereferenceableandalignedPointer(Selinst-> Getoperand(2),对齐,+大小,DL,CTXI,DT,TLI,+访问,MaxDepth); // for gc.relocate,通过迁移(const gcrelocateinst * ReloationInst = dyn_cast< gcrelocateinst>(v))返回isdereferenceableandaligneder(RetodingInst-> getDerivedPtr(),

它确实是将选择说明添加到指令类型列表中,以考虑可能无法转移。虽然似乎修复了这个问题(并且Alive2似乎喜欢它),但这是否则未经测试的。此外,Codegen仍然不完美。虽然删除了冗余内存访问,但仍有比我们的Libcxx修复程序(和RUDE)的更多指令更有指令:

.lcpi0_0:。 long 0x3f800000#float 1.lcpi0_1:。 long 0xbf800000#float - 1clamp2(float):#@ clamp2(float)movss xmm1,dword ptr [rip + .lcpi0_0]#xmm1 = mem [0],零,零,零minss xmm1,xmm0 movss xmm2,dword ptrs [ RIP + .LCPI0_1]#XMM2 = MEM [0],零,零,零CMPLTS XMM0,XMM2 MOVAPS XMM3,XMM0 ANDNP XMM3,XMM1 ANDPS XMM0,XMM2 ORPS XMM0,XMM3 RET

模板< class _tp,class _compare> const _tp& CLAMP(const _tp& __v,const _tp& __lo,const _tp& __hi,_compare __comp){_libcpp_assert(!__ comp(__ hi,__lo),"不良界限传递给std :: clamp") ;返回__comp(__ v,__lo)? __lo:__comp(__嗨,__v)? __艾滋病病毒;}

原因这看起来不太好,是因为LLVM需要将__v的原始值存储为第二个比较。由此,它不能将该计算的第二部分优化为最大值,因为当_LO大于__hi并且__v为负时会产生不同的行为。

const float& Ref_clamp(Const Float& V,Const Float& LO,Const Float& Hi){返回(v< lo)? LO :( v>嗨)? 嗨:v;} const float& every_ref_clamp(const float& v,const float& lo,const float& hi){const float * Out; 出=(v< lo)? & lo:& v; 出局=(* OUT>嗨)? &嗨:out; 返回* out;} int main(){printf("%f \ n" ref_clamp( - 2.f,1.f, - 1.f)); //这个打印1.000 printf("%f \ n" follow_ref_clamp( - 2.f,1.f, - 1.f)); //这个打印-1.000} 即使我们知道这是C ++中的未定义行为,LLVM没有足够的信息要知道。 因此,调整代码也不会简单。 尽管如此,它确实显示了真正的多才多艺的LLVM; 相对简单的变化可能具有显着的结果。