CUDA編程(六)進一步並行

CUDA編程(六)

進一步並行

在之前我們使用Thread完成了簡單的並行加速,雖然我們的程序運行速度有了50甚至上百倍的提升,但是根據內存帶寬來評估的話我們的程序還遠遠不夠,在上一篇博客中給大家介紹了一個訪存方面非常重要的優化,我們通過使用連續的內存存取模式,取得了令人滿意的優化效果,最終內存帶寬也達到了GB/s的級別。

之前也已經提到過了,CUDA不僅提供了Thread,還提供了Grid和Block以及Share Memory這些非常重要的機制,我的顯卡的Thread極限是1024,但是通過block和Grid,線程的數量還能成倍增長,甚至用幾萬個線程。所以本篇博客我們將再次回到線程和並行的角度,進一步的並行加速我們的程序。

Thread AND Block AND Grid

這裏寫圖片描述

第一篇博客的時候就給大家說明過thread-block-grid 結構了,這裏我們再複習一下。

在 CUDA 架構下,顯示芯片執行時的最小單位是thread。數個 thread 可以組成一個block。一個 block 中的 thread 能存取同一塊共享的內存,而且可以快速進行同步的動作。

每一個 block 所能包含的 thread 數目是有限的。不過,執行相同程序的 block,可以組成grid。不同 block 中的 thread 無法存取同一個共享的內存,因此無法直接互通或進行同步。因此,不同 block 中的 thread 能合作的程度是比較低的。不過,利用這個模式,可以讓程序不用擔心顯示芯片實際上能同時執行的 thread 數目限制。例如,一個具有很少量執行單元的顯示芯片,可能會把各個 block 中的 thread 順序執行,而非同時執行。不同的 grid 則可以執行不同的程序(即 kernel)。

每個 thread 都有自己的一份 register 和 local memory 的空間。同一個 block 中的每個thread 則有共享的一份 share memory。此外,所有的 thread(包括不同 block 的 thread)都共享一份 global memory、constant memory、和 texture memory。不同的 grid 則有各自的 global memory、constant memory 和 texture memory。

大家可能注意到不同block之間是無法進行同步工作的,不過,在我們的程序中,其實不太需要進行 thread 的同步動作,因此我們可以使用多個 block 來進一步增加thread 的數目。

通過多個block使用更多的線程

下面我們就開始繼續修改我們的程序:

先貼一下之前的完整代碼:

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

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

//1M
#define DATA_SIZE 1048576

#define THREAD_NUM 1024

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;

    int sum = 0;

    int i;

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

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

    for (i = tid; i < DATA_SIZE; i += THREAD_NUM) {

        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;
}

我們要去加入多個block來繼續增加我們的線程數量:

首先define一個block的數目

#define THREAD_NUM 256
#define BLOCK_NUM 32

我們準備建立 32 個 blocks,每個 blocks 有 256個 threads,也就是說總共有 32*256= 8192個threads,這裏有一個問題,我們爲什麼不用極限的1024個線程呢?那樣就是32*1024 = 32768 個線程,難道不是更好嗎?其實並不是這樣的,從線程運行的原理來看,線程數量達到一定大小後,我們再一味的增加線程也不會取得性能提升了,反而有可能會讓性能下降,感興趣的同學可以改一下數量試一下。另外我們的加和部分是在CPU上進行的,越多的線程意味着越多的結果,而這也意味着CPU上的運算壓力會越來越大。

接着,我們需要修改kernel 部份,加入bid = blockIdx.x:


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

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

    //表示目前的 thread 屬於第幾個 block(由 0 開始計算)
    const int bid = blockIdx.x;


    int sum = 0;

    int i;

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

    //只在 thread 0(即 threadIdx.x = 0 的時候)進行記錄,每個 block 都會記錄開始時間及結束時間
    if (tid == 0) time[bid]= clock();

    //thread需要同時通過tid和bid來確定,同時不要忘記保證內存連續性
    for (i =  bid * THREAD_NUM + tid; i < DATA_SIZE; i += BLOCK_NUM * THREAD_NUM) {

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

    }

    //Result的數量隨之增加
    result[bid * THREAD_NUM + tid] = sum;

    //計算時間的動作,只在 thread 0(即 threadIdx.x = 0 的時候)進行,每個 block 都會記錄開始時間及結束時間
    if (tid == 0) time[bid + BLOCK_NUM] = clock();

}

