UPL9-16——善用 GPU 并行特性
UPL9-16——善用 GPU 并行特性
善用 GPU 并行特性是什么意思
GPU 的强项是高度并行,同时处理成百上千个像素和顶点、线程同时工作
善用并行特性,意思就是把计算分成许多相对简单、独立、可并行的工作单元,避免会破坏并行效率的做法!
目标是让每个 GPU 线程做少量且规则的工作,从而提高吞吐量并降低延迟与功耗
向量化运算
GPU 的 ALU(算术逻辑单元)通常以 4-wide SIMD 形式工作,能同时处理 4 个 float 分量
4-wide SIMD (单指令多数据流,Single Instruction, Multiple Data),即 GPU(或某个执行单元)一次执行同样的运算指令时,会并行处理 4 个数据元素
因此我们在着色器中进行计算时,最好进行一次性“向量运算”,比如:
-
不好的做法:分开计算
1
2
3float r = tex2D(_Tex, uv).r;
float g = tex2D(_Tex, uv).g;
float b = tex2D(_Tex, uv).b; -
好的做法:一次性向量运算
1
float3 rgb = tex2D(_Tex, uv).rgb;
向量运算示例:
1 | float3 a = float3(1, 2, 3); |
避免线程发散
GPU最小的同步执行单元(warp (NVIDIA) / wavefront (AMD))中所有线程必须执行相同指令,分支会导致部分线程闲置
比如:
-
不好的做法:动态分支
1
2
3
4
5
6
7
8if (dot(N, L) > 0)
{
color += CalculateLight(N, L);
}
else
{
color += float3(0, 0, 0);
} -
好的做法:使用
lerp 或saturate避免分支1
2float NdL = saturate(dot(N, L)) ; //saturate函数 将输入值限制(clamp)在 [0, 1] 区间内
color += CalculateLight(N, L) * NdL;这样做可以避免线程闲置,尽量不要在 Shader 中使用
if语句
如果确实存在分支,建议使用 Shader 关键字生成不同 Shader 变体
内存访问合并
连续的全局内存访问可以被 GPU 合并为更少的内存事务
随机访问或间隔很大的索引会导致散列访问,严重拉低带宽效率
比如:
-
不好的做法:随机访问
1
2
3groupshared float data[256];
uint index = some_complex_calculation(tid); //计算出来一个随机索引
float value = data[index]; -
好的做法:连续访问
1
2
3groupshared float data[256];
uint index = tid; // 连续索引
float value = data[index];
预计算并行查找表
预计算并行查找表是,先离线或在初始化阶段预先计算好复杂函数的结果,然后在运行时通过查表快速获得结果,而不是实时计算
假设有 1024 个像素线程需要计算相同的函数 f(x),如果你每个像素都算一次
那么每个线程都要运行复杂指令、性能消耗线性增长
而如果我们通过查找表,每个线程只是访问一个数组或纹理,
GPU 的纹理采样硬件是高度并行的,访问延迟被隐藏,带宽远比算力充裕,
所以使用预计算查找表,可以让大量线程同时查表而不互相干扰,大幅减少算力压力
批处理
合并 Drawcall 可以减少 CPU 提交压力,让 GPU 在更大的批中并行工作,提升吞吐
当存在大量相似小物体渲染时(草、树、子弹、UI 等),应该果断使用批处理
减少与避免昂贵的原子操作
原子操作
指一种不可被中断的操作,在执行过程中不会被其他线程打断
要么整个操作执行完毕,要么根本没开始执行,中间不会出现部分完成的状态
原子操作保证了 多个线程对同一变量的读改写是原子的(不可分割的)
原子操作一般是用在 GPU 并行计算 或 Compute Shader 或 多线程编程 时
因此原子操作在高并发下会串行化访问同一内存地址,成为瓶颈
我们应该避免在大并发写入场景(大量线程写同一计数器 / 全局 buffer)直接使用原子操作
减少数据依赖
数据依赖(Data Dependency)指的是一个计算任务必须依赖另一个计算结果才能继续执行,比如:
-
顺序依赖
1
2float a = data[i];
float b = a * 2;这里
b 的计算必须等a计算完成,形成顺序依赖 -
循环依赖
一个循环中迭代依赖前一次结果1
2
3
4for (int i)
{
data[i] = data[i - 1] * 0.5;
} -
前后帧依赖
在 GPU 迭代计算中,如果每个线程要用到前一个线程的结果,也属于数据依赖
如果线程之间存在依赖,就必须等待其他线程完成,导致 串行化,降低并行度
等等
因此我们要尽量减少属于依赖,让每个线程尽量独立计算,不依赖其他线程或前序结果,从而提升 GPU 并行度和吞吐率,比如:
-
数据拆分、独立计算
尽量让每个线程操作独立的数据,不依赖其他线程
1
float b = data[id] * 2; // 每个线程只读自己的数据
-
尽量把依赖计算移到 CPU
某些序列依赖逻辑,用 CPU 先算,再传给 GPU 并行处理独立部分 1
-
减少原子操作
等等
