DirectCompute & DirectX 11 计算着色器编程简介(翻译)
译者注:DirectX一直是Windows上图形和游戏开发的核心技术。DirectX提供了一种在显卡上运行的程序——着色器(Shader)。在DirectX 11之前,着色器是与具体的渲染步骤绑定的,例如像素着色器,顶点着色器等等。而从DirectX11开始,DirectX增加了一种计算着色器(Compute Shader),它是专门为与图形无关的通用计算设计的。因此DirectX就变成了一个通用GPU计算的平台。鉴于GPU拥有极其强大的并行运算能力,学习使用DirectCompute是很有意义的。而大部分人从未用过DirectX的图形接口,缺乏使用DirectX的经验。本文是一篇完全从零开始学习DirectCompute通用计算技术的文章,无需图形编程经验,所以我就把它翻译过来了。有兴趣的兄弟可以看看学学。
原文地址:http://openvidia.sourceforge.net/index.php/DirectCompute
本文将介绍DirectCompute程序设计,旨在为没有DirectX编程经验的人展示DirectCompute从头开始进行程序设计的一些概念。本文还将介绍DirectX 11计算着色器(Compute Shader)。希望本文可以帮助大家了解使用DirectCompute进行GPU通用计算技术的相关知识。
示例代码介绍
完整示例代码 该链接包含了一个基于控制台的完整DirectCompute程序所需的最小代码示例(.cpp)。该程序是控制台程序,不含任何窗口或图形代码。
计算着色器代码 该链接包含了完整的着色器代码(.hlsl)
示例程序深入展示了以下几个运行计算着色器所需的以下步骤:
- 初始化设备和上下文
- 从HLSL文件加载着色器程序并编译
- 为着色器创建并初始化资源(如缓冲区)
- 设定着色器状态,并执行
- 取回运算结果。
下面将逐个讨论每一个步骤
设备管理
基本上,DirectCompute需要通过计算着色器5.0(Compute Shader)编程模型(即CS 5.0)才能完全实现。然而CS 5.0需要DirectX 11硬件才能支持。如果没有Direct X 11硬件(本文编写时Direct X 11硬件还非常稀少,仅有Ati HD5000系列显卡),我们仍然可以进行DirectCompute编程,一种方法是使用参考硬件模式(软件模拟),另一种方法是使用落后一点的配置,在DirectX 10硬件上运行可以实现部分计算着色器能力的“计算着色器4.0”(如nVidia G80, G92, GT200系列显卡)。做法是:
- 使用DirectX 11 API编写程序(比如调用ID3D11…)
- 创建DX11设备,但是创建时指定使用DX10和CS 4.0特性等级。
下面的代码演示如何多次调用D3D11CreateDevice…()方法,每次创建一种不同的驱动类型(软件模拟的参考型或真正GPU加速的硬件型),以及不同的特性等级(DX10、DX10.1或DX11)
D3D_FEATURE_LEVEL levelsWanted[] = { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; UINT numLevelsWanted = sizeof( levelsWanted ) / sizeof( levelsWanted[0] ); D3D_DRIVER_TYPE driverTypes[] = { D3D_DRIVER_TYPE_REFERENCE, D3D_DRIVER_TYPE_HARDWARE, }; UINT numDriverTypes = sizeof( driverTypes ) / sizeof( driverTypes[0] ); // 遍历每一种驱动类型,先尝试参考驱动,然后是硬件驱动 // 成功创建一种之后就退出循环。 // 你可以更改以上顺序来尝试各种配置 // 这里我们只需要参考设备来演示API调用 for( UINT driverTypeIndex = 0; driverTypeIndex < numDriverTypes; driverTypeIndex++ ) { D3D_DRIVER_TYPE g_driverType = driverTypes[driverTypeIndex]; UINT createDeviceFlags = NULL; hr = D3D11CreateDevice( NULL, g_driverType, NULL, createDeviceFlags, levelsWanted, numLevelsWanted, D3D11_SDK_VERSION, &g_pD3DDevice, &g_D3DFeatureLevel, &g_pD3DContext ); } |
成功运行后,这段代码将产生一个设备指针,一个上下文指针还有一个特性等级的Flag。
注意:为简单起见以上代码省略了许多变量声明的代码。完整示例代码需要补上这些代码。这里的代码片段仅用来展示程序中发生的事情。
选择要用的显卡
用IDXGIFactory对象即可枚举系统中安装的显卡,如下面代码所示。首先创建一个IDXGIFactory对象,然后调用EnumAdapters并传入一个代表正在枚举显卡的整数。如果不存在,它会返回DXGI_ERROR_NOT_FOUND。
// 获取所有安装的显卡 std::vector<IDXGIAdapter1*> vAdapters; IDXGIFactory1* factory; CreateDXGIFactory1(__uuidof(IDXGIFactory1), (void**)&factory); IDXGIAdapter1 * pAdapter = 0; UINT i=0; while(factory->EnumAdapters1(i, &pAdapter) != DXGI_ERROR_NOT_FOUND) { vAdapters.push_back(pAdapter); ++i; } |
接下来,在调用D3DCreateDevice创建设备的时候从第一个参数传入想用的显卡适配器指针,并且将驱动类型设为D3D_DRIVER_TYPE_UNKNOWN。详细信息请参见D3D11文档中D3DCreateDevice函数的帮助。
g_driverType = D3D_DRIVER_TYPE_UNKNOWN; hr = D3D11CreateDevice( vAdapters[devNum], g_driverType, NULL, createDeviceFlags, levelsWanted, numLevelsWanted, D3D11_SDK_VERSION, &g_pD3DDevice, &g_D3DFeatureLevel, &g_pD3DContext ); |
运行计算着色器
译注:着色器(Shader)是在显卡上运行的程序,它并不同于CPU上执行的程序,可以用HLSL来编写(见后文)。
DirectCompute程序中的计算着色器是通过Dispatch函数执行的:
// 现在分派(运行) 计算着色器, 分成16x16个线程组。
g_pD3DContext->Dispatch( 16, 16, 1 );
|
以上语句分派了16x16个线程组。
注意,着色器的输入通常考虑成“状态”。就是说你应当在分派着色器程序之前设定状态,而一旦分派了,“状态”决定输入变量的值。所以着色器分派代码通常应该像这样:
pd3dImmediateContext->CSSetShader( ... ); pd3dImmediateContext->CSSetConstantBuffers( ...); pd3dImmediateContext->CSSetShaderResources( ...); // CS 输入 // CS 输出 pd3dImmediateContext->CSSetUnorderedAccessViews( ...); // 运行 CS pd3dImmediateContext->Dispatch( dimx, dimy, 1 ); |
以上所有常量缓冲(constant buffer),缓冲等可以在着色器程序中看到东东都是在分派线程之前通过调用CSSet…()设定的。
与CPU进行同步
请注意上面所有的调用都是异步的。CPU方面总是会立即返回然后才具体执行。如果有必要,其后调用的缓冲区“映射”操作(详见下文缓冲区部分)时CPU的调用线程才会停下来等待所有异步操作的完成。
事件:基本剖析和同步操作
DirectCompute提供一种基于“查询”的事件机制API。你可以创建、插入并等待特定状态的查询来判断着色器(或其他异步调用)具体在何时执行。下面的例子创建了一个查询,然后通过等待查询来确保运行到某一点时所有该执行的操作都已经执行了,再分派着色器,最后等待另一个查询并确认着色器程序已执行完毕。
创建查询对象:
D3D11_QUERY_DESC pQueryDesc; pQueryDesc.Query = D3D11_QUERY_EVENT; pQueryDesc.MiscFlags = 0; ID3D11Query *pEventQuery; g_pD3DDevice->CreateQuery( &pQueryDesc, &pEventQuery ); |
然后在一系列调用中插入“篱笆”,再等待之。如果查询的信息不存在,GetData()将返回S_FALSE。
g_pD3DContext->End(pEventQuery); // 在 pushbuffer 中插入一个篱笆 while( g_pD3DContext->GetData( pEventQuery, NULL, 0, 0 ) == S_FALSE ) {} // 自旋等待事件结束 g_pD3DContext->Dispatch(,x,y,1); // 启动着色器 g_pD3DContext->End(pEventQuery); // 在 pushbuffer 中插入一个篱笆 while( g_pD3DContext->GetData( pEventQuery, NULL, 0, 0 ) == S_FALSE ) {} // 自旋等待事件结束 |
最后用这条语句释放查询对象:
pEventQuery->Release(); |
请小心创建和释放查询对象以免弄出太多的查询来(特别是你处理一帧画面的时候)。
DirectCompute中的资源
译注:资源是指可以被GPU或CPU访问的数据,是着色器的输入与输出。包括缓冲区和纹理等类型。
DirectX中资源是按照以下步骤创建出来的:
- 首先创建一个资源描述器,用来描述所要创建的资源。资源描述器是一种内含许多Flag和所需资源信息的结构体。
- 调用某种Create系方法,传入描述器作为参数并创建资源。
CPU与GPU之间的通讯
gD3DContext->CopyResouce()函数可以用来读取或复制资源。这里复制是指两个资源之间的复制。如果要在CPU和GPU之间(译注:就是在内存和显存之间)进行复制的话,先要创建一个CPU这边的“中转”资源。中转资源可以映射到CPU的内存指针上,这样就可以从中转资源中读取数据或者复制数据。之后解除中转资源的映射,再用CopyResource()方法进行与GPU之间的复制。
CPU与GPU之间缓冲区复制的性能
CUDA-C语言(CUDA是nVidia的GPU通用计算平台)可以分配定址(pinned)宿主指针和写入联合(write combined)宿主指针,通过它们可以进行性能最佳的GPU数据复制。而在DirectCompute中,缓冲区的“usage”属性决定了内存分配的类型和访问时的性能。
- D3D11_USAGE_STAGING 这种usage的资源是系统内存,可以直接由GPU进行读写。但是他们仅能用作复制操作(CopyResource(), CopySubresourceRegion())的源或目标,而不能直接在着色器中使用。
- 如果资源创建的时候指定了D3D11_CPU_ACCESS_WRITE flag那么从CPU到GPU复制的性能最佳。
- 如果用了D3D11_CPU_ACCESS_READ该资源将是一个由CPU缓存的资源,性能较低(但是支持取回操作)
- 如果同时指定,READ比WRITE优先。
- D3D11_USAGE_DYNAMIC (仅能用于缓冲区型资源,不能用于纹理资源)用于快速的CPU->GPU内存传输。这种资源不但可以作为复制和源和目标,还可以作为纹理(用D3D的术语说,叫做着色器资源视图ShaderResourceView)在着色器中读取。但是着色器不能写入这种资源。这些资源的版本由驱动程序来控制,每次你用DISCARD flag映射内存的时候,如果这块内存还在被GPU所使用,驱动程序就会产生一块新的内存来,而不会等GPU的操作结束。它的意义在于提供一种流的方式将数据输送到GPU。
结构化缓冲区和乱序访问视图
ComputeShader的一个很重要的特性是结构化缓冲区和乱序访问视图。结构化缓冲区(structured buffer)在计算着色器中可以像数组一样访问。任意线程可以读写任意位置(即并行程序的散发scatter和收集gather动作)。乱序访问视图(unordered access view,UAV)是一种将调用方创建的资源绑定到着色器中的机制,并且允许……乱序访问。
声明结构化缓冲区
我们可以用D3D11_RESOURCE_MISC_BUFFER_STRUCTURED来创建结构化缓冲区。下面指定的绑定flag表示允许着色器乱序访问。下边采用的默认usage表示它可以被GPU进行读写,但需要复制到中转资源当中才能被CPU读写。 #p#page_title#e#
// 创建结构化缓冲区 // D3DXVECTOR4 声明在 D3DX10Math.h 中 // http://msdn.microsoft.com/en-us/library/bb205130(VS.85).aspx D3D11_BUFFER_DESC sbDesc; sbDesc.BindFlags =D3D11_BIND_UNORDERED_ACCESS | D3D11_BIND_SHADER_RESOURCE ; sbDesc.Usage =D3D11_USAGE_DEFAULT; sbDesc.CPUAccessFlags =0; sbDesc.MiscFlags =D3D11_RESOURCE_MISC_BUFFER_STRUCTURED ; sbDesc.StructureByteStride =sizeof(D3DXVECTOR4); sbDesc.ByteWidth =sizeof(D3DXVECTOR4) * w * h; hr = g_pD3DDevice->CreateBuffer( &sbDesc, NULL, &pStructuredBuffer ); |
声明乱序访问视图
下面我们声明一个乱序访问视图。注意需要给他一个结构化缓冲区的指针。
// 创建一个乱序访问视图,指向结构化缓冲区
D3D11_UNORDERED_ACCESS_VIEW_DESC sbUAVDesc;
sbUAVDesc.Buffer.FirstElement =0;
sbUAVDesc.Buffer.Flags =0;
sbUAVDesc.Buffer.NumElements =w * h;
sbUAVDesc.Format =DXGI_FORMAT_UNKNOWN;
sbUAVDesc.ViewDimension =D3D11_UAV_DIMENSION_BUFFER;
hr = g_pD3DDevice->CreateUnorderedAccessView( pStructuredBuffer, &sbUAVDesc,
&g_pStructuredBufferUAV );
|
之后,在分派着色器线程之前,我们需要激活着色器使用的结构化缓冲:
g_pD3DContext->CSSetUnorderedAccessViews( 0, 1, &g_pStructuredBufferUAV, &initCounts ); |
分派线程之后,如果使用CS 4.x硬件,一定要将其解除绑定。因为CS4.x每条渲染流水线仅支持绑定一个UAV。
// 运行在 D3D10 硬件上的时候: 每条流水线仅能绑定一个UAV
// 设成NULL就可以解除绑定
ID3D11UnorderedAccessView *pNullUAV = NULL;
g_pD3DContext->CSSetUnorderedAccessViews( 0, 1, &pNullUAV, &initCounts );
|
DirectCompute中的常量缓冲
常量缓冲(constant buffer)是一组计算着色器运行时不能更改的数据。用作图形程序是,常量缓冲可以是视角矩阵或颜色常量。在通用计算程序中,常量缓冲可以存放诸如信号过滤的权重和图像处理的说明等数据。
如果要使用常量缓冲:
- 创建缓冲区资源
- 用内存映射的方式初始化数据(也可以用效果接口)
- 用CSSetConstantBuffers设定常量缓冲的值
下面代码创建了一个常量缓冲。注意常量缓冲的尺寸,这里我们知道在HLSL中它是一个四元矢量。
// 创建常量缓冲 // D3DXVECTOR4 声明在 D3DX10Math.h 中 // http://msdn.microsoft.com/en-us/library/bb205130(VS.85).aspx D3D11_BUFFER_DESC cbDesc; cbDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER ; cbDesc.Usage = D3D11_USAGE_DYNAMIC; // CPU 可写, 这样我们可以每帧更新数据 cbDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; cbDesc.MiscFlags = 0; cbDesc.ByteWidth = sizeof(D3DXVECTOR4) ; |
接下来用内存映射的方式将数据发送到常量缓冲。通常程序员会在CPU程序里定义和HLSL一样的结构体,因此会用sizeof取得尺寸,然后将缓冲区的指针映射到结构体来填充数据。
// 必须用 D3D11_MAP_WRITE_DISCARD // http://msdn.microsoft.com/en-us/library/bb205318(VS.85).aspx D3D11_MAPPED_SUBRESOURCE mappedResource; g_pD3DContext->Map( pConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedResource ); unsigned int *data = (unsigned int *)(mappedResource.pData); for( int i=0 ; i<4; i++ ) data[i] = i; g_pD3DContext->Unmap( pConstantBuffer, 0 ); |
注意计算着色器的输入变量(在这里就是常量缓冲)是当成“状态”变量的,因此在分派计算着色器之前需要用CSSet…()函数设置状态。这样计算着色器执行的时候就能访问到这些变量。
// 在计算着色器中激活
g_pD3DContext->CSSetShader( g_pComputeShader, NULL, 0 );
g_pD3DContext->CSSetConstantBuffers( 0 ,1, &pConstantBuffer );
|
然后我们可以在着色器中声明一些变量作为常量缓冲区变量:
cbuffer consts { uint4 const_color; }; |
最后你就可以在着色器的代码里使用它们了:
uint4 color = uint4( groupID.x, groupID.y , const_color.x, const_color.z ); |
多个常量缓冲
你还可以定义多组不同的常量缓冲。通常情况下它们的值必须一起更新。只要这么写就可以做到:
cbuffer consts { uint4 const_color_0; uint4 const_color_1; }; |
甚至还可以这么写:
cbuffer consts { uint4 const_color_0; }; cbuffer more_consts { uint4 const_color_1; }; |
如果const_color_0每次调用都要更新,而const_color_1每100次调用才需要更新的话,这样写就很有用。若要分别设置它们,像刚才一样创建缓冲区再映射内存就行了。之后在分派计算着色器之前,给每一个常量缓冲指定一个“槽位”数值。这个槽位数值决定了在HLSL中的出现数据。
g_pD3DContext->CSSetConstantBuffers( 0 ,1, &pConstantBuffer ); g_pD3DContext->CSSetConstantBuffers( 1 ,1, &pVeryConstantBuffer ); g_pD3DContext->CSSetShader( g_pComputeShader, NULL, 0 ); |
最后当计算着色器运行的时候,g_pComputeShader所指向的着色器就可以访问这两个常量缓冲。
计算着色器(CS)HLSL编程
运行在显卡上的计算着色器是用HLSL(High Level Shader Language 高级着色器语言)写成的。在我们的例子中它是以文本形式存在,并且在运行时动态编译的。计算着色器是一种单一程序被许多线程并行执行的程序。这些线程分成多个“线程组”,在线程组内的线程之间可以共享数据或互相同步。
线程组是通过Dispatch调用来创建的,例如Dispatch(16, 16, 1)创建了16x16x1个线程组。而每一个线程组中的线程是在着色器代码中用这个语法来指定的:
[numthreads( 4, 4, 1)] |
推荐把线程组内的线程数(这里的4,4)用#define定义成常量,这样你就能在着色器代码中使用这个数值。
// 线程组尺寸 #define thread_group_size_x 4 #define thread_group_size_y 4 RWStructuredBuffer<BufferStruct> g_OutBuff; /* 这表示线程组中的线程数,本例中是4x4x1 = 16个线程 */ // 等价于 [numthreads( 4, 4, 1 )] [numthreads( thread_group_size_x, thread_group_size_y, 1 )] void main( uint3 threadIDInGroup : SV_GroupThreadID, uint3 groupID : SV_GroupID, uint groupIndex : SV_GroupIndex, uint3 dispatchThreadID : SV_DispatchThreadID ) { int N_THREAD_GROUPS_X = 16; // 假设等于 16, 是这么分派的 dispatch(16,16,1) int stride = thread_group_size_x * N_THREAD_GROUPS_X; // 缓冲区跨度,假设跨度就是数据宽度(没有边距) int idx = dispatchThreadID.y * stride + dispatchThreadID.x; float4 color = float4(groupID.x, groupID.y, dispatchThreadID.x, dispatchThreadID.y); g_OutBuff[ idx ].color = color; } |
所有线程都是执行这同一个函数。每个线程都有它自己唯一的组内线程ID,而每个线程组又有自己的组ID。通常用这些ID来算出一个数组中要访问的位置。这样你就可以开任意数目的线程,让他们并行地访问数组中的每一个元素。计算着色器的函数只能接受下列参数的任意组合,他们代表特殊的意义:
- uint3 threadIDInGroup : SV_GroupThreadID(组内线程ID,三个维度)
- uint3 groupID : SV_GroupID(线程组ID,三个维度)
- uint groupIndex : SV_GroupIndex(线性化的组ID,由三个维度计算而成,像光栅操作那样)
- uint3 dispatchThreadID : SV_DispatchThreadID(在所有分派线程中的跨组线程ID,三个维度)
后记:这个文章可以让你了解大量进行Compute Shader编程的入门问题。不过它没有介绍太多HLSL的语法以及计算着色器程序的原子操作、同步等重要内容,也没有介绍寄存器,着色器资源视图(SRV),二维纹理,可读写纹理等内容。要想充分利用DirectCompute真是需要学习不少东西啊~