什么是计算着色器(compute shader)?
- shader中文是着色器,简而言之,着色器是一个小程序,借助图形 API,我们可以在显卡上运行它。这些着色器最常用于渲染,如果用于计算的着色器就是计算着色器(compute shader)。
什么地方可以使用计算着色器(compute shader)?
- 粒子/流体模拟
- 编写自定义渲染代码,例如后期处理
- 创建几何图形、噪声、纹理等
- AI训练和推理上的数学计算操作
- 任何可以采用大规模并行计算可能的地方
使用计算着色器(compute shader)之前,需要考虑哪些方面?
- 并非所有代码都适合并行计算,着色器无法处理大量分支,线程也无法像在 CPU 上那样相互通信。
- 资源需要在 GPU 上可用,上传或读回 GPU 资源有时可能是一个复杂的过程。
- 发送的数据可以被任何线程访问,意味着我们可以从任何线程读取和写入数据,索引错误的数据或两次相同的数据可能会导致未定义的行为。
- 优化计算着色器可能很棘手,平衡着色器以实现最大线程占用率可能是一个相当困难的过程。
了解GPU上的线程概念
那么在开始编程之前,我们需要了解gpu进行计算时线程的不同级别。 我们首先需要理解的是,线程被视为3D块,直接看一下图比较直观:
接着解释几个主要概念:
- Thread:真正运行着色器代码的线程。
- Thread Group(线程组):在计算着色器顶部的 [numthreads(x, y, z)] 部分中定义。它表示将执行着色器代码的线程数。
- Dispatch(调度): 从CPU代码中告诉GPU调度要运行相同着色器的线程组数量。
对于线程组,可用的线程数取决于 GPU 和着色器模型版本。但对于大多数现代 GPU 和着色器来说,线程数可能被限制为 1024 个,例如下面的shader代码是允许的:
// 32 x 32 x 1 = 1024
[numthreads(32, 32, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
对于调度,可以看作是“要执行的工作任务列表”。我们告诉 GPU,我们要执行工作任务 X * Y * Z 次。每个维度的最大调度数量为 65535。从技术上讲,这意味着我们可以调度 65535^3 个线程组,这……相当多。稍微看一下常用的调度代码
// 1. 绑定 计算管道和根签名
commandList->SetComputeRootSignature(rootSignature->GetAddress());
commandList->SetPipelineState(computePipeline->GetAddress());
// 2.绑定相关的根参数
commandList->SetComputeRootDescriptorTable(0, targetTexture->GetUAV());
// 3. 执行计算着色器
commandList->Dispatch(1024, 1024, 1);
DispatchThreadID 是什么?
我们现在知道如何分配线程来执行我们的着色器,但如何使用这些线程来工作呢?在计算着色器中,其中有两个重要的概念:
- SV_GroupThreadID:线程组内线程的本地 3D 索引
- SV_DispatchThreadID:调度中线程的全局 3D 索引
先看看GroupThreadID,线程组中的每个线程都有自己的 3D 索引,如上图所示,我们有一个大小为 (2, 2, 1) 的线程组以及一个使用相同尺寸的调度(dispatch),然后是DispatchThreadID,线程具有与其在调度中的位置相关的索引,因为现在每个线程都有一些唯一的索引,DispatchThreadID 是让单个线程计算部分数据的关键。 最后看看两个调度的代码:
第一例子假设我们有一个尺寸为 512×512 的 2D 纹理,我们的目标是使纹理中的所有像素都变成黑色
HLSL
RWTexture2D<float4> targetTexture : register(u0);
[numthreads(1, 1, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
targetTexture[dispatchThreadID.xy] = float4(0.0f, 0.0f, 0.0f, 1.0f);
}
C++
//1. 绑定 计算管道和根签名
commandList->SetComputeRootSignature(rootSignature->GetAddress());
commandList->SetPipelineState(computePipeline->GetAddress());
// 2.绑定相关的根参数
commandList->SetComputeRootDescriptorTable(0, targetTexture->GetUAV());
// 3. 执行计算着色器
commandList->Dispatch(512, 512, 1);
另外因为大多数纹理大小都可以被 8 整除,我们可以把增大线程组大小,减少调度次数
HLSL
[numthreads(8, 8, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
targetTexture[dispatchThreadID.xy] = float4(0.0f, 0.0f, 0.0f, 1.0f);
}
C++
//注意 并不是所有的纹理都可以被8整除
unsigned int dispatchX = targetTexture.Width() / 8;
unsigned int dispatchY = targetTexture.Height() / 8;
commandList->Dispatch(dispatchX, dispatchY, 1);
总之,通过平衡线程,有很多方法可以解决同一问题。GPU 供应商已建议线程组大小。Nvidia 建议的线程组大小为 32,而 AMD 通常为 64(或根据新 RDNA 架构的驱动程序设置,为 32)。但我们不能受这些尺寸的限制,如果需要更多或更少,着色器仍然可以工作,甚至效率更高,这需要进一步做实际测试。
首次在掘金上写技术文章,后续我将继续输出,如何喜欢或想了解更多关于direcx12的知识,欢迎留言,让我们一起进步。