这是分行吗?

2021-02-17 18:14:11

让我们尝试一种新的格式-“短裤”;小型博客文章,其中详细介绍了我将在Twitter上讨论的想法,但这些想法一遍又一遍,或者简短的形式无法传达所有细微差别。

我经常看到不惜一切代价避免在代码中使用“ if”语句(尤其是GPU着色器)的建议。最糟糕的是,这种情况发生在glsl中,并且程序员用(主观)可怕的步骤指令替换了一些简单的if语句,这些指令完全破坏了所有可读性,甚至更糟–混合,最大值,片段和额外的ALU序列。

首先让我们解决一下程序员为什么要避免分支的原因。有一些很好的理由。

在CPU上,非SIMD代码中的“分支”可能看起来“无辜”,这只是有条件地跳转到其他代码。代替执行下一个地址的指令,我们跳转到其他地址的指令b。不幸的是,引擎盖下发生了许多复杂的事情:所有现代CPU都具有大量流水线,超标量和乱序。

为了获得最大的效率,他们使用推测执行模型-CPU提前“猜测”和“猜测”分支的结果,不知道是否执行该分支,然后开始执行这些指令。如果是的话,那就太好了!但是,如果不是这样,则CPU需要停顿,取消所有猜测,返回分支并执行其他指令。哎呀。

幸运的是,CPU通常非常擅长这种“猜测”(它们实际上是分析过去的分支模式并将其考虑在内!)。另一方面,无需过多讨论,每个分支都会消耗一些分支预测器单元资源。在一些简短的无辜代码上增加一个分支可能会使分支预测的结果变差,并减慢其他代码的速度(在这种情况下,可能需要使用分支预测器才能获得良好的性能!)。

简化–单个程序和相同的指令流在相同数据的多个“通道”上执行,同时处理4/8/16/32/64个元素。

如果您想在某些元素采用一条路径而另一些元素采用另一条路径时进行分支转移,则有两个主要选择:要么放弃向量化并标量化代码(非常糟糕的选择;通常意味着对给定的一段代码不值得向量化),或执行两个代码路径–第一个是第一个,将结果存储在某个地方,执行第二个,然后“组合”结果。

这是一个普遍的(过度)简化,示例GPU具有专用硬件来帮助有效地做到这一点,而又不会浪费资源(硬件执行掩码等),但是通常意味着管理这些掩码,将结果存储在某个地方,执行成本加倍以及最终产生额外的成本。 –分支机构可以充当障碍,防止隐藏某些延迟(请参阅我的旧博客文章)。

考虑到这两个,似乎避免分支似乎是合理的,而避免ifs的常见建议很有意义。但是避免分支是否有意义更复杂(稍后再讨论),而第二个分支(如果if是分支)则并非如此。

当您在代码中写入“ if”时,编译器不一定会生成带有跳转的分支。

现在,我将重点介绍CPU,主要是因为使用Matt Godbolt出色的编译器浏览器进行测试非常容易。 🙂(加上与许多不同的着色器ISA不同,如何有两种主流架构)

我用3个x64编译器(GCC x64干线,Clang x64干线,MSVC 19.28)和一个ARM(armv8-a lang)进行了测试,只有MSVC在那里生成了实际分支。

clang mov eax,edi sub eax,esi add esi,edi add eax,eax cmp edi,10 cmovl eax,esi ret gcc mov eax,edi lea edx,[rdi + rsi] sub eax,esi add eax,eax cmp edi, 9 cmovle eax,edx ret msvc cmp ecx,10 jge SHORT $ LN2 @ is_this_a_ BRANCH !!!分支! lea eax,DWORD PTR [rcx + rdx] ret 0 $ LN2 @ is_this_a_:sub ecx,edx lea eax,DWORD PTR [rcx + rcx] ret 0 arm clang sub w9,w0,w1 add w8,w1,w0 lsl w9, w9,#1 cmp w0,#10 // = 10 csel w0,w8,w9,lt

