前言
在学习shader时,经常能看到unroll loop branch flatten这四个指令,它们可以用于优化shader性能
但他们是怎么优化的呢?什么时候该用unrool/loop;什么时候该用branch/flatten?这是本篇将解答的
unrool & loop
- 用途:用于for循环
-
作用
- unrool :将for循环展开,相当于把循环里的代码复制 N 遍,无需额外考虑循环控制和条件跳转指令
- loop:在生成的汇编代码中保留真实的循环结构,即不做更改,默认情况下是loop
- 实例
float sum = 0; [loop] for(int i = 0; i < 3; i++) { sum += data[i]; }以上代码换算成汇编伪代码如下:
// --- 1. 初始化 --- mov r_sum, 0 // sum = 0 mov r_i, 0 // i = 0 LOOP_START: // (这是一个地址标签,不占指令) // --- 2. 条件跳转指令 (循环出口) --- cmp r_i, 3 // 比较 i 和 3 jge LOOP_END // 【条件跳转】:如果 i >= 3 (Jump if Greater or Equal),修改 PC 指针,跳到 LOOP_END // --- 3. 循环体 --- load r_temp, data, r_i // 读取 data[i] add r_sum, r_sum, r_temp // sum += temp // --- 4. 循环控制 (步进与回环) --- add r_i, r_i, 1 // i++ jmp LOOP_START // 【无条件跳转】:强行修改 PC 指针,跳回 LOOP_START 处继续执行 LOOP_END: // (循环结束,继续执行后续代码) ...如果将loop换成unroll,汇编伪代码如下:
load r_temp0, data, 0 add r_sum, r_sum, r_temp0 load r_temp1, data, 1 add r_sum, r_sum, r_temp1 load r_temp2, data, 2 add r_sum, r_sum, r_temp2不难发现,unroll下,指令更少,尤其是没有跳转指令
那岂不是无脑用unroll就好?unroll也并不是没有缺点,当循环次数过多,展开后生成的 Shader 汇编代码过多,会导致L1指令缓存不够用,从而造成指令获取停顿
为什么会这样呢?loop下,"sum += data[i];"这条指令加载进L1指令缓存,后续不断反复读取这条相同的指令,L1命中率极高;unroll下,如果循环次数来到100次,这条指令存在100个,且不相同,这导致读取时,若超出L1缓存,需要等待GPU扔掉前面的指令,前往L2/L3/全局内存拿(看GPU架构),这就导致了延迟
-
优缺点
- unroll:
-
消除分支开销:没有循环的条件判断和跳转指令
-
指令缓存膨胀:展开后生成的 Shader 汇编代码过长。如果代码过大,超出了 GPU 的 L1 缓存,会导致指令获取停顿
- 全局优化空间更大:编译器可以跨越多个“迭代”进行指令重排、常量折叠和依赖隐藏
-
常量折叠:把运行时的计算提前到编译期。如果编译器发现某些表达式的结果在编译期间就能确定,它就会直接把结果算出来,替换掉原来的代码。这样 GPU 在运行时就不用再算一遍
float offset = 0; [unroll] for(int i = 0; i < 3; i++) { offset += data[i] * (i * 2.5); // i 乘以一个固定系数 }unroll后是这样的:
offset += data[0] * (0 * 2.5); offset += data[1] * (1 * 2.5); offset += data[2] * (2 * 2.5);编辑器发现0 * 2.5、1 * 2.5、2 * 2.5,这三个对象在编译期即可确定,无需在运行期计算,可优化成如下结果:
offset += data[0] * 0.0; offset += data[1] * 2.5; offset += data[2] * 5.0; - 指令重排 & 依赖隐藏
这两个概念是紧密绑定在一起的。指令重排是“手段”,而依赖隐藏是“目的”
在 GPU 中,从内存读取数据是非常非常慢的,可能需要几百个时钟周期。如果下一行代码需要用这个数据来做加法,这叫做数据依赖。在数据没读回来之前,加法指令只能干等着
float sum = 0; [loop] for(int i = 0; i < 3; i++) { float val = tex.Load(i); // 【耗时操作】 sum += val; // 【依赖上一行的结果】 }如果是loop,"sum += val; "要想执行必须等待tex加载完毕,且每次循环都需要等待
如果是unroll,会展开三行"float val = tex.Load(i);",编辑器发现这三行tex.Load可以统一先加载,且无需等待前面的加载完再加载后面的,无线接近于消耗一个Load的周期,等加载完再"sum += val"
1. 发起读取 tex[0] 2. 发起读取 tex[1] // 不等 tex[0] 回来,直接发请求! 3. 发起读取 tex[2] // 不等前两次回来,直接发请求! 4. ---- 等待数据返回 ---- // 只在这一个地方集中等待! 5. 拿到 tex[0],执行 sum += val0 6. 拿到 tex[1],执行 sum += val1 7. 拿到 tex[2],执行 sum += val2
- 寄存器压力剧增:编译器为了极力隐藏延迟,可能会将多次迭代的变量同时加载到寄存器中。这会导致单个线程分配的矢量寄存器数量飙升,总体的wave变少
-
loop:
- 指令体积小,L1缓存友好:Shader 二进制文件紧凑,极大地减少了指令获取的带宽压力
- 严格控制寄存器生命周期: 每次迭代后,临时变量的寄存器可以被立即复用,这对于维持高数量wave至关重要
- 分支发散风险: 如果同一个 Wavefront/Warp 中的不同线程在不同的迭代次数时退出循环,还在执行的线程会拖慢整个 Wave
-
如何选择unrool、loop
当满足以下条件时,使用unroll
- 固定且较小的迭代次数
- 循环内部索引要求必须是编译期常量
- 不存在 Early Exit
当满足以下条件时,使用loop
- 动态且可能很高的迭代次数
-
依赖动态数据的 Early Exit
-
跨平台性能调优
在移动端的 TBDR 架构下,寄存器堆比 PC 桌面级 GPU 更加受限,Context Switch的成本极高。对于中等规模的循环,强制 [loop] 往往能避免因寄存器溢出导致的掉帧
-
经验法则
- 永远为 Early Exit(提前跳出)使用
[loop] - 高寄存器压力(VGPR)的复杂计算,使用
[loop] - 微小且固定的迭代,使用
[unroll] - 资源数组的动态索引,使用
[unroll] - 如果Shader 突然出现严重的性能下降,或在 Nsight 中看到 Occupancy 极低,首先去检查那些被隐式展开的循环。通过手动添加
[loop]往往能瞬间释放寄存器压力
- 永远为 Early Exit(提前跳出)使用
banch & flatten
- 用途:用于if-else
- 作用
- branch:指示编译器保留真正的条件跳转指令。GPU 会在运行时评估条件,并决定跳转到
if块还是else块 - flatten:指示编译器消除分支跳转指令。 GPU 无条件地把
if和else里的代码全部执行一遍
- branch:指示编译器保留真正的条件跳转指令。GPU 会在运行时评估条件,并决定跳转到
- 实例
- 昂贵的计算
float3 finalColor = ambientColor; float dist = distance(worldPos, lightPos); // 如果像素在光源半径内,则计算复杂光照;否则什么都不做。 [branch] if (dist < lightRadius) { // 【极其昂贵的开销】 // 1. 多次采样阴影贴图 (PCF / PCSS) float shadow = CalculateHighQualityShadow(worldPos); // 2. 复杂的 PBR 物理光照模型计算 float3 diffuse = ... float3 specular = ... finalColor += (diffuse + specular) * shadow; } return finalColor;if里面有成百上千个周期的算术和内存读取,而else(条件不成立)什么都不用做,开销为0。屏幕上距离光源远的像素,往往是连成一片的, GPU 直接跳过那段昂贵的代码,瞬间省下巨量算力和显存带宽如果这里用flatten,GPU会强制让没在光源半径的pixel跑if分支,导致开销暴涨
- 廉价的计算
float3 specularColor; float isMetallic = MetallicMaskTexture.Sample(sampler, uv).r; // 可能是高频噪点或粗糙边缘 //简单的二选一赋值 [flatten] if (isMetallic > 0.5) { // 【极其廉价的开销】 specularColor = albedoColor; // 金属的高光颜色等于它的基础色 } else { // 【极其廉价的开销】 specularColor = float3(0.04, 0.04, 0.04); // 非金属的高光颜色是固定的 4% (0.04) }if和else里面都只有一次最基础的寄存器赋值操作,哪怕让 GPU 全算一遍,也只需要 1~2 个时钟周期,但现在的游戏资产极其精细,材质的边缘往往是高频的,这就导致isMetallic > 0.5这个条件在一个线程组里,大概率是一半 True 一半 False如果用
[branch],GPU 为了这 1 个周期的赋值,需要花费十几个周期去建立分支跳转逻辑和线程掩码(用于解决分支发散)分支跳转逻辑和线程掩码大致理解如下:
[branch] if (x > 0) { a = 1; // 仅需 1 个周期 } else { a = 2; // 仅需 1 个周期 }以以上代码为例,同一个 Warp 的 32 个线程中,有 16 个线程的
x > 0(想走if),另外 16 个线程的x <= 0(想走else),导致了分支发散,但GPU是并行的,同一周期内,所有线程都执行相同的指令。为此,GPU只能用时间换空间,它必须让这 32 个线程把if和else两条路依次都走一遍。为了保证逻辑正确,它引入了线程掩码。大致流程如下:- 评估条件与生成掩码
指令控制器首先让所有 32 个线程评估
x > 0。硬件会生成一个 32 位的布尔掩码(Active Mask),比如11110000...(1 代表true,0 代表false) -
压栈与跳转逻辑(开销大头)
硬件发现出现了分歧,它不能直接往下跑。它必须把当前的掩码状态、程序计数器等上下文信息,压入硬件内部的控制流栈。这就是所谓的“建立分支跳转逻辑”
-
执行
if块硬件会根据掩码
11110000...关闭掉后面 16 个 ALU 的写回权限。那 16 个走else的线程此时在空转,白白浪费 1 个周期 -
反转掩码与弹出栈
if块走完了,硬件需要从控制流栈中弹出之前的状态,并将掩码反转为 00001111...
-
执行
else块前 16 个线程发呆,后 16 个线程执行
-
重新汇合
清除掩码,32 个线程重新汇合
不难发现,分支发散需要执行多次比较、位掩码操作、寄存器读写和指令地址计算,这些准备工作加起来,通常需要花费 10 到 20 个周期
-
优缺点
- branch
- 避免极其昂贵的开销
- 提前跳出
- 分支发散惩罚
- 纹理采样受限:在分支内部无法直接使用带有自动 Mipmap 层级计算的纹理采样指令。因为计算 Mipmap 需要相邻像素的偏导数,而分支发散会导致相邻像素可能处于非激活状态
- 额外的控制流开销:硬件需要额外的时钟周期去执行条件判断、跳转指令以及管理线程掩码
- flatten
- 绝对没有分支发散
- 对编译器极其友好:没有了跳来跳去的逻辑,编译器可以纵观全局,进行指令重排和延迟隐藏
- 支持标准纹理采样:支持Sample()
- 无脑浪费算力
- 经验法则
- 如果一侧是“核弹级”开销,使用
[branch] -
如果两边都是廉价的 ALU 算术题,使用
[flatten] -
看屏幕空间的一致性:条件在 8x8 的像素块内是否一样
比如 CSM的层级选择,一个 8x8 的像素块极大概率都落在同一个 Shadow Cascade
而Checkerboard、Dither Noise阈值剔除,相邻像素条件结果剧烈波动,
[branch]必然发散- 纹理采样的硬性限制:if里包含Sample(),使用
[flatten]
- 如果一侧是“核弹级”开销,使用




Comments | NOTHING