《GPU高性能編程CUDA實戰》學習筆記(三)

第三章 第一段CUDA C代碼+Host/Device

3.1 第一個程序

"Hello,World!"

3.1.1 Hello,World!

#include "../common/book.h"

int main( void ) {
    printf( "Hello, World!\n" );
    return 0;
}


當看到這段代碼時,你肯定在懷疑本書是不是一個騙局。這不就是C嗎? CUDA C是不是真的存在?這些問題的答案都是肯定的。當然,本書也不是一個騙局。這個簡單的“ Hello,World!”示例只是爲了說明, CUDA C與你熟悉的標準C在很大程度上是沒有區別的。這個示例很簡單,它能夠完全在主機上運行。然而,這個示例引出了本書的一個重要區分:
我們將CPU以及系統的內存稱爲主機,而將GPU及其內存稱爲設備。這個示例程序與你編寫過的代碼非常相似,因爲它並不考慮主機之外的任何計算設備。
爲了避免使你產生一無所獲的感覺,我們將逐漸完善這個簡單示例。我們來看看如何使用GPU(這就是一個設備)來執行代碼。在GPU設備上執行的函數通常稱爲核函數( Kernel)。

3.1.2  核函數調用

現在,我們在示例程序中添加一些代碼,這些代碼比最初的“ Hello,World!”程序看上去會陌生一些。
#include "../common/book.h"

<span style="color:#ff0000;background-color: rgb(255, 255, 51);">__global__ void kernel( void ) {
}</span>

int main( void ) {
    <span style="color:#ff0000;background-color: rgb(255, 255, 51);">kernel<<<1,1>>>();</span>
    printf( "Hello, World!\n" );
    return 0;
}

這個程序與最初的“ Hello, World!”相比,多了兩個值得注意的地方:
• 一個空的函數kernel(),並且帶有修飾符__global__
• 對這個空函數的調用,並且帶有修飾字符<<<1,1>>>

在上一節中看到,代碼默認是由系統的標準C編譯器來編譯的。例如,在Linux操作系統上用GNU gcc來編譯主機代碼,而在Windows系統上用Microsoft Visual C來編譯主機代碼。NVIDIA工具只是將代碼交給主機編譯器,它表現出的行爲就好像CUDA不存在一樣。

現在,我們看到了CUDA C爲標準C增加的__global__修飾符。這個修飾符將告訴編譯器,函數應該編譯爲在設備而不是主機上運行。在這個簡單的示例中,函數kernel()將被交給編譯設備代碼的編譯器,而main()函數將被交給主機編譯器(與上一個例子一樣)。

那麼, kernel()的調用究竟代表着什麼含義,並且爲什麼必須加上尖括號和兩個數值?注意,這正是使用CUDA C的地方。
我們已經看到, CUDA C需要通過某種語法方法將一個函數標記爲“設備代碼( DeviceCode)”。這並沒有什麼特別之處,而只是一種簡單的表示方法,表示將主機代碼發送到一個編譯器,而將設備代碼發送到另一個編譯器。事實上,這裏的關鍵在於如何在主機代碼中調用設備代碼。 CUDA C的優勢之一在於,它提供了與C在語言級別上的集成,因此這個設備函數調用看上去非常像主機函數調用。在後面將詳細介紹在這個函數調用背後發生的動作,但就目前而言,只需知道CUDA編譯器和運行時將負責實現從主機代碼中調用設備代碼。

因此,這個看上去有些奇怪的函數調用實際上表示調用設備代碼,但爲什麼要使用尖括號和數字?尖括號表示要將一些參數傳遞給運行時系統。這些參數並不是傳遞給設備代碼的參數,而是告訴運行時如何啓動設備代碼。在第4章中,我們將瞭解這些參數對運行時的作用。傳遞給設備代碼本身的參數是放在圓括號中傳遞的,就像標準的函數調用一樣。

3.1.3 傳遞參數


前面提到過可以將參數傳遞給核函數,現在就來看一個示例。考慮下面對“Hello,World!”應用程序的修改:
#include "../common/book.h"

<span style="color:#ff0000;background-color: rgb(255, 255, 51);">__global__ void add( int a, int b, int *c ) {
    *c = a + b;
}</span>

