CUDA編程(四)並行化我們的程序

CUDA編程(四)

CUDA編程(四)並行化我們的程序

上一篇博客主要講解了怎麼去獲取核函數執行的準確時間,以及如何去根據這個時間評估CUDA程序的表現,也就是推算所謂的內存帶寬,博客的最後我們計算了在GPU上單線程計算立方和的程序的內存帶寬,發現其內存帶寬的表現是十分糟糕的,其所使用的內存帶寬大概只有 5M/s,而像GeForce 8800GTX這樣比較老的顯卡,也具有超過50GB/s 的內存帶寬 。

面對我們首先需要解決的內存帶寬問題,我們首先來分析這個問題,然後我們將使用並行化來大大改善這一情況。

爲什麼我們的程序表現的這麼差?

爲什麼我們的程序使用的內存帶寬這麼小?這裏我們需要好好討論一下。

在 CUDA 中,一般的數據複製到的顯卡內存的部份,稱爲global memory。這些內存是沒有 cache 的,而且,存取global memory 所需要的時間(即 latency)是非常長的,通常是數百個 cycles。由於我們的程序只有一個 thread,所以每次它讀取 global memory 的內容,就要等到實際讀取到數據、累加到 sum 之後,才能進行下一步。這就是爲什麼表現會這麼的差,所使用的內存帶寬這麼的小。

由於 global memory 並沒有 cache,所以要避開巨大的 latency 的方法,就是要利用大量的threads。假設現在有大量的 threads 在同時執行,那麼當一個 thread 讀取內存,開始等待結果的時候,GPU 就可以立刻切換到下一個 thread,並讀取下一個內存位置。因此,理想上當thread 的數目夠多的時候,就可以完全把 global memory 的巨大 latency 隱藏起來了,而此時就可以有效利用GPU很大的內存帶寬了。

這裏寫圖片描述

使用多Thread完成程序的初步並行化

上面已經提到過了,要想隱藏IO巨大的Latency,也就是能充分利用GPU的優勢——巨大內存帶寬,最有效的方法就是去並行化我們的程序。現在我們還是基於上次單線程計算立方和的程序,使用多Thread完成程序的初步並行。

先貼一下單線程的程序代碼,我們將繼續在這個代碼的基礎上進行改進:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

//CUDA RunTime API
#include <cuda_runtime.h>

//1M
#define DATA_SIZE 1048576

int data[DATA_SIZE];

//產生大量0-9之間的隨機數
void GenerateNumbers(int *number, int size)
{
    for (int i = 0; i < size; i++) {
        number[i] = rand() % 10;
    }
}

