UPL9-16——善用 GPU 并行特性

善用 GPU 并行特性是什么意思

GPU 的强项是高度并行,同时处理成百上千个像素和顶点、线程同时工作
善用并行特性,意思就是把计算分成许多相对简单、独立、可并行的工作单元,避免会破坏并行效率的做法!
目标是让每个 GPU 线程做少量且规则的工作,从而提高吞吐量并降低延迟与功耗

向量化运算

GPU 的 ALU(算术逻辑单元)通常以 4-wide SIMD 形式工作,能同时处理 4 个 float 分量
4-wide SIMD (单指令多数据流,Single Instruction, Multiple Data),即 GPU(或某个执行单元)一次执行同样的运算指令时,会并行处理 4 个数据元素

因此我们在着色器中进行计算时,最好进行一次性“向量运算”,比如:

  • 不好的做法:分开计算

    1
    2
    3
    float 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
2
3
float3 a = float3(1, 2, 3);
float3 b = float3(4, 5, 6);
float3 c = a * b; // GPU可以并行计算3个分量

避免线程发散

GPU最小的同步执行单元(warp (NVIDIA) / wavefront (AMD))中所有线程必须执行相同指令,分支会导致部分线程闲置
比如:

  • 不好的做法:动态分支

    1
    2
    3
    4
    5
    6
    7
    8
    if (dot(N, L) > 0)
    {
    color += CalculateLight(N, L);
    }
    else
    {
    color += float3(0, 0, 0);
    }
  • 好的做法:使用 lerp​ 或 saturate 避免分支

    1
    2
    float NdL = saturate(dot(N, L)) ;    //saturate函数 将输入值限制(clamp)在 [0, 1] 区间内
    color += CalculateLight(N, L) * NdL;

    这样做可以避免线程闲置,尽量不要在 Shader 中使用 if 语句
    如果确实存在分支,建议使用 Shader 关键字生成不同 Shader 变体

内存访问合并

连续的全局内存访问可以被 GPU 合并为更少的内存事务
随机访问或间隔很大的索引会导致散列访问,严重拉低带宽效率

比如:

  • 不好的做法:随机访问

    1
    2
    3
    groupshared float data[256];
    uint index = some_complex_calculation(tid); //计算出来一个随机索引
    float value = data[index];
  • 好的做法:连续访问

    1
    2
    3
    groupshared 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. 顺序依赖

    1
    2
    float a = data[i];
    float b = a * 2;

    这里 b​ 的计算必须等 a 计算完成,形成顺序依赖

  2. 循环依赖
    一个循环中迭代依赖前一次结果

    1
    2
    3
    4
    for (int i) 
    {
    data[i] = data[i - 1] * 0.5;
    }
  3. 前后帧依赖

    在 GPU 迭代计算中,如果每个线程要用到前一个线程的结果,也属于数据依赖
    如果线程之间存在依赖,就必须等待其他线程完成,导致 串行化,降低并行度

等等

因此我们要尽量减少属于依赖,让每个线程尽量独立计算,不依赖其他线程或前序结果,从而提升 GPU 并行度和吞吐率,比如:

  1. 数据拆分、独立计算

    尽量让每个线程操作独立的数据,不依赖其他线程

    1
    float b = data[id] * 2; // 每个线程只读自己的数据
  2. 尽量把依赖计算移到 CPU

    某些序列依赖逻辑,用 CPU 先算,再传给 GPU 并行处理独立部分 1

  3. 减少原子操作

等等