前言

在学习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] 往往能瞬间释放寄存器压力

banch & flatten

  • 用途:用于if-else
  • 作用
    • branch:指示编译器保留真正的条件跳转指令。GPU 会在运行时评估条件,并决定跳转到 if 块还是 else
    • flatten:指示编译器消除分支跳转指令。 GPU 无条件地把 ifelse 里的代码全部执行一遍
  • 实例
    • 昂贵的计算
    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)
    }
    

    ifelse 里面都只有一次最基础的寄存器赋值操作,哪怕让 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 个线程把 ifelse 两条路依次都走一遍。为了保证逻辑正确,它引入了线程掩码。大致流程如下:

    • 评估条件与生成掩码

      指令控制器首先让所有 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]

他们曾如此骄傲的活过,贯彻始终