int main( void ) {
    int c;
    int *dev_c;
    <span style="background-color: rgb(255, 255, 51);">HANDLE_ERROR( cudaMalloc( (void**)&dev_c, sizeof(int) ) );</span>

    <span style="color:#ff0000;background-color: rgb(255, 255, 51);">add<<<1,1>>>( 2, 7, dev_c );</span>

    <span style="background-color: rgb(255, 255, 51);">HANDLE_ERROR( cudaMemcpy( &c, dev_c, sizeof(int),
                              cudaMemcpyDeviceToHost ) );</span>
    printf( "2 + 7 = %d\n", c );
   <span style="background-color: rgb(255, 255, 51);"> HANDLE_ERROR( cudaFree( dev_c ) );</span>

    return 0;
}

注意這裏增加了多行代碼,在這些代碼中包含兩個概念:
• 可以像調用C函數那樣將參數傳遞給核函數。
• 當設備執行任何有用的操作時,都需要分配內存,例如將計算值返回給主機。

在將參數傳遞給核函數的過程中沒有任何特別之處。除了尖括號語法之外,核函數的外表和行爲看上去與標準C中的任何函數調用一樣。運行時系統負責處理將參數從主機傳遞給設備的過程中的所有複雜操作。

更需要注意的地方在於通過cudaMalloc()來分配內存。這個函數調用的行爲非常類似於標準的C函數malloc(),但該函數的作用是告訴CUDA運行時在設備上分配內存。
  • 第一個參數是一個指針,指向用於保存新分配內存地址的變量,
  • 第二個參數是分配內存的大小。除了分配內存的指針不是作爲函數的返回值外,這個函數的行爲與malloc()是相同的,並且返回類型爲void*。
函數調用外層的HANDLE_ERROR()是我們定義的一個宏,作爲本書輔助代碼的一部分。這個宏只是判斷函數調用是否返回了一個錯誤值,如果是的話,那麼將輸出相應的錯誤消息,退出應用程序並將退出碼設置爲EXIT_FAILURE。雖然你也可以在自己的應用程序中使用這個錯誤處理碼,但這種做法在產品級的代碼中很可能是不夠的

這段代碼引出了一個微妙但卻重要的問題。 CUDA C的簡單性及其強大功能在很大程度上都是來源於它淡化了主機代碼和設備代碼之間的差異。然而,程序員一定不能在主機代碼中對cudaMalloc()返回的指針進行解引用( Dereference)。主機代碼可以將這個指針作爲參數傳遞,對其執行算術運算,甚至可以將其轉換爲另一種不同的類型。但是,絕對不可以使用這個指針來讀取或者寫入內存。
遺憾的是,編譯器無法防止這種錯誤的發生。如果能夠在主機代碼中對設備指針進行解引用,那麼CUDA C將非常完美,因爲這看上去就與程序中其他的指針完全一樣了。我們可以將設備指針的使用限制總結如下:
  • 可以將cudaMalloc()分配的指針傳遞給在設備上執行的函數。
  • 可以在設備代碼中使用cudaMalloc()分配的指針進行內存讀/寫操作。
  • 可以將cudaMalloc()分配的指針傳遞給在主機上執行的函數。
  • 不能在主機代碼中使用cudaMalloc()分配的指針進行內存讀/寫操作。
如果你仔細閱讀了前面的內容,那麼可以得出以下推論:不能使用標準C的free()函數來釋放cudaMalloc()分配的內存。要釋放cudaMalloc()分配的內存,需要調用cudaFree(),這個函數的行爲與free()的行爲非常相似。

我們已經看到了如何在設備上分配內存和釋放內存,同時也清楚地看到,在主機上不能對這塊內存做任何修改。在示例程序中剩下來的兩行代碼給出了訪問設備內存的兩種最常見方法—-----在設備代碼中使用設備指針以及調用cudaMemcpy()。

設備指針的使用方式與標準C中指針的使用方式完全一樣。語句*c = a + b的含義同樣非常簡單:將參數a和b相加,並將結果保存在c指向的內存中。這個計算過程非常簡單,甚至吸引不了我們的興趣。

在前面列出了在設備代碼和主機代碼中可以/不可以使用設備指針的各種情形。在主機指針的使用上有着類似的限制。雖然可以將主機指針傳遞給設備代碼,但如果想通過主機指針來訪問設備代碼中的內存,那麼同樣會出現問題。總的來說,主機指針只能訪問主機代碼中的內存,而設備指針也只能訪問設備代碼中的內存。