關於修改註釋已經寫得很清楚了。

blockIdx.x 和 threadIdx.x 一樣是 CUDA 內建的變量,它表示的是目前的 block 編號。

另外我們把計算時間的方式改成每個 block 都會記錄開始時間及結束時間。

因此我們result和time變量的長度需要進行更改:

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

然後在調用核函數的時候,把控制block數量的的參數改成我們的block數:


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

注意從顯存複製回內存的部分也需要修改(由於result和time長度的改變):


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

    int sum[THREAD_NUM*BLOCK_NUM];

    clock_t time_use[BLOCK_NUM * 2];

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

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

    int final_sum = 0;

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

        final_sum += sum[i];

    }

此外,由於涉及到block,我們需要採取不同的計時方式,即把每個 block 最早的開始時間,和最晚的結束時間相減,取得總運行時間。

    //採取新的計時策略 把每個 block 最早的開始時間,和最晚的結束時間相減,取得總運行時間
    clock_t min_start, max_end;

    min_start = time_use[0];

    max_end = time_use[BLOCK_NUM];

    for (int i = 1; i < BLOCK_NUM; i++) {
        if (min_start > time_use[i])
            min_start = time_use[i];
        if (max_end < time_use[i + BLOCK_NUM])
            max_end = time_use[i + BLOCK_NUM];
    }

    printf("GPUsum: %d  gputime: %d\n", final_sum, max_end - min_start);

完整程序:

#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

#define BLOCK_NUM 32

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__ 函數 (GPU上執行) 計算立方和
__global__ static void sumOfSquares(int *num, int* result, clock_t* time)
{

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

    //表示目前的 thread 屬於第幾個 block(由 0 開始計算)
    const int bid = blockIdx.x;


    int sum = 0;

    int i;

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

    //只在 thread 0(即 threadIdx.x = 0 的時候)進行記錄,每個 block 都會記錄開始時間及結束時間
    if (tid == 0) time[bid] = clock();

    //thread需要同時通過tid和bid來確定,同時不要忘記保證內存連續性
    for (i = bid * THREAD_NUM + tid; i < DATA_SIZE; i += BLOCK_NUM * THREAD_NUM) {

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

    }

    //Result的數量隨之增加
    result[bid * THREAD_NUM + tid] = sum;

    //計算時間的動作,只在 thread 0(即 threadIdx.x = 0 的時候)進行,每個 block 都會記錄開始時間及結束時間
    if (tid == 0) time[bid + BLOCK_NUM] = clock();

}





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* BLOCK_NUM);
    cudaMalloc((void**)&time, sizeof(clock_t)* BLOCK_NUM * 2);

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

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


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

    int sum[THREAD_NUM*BLOCK_NUM];

    clock_t time_use[BLOCK_NUM * 2];

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

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

    int final_sum = 0;

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

        final_sum += sum[i];

    }

    //採取新的計時策略 把每個 block 最早的開始時間,和最晚的結束時間相減,取得總運行時間
    clock_t min_start, max_end;

    min_start = time_use[0];

    max_end = time_use[BLOCK_NUM];

    for (int i = 1; i < BLOCK_NUM; i++) {
        if (min_start > time_use[i])
            min_start = time_use[i];
        if (max_end < time_use[i + BLOCK_NUM])
            max_end = time_use[i + BLOCK_NUM];
    }

    printf("GPUsum: %d  gputime: %d\n", final_sum, max_end - min_start);

    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;
}

運行結果:

這裏寫圖片描述

爲了對比我們把block改成1再運行一次:

這裏寫圖片描述

我們看到32block 256 thread 連續存取的情況下運行用了133133個時鐘週期

而在 1block 256 thread 連續存取的情況下運行用了3488971個時鐘週期

3488971/133133= 26.21倍

可以看到我們的速度整整提升了26倍,這個版本的程序,執行的時間減少很多。

我們還是從內存帶寬的角度來進行一下評估:

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

133133/ (797000 * 1000) = 1.67e-4S

然後計算使用的帶寬:

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

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

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

4MB / 1.67e-4S = 23945.9788MB/s = 23.38GB/s

