69.GPU影响因素之并行度与调度

69.性能优化-GPU-影响因素-并行度与调度


69.1 知识点

并行度与调度的基本概念

补充:GPU 的执行模型

GPU 的线程不是单独执行的,而是一批一批按线程组(Warp / Wavefront)执行:

  • NVIDIA:一个 Warp = 32 个线程
  • AMD:一个 Wavefront = 64 个线程

一批次内的线程中,同一条指令同时在所有线程上跑,只是每个线程的数据不同。
理想情况:所有线程执行同一条路径,利用率 100%。

并行度

GPU 天然是大规模并行处理器,成千上万个小核心(算术逻辑单元)在同时执行。
并行度指的是能同时被 GPU 执行的任务数量和有效利用率。

常见的并行度层次(GPU 在不同维度的并行能力分类):

  1. 数据并行
    大量像素 / 顶点 / 线程同时计算。
  2. 任务并行
    不同 Pass 或 Shader 阶段并行执行。
  3. 指令并行
    单个线程里,独立指令是否能被调度器交错执行,从而减少空等、提高 GPU 硬件单元利用率。

调度

GPU 内部调度器负责在不同的线程组(Warp / Wavefront)之间切换,把 ALU(算术逻辑单元)、内存访问、纹理采样、光栅化等任务交错执行。
调度的目标是尽量保持硬件单元忙碌。

通俗理解

  • 并行度:表示有没有足够的线程让 GPU 忙起来。
  • 调度:表示 GPU 能否在等待延迟时把空闲单元塞满。

为什么并行度与调度会影响性能

并行度

并行度影响性能的主要原因:

1. 并行度不足

GPU 核心会出现空转(闲置状态),算力浪费。比如:

  • 小批量 DrawCall(每次只处理少量像素/顶点),并行度就会不足。
  • Shader 中大量分支,线程分化,造成部分线程空跑。

当 Shader 里有条件分支 if / elseswitchloop 时,线程组(Warp)内的线程可能走不同路径:

if (color.r > 0.5)
{
    // 分支 A
    doSomething();
}
else
{
    // 分支 B
    doSomethingElse();
}

假设 Warp 内 32 个线程,有一半满足条件走 A,另一半走 B。GPU 不能同时执行两条不同的指令,调度器会把分支拆开,先执行 A 的线程,再执行 B 的线程。当执行 A 时,走 B 的线程空转;当执行 B 时,走 A 的线程空转。这就是所谓的线程分化

  • 分支完全对齐时:32 线程并行,1 次执行完成。
  • 分支分化时:等于串行化,执行时间翻倍(甚至更差)。

这就造成了 ALU(算术逻辑单元)利用率下降。

2. 并行度过高

内存带宽、寄存器压力增加,反而造成瓶颈。比如:

假设在片元着色器中对 4 张 4K 纹理进行采样。4K ≈ 830 万像素,如果是 60FPS 的游戏,那每秒采样 830万 x 60 x 4 = 20 亿次纹理访问。这样并行度虽然非常高(数百万像素同时跑),但会造成 GPU 的内存带宽被吃满。ALU(算术逻辑单元)很空闲,还能算更多,但带宽不足,取不到数据,造成 GPU 利用率下降。

调度

调度影响性能的主要原因:如果调度不合理(或没有足够可切换的任务),GPU 会出现 ALU(算术逻辑单元)闲置的情况。

1. 等待内存,ALU 没事干

比如在片元着色器中进行纹理采样时:

float4 frag(v2f i) : SV_Target
{
    // 从显存里采样一张大纹理
    float4 texColor = tex2D(_MainTex, i.uv);

    // 得到采样结果才能计算
    float brightness = dot(texColor.rgb, float3(0.3, 0.59, 0.11));
    return float4(brightness.xxx, 1.0);
}

从纹理中采样时,需要去缓存或显存中获取数据,这里就会存在延迟等待。在采样结果没有回来之前,后面的相关计算都无法执行,相当于就是在等待内存。此时的 ALU(算术逻辑单元)在此期间就无事可做。

2. 算术、依赖链过长

比如执行以下指令:

float a = heavyFunc1(x);
float b = heavyFunc2(a);
float c = heavyFunc3(b);

必须等前一条执行完才能执行下一条,调度器无法插入别的计算。

并行度与调度的优化思路