前面曾提到過,在主機代碼中可以通過調用cudaMemcpy()來訪問設備上的內存。這個函數調用的行爲類似於標準C中的memcpy(),只不過多了一個參數來指定設備內存指針究竟是源指針還是目標指針。在這個示例中,注意cudaMemcpy()的最後一個參數爲cudaMemcpyDeviceToHost,這個參數將告訴運行時源指針是一個設備指針,而目標指針是一個主機指針。

顯然, cudaMemcpyHostToDevice將告訴運行時相反的含義,即源指針位於主機上,而目標指針是位於設備上。此外還可以通過傳遞參數cudaMemcpyDeviceToDevice來告訴運行時這兩個指針都是位於設備上。如果源指針和目標指針都位於主機上,那麼可以直接調用標準C的memcpy()函數。

3.2  查詢設備

由於我們希望在設備上分配內存和執行代碼,因此如果在程序中能夠知道設備擁有多少內存以及具備哪些功能,那麼將非常有用。而且,在一臺計算機上擁有多個支持CUDA的設備也是很常見的情形。在這些情況中,我們希望通過某種方式來確定使用的是哪一個處理器。
例如,在許多主板中都集成了NVIDIA圖形處理器。當計算機生產商或者用戶將一塊獨立的圖形處理器添加到計算機時,那麼就有了兩個支持CUDA的處理器。某些NVIDIA產品,例如GeForce GTX 295,在單塊卡上包含了兩個GPU,因此使用這類產品的計算機也就擁有了兩個支持CUDA的處理器。

在深入研究如何編寫設備代碼之前,我們需要通過某種機制來判斷計算機中當前有哪些設備,以及每個設備都支持哪些功能。幸運的是,可以通過一個非常簡單的接口來獲得這種信息。首先,我們希望知道在系統中有多少個設備是支持CUDA架構的,並且這些設備能夠運行基於CUDA C編寫的核函數。要獲得CUDA設備的數量,可以調用cudaGetDeviceCount()。這個函數的作用從它的名字就可以看出來。

int count;
HANDLE_ERROR( cudaGetDeviceCount( &count ) );

在調用cudaGetDeviceCount()後,可以對每個設備進行迭代,並查詢各個設備的相關信息。CUDA運行時將返回一個cudaDeviceProp類型的結構,其中包含了設備的相關屬性。我們可以獲得哪些屬性?從CUDA 3.0開始,在cudaDeviceProp結構中包含了以下信息:
struct cudaDeviceProp 
{
	char name[256];
	size_t totalGlobalMem;
	size_t sharedMemPerBlock;
	int regsPerBlock;
	int warpSize;
	size_t memPitch;
	int maxThreadsPerBlock;
	int maxThreadsDim[3];
	int maxGridSize[3];
	size_t totalConstMem;
	int major;
	int minor;
	int clockRate;
	size_t textureAlignment;
	int deviceOverlap;
	int multiProcessorCount;
	int kernelExecTimeoutEnabled;
	int integrated;
	int canMapHostMemory;
	int computeMode;
	int maxTexture1D;
	int maxTexture2D[2];
	int maxTexture3D[3];
	int maxTexture2DArray[3];
	int concurrentKernels;
}

其中,有些屬性的含義是顯而易見的,其他屬性的含義如下所示(見表3.1)。
表3.1 CUDA設備屬性
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
設 備 屬 性                              描  述
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
char name[256];                          標識設備的ASCII字符串(例如, "GeForce GTX 280")
size_t totalGlobalMem                設備上全局內存的總量,單位爲字節
size_t sharedMemPerBlock        在一個線程塊( Block)中可使用的最大共享內存數量,單位爲字節
int regsPerBlock                         每個線程塊中可用的32位寄存器數量
int warpSize                                在一個線程束( Warp)中包含的線程數量
size_t memPitch                         在內存複製中最大的修正量( Pitch),單位爲字節
int maxThreadsPerBlock            在一個線程塊中可以包含的最大線程數量
int maxThreadsDim[3]                在多維線程塊數組中,每一維可以包含的最大線程數量
int maxGridSize[3]                      在一個線程格( Grid)中,每一維可以包含的線程塊數量
size_t totalConstMem                 常量內存的總量
int major                                     設備計算功能集( Compute Capability)的主版本號
int minor                                     設備計算功能集的次版本號
size_t textureAlignment             設備的紋理對齊( Texture Alignment)要求
int deviceOverlap                        一個布爾類型值,表示設備是否可以同時執行一個cudaMemory()調用和一個核函數調用
int multiProcessorCount            設備上多處理器的數量
int kernelExecTimeoutEnabled  一個布爾值,表示在該設備上執行的核函數是否存在運行時限制
int integrated                             一個布爾值,表示設備是否是一個集成GPU(即該GPU屬於芯片組的一部分而非獨立的GPU)
int canMapHostMemory            一個布爾類型的值,表示設備是否將主機內存映射到CUDA設備地址空間
int computeMode                      表示設備的計算模式:默認( Default),獨佔( Exclusive),或者禁止( Prohibited)
int maxTexture1D                      一維紋理的最大大小
int maxTexture2D[2]                 二維紋理的最大維數
int maxTexture3D[3]                 三維紋理的最大維數
int maxTexture2DArray[3]        二維紋理數組的最大維數
int concurrentKernels              一個布爾類型值,表示設備是否支持在同一個上下文中同時執行多個核函數
------------------------------------------------------------------------------------------------------------------------------------------------------------------------