//打印設備信息
void printDeviceProp(const cudaDeviceProp &prop)
{
    printf("Device Name : %s.\n", prop.name);
    printf("totalGlobalMem : %d.\n", prop.totalGlobalMem);
    printf("sharedMemPerBlock : %d.\n", prop.sharedMemPerBlock);
    printf("regsPerBlock : %d.\n", prop.regsPerBlock);
    printf("warpSize : %d.\n", prop.warpSize);
    printf("memPitch : %d.\n", prop.memPitch);
    printf("maxThreadsPerBlock : %d.\n", prop.maxThreadsPerBlock);
    printf("maxThreadsDim[0 - 2] : %d %d %d.\n", prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
    printf("maxGridSize[0 - 2] : %d %d %d.\n", prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
    printf("totalConstMem : %d.\n", prop.totalConstMem);
    printf("major.minor : %d.%d.\n", prop.major, prop.minor);
    printf("clockRate : %d.\n", prop.clockRate);
    printf("textureAlignment : %d.\n", prop.textureAlignment);
    printf("deviceOverlap : %d.\n", prop.deviceOverlap);
    printf("multiProcessorCount : %d.\n", prop.multiProcessorCount);
}

//CUDA 初始化
bool InitCUDA()
{
    int count;

    //取得支持Cuda的裝置的數目
    cudaGetDeviceCount(&count);

    if (count == 0) {
        fprintf(stderr, "There is no device.\n");
        return false;
    }

    int i;

    for (i = 0; i < count; i++) {

        cudaDeviceProp prop;
        cudaGetDeviceProperties(&prop, i);
        //打印設備信息
        printDeviceProp(prop);

        if (cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
            if (prop.major >= 1) {
                break;
            }
        }
    }

    if (i == count) {
        fprintf(stderr, "There is no device supporting CUDA 1.x.\n");
        return false;
    }

    cudaSetDevice(i);

    return true;
}


// __global__ 函數 (GPU上執行) 計算立方和
__global__ static void sumOfSquares(int *num, int* result, clock_t* time)
{
    int sum = 0;

    int i;

    clock_t start = clock();

    for (i = 0; i < DATA_SIZE; i++) {

        sum += num[i] * num[i] * num[i];

    }

    *result = sum;

    *time = clock() - start;

}





int main()
{

    //CUDA 初始化
    if (!InitCUDA()) {
        return 0;
    }

    //生成隨機數
    GenerateNumbers(data, DATA_SIZE);

    /*把數據複製到顯卡內存中*/
    int* gpudata, *result;

    clock_t* time;

    //cudaMalloc 取得一塊顯卡內存 ( 其中result用來存儲計算結果,time用來存儲運行時間 )
    cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
    cudaMalloc((void**)&result, sizeof(int));
    cudaMalloc((void**)&time, sizeof(clock_t));

    //cudaMemcpy 將產生的隨機數複製到顯卡內存中
    //cudaMemcpyHostToDevice - 從內存複製到顯卡內存
    //cudaMemcpyDeviceToHost - 從顯卡內存複製到內存
    cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);

    // 在CUDA 中執行函數 語法:函數名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);
    sumOfSquares << <1, 1, 0 >> >(gpudata, result, time);


    /*把結果從顯示芯片複製回主內存*/

    int sum;
    clock_t time_used;

    //cudaMemcpy 將結果從顯存中複製回內存
    cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t), cudaMemcpyDeviceToHost);

    //Free
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

    printf("GPUsum: %d time: %d\n", sum, time_used);

    sum = 0;

    for (int i = 0; i < DATA_SIZE; i++) {
        sum += data[i] * data[i] * data[i];
    }

    printf("CPUsum: %d \n", sum);

    return 0;
}

下面我們要把程序並行化,那麼要怎麼把計算立方和的程序並行化呢?

最簡單的方法,就是把數字分成若干組,把各組數字分別計算立方和後,最後再把每組的和加總起來就可以了。目前,我們可以寫得更簡單一些,就是把最後加總的動作交給 CPU 來進行。

那麼接下來我們就按照這個思路來並行我們的程序~

首先我們先define一下我們的Thread數目

#define THREAD_NUM 256

接着我們要修改一下我們的kernel函數:

// __global__ 函數 (GPU上執行) 計算立方和
__global__ static void sumOfSquares(int *num, int* result, clock_t* time)
{

    //表示目前的 thread 是第幾個 thread(由 0 開始計算)
    const int tid = threadIdx.x; 

    //計算每個線程需要完成的量
    const int size = DATA_SIZE / THREAD_NUM;

    int sum = 0;

    int i;

    //記錄運算開始的時間
    clock_t start;

    //只在 thread 0(即 threadIdx.x = 0 的時候)進行記錄
    if (tid == 0) start = clock();

    for (i = tid * size; i < (tid + 1) * size; i++) {

        sum += num[i] * num[i] * num[i];

    }

    result[tid] = sum;

    //計算時間的動作,只在 thread 0(即 threadIdx.x = 0 的時候)進行
    if (tid == 0) *time = clock() - start;

}

threadIdx 是 CUDA 的一個內建的變量,表示目前的 thread 是第幾個 thread(由 0 開始計算)。

在我們的例子中,有 256 個 threads,所以同時會有 256 個 sumOfSquares 函數在執行,但每一個的 threadIdx.x 是不一樣的,分別會是 0 ~ 255。所以利用這個變量,我們就可以讓每一個函數執行時,對整個數據的不同部份計算立方和。

另外,我們讓時間計算只在 thread 0進行。

這樣就會出現一個問題,由於有 256 個計算結果,所以原來存放 result 的內存位置也要擴大。

    /*把數據複製到顯卡內存中*/
    int* gpudata, *result;

    clock_t* time;

    //cudaMalloc 取得一塊顯卡內存 ( 其中result用來存儲計算結果,time用來存儲運行時間 )
    cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
    cudaMalloc((void**)&time, sizeof(clock_t));

    //擴大記錄結果的內存,記錄THREAD_NUM個結果
    cudaMalloc((void**)&result, sizeof(int) * THREAD_NUM);

之前也提到過了,我們使用

    函數名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);

這種形式調用核函數,現在這裏的線程數也要隨之改變

    sumOfSquares < << 1, THREAD_NUM, 0 >> >(gpudata, result, time);

