1. 程式人生 > >CUDA 學習筆記 (二) 【Chapter4 CUDA並行程式設計】

CUDA 學習筆記 (二) 【Chapter4 CUDA並行程式設計】

Capter4  CUDA並行程式設計

前面我們看到將一個標準C函式放到GPU裝置上執行是很容易的。只需要在函式定義前面加上 __globle__ 修飾符,並通過一種特殊的尖括號語法來呼叫它,就可以在GPU上執行這個函式。然而,前面的示例只調用了一個和函式,並且該函式在GPU上以序列方式執行。本章將學習如何啟動一個並行執行的裝置函式。


4.1 向量求和運算

我們先通過一個簡單的示例說明執行緒的概念,以及如何使用 CUDA C來實現執行緒。假設有兩組資料,我們需要將這兩組資料中對應的元素兩兩相加,並將結果儲存在第三陣列中。

4.1.1 基於CPU的向量求和

首先看看如何通過傳統的C程式碼來實現這個求和運算:

#include "../common/book.h"
#define N 10

void add( int *a, int *b, int *c ) {
    int tid = 0;    // 這是第0個CPU,因此索引從0開始
    while (tid < N) {
        c[tid] = a[tid] + b[tid];
        tid += 1;   // 由於只有一個CPU,所以每次遞增1
    }
}