就目前而言,我們不會詳細介紹所有這些屬性。事實上,在上面的列表中沒有給出屬性的一些重要細節,因此你需要參考《 NVIDIA CUDA Programming Guide》以瞭解更多的信息。當開始編寫應用程序時,這些屬性會非常有用。但就目前而言,我們只是給出瞭如何查詢每個設備並且報告設備的相應屬性。下面給出了對設備進行查詢的代碼:
#include "../common/book.h"

int main( void ) {
    <span style="background-color: rgb(255, 255, 51);">cudaDeviceProp  <span style="color:#ff0000;">prop</span>;</span>
    int dev;

    HANDLE_ERROR( <span style="background-color: rgb(255, 255, 51);">cudaGetDevice( &dev ) </span>);
    printf( "ID of current CUDA device:  %d\n", dev );

    memset( &prop, 0, sizeof( cudaDeviceProp ) );
    prop.major = 1;
    prop.minor = 3;
    HANDLE_ERROR( <span style="background-color: rgb(255, 255, 51);">cudaChooseDevice</span>( &dev, &prop ) );
    printf( "ID of CUDA device closest to revision 1.3:  %d\n", dev );

    HANDLE_ERROR( <span style="background-color: rgb(255, 255, 51);">cudaSetDevice</span>( dev ) );
}

在知道了每個可用的屬性後,接下來就可以將註釋“對設備的屬性執行某些操作”替換爲一些具體的操作:

#include "../common/book.h"

int main( void ) {
    cudaDeviceProp  prop;

    int count;
    HANDLE_ERROR( cudaGetDeviceCount( &count ) );
    for (int i=0; i< count; i++) 
    {
        HANDLE_ERROR( cudaGetDeviceProperties( &prop, i ) );
        printf( "   --- General Information for device %d ---\n", i );
        printf( "Name:  %s\n", prop.name );
        printf( "Compute capability:  %d.%d\n", prop.major, prop.minor );
        printf( "Clock rate:  %d\n", prop.clockRate );
        printf( "Device copy overlap:  " );
        if (prop.deviceOverlap)
            printf( "Enabled\n" );
        else
            printf( "Disabled\n");
        printf( "Kernel execution timeout :  " );
        if (prop.kernelExecTimeoutEnabled)
            printf( "Enabled\n" );
        else
            printf( "Disabled\n" );

        printf( "   --- Memory Information for device %d ---\n", i );
        printf( "Total global mem:  %ld\n", prop.totalGlobalMem );
        printf( "Total constant Mem:  %ld\n", prop.totalConstMem );
        printf( "Max mem pitch:  %ld\n", prop.memPitch );
        printf( "Texture Alignment:  %ld\n", prop.textureAlignment );

        printf( "   --- MP Information for device %d ---\n", i );
        printf( "Multiprocessor count:  %d\n",
                    prop.multiProcessorCount );
        printf( "Shared mem per mp:  %ld\n", prop.sharedMemPerBlock );
        printf( "Registers per mp:  %d\n", prop.regsPerBlock );
        printf( "Threads in warp:  %d\n", prop.warpSize );
        printf( "Max threads per block:  %d\n",
                    prop.maxThreadsPerBlock );
        printf( "Max thread dimensions:  (%d, %d, %d)\n",
                    prop.maxThreadsDim[0], prop.maxThreadsDim[1],
                    prop.maxThreadsDim[2] );
        printf( "Max grid dimensions:  (%d, %d, %d)\n",
                    prop.maxGridSize[0], prop.maxGridSize[1],
                    prop.maxGridSize[2] );
        printf( "\n" );
    }
}

