【CUDA】grid、block、thread的關係及thread索引的計算

        由於項目需要用到GPU,所以最近開始學習CUDA編程模型,剛開始接觸,先搞清楚線程關係和內存模型是非常重要的,但是發現書上和許多博客關於線程這些關係沒講明白,所以就着自己的理解,做點筆記,歡迎討論。

        這篇文章針對於已經瞭解過了CUDA線程的相關知識,最好已經動手寫過CUDA C的代碼,而對並行線程感到迷惑,不知道怎麼計算線程索引的讀者,如果沒接觸過,那麼先看看書,敲兩段代碼跑跑,如果你理解了那麼恭喜你,如果還有疑惑,那麼再來看看這篇文章,或許有幫助。

        首先,認識一下線程。(⊙o⊙)…雖然畫成這樣總是感覺有點怪怪的,但是你應該已經見怪不怪了吧~

                                        

下面我們來看一段代碼,其功能是對兩個數組求和,並保存到另一個數組,很簡單吧~

#include <cuda_runtime.h>
#include <device_launch_parameters.h>
#include <iostream>

using namespace std;

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = threadIdx.x;
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}

int main() {
    const int length = 16;                                      // 數組長度爲16
    float a[length], b[length], c[length];                      // host中的數組
    for (int i = 0; i < length; i++) {                          // 初始賦值
        a[i] = b[i] = i;
    }
    float* a_device, *b_device, *c_device;                      // device中的數組

    cudaMalloc((void**)&a_device, length * sizeof(float));      // 分配內存
    cudaMalloc((void**)&b_device, length * sizeof(float));
    cudaMalloc((void**)&c_device, length * sizeof(float));

    cudaMemcpy(a_device, a, length * sizeof(float), cudaMemcpyHostToDevice);    // 將host數組的值拷貝給device數組
    cudaMemcpy(b_device, b, length * sizeof(float), cudaMemcpyHostToDevice);

    // 一:參數配置
    dim3 grid(1, 1, 1), block(length, 1, 1);                    // 設置參數
    vector_add<<<grid,block>>>(a_device, b_device, c_device, length);           // 啓動kernel

    cudaMemcpy(c, c_device, length * sizeof(float), cudaMemcpyDeviceToHost);    // 將結果拷貝到host

    for (int i = 0; i < length; i++) {                          // 打印出來方便觀察
        cout << c[i] << " ";
    }
    cout << endl;

    system("pause");
    return 0;
}
運行結果:

結果是對的,也是我們所能預料到的。那麼現在我們來分析代碼中註釋的處究竟該怎麼來寫。

        首先,我們要明白,上面的代碼計算的是兩個一維向量的和。由於數組大小是16,所以我們使用了16個線程來計算。

dim3 grid(1, 1, 1), block(length, 1, 1);                    // 設置參數
        先說grid,在這段代碼中,我們設置參數爲線程格(grid)中只有一個一維的block,該block的x維度上有16個,這個應該一下就看出來啦。因爲grid(x,y,z)中的x=1,y=1,z=1,即各個維度均爲1,所以是一維的,數量爲x*y*z=1*1*1=1。如果沒明白,再看兩個例子:
dim3 grid1(2, 1, 1); // x=2, y=1, z=1
dim3 grid2(4, 2, 1); // x=4, y=2, z=1
dim3 grid3(2, 3, 4); // x=2, y=3, z=4
        可以知道,grid1是一維的(因爲y,z維度是1),grid2是二維的(因爲z維度是1),grid3是三維的,且grid1,grid2,grid3中分別有2、8、24個block。

        同理,對於線程塊(block),我們知道之前的代碼中,block中存在16個線程,且該線程塊維度是一維的,因爲block(x,y,z)中x=length=16,y=1,z=1。

我畫個圖來幫助理解,大概就是這樣子的:

dim3 grid(1, 1, 1), block(length, 1, 1);                    // 設置參數


        OK,我想這下應該就清楚了,就是一個一維的block(此處只有x維度上存在16個線程)。所以,內建變量只有一個在起作用,就是threadIdx.x,它的範圍是[0,15]。因此,我們在計算線程索引是,只用這個內建變量就行了(其他的爲0,寫了也不起作用):

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = threadIdx.x;              // 只使用了threadIdx.x
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}

OK,看到這裏,你可能還是不大明白什麼一維二維的,我們再來看一個:

dim3 grid(1, 1, 1), block(8, 2, 1);                    // 設置參數


        根據上面的介紹,我們知道這個線程格只有一個一維的線程塊,該線程塊內的線程是二維的,x的維度爲8,y的維度爲2,共有8*2=16個線程,如果要用這16個線程來計算數組的累加,當然是可以的,但是我們這裏需要改動一下線程執行代碼中的索引計算方式了。
// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = threadIdx.y * blockDim.x +  threadIdx.x;  // 使用了threadIdx.x, threadIdx.x, blockDim.x
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
        我們一定要有並行思想,這裏有16個線程,kernel啓動後,每個線程都有自己的索引號,比如某個線程位於grid中哪個維度的block(即blockIdx.x,block.y,block.z),又位於該block的哪個維度的線程(即threadIdx.x,threadIdx.y,threadIdx.z),利用這些線程索引號映射到對應的數組下標,我們要做的工作就是將保證這些下標不重複(如果重複的話,那就慘了),最初那種一維的計算方式就不行了。因此,通過使用threadIdx,blockDim來進行映射(偏移)。blockDim.x=8,blockDim.y=2,如上代碼。

        其實,我感覺有些我不能用文字準確、清晰的描述出來,所以咯,我們再來一個例子吧,我相信,多看一看,多想一想就明白了。

dim3 grid(1, 1, 1), block(4, 4, 1);                    // 設置參數
        我們將block改成上面的這樣,其線程模型爲下圖:


當然,kernel函數的代碼依然可以不用變動,這個應該想得清楚,還是再寫一下吧。

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = threadIdx.y * blockDim.x +  threadIdx.x;  // 使用了threadIdx.x, threadIdx.x, blockDim.x
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
—————————————————————————————————————————————————————————————————————————————

        以上內容我們分別介紹了用一維和二維線程來計算一維數組的求和,實際上數組的維度與線程格、線程塊和線程的維度並不是那麼密不可分的,都可以組合實現,只不過在實現時,良好的參數配置對索引的計算很方便,而且由於grid、block、thread維度的限制,還有warpSize的限制,所以對於較大的數據量來說,我們應該做到心中有數,進行有效的塊分解。

        現在來看看二維的block,在整個文章中,我只講解一維、二維的,因爲三維的我不知道怎麼畫圖啦,而且不好描述,免得誤導大家。
        還是上面的一維數組,長度爲16。

dim3 grid(16, 1, 1), block(1, 1, 1);                    // 設置參數
        先來個線程模型圖,我想大家並不會感到驚訝,綠色的區域表示grid,藍色的區域表示block,圖中有一個grid和16個block,每個block都是一維,而且x維度上只有一個線程的:

顯然,我們的線程索引代碼應該爲如下:

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = blockIdx.x;
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
或許你會有疑惑,那麼我們再來看一個:

dim3 grid(4, 1, 1), block(4, 1, 1);

線程索引代碼應該爲如下:

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    int tid = blockIdx.x * gridDim.x + threadIdx.x;
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
        到現在爲止,我覺得你應該有所領悟。如果還是不曉得的話,我想你應該認認真真的看圖並動手分析了,圖中的每一個塊,每一個字都是有它的作用的,你不應該就此放棄。
        我依然相信,能用圖解決,就不嗶嗶。就好像你給一個人描述一座宮殿是多麼多麼的宏偉,富麗堂皇,他並不不會感冒。你就說,嘿大傻,給你瞧瞧我去歐洲玩的教堂,這是照片,不用多說,大傻自己就知道了。

比如,我描述說:

dim3 grid(2, 2, 1), block(2, 2, 1);
這樣肯定不直觀,那我再給你一幅示意圖:


那麼執行代碼及索引計算如下:

// 二:線程執行代碼
__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    // 在第幾個塊中 * 塊的大小 + 塊中的x, y維度(幾行幾列)
    int tid = (blockIdx.y * gridDim.x + blockIdx.x) * (blockDim.x * blockDim.y) + threadIdx.y * blockDim.y + threadIdx.x;
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
        上面的代碼可能要複雜一點,但是你慢慢的會發現這很有趣。
        到此,我想講的就完了。當然對於二維的數組或是三維的數組,我想多看幾個例子也就會有體會了。

        這裏還是忍不住要吐槽罵人一下內建變量threadIdx和blockIdx的命名了,每次看到這些內建變量其最後一個字母是x,就會給我一種誤會是x維度上的發火,我覺得使用threadId和blockId是多麼的良好可憐。當然,勝利的總是API一方,我也只能吐吐槽快哭了

—————————————————————————————————————————————————————————————————————————————

最後再來一發,我給個圖,我們來倒推其參數及相關執行代碼,如下:


由於上傳圖片大小限制,由BMP轉成JPG格式的了,有點不清晰,但足夠看了。

顯然參數爲:

dim3 grid(8, 4, 1), block(8, 2, 1);
共有8*4*8*2=512個線程,當然在CUDA編程中,這算很少的了。如果是一幅512x512大小的圖像做加或點乘之類的運算,隨隨便便就是幾十萬的線程數了。
萬變不離其宗,其一維的計算方式如下:

__global__ void vector_add(float* vec1, float* vec2, float* vecres, int length) {
    // 在第幾個塊中 * 塊的大小 + 塊中的x, y維度(幾行幾列)
    int tid = (blockIdx.y * gridDim.x + blockIdx.x) * (blockDim.x * blockDim.y) + threadIdx.y * blockDim.y + threadIdx.x;
    if (tid < length) {
        vecres[tid] = vec1[tid] + vec2[tid];
    }
}
再給出二維的:

__global__ void vector_add(float** mat1, float** mat2, float** matres, int width) {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    if (x < width && y < width) {
        matres[x][y] = mat1[x][y] + mat2[x][y];
    }
}

發佈了117 篇原創文章 · 獲贊 391 · 訪問量 62萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章