Base-Shader分支与GPU线程发散
Shader里的if并不是一定慢,真正需要关注的是:同一个GPU执行组内,不同线程是否走了不同路径。
如果整个执行组都进入同一个分支,动态分支反而可以跳过大量无用计算;如果组内线程分别进入不同分支,就会产生线程发散(Divergence)。
GPU为什么容易受到分支影响?
CPU擅长处理复杂控制流,有分支预测、乱序执行等硬件。GPU的目标是用大量相对简单的线程并行处理数据,更接近SIMT(Single Instruction, Multiple Threads,单指令多线程)的执行方式。
GPU不会完全独立地调度每一个Shader线程,而是把一批线程组成一个执行组:
Nvidia:一个Warp通常包含32个线程。
AMD GCN:传统上使用Wave64,一个Wave包含64个线程。
AMD RDNA:通常使用Wave32,也支持Wave64,实际模式取决于架构、Shader阶段和编译结果。
移动GPU:不同厂商和架构的执行宽度不同,不能统一假设为32或64。
同一个Warp/Wave在某个时刻共享一条指令流,但每个线程有自己的寄存器、数据和Active Mask。
没有发散的情况
1 | |
如果_EnableDetail是一次DrawCall内不变的材质参数,那么同一执行组里的线程会一起进入或跳过分支。此时有控制流开销,但通常没有分支发散,并且关闭功能时能够跳过SampleDetail()。
发生发散的情况
1 | |
一个Warp内可能有部分像素满足条件,另一部分不满足。GPU通常需要:
1.执行PathA,只激活满足条件的线程。
2.执行PathB,只激活剩余线程。
3.在控制流汇合点重新合并线程。
两条路径执行期间都有一部分Lane被屏蔽,因此并行利用率下降。
但“出现分支后吞吐量一定减半”并不准确。真实消耗还取决于:
1.两条路径各自的指令数量。
2.分支在屏幕空间的数据连续性。
3.编译器使用真实跳转,还是将短分支改写为Predication/Select。
4.纹理访问、寄存器压力、Occupancy和延迟隐藏能力。
5.硬件架构和Shader阶段。
最坏情况下多条路径会接近串行执行,但短小分支也可能几乎看不出差异。
Shader分支的三种类型
1.编译期静态分支
1 | |
编译时已经确定结果,不需要在GPU运行时判断。未使用的路径会被编译器移除,所以不会产生运行时分支和线程发散。
在Unity中通常对应shader_feature、multi_compile和不同Shader变体。
优点:
运行时没有分支开销,可以彻底删除不用的代码和资源。
缺点:
变体数量可能指数增长,增加编译时间、包体、运行时内存和PSO切换成本。
适合影响范围大、路径很重,并且组合数量可控的功能,例如阴影模式、法线贴图、不同光照模型。
2.Uniform动态分支
1 | |
条件在一次DrawCall内保持一致,例如材质参数、Pass参数、常量缓冲区数据。整个Warp/Wave通常会做相同选择,所以不会发生Lane之间的分支发散。
这种方式很适合功能很多但不希望产生大量变体的情况。只要关闭分支时确实能跳过足够重的计算,它可能比“把两边都算完”更快。
注意:所谓材质级变量有用,不是因为“整个物体一定组成同一个Warp”,而是因为一次DrawCall内Uniform值一致,执行组里的线程看到相同条件。
3.非Uniform动态分支
1 | |
条件来自每顶点、每像素或每线程数据,例如UV、法线、顶点色、深度、噪声、纹理采样结果。这类条件可能在一个执行组内变化,是线程发散的主要来源。
这里也不能简单写成“用了UV或纹理就必然发散”。如果条件在局部区域高度连续,例如屏幕左半边和右半边,边界之外的大多数Warp仍然只走一条路径;高频噪声、棋盘格和细碎Mask才更容易让几乎每个执行组都发散。
像素Shader还要考虑Quad
像素Shader通常以2x2像素Quad计算屏幕空间导数,ddx、ddy以及普通纹理采样的隐式Mip选择都依赖相邻Lane。
1 | |
如果Quad内部在纹理采样前已经发生不一致的控制流,导数可能不稳定,编译器和硬件也可能需要Helper Lane维持导数计算。不同API、Shader Model和平台对此有不同限制。
工程上需要注意:
1.不要在高度发散的分支内部依赖隐式导数采样。
2.必要时提前计算导数,或使用显式LOD/Gradient采样,但需要确认画质与平台支持。
3.discard/clip会让部分像素失活,也可能影响Early-Z、Helper Lane和后续执行效率。
纹理采样结果作为条件
1 | |
这种写法的问题不是“GPU无法预测分支”。GPU图形Shader通常不依赖CPU那种分支预测,关键仍然是相邻线程的mask是否一致。
它还有两个额外成本:
1.为了知道条件,Mask纹理已经必须采样,分支无法省掉这次采样。
2.如果Mask包含高频细节,会同时带来分支发散和不连续的纹理访问。
如果Mask在空间上大块连续,并且分支内计算非常昂贵,动态分支仍然可能值得使用。不要只看代码形式,要在目标设备上测量。
三元运算、lerp不等于更快
1 | |
三元运算只是语法,编译器可能生成分支,也可能生成Select/Predication。lerp通常意味着先计算两侧结果再选择:它避免了控制流发散,但不一定减少工作量。
适合改写为无分支形式的情况:
两条路径只有少量ALU运算,且分支非常不连续。
不适合的情况:
两条路径包含纹理采样、循环、复杂BRDF或大量计算。此时无分支写法可能强制所有线程把两边都执行一遍。
所以不要为了“代码里没有if”而优化。应该比较编译后的ISA和实际GPU耗时。
HLSL的[branch]和[flatten]可以向编译器表达倾向,但只是Hint,并不能保证最终机器码一定使用跳转或展开。
移动端TBDR是否更怕分支?
TBDR解决的是Tile内颜色、深度数据的片上存储和外部带宽问题;Warp/Wave线程发散解决的是Shader Core的执行利用率问题。Tile不是Warp,Tile内像素也不会作为一个整体共享同一条分支。
因此不能简单理解为“分支会阻塞整个Tile”。分支发散在IMR和TBDR上都存在。
移动端经常对复杂分支更敏感,主要是因为:
1.算力、功耗和散热预算更紧。
2.寄存器压力可能降低并发Wave数量,削弱延迟隐藏能力。
3.分支里的纹理采样会同时增加带宽和Texture Unit压力。
4.discard、深度写入和复杂Alpha Test可能降低隐藏面剔除、Early-Z等优化收益。
这些问题会和TBDR特性叠加,但并不是“Tile内线程等待”这么单一的原因。
分支优化思路
1.先判断条件属于哪一类
编译常量——>静态分支,运行时无发散。
材质/DrawCall常量——>Uniform动态分支,通常不发散。
顶点/像素/纹理数据——>可能发散,需要看空间连续性。
2.大功能用变体,但控制变体数量
重路径适合静态变体,尤其是能够删除纹理、Interpolant和整套光照逻辑时。
不要给每个小开关都增加Keyword。N个二值Keyword理论上可能形成2^N种组合,运行时收益可能还没有构建和内存成本大。
3.中等功能优先考虑Uniform动态分支
同一个材质或DrawCall统一开关,在现代GPU上通常是比较稳妥的折中。它可以减少变体,同时真的跳过关闭的重路径。
4.让非Uniform条件在空间上保持连续
区域Mask、LOD和分层效果尽量形成大块连续区域,避免棋盘格、高频Noise式条件。
Compute Shader还要让线程组布局与数据布局对应,避免相邻Lane处理完全无关的数据。
5.短ALU路径再考虑无分支写法
对几条乘加指令,可以测试step、saturate、lerp或Select。对纹理和复杂函数,不要默认无分支更快。
6.不要只看Shader源码
最终结果由Shader编译器和目标GPU决定。应该通过真机Profile、反汇编和控制变量测试确认。
常用工具:
Nvidia:Nsight Graphics。
AMD:Radeon GPU Profiler、Radeon GPU Analyzer。
Arm Mali:Arm Performance Studio、Mali Offline Compiler。
Apple:Xcode GPU Frame Capture。
Qualcomm:Snapdragon Profiler。
重点观察Shader耗时、Wave Occupancy、寄存器数量、纹理等待和分支/线程利用率,而不是只统计源码里有几个if。
总结
Shader分支的核心不是有没有if,而是执行组内的线程是否走相同路径,以及分支到底跳过了多少工作。
1.静态分支没有运行时发散,但要承担变体成本。
2.Uniform动态分支通常不会发散,适合减少变体并跳过重计算。
3.非Uniform动态分支可能发散,严重程度取决于数据的空间连续性和路径长度。
4.三元运算和lerp不是万能优化,它们可能让两条路径都执行。
5.纹理条件不是绝对禁止,要同时考虑采样成本、Mask连续性和被跳过的工作量。
6.TBDR的Tile和Shader执行组不是同一个概念,移动端问题需要结合算力、带宽、寄存器与隐藏面剔除一起分析。
最终原则还是:先分类,再看编译结果,最后在目标硬件上测量。
引用参考:
NVIDIA CUDA C++ Programming Guide - SIMT Architecture
AMD GPUOpen - RDNA Performance Guide