這對於我這塊640,頻率僅有797000,已經是一個很不錯的效果了,不過,這個程序雖然在GPU上節省了時間,但是在 CPU 上執行的部份,需要的時間加長了(因爲 CPU 現在需要加總 8192 個數字)。爲了避免這個問題,下一步我們可以讓每個 block 把自己的每個 thread 的計算結果進行加總。

關於更多線程的小實驗,越多線程越好?

之前中間提過我們爲什麼不用更多的線程,比如一個block 1024個,或者更多的block。這是因爲從線程運行的原理來看,線程數量達到一定大小後,我們再一味的增加線程也不會取得性能提升了,反而有可能會讓性能下降。我們可以試驗一下:

1024Thread *128block = 101372 個 Thread 夠多了吧,我們看下運行結果:

這裏寫圖片描述

我們看到最終用了153292個時鐘週期,勁爆的10萬個線程真的變慢了。

爲什麼會這樣呢?下面我們從GPU的原理上來講解這個問題。

從GPU結構理解線程:

之前關於爲什麼線程不能這麼多的問題,說的還是不是很清楚,其實從硬件角度分析,支持CUDA的NVIDIA 顯卡,都是由多個multiprocessors 組成。每個 multiprocessor 裏包含了8個stream processors,其組成是四個四個一組,也就是兩組4D的處理器。

每個 multiprocessor 還具有 很多個(比如8192個)寄存器,一定的(比如16KB) share memory,以及 texture cache 和 constant cache

這裏寫圖片描述

在 CUDA 中,大部份基本的運算動作,都可以由 stream processor 進行。每個 stream processor 都包含一個 FMA(fused-multiply-add)單元,可以進行一個乘法和一個加法。比較複雜的運算則會需要比較長的時間。

在執行 CUDA 程序的時候,每個 stream processor 就是對應一個 thread。每個 multiprocessor 則對應一個 block。但是我們一個block往往有很大量的線程,之前我們用到了256個和1024個,遠超一個 multiprocessor 所有的8個 stream processor 。

實際上,雖然一個 multiprocessor 只有八個 stream processor,但是由於 stream processor 進行各種運算都有 latency,更不用提內存存取的 latency,因此 CUDA 在執行程序的時候,是以warp 爲單位。

比如一個 warp 裏面有 32 個 threads,分成兩組 16 threads 的 half-warp。由於 stream processor 的運算至少有 4 cycles 的 latency,因此對一個 4D 的stream processors 來說,一次至少執行 16 個 threads(即 half-warp)纔能有效隱藏各種運算的 latency。也因此,線程數達到隱藏各種latency的程度後,之後數量的提升就沒有太大的作用了。

還有一個重要的原因是,由於 multiprocessor 中並沒有太多別的內存,因此每個 thread 的狀態都是直接保存在multiprocessor 的寄存器中。所以,如果一個 multiprocessor 同時有愈多的 thread 要執行,就會需要愈多的寄存器空間。例如,假設一個 block 裏面有 256 個 threads,每個 thread 用到20 個寄存器,那麼總共就需要 256x20 = 5,120 個寄存器才能保存每個 thread 的狀態。

而一般每個 multiprocessor 只有 8,192 個寄存器,因此,如果每個 thread 使用到16 個寄存器,那就表示一個 multiprocessor 的寄存器同時最多隻能維持 512 個 thread 的執行。如果同時進行的 thread 數目超過這個數字,那麼就會需要把一部份的數據儲存在顯卡內存中,就會降低執行的效率了。

總結:

這篇博客主要使用block進行了進一步增大了線程數,進行進一步的並行,最終的結果還是比較令人滿意的,至少對於我這塊顯卡來說已經很不錯了,因爲我的顯卡主頻比較低,如果用一塊1.5Ghz的顯卡,用的時間就會是我的一半,而這時候內存帶寬也就基本達到45GB/s左右了。

同時也回答了很多人都會有的疑問,即爲什麼我們不搞幾萬個線程。

但是我們也看到了新的問題,我們在CPU端的加和壓力變得很大,那麼我們能不能從GPU上直接完成這個工作呢?我們知道每個block內部的Thread之間是可以同步和通訊的,下一步我們將讓每個block把每個thread的計算結果進行加和。

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

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

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