然後從GPU拿回結果的地方也需要改,因爲先在不僅要拿回一個sum,而是線程個sum,然後用CPU進行最後的加和操作

    int sum[THREAD_NUM];

    //cudaMemcpy 將結果從顯存中複製回內存
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM, cudaMemcpyDeviceToHost);

最後在CPU端進行加和

int final_sum = 0;

    for (int i = 0; i < THREAD_NUM; i++) {

        final_sum += sum[i];

    }

    printf("sum: %d  gputime: %d\n", final_sum, time_use);

同樣不要忘記check結果:

final_sum = 0;

    for (int i = 0; i < DATA_SIZE; i++) {

        final_sum += data[i] * data[i] * data[i];

    }

    printf("CPUsum: %d \n", final_sum);

這樣我們的程序就分在了256個線程上進行,讓我們看一下這次的效率是否有一些提升

完整程序:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

//CUDA RunTime API
#include <cuda_runtime.h>

//1M
#define DATA_SIZE 1048576

#define THREAD_NUM 256

int data[DATA_SIZE];

//產生大量0-9之間的隨機數
void GenerateNumbers(int *number, int size)
{
    for (int i = 0; i < size; i++) {
        number[i] = rand() % 10;
    }
}

//打印設備信息
void printDeviceProp(const cudaDeviceProp &prop)
{
    printf("Device Name : %s.\n", prop.name);
    printf("totalGlobalMem : %d.\n", prop.totalGlobalMem);
    printf("sharedMemPerBlock : %d.\n", prop.sharedMemPerBlock);
    printf("regsPerBlock : %d.\n", prop.regsPerBlock);
    printf("warpSize : %d.\n", prop.warpSize);
    printf("memPitch : %d.\n", prop.memPitch);
    printf("maxThreadsPerBlock : %d.\n", prop.maxThreadsPerBlock);
    printf("maxThreadsDim[0 - 2] : %d %d %d.\n", prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
    printf("maxGridSize[0 - 2] : %d %d %d.\n", prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
    printf("totalConstMem : %d.\n", prop.totalConstMem);
    printf("major.minor : %d.%d.\n", prop.major, prop.minor);
    printf("clockRate : %d.\n", prop.clockRate);
    printf("textureAlignment : %d.\n", prop.textureAlignment);
    printf("deviceOverlap : %d.\n", prop.deviceOverlap);
    printf("multiProcessorCount : %d.\n", prop.multiProcessorCount);
}

//CUDA 初始化
bool InitCUDA()
{
    int count;

    //取得支持Cuda的裝置的數目
    cudaGetDeviceCount(&count);

    if (count == 0) {
        fprintf(stderr, "There is no device.\n");
        return false;
    }

    int i;

    for (i = 0; i < count; i++) {

        cudaDeviceProp prop;
        cudaGetDeviceProperties(&prop, i);
        //打印設備信息
        printDeviceProp(prop);

        if (cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
            if (prop.major >= 1) {
                break;
            }
        }
    }

    if (i == count) {
        fprintf(stderr, "There is no device supporting CUDA 1.x.\n");
        return false;
    }

    cudaSetDevice(i);

    return true;
}


// __global__ 函數 (GPU上執行) 計算立方和
__global__ static void sumOfSquares(int *num, int* result, clock_t* time)
{

    //表示目前的 thread 是第幾個 thread(由 0 開始計算)
    const int tid = threadIdx.x;

    //計算每個線程需要完成的量
    const int size = DATA_SIZE / THREAD_NUM;

    int sum = 0;

    int i;

    //記錄運算開始的時間
    clock_t start;

    //只在 thread 0(即 threadIdx.x = 0 的時候)進行記錄
    if (tid == 0) start = clock();

    for (i = tid * size; i < (tid + 1) * size; i++) {

        sum += num[i] * num[i] * num[i];

    }

    result[tid] = sum;

    //計算時間的動作,只在 thread 0(即 threadIdx.x = 0 的時候)進行
    if (tid == 0) *time = clock() - start;

}





int main()
{

    //CUDA 初始化
    if (!InitCUDA()) {
        return 0;
    }

    //生成隨機數
    GenerateNumbers(data, DATA_SIZE);

    /*把數據複製到顯卡內存中*/
    int* gpudata, *result;

    clock_t* time;

    //cudaMalloc 取得一塊顯卡內存 ( 其中result用來存儲計算結果,time用來存儲運行時間 )
    cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
    cudaMalloc((void**)&result, sizeof(int)*THREAD_NUM);
    cudaMalloc((void**)&time, sizeof(clock_t));

    //cudaMemcpy 將產生的隨機數複製到顯卡內存中
    //cudaMemcpyHostToDevice - 從內存複製到顯卡內存
    //cudaMemcpyDeviceToHost - 從顯卡內存複製到內存
    cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);

    // 在CUDA 中執行函數 語法:函數名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);
    sumOfSquares << < 1, THREAD_NUM, 0 >> >(gpudata, result, time);


    /*把結果從顯示芯片複製回主內存*/

    int sum[THREAD_NUM];

    clock_t time_use;

    //cudaMemcpy 將結果從顯存中複製回內存
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM, cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_use, time, sizeof(clock_t), cudaMemcpyDeviceToHost);

    //Free
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

    int final_sum = 0;

    for (int i = 0; i < THREAD_NUM; i++) {

        final_sum += sum[i];

    }

    printf("GPUsum: %d  gputime: %d\n", final_sum, time_use);

    final_sum = 0;

    for (int i = 0; i < DATA_SIZE; i++) {

        final_sum += data[i] * data[i] * data[i];

    }

    printf("CPUsum: %d \n", final_sum);

    return 0;
}