总体思路:让 GPU 永远有足够的独立的事情可做,避免任何单元长时间空闲。

并行度优化

优化主要目标:保证 GPU 有足够多的线程在跑,避免核心闲置。

  1. 提高批处理力度
    用之前学习的批处理技术,减少 DrawCall,让每次处理更多的顶点和像素。GPU Instancing 和 SRP Batcher 还可以让 GPU 同时处理更多对象。
  2. 避免线程组(Warp)内线程分化
    减少 Shader 内部的 if / else 的使用,用数学函数(lerpstep)替代分支。
  3. 控制寄存器压力
    避免单个 Shader 太臃肿,中间变量过多。用 half 代替 float(移动端尤为重要)。拆分 Shader Pass,降低寄存器占用,让更多线程组(Warp)能并行驻留。
  4. 保证任务规模足够大
    避免超小网格、超小计算任务(否则并行度不足)。如果使用 Compute Shader(计算着色器),要合理设计工作组大小,让 GPU 核心填满。

调度优化

优化主要目标:让 GPU 能在等待(内存/采样/依赖)时切换别的任务,避免管线停滞。

  1. 内存访问优化
    • 减少带宽需求:纹理压缩(ASTC / BCn / ETC2)、Mipmap、合理精度格式。
    • 提高缓存命中率:让相邻像素访问相邻内存(UV 连续、把多种需要的数据合并到一张纹理里,而不是分散存放)。
    • 减少依赖采样:UV 复杂运算/链式采样容易停顿,尽量预计算。
  2. 指令调度优化
    打散指令依赖链,让调度器有空间插入别的运算。
  3. 负载均衡
    避免片元过重、顶点过轻,减少 OverDraw。动态分辨率等优化分辨率方案来控制片元数量。

69.2 知识点代码

Lesson69_性能优化_GPU_影响因素_并行度和调度.cs

public class Lesson69_性能优化_GPU_影响因素_并行度和调度
{
    #region 知识点一 并行度与调度的基本概念

    #region 补充——GPU的执行模型

    //GPU 的线程不是单独执行的,而是 一批一批 按 线程组(Warp / Wavefront) 执行
    //NVIDIA:一个 Warp = 32 个线程
    //AMD:一个 Wavefront = 64 个线程

    //而一批次内的线程中
    //同一条指令同时在所有线程上跑,只是每个线程的数据不同
    //理想情况:所有线程执行同一条路径,利用率 100%

    #endregion

    #region 并行度

    //GPU 天然是 大规模并行处理器,成千上万个小核心(算术逻辑单元)在同时执行
    //并行度 指的是能同时被 GPU 执行的任务数量和有效利用率
    //常见的并行度层次(GPU 在不同维度的并行能力分类)有:
    //1.数据并行
    //  大量像素 / 顶点 / 线程同时计算
    //2.任务并行
    //  不同 Pass 或 Shader 阶段并行执行
    //3.指令并行
    //  单个线程里,独立指令是否能被调度器交错执行,从而减少空等、提高 GPU 硬件单元利用率

    #endregion

    #region 调度

    //GPU 内部调度器负责在 不同的线程组(Warp/Wavefront) 之间切换
    //把 ALU(算术逻辑单元)、内存访问、纹理采样、光栅化等任务交错执行
    //调度的目标是 尽量保持硬件单元忙碌

    #endregion

    #region 说人话

    //并行度
    //表示 有没有足够的线程让 GPU 忙起来
    //调度
    //表示 GPU 能否在等待延迟时把空闲单元塞满

    #endregion

    #endregion

    #region 知识点二 为什么并行度与调度会影响性能

    #region 并行度

    //并行度影响性能的主要原因有:
    //1.并行度不足
    //  GPU 核心会出现空转(闲置状态),算力浪费
    //  比如
    //  小批量 DrawCall(每次只处理少量像素/顶点),并行度就会不足
    //  Shader 中大量分支,线程分化,造成部分线程空跑
    //  当 Shader 里有 条件分支 if/else、switch、loop 时,线程组(Warp)内的线程可能走不同路径
    //  比如:
    //  if (color.r > 0.5)
    //  {
    //      // 分支 A
    //      doSomething();
    //  }
    //  else
    //  {
    //      // 分支 B
    //      doSomethingElse();
    //  }
    //  假设 Warp 内 32 个线程,有一半满足条件走 A,另一半走 B
    //  GPU 不能同时执行两条不同的指令
    //  调度器会把分支拆开,先执行 A 的线程,再执行 B 的线程
    //  当执行 A 时,走 B 的线程空转;当执行 B 时,走 A 的线程空转
    //  这就是所谓的线程分化
    //  分支完全对齐时:32 线程并行,1 次执行完成
    //  分支分化时:等于串行化,执行时间翻倍(甚至更差)
    //  就造成了ALU(算术逻辑单元)利用率下降

