关于计算着色器中计算单元组(Workgroup)之间执行循环顺的随机性 和 计算单元组内工作项(Work Item / Invocation)执行循环的特性笔迹

0 阅读4分钟

OpenGL ES 3.1 计算着色器基础

1. 计算着色器概述

OpenGL ES 3.1 引入了计算着色器(Compute Shader),它是一种用于通用计算(GPGPU)的着色器类型,不依赖于图形管线的顶点/片段处理阶段。计算着色器允许开发者直接在 GPU 上执行大规模并行计算,例如物理模拟、图像处理或矩阵运算。

1.1线程组织结构

工作组(Workgroup):一组工作项组成的单位,每个工作组在GPU中独立调度 工作项(Invocation/Work Item): 工作组内单个执行单元,要有全局和局部的ID,用于索引计算单元

计算着色器单元说明.png

需要说明的是,计算单元是我对工作项的代称,计算单元组是对工作组的代称

gsls入口函数

#version 310 es  
layout(local_size_x = 2, local_size_y = 2) in;  
void main() {  
// 每个工作项执行的计算  
}

其中 local_size_xlocal_size_ylocal_size_z 定义了工作组中工作项的维度,即计算单元组中的x、y、z轴上计算单元的数量。示例中local_size_z被忽略不写默认为local_size_z = 1

2. 计算着色器循环执行的并行特性

在 OpenGL ES 3.1 的计算着色器中:

  • 工作组之间的调度是非确定性的
    GPU 调度器会根据硬件资源(如计算单元、寄存器、线程数)动态分配工作组执行顺序,因此:

    • 不同工作组的循环执行顺序可能是随机的。
    • 不能依赖工作组之间的执行顺序来进行同步计算,除非使用全局内存和原子操作。

gsls伪代码

// 假设有2*2*1个工作组,每个工作组处理一块数据
for(int i = 0; i < N; i++) {
    data[gl_GlobalInvocationID.x] += i;
}

说明计算单元在循环计算中特性.png

在同一时刻时,X、Y、Z与W不一定相等!这也是上文中所说的每个工作组在GPU中独立调度,事实上,即便没有循环层数,仅仅是执行一段程序,随着硬件差异,每个计算单元组执行该段程序的时间可能不同。另外需要说明的是,N∈{X,Y,Z,K},n∈{0,1,2},N0、N1和N2可能相同(渲染单次时间大)。

2.2 工作组内工作项的执行特性
  • 同一工作组内的工作项执行顺序是确定的相对同步

    • 在工作组内,所有工作项会在 GPU 的同一计算单元上执行,通常按照 SIMD(单指令多数据)方式并行处理。
    • 通过 barrier() 可以在工作组内同步工作项。
  • 局部循环执行

    • 工作组内的每个工作项会独立执行循环,且循环的迭代顺序对每个工作项是严格确定的。

    • 工作项之间的循环不会互相干扰,但可以通过共享内存(shared)进行协作。 gsls伪代码

shared float temp[16];
uint id = gl_LocalInvocationID.x;

for(int i = 0; i < 10; i++) {
    temp[id] += float(i);
}
barrier(); // 确保工作组内所有工作项完成循环

通过barrier(),能够保证同一工作组内的工作项能够在其他工作项都完成该轮循环的情况下,再进行下一步循环执行,但就我看来,这并非是读写同步!它只是保证了执行顺序。若不使用barrier(),不同GPU会有不同处理,能够保证相同工作组内工作项的执行顺序一致,有的GPU则无法保证相同工作组内工作项的执行顺序一致 同样的,假设没有循环,我们让工作组执行一段渲染着色器shaderCodeX,假如我们想要保证所有工作项执行完改渲染着色器后,在执行下一段操作(可能是opengl自带的指令,可能是新的自定义计算着色器渲染),此时我们可以使用

glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT)

c++伪代码:

glUseProgram(myProgramID);
......   //各种绑定资源与设置资源属性,面向过程的程序就是这样繁琐
glDispatchCompute(workGroupSizeX, workGroupSizeY, (GLuint) 1); // 执行当前绑定的计算着色器
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);  // 所有工作项执行完当前计算着色器后,才会继续执行下一个着色器
//需要注意的是,除非使用glFinsh(),不然cpu在gpu的工作组执行时不会阻塞

3 总结

简言之:

  • 工作组之间是“随机并行”
  • 使用barrier()的前提下工作组内部是“严格顺序循环执行”

这种特性使得计算着色器非常适合在 GPU 上进行大量独立计算,但对需要跨工作组严格同步的算法,需要谨慎设计。

4 展望

如何高效地同步相同工作组之间的工作项、不同工作组之间的工作项?或者说对计算着色器要求读写同步是否是合理的需求?