在本 GPU 驱动章节中,多次提到了计算着色器,但从未展示它们是如何工作的,以及在引擎中以何种方式使用它们。
GPU 硬件入门
GPU 是一台计算机器,与 CPU 非常相似。 GPU 执行任意代码来完成诸如运行着色器或渲染三角形之类的任务。虽然 GPU 最初只是三角形渲染机器,但随着时间的推移,由于需要执行像素/顶点着色器,它们变得越来越像 CPU。最终,图形 API 添加了计算着色器,这是一种特殊的着色器,它不使用固定的图形管线,而只是允许在 GPU 上运行任意计算。
现代 GPU 首先是并行计算机器,具有一些加速图形的固定硬件,例如纹理访问器和三角形光栅化器。 GPU 由一组“计算单元”组成,这些单元大致相当于 CPU 核心。但由于 GPU 首先是关于并行性的,因此它们的核心在一些主要方面有所不同。
首先,核心以非常宽的 SIMD(单指令,多数据)方式执行指令。虽然 CPU 默认情况下一次处理一个项目来执行指令,除非您使用特殊指令,但 GPU 以宽泛的方式执行所有操作,通常一次处理 32 或 64 个项目。这些通常被称为线程/通道/cuda 核心。此外,GPU 中的每个核心一次重叠执行多个 32/64 位宽的指令流,因此如果一个流正在等待内存访问,它可以执行另一个流。这与 CPU 上的超线程非常相似,但它是同一概念的更极端版本。
如果您查看像 RTX 2080ti 这样的 GPU,它有 68 个“流式多处理器”,这是上面提到的“核心”部分。其中每个都有 64 个“cuda 核心”,每个核心映射到一个 SIMD 通道,每个“核心”都有 2 个 32 位宽的执行器。
这总共可以同时执行 4352 个通道。然后您希望至少有几个通道重叠,以便内存访问可以被超线程掩盖,因此最后,要让 2080ti 发挥全部功率,您将需要至少 20,000 多个“线程”同时运行,最好是数百万个。
一个非常重要的细节是,由于执行一次运行 32 或 64 次,因此它们无法在每个元素的基础上进行分支。如果您有一个分支在 50% 的时间内被采用,则 GPU 将不得不依次执行这两个分支。
GPU 计算模型
为了驯服所有这些能力,GPU 运行的编程模型与 CPU 不同。在 CPU 中,您首先进行标量编程,一次处理一个元素,而在 GPU 上,您希望一次对数千个元素执行相同的操作,以让宽 GPU 发挥其作用。正是由于这个原因,顶点着色器和像素着色器以它们的方式运行,一次一个像素或顶点。在驱动程序上,您的像素着色器将与许多线程并行调用。
对于计算着色器,您可以更直接地访问此功能。在计算着色器中,单个元素和“工作组”之间存在分割,“工作组”是各个元素的组。同一工作组内的元素可以执行某些功能,例如以快速方式访问工作组本地内存,这对于许多操作很有用。
要定义工作组大小,您必须在着色器本身中进行设置。
layout (local_size_x = 256) in;
通过这样做,我们让 Vulkan 知道我们希望我们的计算着色器一次以 256 个元素的组执行。这与上面所说的 GPU 一次执行 32 或 64 个指令,并且在同一核心中重叠多个执行流非常吻合。工作组可以映射到这些核心之一,但这由驱动程序决定。大多数时候,人们选择的工作组大小是 64 的倍数,并且不是很大,因为较大的工作组大小必须保留更多内存,并且可能会在多个核心内拆分。工作组可以是多维的。在执行后处理过滤器或其他类似的渲染着色器时,拥有 16x16 工作组大小非常常见。
在计算着色器本身中,您可以使用以下代码找到您所处的“元素”:
gl_GlobalInvocationID.x;
这将是每个着色器调用唯一的,并且是您几乎所有事情都会使用的。您还可以访问 gl_LocalInvocationID
,它为您提供您在工作组中的索引,以及 gl_WorkGroupID
以了解正在执行的工作组。
一旦您有了 ID,就取决于您如何使用它。一个示例可以是矩阵乘法着色器,它将缓冲区中的所有矩阵乘以相机矩阵,并将它们存储在另一个缓冲区中。
layout (local_size_x = 256) in;
layout(set = 0, binding = 0) uniform Config{
mat4 transform;
int matrixCount;
} opData;
layout(set = 0, binding = 1) readonly buffer InputBuffer{
mat4 matrices[];
} sourceData;
layout(set = 0, binding = 2) buffer OutputBuffer{
mat4 matrices[];
} outputData;
void main()
{
//grab global ID
uint gID = gl_GlobalInvocationID.x;
//make sure we don't access past the buffer size
if(gID < matrixCount)
{
// do math
outputData.matrices[gID] = sourceData.matrices[gID] * opData.transform;
}
}
要执行此着色器,您将执行以下操作。
int groupcount = ((num_matrices) / 256) + 1;
vkCmdDispatch(cmd,groupcount , 1, 1);
当您执行 vkCmdDispatch
调用时,您不是在调度单个元素,而是在调度工作组。如果我们的工作组大小为 256,我们需要将元素数量除以 256 并向上取整。
计算着色器和屏障
发送到 VkQueue 的所有 GPU 命令将按顺序启动,但不会按顺序结束。 GPU 可以自由地以其认为合适的任何方式调度和重组命令。这意味着如果我们像上面那样运行着色器,然后尝试进行一些使用所述矩阵缓冲区的渲染,则计算着色器可能在缓冲区被使用之前尚未完成执行,从而导致数据竞争并可能导致不良情况发生。
为了同步这一点,Vulkan 具有屏障的概念。我们之前一直在使用屏障进行数据传输和图像转换,但我们也需要在此处使用它们。
在像上面这样的情况下,我们运行计算着色器来准备一些数据,然后在渲染中使用这些数据,我们将需要像这样屏障它。
VkBufferMemoryBarrier barrier = vkinit::buffer_barrier(matrixBuffer, _graphicsQueueFamily);
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, 0, 0, nullptr, 1, &barrier, 0, nullptr);
在此屏障中,我们将“源访问掩码”设置为 Shader Write,因为我们在原始计算着色器中编写它,然后将“目标访问掩码”设置为 Shader Read,因为我们将从顶点着色器中读取它。
在屏障本身上,我们从计算着色器阶段到顶点着色器阶段进行屏障,因为我们在着色器阶段完成缓冲区写入,然后在顶点着色器中使用它。
在执行屏障时,它们可能会停止命令的执行,直到完成为止,并且 GPU 在填充所有执行单元时会有启动和停止时间。因此,您确实希望将屏障组合在一起以获得最佳性能。
在教程引擎中,有 3 个剔除计算着色器,并且屏障仅在 3 个剔除着色器的调度调用之后执行。这意味着剔除着色器很可能在 GPU 中彼此重叠,然后 GPU 将等待所有计算着色器完成,然后再继续进行使用这些着色器的渲染。如果屏障是针对每个调度完成的,则由于所有的启动和停止时间,gpu 可能无法达到高利用率。