int main( void ) {
    int a[N], b[N], c[N];

    // 在CPU上為陣列a,b賦初值
    for (int i=0; i<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    add( a, b, c );

    // display the results
    for (int i=0; i<N; i++) {
        printf( "%d + %d = %d\n", a[i], b[i], c[i] );
    }

    return 0;
}

我們對add()函式做簡要分析,看看為什麼這個函式有些過於複雜:

函式在while迴圈中計算總和,其中索引 tid 的取值範圍為0到N-1。我們將a[ ] 和 b[ ] 的對應元素相加起來,並將結果儲存在c[ ] 的相應元素中。通常,可以用更簡單的方式來編寫這段程式碼,例如:

void add(int *a, *b, *c){
    for(i=0; i<N; i++)
        c[i]=a[i]+b[i];
}

然而,上面採用while迴圈的方式雖然有些複雜,但這是為了使程式碼能夠在擁有多個CPU或者CP核的系統上並行執行。例如,在雙核處理器上可以將每次遞增的大小改為2,這樣其中一個核從 tid=0 開始執行迴圈 ,而另一個核從 tid=1開始執行迴圈。第一個核將偶數索引的元素相加,而第二個核將奇數索引的元素相加。著相當於在每個CPU核上執行以下程式碼:

// 第1個CPU核心                                
void add(int *a, int *b, int *c){
    int tid = 0;    // 第1個核從tid=0開始迴圈
    while (tid < N) {
        c[tid] = a[tid] + b[tid];
        tid += 2;   // 有兩個核, 所以每次累加2
    }
}

// 第2個CPU核心                                
void add(int *a, int *b, int *c){
    int tid = 1;    // 第2個核從tid=1開始迴圈
    while (tid < N) {
        c[tid] = a[tid] + b[tid];
        tid += 2;   // 有兩個核, 所以每次累加2
    }
}

當然,要在CPU上世紀執行這個運算,還需要增加更多的工作,例如編寫一定數量的程式碼來建立工作執行緒,每個執行緒都執行函式add(),並假設每個執行緒都將並行執行。但是,這種假設是理想而又不實際的,執行緒排程機制的實際執行情況往往並非如此。

4.1.2  基於GPU的向量求和

我們可以在GPU上實現相同的加法運算,這需要將 add() 編寫為一個裝置函式。下面首先給出main()函式:

/* add_loop_gpu.cu */
#include "../common/book.h"
#define N   10

int main( void ) {
    int a[N], b[N], c[N];
    int *dev_a, *dev_b, *dev_c;

    // 在GPU上分配記憶體
    HANDLE_ERROR( cudaMalloc( (void**)&dev_a, N * sizeof(int) ) );
    HANDLE_ERROR( cudaMalloc( (void**)&dev_b, N * sizeof(int) ) );
    HANDLE_ERROR( cudaMalloc( (void**)&dev_c, N * sizeof(int) ) );

    // 在CPU上為陣列a, b賦初值
    for (int i=0; i<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    // 將陣列a,b從CPU複製到GPU
    HANDLE_ERROR( cudaMemcpy( dev_a, a, N * sizeof(int),
                              cudaMemcpyHostToDevice ) );
    HANDLE_ERROR( cudaMemcpy( dev_b, b, N * sizeof(int),
                              cudaMemcpyHostToDevice ) );

    add<<<N,1>>>( dev_a, dev_b, dev_c );

    // 將陣列c從GPU複製到CPU
    HANDLE_ERROR( cudaMemcpy( c, dev_c, N * sizeof(int),
                              cudaMemcpyDeviceToHost ) );

    // display the results
    for (int i=0; i<N; i++) {
        printf( "%d + %d = %d\n", a[i], b[i], c[i] );
    }

    // 釋放在GPU上分配的記憶體
    HANDLE_ERROR( cudaFree( dev_a ) );
    HANDLE_ERROR( cudaFree( dev_b ) );
    HANDLE_ERROR( cudaFree( dev_c ) );

    return 0;
}

上述程式碼使用了一些通用模式:

  • 呼叫cudaMalloc() 在裝置上為三個陣列分配記憶體: 在其中兩個陣列(dev_a和dev_b)中包含了輸入值,而在陣列dev_c中包含了計算結果;
  • 為了避免記憶體洩漏,在使用完GPU記憶體後通過cudaFree() 釋放他們;
  • 通過cudaMemcpy()將輸入資料複製到裝置中,同時指定引數 cudaMemcpyHostToDevice,在計算完成後,將計算結果通過引數cudaMemcpyDeviceToHost是複製回主機。
  • 通過尖括號語法, 在主機main() 函式中執行add()裝置程式碼。

上面程式碼中在CPU中對陣列 a, b 賦初值只是為了說明如何在GPU上實現兩個向量的加法運算,事實上,如果在GPU上為陣列賦值,這個步驟會執行得更快。

接下來是add()函式,這個函式看上去非常類似於基於CPU實現的add() :

__global__ void add( int *a, int *b, int *c ) {
    int tid = blockIdx.x;    // 計算該索引處的資料
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

上面add()函式使用了一種通用模式:

  • 編寫一個在裝置上執行的函式add。採用C來編寫程式碼,並在函式名前新增修飾符__global__ .

前面提到了尖括號中的兩個引數將傳遞給執行時,作用是告訴執行時如何啟動核函式,明確對執行時引數進行解釋見CUDA和函式執行引數


4.2  一個有趣的示例

接下來的示例將介紹如何繪製Julia集的曲線,對於不熟悉Julia集的讀者,可以簡單地將Julia集認為是滿足某個複數計算函式的所有點構成的邊界。

生成Julia集的演算法非常簡單。Julia集的基本演算法是,通過一個簡單的迭代等式對複平面中的點求值。如果在計算某個點時,迭代等式的計算結果是發散的,那麼這個點就不屬於Julia集合。相反,如果在迭代等式中計算得到的一系列值都位於某個邊界範圍之內,那麼這個點就屬於Julia集合。迭代等式如下:

                                                                       Z_{n+1} = Z_{n}^{2} + C

迭代過程包括:首先計算當前值的平方,然後再加一個常數以得到下一個值。

基於CPU的Julia集

/* julia_cpu.cu */
#include "../common/book.h"
#include "../common/cpu_bitmap.h"

#define DIM 1000

struct cuComplex {
    float   r;
    float   i;
    cuComplex( float a, float b ) : r(a), i(b)  {}
    float magnitude2( void ) { return r * r + i * i; }
    cuComplex operator*(const cuComplex& a) {
        return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
    }
    cuComplex operator+(const cuComplex& a) {
        return cuComplex(r+a.r, i+a.i);
    }
};

int julia( int x, int y ) { 
    const float scale = 1.5;
    float jx = scale * (float)(DIM/2 - x)/(DIM/2);
    float jy = scale * (float)(DIM/2 - y)/(DIM/2);

    cuComplex c(-0.8, 0.156);
    cuComplex a(jx, jy);

    int i = 0;
    for (i=0; i<200; i++) {
        a = a * a + c;
        if (a.magnitude2() > 1000)
            return 0;
    }

    return 1;
}

void kernel( unsigned char *ptr ){
    for (int y=0; y<DIM; y++) {
        for (int x=0; x<DIM; x++) {
            int offset = x + y * DIM;

            int juliaValue = julia( x, y );
            ptr[offset*4 + 0] = 255 * juliaValue;
            ptr[offset*4 + 1] = 0;
            ptr[offset*4 + 2] = 0;
            ptr[offset*4 + 3] = 255;
        }
    }
 }

int main( void ) {
    CPUBitmap bitmap( DIM, DIM );
    unsigned char *ptr = bitmap.get_ptr();

    kernel( ptr );

    bitmap.display_and_exit();
}

基於GPU的Julia集

/* julia_gpu.cu */
#include "../common/book.h"
#include "../common/cpu_bitmap.h"

#define DIM 1000

struct cuComplex {
    float   r;
    float   i;
    cuComplex( float a, float b ) : r(a), i(b)  {}
    __device__ float magnitude2( void ) {
        return r * r + i * i;
    }
    __device__ cuComplex operator*(const cuComplex& a) {
        return cuComplex(r*a.r - i*a.i, i*a.r + r*a.i);
    }
    __device__ cuComplex operator+(const cuComplex& a) {
        return cuComplex(r+a.r, i+a.i);
    }
};

__device__ int julia( int x, int y ) {
    const float scale = 1.5;
    float jx = scale * (float)(DIM/2 - x)/(DIM/2);
    float jy = scale * (float)(DIM/2 - y)/(DIM/2);

    cuComplex c(-0.8, 0.156);
    cuComplex a(jx, jy);

    int i = 0;
    for (i=0; i<200; i++) {
        a = a * a + c;
        if (a.magnitude2() > 1000)
            return 0;
    }

    return 1;
}

__global__ void kernel( unsigned char *ptr ) {
    // map from blockIdx to pixel position
    int x = blockIdx.x;
    int y = blockIdx.y;
    int offset = x + y * gridDim.x;

    // now calculate the value at that position
    int juliaValue = julia( x, y );
    ptr[offset*4 + 0] = 255 * juliaValue;
    ptr[offset*4 + 1] = 0;
    ptr[offset*4 + 2] = 0;
    ptr[offset*4 + 3] = 255;
}

// globals needed by the update routine
struct DataBlock {
    unsigned char   *dev_bitmap;
};

int main( void ) {
    DataBlock   data;
    CPUBitmap bitmap( DIM, DIM, &data );
    unsigned char    *dev_bitmap;

    HANDLE_ERROR( cudaMalloc( (void**)&dev_bitmap, bitmap.image_size() ) );
    data.dev_bitmap = dev_bitmap;

    dim3    grid(DIM,DIM);
    kernel<<<grid,1>>>( dev_bitmap );

    HANDLE_ERROR( cudaMemcpy( bitmap.get_ptr(), dev_bitmap,
                              bitmap.image_size(),
                              cudaMemcpyDeviceToHost ) );
                              
    HANDLE_ERROR( cudaFree( dev_bitmap ) );
                              
    bitmap.display_and_exit();
}

 


4.3 本章小結

到目前為止,我們已經看到了如何告訴CUDA執行時線上程塊上並行執行程式。我們把在GPU上啟動的執行緒塊集合稱為一個執行緒格。從名字的含義可以看出,執行緒格既可以是一位的執行緒塊集合,也可以是二維的執行緒塊集合。核函式的每個副本都可以通過內建變數blockIdx來判斷那個執行緒塊正在執行它。同樣,它還可以通過內建變數gridDim來獲得執行緒格的大小。這兩個內建變數在核函式中都是非常有用的,可以用來計算每個執行緒塊需要的資料索引。