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
2
3
4
if (_EnableDetail > 0)
{
color += SampleDetail();
}

如果_EnableDetail是一次DrawCall内不变的材质参数,那么同一执行组里的线程会一起进入或跳过分支。此时有控制流开销,但通常没有分支发散,并且关闭功能时能够跳过SampleDetail()

发生发散的情况

1
2
3
4
5
6
7
8
if (input.uv.x > 0.5)
{
color = RunPathA();
}
else
{
color = RunPathB();
}

一个Warp内可能有部分像素满足条件,另一部分不满足。GPU通常需要:

1.执行PathA,只激活满足条件的线程。

2.执行PathB,只激活剩余线程。

3.在控制流汇合点重新合并线程。

两条路径执行期间都有一部分Lane被屏蔽,因此并行利用率下降。

但“出现分支后吞吐量一定减半”并不准确。真实消耗还取决于:

1.两条路径各自的指令数量。

2.分支在屏幕空间的数据连续性。

3.编译器使用真实跳转,还是将短分支改写为Predication/Select。

4.纹理访问、寄存器压力、Occupancy和延迟隐藏能力。

5.硬件架构和Shader阶段。

最坏情况下多条路径会接近串行执行,但短小分支也可能几乎看不出差异。

Shader分支的三种类型

1.编译期静态分支

1
2
3
#if defined(_USE_DETAIL)
color += SampleDetail();
#endif

编译时已经确定结果,不需要在GPU运行时判断。未使用的路径会被编译器移除,所以不会产生运行时分支和线程发散。

在Unity中通常对应shader_featuremulti_compile和不同Shader变体。

优点:

运行时没有分支开销,可以彻底删除不用的代码和资源。

缺点:

变体数量可能指数增长,增加编译时间、包体、运行时内存和PSO切换成本。

适合影响范围大、路径很重,并且组合数量可控的功能,例如阴影模式、法线贴图、不同光照模型。

2.Uniform动态分支

1
2
3
4
if (_EnableClearCoat > 0)
{
color = EvaluateClearCoat(color);
}

条件在一次DrawCall内保持一致,例如材质参数、Pass参数、常量缓冲区数据。整个Warp/Wave通常会做相同选择,所以不会发生Lane之间的分支发散。

这种方式很适合功能很多但不希望产生大量变体的情况。只要关闭分支时确实能跳过足够重的计算,它可能比“把两边都算完”更快。

注意:所谓材质级变量有用,不是因为“整个物体一定组成同一个Warp”,而是因为一次DrawCall内Uniform值一致,执行组里的线程看到相同条件。

3.非Uniform动态分支

1
2
3
4
if (input.vertexColor.r > 0.5)
{
color = EvaluateWetSurface();
}

条件来自每顶点、每像素或每线程数据,例如UV、法线、顶点色、深度、噪声、纹理采样结果。这类条件可能在一个执行组内变化,是线程发散的主要来源。

这里也不能简单写成“用了UV或纹理就必然发散”。如果条件在局部区域高度连续,例如屏幕左半边和右半边,边界之外的大多数Warp仍然只走一条路径;高频噪声、棋盘格和细碎Mask才更容易让几乎每个执行组都发散。

像素Shader还要考虑Quad

像素Shader通常以2x2像素Quad计算屏幕空间导数,ddxddy以及普通纹理采样的隐式Mip选择都依赖相邻Lane。

1
2
3
4
if (mask > 0.5)
{
float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
}

如果Quad内部在纹理采样前已经发生不一致的控制流,导数可能不稳定,编译器和硬件也可能需要Helper Lane维持导数计算。不同API、Shader Model和平台对此有不同限制。

工程上需要注意:

1.不要在高度发散的分支内部依赖隐式导数采样。

2.必要时提前计算导数,或使用显式LOD/Gradient采样,但需要确认画质与平台支持。

3.discard/clip会让部分像素失活,也可能影响Early-Z、Helper Lane和后续执行效率。

纹理采样结果作为条件

1
2
3
4
5
6
float mask = SAMPLE_TEXTURE2D(_MaskTex, sampler_MaskTex, uv).r;

if (mask > 0.5)
{
color = RunExpensivePath();
}

这种写法的问题不是“GPU无法预测分支”。GPU图形Shader通常不依赖CPU那种分支预测,关键仍然是相邻线程的mask是否一致。

它还有两个额外成本:

1.为了知道条件,Mask纹理已经必须采样,分支无法省掉这次采样。

2.如果Mask包含高频细节,会同时带来分支发散和不连续的纹理访问。

如果Mask在空间上大块连续,并且分支内计算非常昂贵,动态分支仍然可能值得使用。不要只看代码形式,要在目标设备上测量。

三元运算、lerp不等于更快

1
2
3
float3 result = condition ? pathA : pathB;

float3 result = lerp(pathB, pathA, condition);

三元运算只是语法,编译器可能生成分支,也可能生成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路径再考虑无分支写法

对几条乘加指令,可以测试stepsaturatelerp或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

Unity Manual - How Unity compiles branching shaders

Microsoft HLSL - if statement

Arm Mali Offline Compiler