69.性能优化-GPU-影响因素-并行度与调度
69.1 知识点
并行度与调度的基本概念
补充:GPU 的执行模型
GPU 的线程不是单独执行的,而是一批一批按线程组(Warp / Wavefront)执行:
- NVIDIA:一个 Warp = 32 个线程
- AMD:一个 Wavefront = 64 个线程
一批次内的线程中,同一条指令同时在所有线程上跑,只是每个线程的数据不同。
理想情况:所有线程执行同一条路径,利用率 100%。
并行度
GPU 天然是大规模并行处理器,成千上万个小核心(算术逻辑单元)在同时执行。
并行度指的是能同时被 GPU 执行的任务数量和有效利用率。
常见的并行度层次(GPU 在不同维度的并行能力分类):
- 数据并行
大量像素 / 顶点 / 线程同时计算。 - 任务并行
不同 Pass 或 Shader 阶段并行执行。 - 指令并行
单个线程里,独立指令是否能被调度器交错执行,从而减少空等、提高 GPU 硬件单元利用率。
调度
GPU 内部调度器负责在不同的线程组(Warp / Wavefront)之间切换,把 ALU(算术逻辑单元)、内存访问、纹理采样、光栅化等任务交错执行。
调度的目标是尽量保持硬件单元忙碌。
通俗理解
- 并行度:表示有没有足够的线程让 GPU 忙起来。
- 调度:表示 GPU 能否在等待延迟时把空闲单元塞满。
为什么并行度与调度会影响性能
并行度
并行度影响性能的主要原因:
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万 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 有足够多的线程在跑,避免核心闲置。
- 提高批处理力度
用之前学习的批处理技术,减少 DrawCall,让每次处理更多的顶点和像素。GPU Instancing 和 SRP Batcher 还可以让 GPU 同时处理更多对象。 - 避免线程组(Warp)内线程分化
减少 Shader 内部的if/else的使用,用数学函数(lerp、step)替代分支。 - 控制寄存器压力
避免单个 Shader 太臃肿,中间变量过多。用half代替float(移动端尤为重要)。拆分 Shader Pass,降低寄存器占用,让更多线程组(Warp)能并行驻留。 - 保证任务规模足够大
避免超小网格、超小计算任务(否则并行度不足)。如果使用 Compute Shader(计算着色器),要合理设计工作组大小,让 GPU 核心填满。
调度优化
优化主要目标:让 GPU 能在等待(内存/采样/依赖)时切换别的任务,避免管线停滞。
- 内存访问优化
- 减少带宽需求:纹理压缩(ASTC / BCn / ETC2)、Mipmap、合理精度格式。
- 提高缓存命中率:让相邻像素访问相邻内存(UV 连续、把多种需要的数据合并到一张纹理里,而不是分散存放)。
- 减少依赖采样:UV 复杂运算/链式采样容易停顿,尽量预计算。
- 指令调度优化
打散指令依赖链,让调度器有空间插入别的运算。 - 负载均衡
避免片元过重、顶点过轻,减少 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