    //2.并行度过高
    //  内存带宽、寄存器压力增加,反而造成瓶颈
    //  比如
    //  假设我们在片元着色器中对4张4k纹理进行采样
    //  4K ≈ 830 万像素,如果是60FPS的游戏
    //  那每秒采样
    //  830万 * 60 * 4 = 20亿次纹理访问
    //  这样 并行度虽然非常高(数百万像素同时跑)
    //  但是会造成 GPU 的 内存带宽被吃满
    //  ALU(算术逻辑单元)很空闲,还能算更多,但 带宽不足,取不到数据,造成GPU 利用率下降

    #endregion

    #region 调度

    //调度影响性能的主要原因:
    //如果调度不合理(或没有足够可切换的任务)
    //GPU 会出现 ALU(算术逻辑单元)闲置的情况
    //比如
    //1.等待内存,ALU(算术逻辑单元)没事干
    //  比如 我们在片元着色器中进行纹理采样时
    //  float4 frag(v2f i) : SV_Target
    //  {
    //      // 从显存里采样一张大纹理
    //      float4 texColor = tex2D(_MainTex, i.uv);
    //
    //      // 得到采样结果才能计算
    //      float brightness = dot(texColor.rgb, float3(0.3, 0.59, 0.11));
    //      return float4(brightness.xxx, 1.0);
    //  }
    //  当我们从纹理中采样时,需要去缓存或显存中获取数据,那么这里就会存在延迟等待
    //  在采样结果没有回来之前,后面的相关计算,都无法执行,相当于就是在等待内存
    //  此时的ALU(算术逻辑单元)在此期间就无事可做
    //2.算术、依赖链过长
    //  比如 我们执行以下指令
    //  float a = heavyFunc1(x);
    //  float b = heavyFunc2(a);
    //  float c = heavyFunc3(b);
    //  必须等前一条执行完才能执行下一条,调度器无法插入别的计算
    //等等

    #endregion

    #endregion

    #region 知识点三 并行度与调度的优化思路

    //总体思路
    //让GPU永远有足够的独立的事情可做
    //避免任何单元长时间空闲

    #region 并行度

    //优化主要目标
    //保证 GPU 有足够多的线程在跑,避免核心闲置
    //1.提高批处理力度
    //  用之前学习的批处理技术,减少DrawCall,让每次处理更多的顶点和像素
    //  GPU Instancing和SRP Batcher还可以让GPU同时处理更多对象
    //2.避免 线程组(Warp)内线程分化
    //  减少Shader内部的if/else的使用
    //  用数学函数(lerp、step)替代分支
    //3.控制寄存器压力
    //  避免单个 Shader 太臃肿,中间变量过多
    //  用 half 代替 float(移动端尤为重要)
    //  拆分 Shader Pass,降低寄存器占用,让更多 线程组(Warp) 能并行驻留
    //4.保证任务规模足够大
    //  避免超小网格、超小计算任务(否则并行度不足)
    //  如果使用Compute Shader(计算着色器)要合理设计工作组大小,让 GPU 核心填满

    #endregion

    #region 调度

    //优化主要目标:
    //让 GPU 能在等待(内存/采样/依赖)时切换别的任务,避免管线停滞
    //1.内存访问优化
    //  减少带宽需求:纹理压缩(ASTC/BCn/ETC2)、Mipmap、合理精度格式
    //  提高缓存命中率:让相邻像素访问相邻内存(UV连续、把多种需要的数据合并到一张纹理里,而不是分散存放)
    //  减少依赖采样:UV 复杂运算/链式采样容易停顿,尽量预计算
    //2.指令调度优化
    //  打散指令依赖链,让调度器有空间插入别的运算
    //3.负载均衡
    //  避免片元过重、顶点过轻,减少 OverDraw
    //  动态分辨率等优化分辨率方案来控制片元数量
    //等等

    #endregion

    #endregion
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