clang movaps xmm2,xmm0添加xmm2,xmm1 movaps xmm3,xmm0添加xmm3,xmm0添加xmm1,xmm1 cmpltss xmm0,dword ptr [rip + .LCPI1_0] subss xmm3,xmm1和ps xmm2,xmm2和xmm0,nps movss xmm2,DWORD PTR .LC0 [rip]委托xmm2,xmm0 jbe .L10 BRANCH !!!添加xmm0,xmm1 ret.L10:添加xmm1,xmm1添加xmm0,xmm0 subss xmm0,xmm1 ret msvc movss xmm2,DWORD PTR __real @ 41200000 comiss xmm2,xmm0 jbe SHORT $ LN2 @ is_this_a_ BRANCH !!!添加xmm0,xmm1 ret 0 $ LN2 @ is_this_a_:添加xmm0,xmm0添加xmm1,xmm1 subss xmm0,xmm1 ret 0 arm clang fadd s2,s0,s1 fadd s3,s0,s0 fmov s1,s1,4,s1 fsub s1,s3,s1 fcmp s0,s4 fcsel s0,s2,s1,mi ret

这基本上是CPU和编译器“序列化”两个代码路径的方式。如果它认为分支的开销大于它可以节省的工作量,则有必要计算两条代码路径,并使用一条指令选择哪一条是实际结果。

显然,这“取决于”用例,并且取决于编译器进行这种转换是否“合法”(如“副作用”;或者,如果编译器执行了一些内存读取操作,则可能不安全并导致分段)过错)。

这是常见的建议,但通常是不正确的。如果您看一下上面的示例,将其稍微重写一下:

某些编译器可能确实会为此生成无分支代码,但是将其作为一般建议没有任何意义。无论您编写实际的if或三元表达式,它还是高级if语句,只是表达方式有所不同。

在GPU上是相同的;适当时,编译器将生成条件选择。

我使用了Tim Jones Shader Playground和AMD ISA(主要是因为我比其他人相对更好),以及不幸的步进功能:

s_mov_b32 m0,s3 s_nop 0x0000 v_interp_p1_f32 v0,v0,attr0.x v_interp_p2_f32 v0,v1,attr0.x v_cmp_gt_f32 vcc,1.0,v0 v_cndmask_b32 v0、1.0,f,v0,v16,v0,v0,v16,v0,v0,v16 ,v0完成compr vm

当使用fastmath并且不遵循严格的IEEE浮动规范(这是编译实时图形着色器的标准)时,编译器甚至可以从if语句生成其他指令,例如min或max。但是YMMV并一定要检查组装。

如果您喜欢它的编码风格,那么显然由您决定,为此使用尽可能多的东西。但是请考虑那些没有太多着色器或GLSL经验的程序员,他们总是会对此感到困惑。 🙂

这远远超出了我的目标“短”格式,但是分支不一定是“不良”。

关于分支如何坏以及必须避免的许多建议来自史前的旧CPU和GPU或旧编译器时代。无需重新验证就可以通过的建议-这是可以理解的,我也不是每隔几个月就重新检查自己的全部知识或直觉。 🙂

但是,CPU / GPU /编译器架构师显着改进了设计,并使它们适应于通用的,高度分支的代码,是否要付出代价取决于太多细节无法列出或提供建议。

经验法则:如果分支通常是连贯的,则在允许节省内存带宽或缓存利用率的情况下使用分支是有意义的。在GPU上,如果可以避免以高概率和连贯性通过这种方式获取纹理,通常值得添加分支。通常最好设置一些条件掩码,并选择简单/简短的算术运算序列。

不要假设“ if”语句会产生分支。 对于大多数简单的“ if”语句,编译器可以并且将使用条件选择。 编译器可以进行许多强大的转换,有时甚至您根本不会考虑。 除非编写对性能至关重要的代码,否则请优化代码的可读性。 不要使用晦涩难懂的指示和怪异的算术。 如果您关心热门部分的性能,请务必阅读生成的程序集并验证编译器的输出是什么。 不要以为分支是坏的。 在微基准测试以及周围环境中分析您的代码(对缓存,内存带宽和分支预测器的影响)。 此条目发布在“代码/图形”中,并标记了汇编,cpu,gpu,优化,配置文件,编程,simd。 为永久链接添加书签。