3.3 設備屬性的使用

除非是編寫一個需要輸出每個支持CUDA的顯卡的詳細屬性的應用程序,否則我們是否需要了解系統中每個設備的屬性?作爲軟件開發人員,我們希望編寫出的軟件是最快的,因此可能需要選擇擁有最多處理器的GPU來運行代碼。或者,如果核函數與CPU之間需要進行密集交互,那麼可能需要在集成的GPU上運行代碼,因爲它可以與CPU共享內存。這兩個屬性都可以通過cudaGetDeviceProperties()來查詢。

假設我們正在編寫一個需要使用雙精度浮點計算的應用程序。在快速翻閱《 NVIDIA CUDA Programming Guide》的附錄A後,我們知道計算功能集的版本爲1.3或者更高的顯卡才能支持雙精度浮點數學計算。因此,要想成功地在應用程序中執行雙精度浮點運算, GPU設備至少需要支持1.3或者更高版本的計算功能集。

根據在 cudaGetDeviceCount()和 cudaGetDeviceProperties()中返回的結果,我們可以對每個設備進行迭代,並且查找主版本號大於1,或者主版本號爲1且次版本號大於等於3的設備。但是,這種迭代操作執行起來有些繁瑣,因此CUDA運行時提供了一種自動方式來執行這個迭代操作。首先,找出我們希望設備擁有的屬性並將這些屬性填充到一個cudaDeviceProp結構。
cudaDeviceProp prop;
memset( &prop, 0, sizeof( cudaDeviceProp ) );
prop.major = 1;
prop.minor = 3;

在填充完 cudaDeviceProp 結構後,將其傳遞給 cudaChooseDevice(),這樣CUDA運行時將查找是否存在某個設備滿足這些條件。 cudaChooseDevice()函數將返回一個設備ID,然後我們可以將這個ID傳遞給 cudaSetDevice()。隨後,所有的設備操作都將在這個設備上執行。
#include "../common/book.h"
int main( void )
{
	cudaDeviceProp prop;
	int dev;
	HANDLE_ERROR( cudaGetDevice( &dev ) );
	printf( "ID of current CUDA device: %d\n", dev );
	memset( &prop, 0, sizeof( cudaDeviceProp ) );
	prop.major = 1;
	prop.minor = 3;
	HANDLE_ERROR( cudaChooseDevice( &dev, &prop ) );
	printf( "ID of CUDA device closest to revision 1.3: %d\n", dev );
	HANDLE_ERROR( cudaSetDevice( dev ) );
}

當前,在系統中擁有多個GPU已是很常見的情況。例如,許多NVIDIA主板芯片組都包含了集成的並且支持CUDA的GPU。當把一個獨立的GPU添加到這些系統中時,那麼就形成了一個多GPU的平臺。而且, NVIDIA的SLI(Scalable Link Interface,可伸縮鏈路接口)技術使得多個獨立的GPU可以並排排列。無論是哪種情況,應用程序都可以從多個GPU中選擇最適合的GPU。如果應用程序依賴於GPU的某些特定屬性,或者需要在系統中最快的GPU上運行,那麼你就需要熟悉這個API,因爲CUDA運行時本身並不能保證爲應用程序選擇最優或者最合適的GPU。

我們已經開始編寫了CUDA C程序,這個過程比你想象的要更輕鬆。從本質上來說,CUDA C只是對標準C進行了語言級的擴展,通過增加一些修飾符使我們可以指定哪些代碼在設備上運行,以及哪些代碼在主機上運行。在函數前面添加關鍵字__global__將告訴編譯器把該函數放在GPU上運行。爲了使用GPU的專門內存,我們還學習了與C的 malloc(), memcpy()和 free()等API對應的CUDA API。這些函數的CUDA版本,包括cudaMalloc(), cudaMemcpy()以及 cudaFree(),分別實現了分配設備內存,在設備和主機之間複製數據,以及釋放設備內存等功能。

3.4 本章小結

在本書的後面還將介紹一些更有趣的示例,這些示例都是關於如何將GPU設備作爲一種大規模並行協處理器來使用。現在,你應該知道CUDA C程序的入門階段是很容易的,在接下來的第4章中,我們將看到在GPU上執行並行代碼同樣是非常容易的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章