文章目錄
- 範例程式碼介紹
- 裝置管理
- 選擇要用的顯卡
- 運行計算著色器
- DirectCompute中的資源
- 計算著色器(CS)HLSL編程
譯者註: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( ...);// 運行 CSpd3dImmediateContext->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 ) {} // 自旋等待事件結束 |
最後用這條語句釋放查詢對象:
請小心建立和釋放查詢對象以免弄出太多的查詢來(特別是你處理一幀畫面的時候)。
點擊此處查看MSDN關於查詢的協助
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。
點擊此處查看MSDN關於Usage的協助
結構化緩衝區和亂序訪問視圖
ComputeShader的一個很重要的特性是結構化緩衝區和亂序訪問視圖。結構化緩衝區(structured buffer)在計算著色器中可以像數組一樣訪問。任意線程可以讀寫任意位置(即並行程式的散發scatter和收集gather動作)。亂序訪問視圖(unordered access view,UAV)是一種將調用方建立的資源綁定到著色器中的機制,並且允許……亂序訪問。
聲明結構化緩衝區
我們可以用D3D11_RESOURCE_MISC_BUFFER_STRUCTURED來建立結構化緩衝區。下面指定的綁定flag表示允許著色器亂序訪問。下邊採用的預設usage表示它可以被GPU進行讀寫,但需要複製到中轉資源當中才能被CPU讀寫。
// 建立結構化緩衝區// D3DXVECTOR4 聲明在 D3DX10Math.h 中// http://msdn.microsoft.com/en-us/library/bb205130(VS.85).aspxD3D11_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).aspxD3D11_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).aspxD3D11_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個線程組。而每一個線程組中的線程是在著色器代碼中用這個文法來指定的:
推薦把線程組內的線程數(這裡的4,4)用#define定義成常量,這樣你就能在著色器代碼中使用這個數值。
// 線程組尺寸#define thread_group_size_x 4#define thread_group_size_y 4RWStructuredBuffer<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真是需要學習不少東西啊~