運行結果:

這裏寫圖片描述

不知道大家是否還記得不併行時的運行結果:679680304個時鐘週期,現在我們使用256個線程最終只使用了13377388個時鐘週期

679680304/13377388 = 50.8

可以看到我們的速度整整提升了50倍,那麼這個結果真的是非常優秀嗎,我們還是從內存帶寬的角度來進行一下評估:

首先計算一下使用的時間:

13377388 / (797000 * 1000) =  0.016785S

然後計算使用的帶寬:

數據量仍然沒有變 DATA_SIZE 1048576,也就是1024*1024 也就是 1M

1M 個 32 bits 數字的數據量是 4MB。

因此,這個程序實際上使用的內存帶寬約爲:

4MB / 0.016785S = 238MB/s

可以看到這和一般顯卡具有的幾十G的內存帶寬仍然具有很大差距,我們還差的遠呢。

使用更多的Thread?

大家可以看到即使取得了50倍的加速,但是從內存帶寬的角度來看我們還只是僅僅邁出了第一步,那麼是因爲256個線程太少了嗎?我們最多可以打開多少個線程呢?我們可以看到我們打印出來的顯卡屬性中有這麼一條:

MaxThreadPerBlock : 1024

也就是說我們最多可以去開1024個線程,那麼我們就試試極限線程數量下有沒有一個滿意的答案:

#define THREAD_NUM 1024

運行結果:

這裏寫圖片描述

剛纔我們使用256個線程使用了13377388個時鐘週期,現在1024個線程的最終使用時間又小了一個數量級,達到了6489302

679680304/6489302 = 104.7 

13377388/6489302 = 2.06

可以看到我們的速度相對於單線程提升了100倍,但是相比256個線程只提升了2倍,我們再從內存帶寬的角度來進行一下評估:

使用的時間:

6489302 / (797000 * 1000) =  0.00814S

然後計算使用的帶寬:

4MB / 0.00814S = 491MB/s

我們發現極限線程的情況下帶寬仍然不夠看,但是大家別忘了,我們之前似乎除了Thread還講過兩個概念,就是Grid和Block,當然另外還有共享內存,這些東西可不會沒有他們存在的意義,我們進一步並行加速就要通過他們。另外之前也提到了很多優化步驟,每個步驟中都有大量的優化手段,所以我們僅僅用了線程並行這一個手段,顯然不可能一蹴而就。

總結:

這篇博客主要講解了怎麼去使用Thread去簡單的並行我們的程序,雖然我們的程序運行速度有了50甚至上百倍的提升,但是根據內存帶寬來評估的化我們的程序還遠遠不夠,甚至離1G/S的水平都還差不少,所以我們的優化路還有很長。

雖然我們現在想到除了使用多個Thread外,我們還可以去使用多個block,讓每個block包含大量的線程,我們的線程數將成千上萬,毫無疑問這對提升帶寬是很有用的,但是我們下一步先把這個事情放一放,爲了讓大家印象深刻,我們插播一個訪存方面非常重要的優化,同樣可以大幅提高程序的性能。

希望我的博客能幫助到大家~

參考資料:《深入淺出談CUDA》

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