由于图形处理器每秒能够进行数以亿计次的计算,它已成为一种性能十分惊人的器件。过去,这种处理器主要被设计用于承担实时图形渲染中海量的数学运算。然而,其潜在的计算能力也可用于处理与图形无关的任务,特别是当无法很好地与固定功能的图形管线结合的时候。为了使得这种应用成为可能,OpenG引入一种特殊的着色器:计算着色器。计算着色器可以认为是一个只有一级的管线,没有固定的输入和输出,所有默认的输入通过一组内置变量来传递。当需要额外的输入时,可以通过那些固定的输入输出来控制对纹理和缓冲的访问。所有可见的副作用是图像存储,原子操作,以及对原子计数器的访问。然而加上通用的显存读写操作,这些看上去似乎有限的功能使计算着色器获得一定程度的灵活性,同时摆脱图形相关的束缚,以及打开广阔的应用空间。
OpenGL中的计算着色器和其他着色器很相似。它通过glCreateShader() 函数创建,用glCompilerShader()进行编译,通过glAttachShader()对程序进行绑定,最后按通用的做法用glLinkProgram()对这些程序进行链接。计算着色器使用GLSL编写,原则上,所有其他图形着色器(比如顶点着色器,几何着色器或者片元着色器)能够使用的功能它都可以使用。当然,这不包括诸如几何着色器中的EmitVertex()或者EndPrimitive()等功能,以及其他类似的与图形管线特有的内建变量。另一方面,计算着色器也包含一些独有的内置变量和函数,这些变量和函数在OpenGL管线的其他地方无法访问。
正如图形着色器被置于管线的不同阶段用来操作与图形相关的单元一样,将计算着色器被有效地放入一个一级的计算管线中,然后处理与计算相关的单元。按照这种类比,顶点着色器作用于每个顶点,几何着色器作用于每个图元,而片元着色器则作用于每个片元。图形硬件主要通过并行来获得性能,这种并行则通过大量的顶点、图元和片元流过相应的管线阶段而得以实现。而在计算着色器中,这种并行性则显得更为直接,任务以组为单位进行执行,我们称为工作组(work group)。拥有邻居的工作组被称为本地工作组(local workgroup), 这些组可以组成更大的组,称为全局工作组(global workgroup),而其通常作为执行命令的一个单位。
计算着色器会被全局工作组中每一个本地工作组中的每一个单元调用一次,工作组的每一个单元称为工作项(work item),每一次调用称为一次执行。执行的单元之间可以通过变量和显存进行通信,且可执行同步操作保持一致性。图12-1 对这种工作方式进行了说明。在这个简化的例子中,全局工作组包含16个本地工作组, 而每个本地工作组又包含16个执行单元,排成4*4的网格。每个执行单元拥有一个2维向量表示的索引值。
尽管在图12-1中,全局和本地工作组都是2维的,而事实上它们是3维的,为了能够在逻辑上适应1维、2维的任务,只需要把额外的那2维或1维的大小设为0即可。计算着色器的每一个执行单元本质上是相互独立的,可以并行地在支持OpenGL的GPU硬件上执行。实际中,大部分OpenGL硬件都会把这些执行单元打包成较小的集合(lockstep),然后把这些小集合拼起来组成本地工作组。本地工作组的大小在计算着色器的源代码中用输入布局限定符来设置。全局工作组的大小则是本地工作组大小的整数倍。当计算着色器执行的时候,它可以内置变量来知道当前在本地工作组中的相对坐标、本地工作组的大小, 以及本地工作组在全局工作组中的相对坐标。基于这些还能进一步获得执行单元在全局工作组中的坐标等。着色器根据这些变量来决定应该负责计算任务中的哪些部分,同时也能知道一个工作组中的其他执行单元,以便于共享数据。
图12-1 计算工作量的图示
输入布局限定符在计算着色器中声明本地工作组的大小,分别使用local_size_x、local_size_y以及local_size_z,它们的默认值都是1。举例来说如果忽略local_size_z,就会创建N * M的2维组。比如在例子12.1中就声明了一个本地工作组大小为16 * 16的着色器。
例12.1简单的本地工作组声明
尽管例子12.1中的着色器什么事情也没做,它仍然是一个“完整”的着色器,可以正常的编译、链接并且在OpenGL硬件中执行。要创建一个计算着色器,只需调用glCreateShader ()函数,将类型设置为GL_COMPUTE_SHADER,并且调用glShaderSource()函数来设置着色器的源代码, 接着就能按正常编译了。然后把着色器附加到一个程序上,调用glLinkProgram()。这样就会产生计算着色器阶段需要的可执行程序。例12.2展示了从创建到链接一个计算程序(使用“计算程序”来表示使用计算着色器来编译的程序)的完整步骤。
例12.2 创建,编译和链接计算着色器
一旦像例12.2中那样创建并链接一个计算着色器后,就可以用glUseProgram()函数把它设置为当前要执行的程序,然后用glDispatchCompute()把工作组发送到计算管线上,其原型如下:
Void glDispatchCompute(GLuint num_groups_x, GLuint num_groups_y, GLuint num_groups_z);
在3个维度上分发计算工作组。num_groups_x,num_groups_y和num_groups_z分别设置工作组在X,Y和Z维度上的数量。每个参数都必须大于0,小于或等于一个与设备相关的常量数组GL_MAX_COMPUTE_WORK_GROUP_SIZE的对应元素。
在调用glDispatchCompute()时,OpenGL会创建一个包含大小为num_groups_x * num_groups_y * num_gourps_z的本地工作组的3维数组。注意三个维度中一个或两个维度可以为1或者glDispatchCompute()的参数的任何值。所以计算着色器中执行单元的总数是这个3维数组的大小乘以着色器代码中定义的本地工作组的大小。可想而知,这种方法可以为图像处理器创建非常大规模的工作负载,而通过计算着色器则可以相对容易地获得并行性。
正如glDrawArraysIndirect()和glDrawArrays()的关系一样,除了使用glDispatchCompute()之外通过glDispatchComputeIndirect()可以使用存储在缓冲区对象上的参数来发送计算任务。缓冲区对象被绑定在GL_DISPATCH_INDIRECT_BUFFER上,并且缓冲区中存储的参数包含三个打包在一起的无符号整数。这三个无符号整数的作用和glDispatchCompute()中的参数是等价的。参考glDispatchComputeIndirect的原型如下:
void glDispatchComputeIndirect(GLintptr indirect);
在三个维度上分发计算工作组,同时使用缓存对象中存储的参数。indirect表示缓存数据中存储参数的位置偏移量,使用基本机器单位。缓存中当前偏移位置的参数,是紧密排列的三个无符号整数值,用来表示本地工作组的数量。这些无符号整数值等价于glDispatchCompute()中的num_groups_x,num_groups_y和num_groups_z参数。每个参数都必须大于0,小于或等于一个设备相关的常量数组GL_MAX_COMPUTE_WORK_GROUP_SIZE的对应元素。
绑定在GL_DISPATCH_INDIRECT_BUFFER上的缓冲区数据的来源可以多种多样,比如由另外一个计算着色器生成。这样一来,图形处理器就能够通过设置缓冲区中的参数来给自身发送任务做计算或绘图。例12.3中使用glDispatchComputeIndirect()来发送计算任务。
例12.3 分发计算工作量
注意到例12.3简单地使用glUseProgram()把当前的程序对象指向某个特定计算程序。除了不能访问图形管线中的那些固定功能部分(如光栅器或帧缓存),计算着色器及其程序是完全正常的,这意味着你可以用glGetProgramiv()来请求它们的一些属性(比如有效的uniform常量,或者存储块)或者像往常一样访问uniform常量。当然,计算着色器可以访问所有其他着色器能访问的资源,比如图像,采样器,缓冲区,原子计数器,以及常量存储块。
计算着色器及其程序还有一些独有的属性。比如,获得本地工作组的大小(在源代码的布局限定符中设置),调用glGetProgramiv()时将pname设置成GL_MAX_COMPUTE_WORK_GROUP_SIZE以及把param设置成包含三个无符号整型数的数组地址。这数组中的三个数会按顺序被赋值为本地工作组在X,Y和Z方向上的大小。
一旦开始执行计算着色器,它就有可能需要对输出数组的一个或多个单元赋值(比如一副图像或者一个原子计数器数组),或者需要从一个输入数组的特定位置读取数据。为此得知道当前处于本地工作组中的什么位置,以及在更大范围的全局工作组中的位置。于是,OpenGL为计算着色器提供一组内置变量。如例12.4所示,这些内置变量被隐含地声明。
例12.4 计算着色器中的内置变量声明
这些计算着色器的定义如下:
假设已经知道自己在本地工作组和全局工作组中的位置,则可以利用信息来操作数据。如例12.5所示,加入一个图像变量使得我们能够将数据写入由当前执行单元坐标决定的图像位置中去,并且可以在计算着色器中更新。
例12.5 数据的操作
例12.5中的着色器把执行单元在本地工作组中的坐标按本地工作组大小进行归一化, 然后将该结果写入由全局请求ID确定的图像位置上去。 图像结果表达了全局和本地的请求ID的关系,并且展示在计算着色器中定义的矩形的工作组。(本例有32*16个执行单元,图像如12.2所示)
为了生成如图12-2的图像, 在计算着色器写完数据后,只需简单地将纹理渲染至一个全屏的三角条带上即可。
当调用glDispatchCompute()(或者glDispatchComputeIndirect())的时候,图形处理器的内部将执行大量的工作。图形处理器会尽可能采取并行的工作方式,并且每个计算着色器的请求都被看作是一个执行某项任务的小队。我们必然要通过通信来加强团队之间的合作,所以即使OpenGL并没有定义执行顺序和并行等级的信息,我们还是可以在请求之间建立某种程度的合作关系,以实现变量的共享。此外,我们还可以对一个本地工作组的所有请求进行同步,让它们在同一时刻同时抵达着色器的某个位置。
图12-2 全局和本地的请求ID的关系
我们可以使用shared关键字来声明着色器中的变量,其格式与其它的关键字,例如uniform、in、out等类似。例12.6给出了一个使用shared关键字来进行声明的示例。
例12.6 声明共享变量的示例
如果一个变量被声明为shared,那么它将被保存到特定的位置,从而对同一个本地工作组内的所有计算着色器请求可见。如果某个计算着色器请求对共享变量进行写入,那么这个数据的修改信息将最终通知给同一个本地工作组的所有着色器请求。在这里我们用了“最终”这个词,这是因为各个着色器请求的执行顺序并没有定义,就算是同一个本地工作组内也是如此。因此,某个着色器请求写入共享shared变量的时刻可能与另一个请求读取该变量的时刻相隔甚远,无论先写入后读取还是先读取后写入。为了确保能够获得期望的结果,我们需要在代码中使用某种同步的方法。下一个小节详细介绍这一问题。
通常访问共享shared变量的性能会远远好于访问图像或者着色器存储缓存(例如主内存)的性能。因为着色器处理器会将共享内存作为局部量处理,并且可以在设备中进行拷贝,所以访问共享变量可能比使用缓冲区的方法更迅速。因此我们建议,如果你的着色器需要对一处内存进行大量的访问,尤其是可能需要多个着色器请求访问同一处内存地址的时候,不妨先将内存拷贝到着色器的共享变量中,然后通过这种方法进行操作,如果有必要,再把结果写回到主内存中。
因为需要把声明为shared的变量存储到图形处理器的高性能资源环境中,而这样的资源环境是有限的,所以需要查询和了解某个计算着色器程序的共享变量的最大数量。要获取这个限制值,可以调用glGetIntegerv()并设置pname为GL_MAX_COMPUTE_SHARED_MEMORY_SIZE。
如果本地工作组请求的执行顺序,以及全局工作组中的所有本地工作组的执行顺序都没有定义,那么请求执行操作的时机与其他请求就是完全无关的。如果请求之间不需要互相通信,只需完全独立地执行,那么这样并没有什么问题。但是,如果请求之间需要进行通信,无论是通过图像,缓存还是共享内存,那么我们就有必要对它们的操作进行同步处理了。
同步命令的类型有两种。首先是运行屏障(execution barrier),可以通过barrier()函数触发。它与细分控制着色器中的barrier()函数类似,后者可以用来实现控制点处理过程中的请求同步。如果计算着色器的一个请求遇到了barrier(),那么它会停止运行,并等待同一个本地工作组的所有请求到达为止。当请求从barrier()中断的地方重新开始运行的时候,我们可以断定其它所有的请求也已经到达了barrier(),并且在此之前的所有操作均已经完成。barrier()函数在计算着色器中的用法比在细分控制着色器中更为灵活。尤其是,不需要限制在着色器中的main()函数中执行barrier()。但是,必须在统一的流控制过程中调用barrier()。也就是说,如果本地工作组的一个请求执行了barrier()函数,那么同一工作组的所有请求都必须执行这个函数。这样是合理的,因为着色器的某个请求不可能知道其它请求的控制流情况,所以只能假设其它请求也能到达屏障的位置,否则将会发生死锁的情形。
如果在本地工作组内进行请求间的通信,那么可以在一个请求中写入共享变量,然后在另一个请求中读取。但是,我们必须确定目标请求中读取共享变量的时机,即在源请求已经完成对应的写入操作之后。为了确保这一点,我们可以在源请求中写入变量,然后在两个请求中同时执行barrier()函数。当目标请求从barrier()返回的时候,源请求必然已经执行了同一个函数(也就是完成共享变量的写入),因此可以安全地读取变量的值了。
第二种类型的同步叫做内存屏障(memory barrier)。内存屏障的最直接的版本就是memoryBarrier()。如果调用memoryBarrier(),那么就可以保证着色器请求内存的写入操作一定是提交到内存端,而不是通过缓冲区(cache)或者调度队列之类的方式。所有发生在memoryBarrier()之后的操作在读取同一处内存的时候,都可以使用这些内存写入的结果,即使是同一个计算着色器的其它请求也是如此。此外,memoryBarrier()还可以给着色器编译器做出指示,让它不要对内存操作重排序,以免因此跨越屏障函数。如果你觉得memoryBarrier()的约束过于严格,那么你的感觉很正确。事实上,memoryBarrier()系列中还有其它不同的内存屏障子函数。memoryBarrier()所做的只是简单地按照某种未定义的顺序(这个说法不一定准确)依次调用这些子函数而已。
memoryBarrierAtomicCounter()函数会等待原子计数器更新,然后继续执行。memoryBarrierBuffer()和memoryBarrierImage()函数会等待缓存和图像变量的写入操作完成。memoryBarrierShared()函数会等待带有shared限定符的变量更新。这些函数可以对不同类型的内存访问提供更为精细的控制和等待方法。举例来说,如果正在使用原子计数器来实现缓存变量的访问,我们可能希望确保原子计数器的更新被通知到着色器的其它请求,但是不需要等待缓存写入操作本身完成,因为后者可能会花费更长的时间。此外,调用memoryBarrierAtomicCounter()允许着色器编译器对缓存变量的访问进行重排序,而不会受到原子计数器操作的逻辑影响。
注意,就算是调用memoryBarrier()或者它的某个子函数,我们依然不能保证所有的请求都到达着色器的同一个位置。为了确保这一点,我们只有调用执行屏障函数barrier(),然后再读取内存数据,而后者应该是在memoryBarrier()之前被写入的。
内存屏障的使用,对于单一着色器请求中内存交换顺序的确立来说并不是必需的。在着色器的某个请求中读取变量的值总是会返回最后一次写入这个变量的结果,无论编译器是否对它们进行重排序操作。
我们介绍的最后一个函数叫做groupMemoryBarrier(),它等价于memoryBarrier(),但是它只能应用于同一个本地工作组的其它请求。而所有其它的屏障函数都是应用于全局的。也就是说,它们会确保全局工作组中的任何内存写入请求都会在提交之后,再继续执行程序。
本文摘自《OpenGL编程指南(原书第8版)》第12章:计算着色器,机械工业出版社出版。
作者介绍:
Dave Shreiner:ARM公司图形与GPU计算部门主管,自从OpenGL诞生之日起就积极地参与到它的开发当中。他创建了OpenGL的第一个商业培训课程,并且拥有超过20年的OpenGL编程教学经验。
Graham Sellers:《OpenGL超级宝典》的合著者,在AMD负责OpenGL的软件开发。他同时还是很多OpenGL特性规范的作者,并且协助将OpenGL ES移植到台式机平台。
John Kessenich:OpenGL着色语言的规范编者,LunarG公司的顾问,负责GLSL的编译器技术。他在3DLabs和Intel帮助下开发了OpenGL 2.0和OpenGL ES 2.0。
Bill Licea-Kane:AMD的技术部门核心成员,《OpenGL Shading Language Guide》的合著者,OpenGL着色语言技术子部门的负责人。
阅读和此文章类似的: 程序员专区