《GPU高性能編程 CUDA實戰》(CUDA By Example)讀書筆記

寫在最前

這本書是2011年出版的,按照計算機的發展速度來說已經算是上古書籍了,不過由於其簡單易懂,仍舊被推薦爲入門神書。先上封面:
cuda by example
由於書比較老,而且由於學習的目的不同,這裏只介紹了基礎代碼相關的內容,跳過了那些圖像處理的內容。
另外這本書的代碼這裏:csdn資源

前兩章 科普

就各種講CUDA的變遷,然後第二章講如何安裝CUDA。不會安裝的請移步這裏:安裝CUDA.

第三章 CUDA C簡介

  1. 輸出hello world

    
    #include<stdio.h>
    
    __global__ void kernel() {
      printf("hello world");
    }
    
    int main() {
      kernel<<<1, 1>>>();
      return 0;
    }
    

    這個程序和普通的C程序的區別值得注意

    • 函數的定義帶有了__global__這個標籤,表示這個函數是在GPU上運行
    • 函數的調用除了常規的參數之外,還增加了<<<>>>修飾。而其中的數字將傳遞個CUDA的運行時系統,至於能幹啥,下一章會講。
  2. 進階版

    
    #include<stdio.h>
    
    __global__ void add(int a,int b,int *c){
      *c = a + b;
    }
    int main(){
      int c;
      int *dev_c;
      cudaMalloc((void**)&dev_c,sizeof(int));
      add<<<1,1>>>(2,7,dev_c);
      cudaMemcpy(&c,dev_c,sizeof(int),cudaMemcpyDeviceToHost);
      printf("2 + 7 = %d",c);
      return 0;
    }

    這裏就涉及了GPU和主機之間的內存交換了,cudaMalloc是在GPU的內存裏開闢一片空間,然後通過操作之後,這個內存裏有了計算出來內容,再通過cudaMemcpy這個函數把內容從GPU複製出來。就是這麼簡單。

第四章 CUDA C並行編程

這一章開始體現CUDA並行編程的魅力。
以下是一個數組求和的代碼

#include<stdio.h>

#define N   10

__global__ void add( int *a, int *b, int *c ) {
    int tid = blockIdx.x;    // this thread handles the data at its thread id
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

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

    // allocate the memory on the GPU
    cudaMalloc( (void**)&dev_a, N * sizeof(int) );
    cudaMalloc( (void**)&dev_b, N * sizeof(int) );
    cudaMalloc( (void**)&dev_c, N * sizeof(int) );

    // fill the arrays 'a' and 'b' on the CPU
    for (int i=0; i<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    // copy the arrays 'a' and 'b' to the GPU
    cudaMemcpy( dev_a, a, N * sizeof(int),
                              cudaMemcpyHostToDevice );
    cudaMemcpy( dev_b, b, N * sizeof(int),
                              cudaMemcpyHostToDevice );

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

    // copy the array 'c' back from the GPU to the CPU
    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] );
    }

    // free the memory allocated on the GPU
    cudaFree( dev_a );
    cudaFree( dev_b );
    cudaFree( dev_c );
    return 0;
}

重點也是對於初學者最難理解的就是kernel函數了:

 __global__ void add( int *a, int *b, int *c ) {
    int tid = blockIdx.x;
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

GPU編程和CPU編程的最大區別也就在這裏體現出來了,就是數組求和竟然不要循環!爲什麼不要循環,就是因爲這裏的tid可以把整個循環的工作做了。這裏的tid也就是thread的id,每個thread負責數組一個數的操作,所以將10個循環操作拆分成了十個線程同時搞定。這裏的kernel函數也就是可以同時併發執行,而裏面的tid的數值是不一樣的。

第五章 線程協作

GPU邏輯結構

這章就開始介紹線程塊和網格的相關知識了,也就是<<<>>>這裏面數字的含義。首先講一下什麼叫線程塊,顧名思義就是線程組成的塊咯。GPU的邏輯結構如下圖所示:
gpu邏輯結構
這個圖來自NVIDIA官方文檔,其中CTA就是線程塊,Grid就是線程塊組成的網格,每個線程塊裏有若干線程束warp,然後線程束內有最小的單位線程(文檔裏會稱其爲lanes,翻譯成束內線程)。
基礎知識稍微介紹一下,就開始介紹本章的內容了,本章的內容主要基於以下這個事實:

我們注意到硬件將線程塊的數量限制爲不超過65535.同樣,對於啓動核函數每個線程塊中的線程數量,硬件也進行了限制。

由於這種限制的存在,我們就需要一些更復雜的組合來操作更大長度的數組,而不僅僅是使用threadIdx這種naive的東西了。
我們提供了以下的kernel來操作比較長的數組:

__global__ void add(int *a, int *b, int *c) {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    while (tid < N) {
        c[tid] = a[tid] + b[tid];
        tid += blockDim.x * gridDim.x;
    }
}

嗯,理解透了int tid = threadIdx.x + blockIdx.x * blockDim.x;這句話,這章就算勝利完工了。首先,爲啥是x,那有沒有y,z呢,答案是肯定的,但是這裏(對,就這本書裏),用不上。其實線程塊和網格都並不是只有一維,線程塊其實有三個維度,而網格也有兩個維度。因此存在.x的現象。當然我們不用管這些事,就當做它們只有一維好了。那就看下面這個圖:
線程網格

這就是隻有一維的線程網格。其中,threadIdx.x就是每個線程在各自線程塊中的編號,也就是圖中的thread 0,thread 1。但是問題在於,每個block中都有thread 0,但是想讓這不同的thread 0操作不同的位置應該怎麼辦。引入了blockIdx.x,這個就表示了線程塊的標號,有了線程塊的標號,再乘上每個線程塊中含有線程的數量blockDim.x,就可以給每個線程賦予依次遞增的標號了,程序猿們就可以操作比較長的數組下標了。

但是問題又來了,要是數組實在太大,我用上所有的線程都沒辦法一一對應咋辦,這裏就用tid += blockDim.x * gridDim.x;這句話來讓一個線程操作很好幾個下標。具體是怎麼實現的呢,就是在處理過當前的tid位置後,讓tid增加所以線程的數量,blockDim.x是一塊中線程總數,而gridDim.x則是一個網格中所有塊的數量,這樣乘起來就是所有線程的數量了。

至此,線程協作也講完了。再上一個更直觀的圖:
更直觀的網格圖

共享內存

共享內存是個好東西,它只能在block內部使用,訪問速度巨快無比,好像是從離運算器最近的L1 cache中分割了一部分出來給的共享內存,因此巨快。所以我們要把這玩意用起來。
這裏的例子是點積的例子,就是:
點積
最後得到一個和。主要思想如下:

  • 前一半加後一半:
    加法
  • 要同步,別浪
  • 把最後的並行度小的工作交給CPU
    具體代碼是醬嬸兒的:
__global__ void dot(float *a, float *b, float *c) {
    //建立一個thread數量大小的共享內存數組
    __shared__ float cache[threadsPerBlock];
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    int cacheIndex = threadIdx.x;
    float temp = 0;
    while (tid < N) {
        temp += a[tid] * b[tid];
        tid += blockDim.x * gridDim.x;
    }
    //把算出的數存到cache裏
    cache[cacheIndex] = temp;
    //這裏的同步,就是說所有的thread都要達到這裏之後程序纔會繼續運行
    __syncthreads();
    //下面的代碼必須保證線程數量的2的指數,否則總除2會炸的
    int i = blockDim.x / 2;
    while (i != 0) {
        if (cacheIndex < i)
            cache[cacheIndex] += cache[cacheIndex + i];
        //這裏這個同步保證了0號線程不要一次浪到底就退出執行了,一定要等到都算好才行
        __syncthreads();
        i /= 2;
    }
    if (cacheIndex == 0)
        c[blockIdx.x] = cache[0];
}

其中這個數組c其實只是所以結果中的一部分,最後會返回block數量個c,然後由cpu執行最後的加法就好了。

第九章 原子性操作

原子性操作,就是,像操作系統的PV操作一樣,同時只能有一個線程進行。好處自然是不會產生同時讀寫造成的錯誤,壞處顯而易見是增加了程序運行的時間。

計算直方圖

原理:假設我們要統計數據範圍是[0,255],因此我們定義一個unsigned int histo[256]數組,然後我們的數據是data[N],我們遍歷data數組,然後histo[data[i]]++,就可以在最後計算出直方圖了。這裏我們引入了原子操作

__global__ void histo_kernel(unsigned char *buffer, long size,
        unsigned int *histo) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int stride = blockDim.x * gridDim.x;
    while (i < size) {
        atomicAdd(&(histo[buffer[i]]), 1);
        i += stride;
    }
}

這裏的atomicAdd就是同時只能有一個線程操作,防止了其他線程的騷操作。但是,巨慢,書裏說自從服用了這個,竟然比CPU慢四倍。因此我們需要別的。

升級版計算直方圖

使用原子操作很慢的原因就在於,當數據量很大的時候,會同時有很多對於一個數據位的操作,這樣操作就在排隊,而這次,我們先規定線程塊內部有256個線程(這個數字不一定),然後在線程內部定義一個臨時的共享內存存儲臨時的直方圖,然後最後再將這些臨時的直方圖加總。這樣衝突的範圍從全局的所有的線程,變成了線程塊內的256個線程,而且由於也就256個數據位,這樣造成的數據衝突會大大減小。具體見以下代碼:

__global__ void histo_kernel(unsigned char *buffer, long size,
        unsigned int *histo) {
    __shared__ unsigned int temp[256];
    temp[threadIdx.x] = 0;
    //這裏等待所有線程都初始化完成
    __syncthreads();
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int offset = blockDim.x * gridDim.x;
    while (i < size) {
        atomicAdd(&temp[buffer[i]], 1);
        i += offset;
    }
    __syncthreads();
    //等待所有線程完成計算,講臨時的內容加總到總的直方圖中
    atomicAdd(&(histo[threadIdx.x]), temp[threadIdx.x]);
}

第十章 流

  1. 頁鎖定內存
    這種內存就是在你申請之後,鎖定到了主機內存裏,它的物理地址就固定不變了。這樣訪問起來會讓效率增加。
  2. CUDA流
    流的概念就如同java裏多線程的概念一樣,你可以把不同的工作放入不同的流當中,這樣可以併發執行一些操作,比如在內存複製的時候執行kernel:
    cuda流
    文後講了一些優化的方法,但是親測無效啊,可能是cuda對於流的支持方式變了,關於流的知識會在以後的博文裏再提及。

十一章 多GPU

這章主要看了是第一節零拷貝內存,也十分好理解就是,在CPU上開闢一片內存,而GPU可以直接訪問而不用複製到GPU的顯存裏。至於和頁鎖定內存性能上的差距和區別,需要實驗來驗證

===================2017.7.30更新========================
在閱讀代碼時發現有三種函數前綴:
(1)__host__ int foo(int a){}與C或者C++中的foo(int a){}相同,是由CPU調用,由CPU執行的函數
(2)__global__ int foo(int a){}表示一個內核函數,是一組由GPU執行的並行計算任務,以foo<<>>(a)的形式或者driver API的形式調用。目前global函數必須由CPU調用,並將並行計算任務發射到GPU的任務調用單元。隨着GPU可編程能力的進一步提高,未來可能可以由GPU調用。
(3)__device__ int foo(int a){}則表示一個由GPU中一個線程調用的函數。由於Tesla架構的GPU允許線程調用函數,因此實際上是將__device__ 函數以__inline形式展開後直接編譯到二進制代碼中實現的,並不是真正的函數。

具體來說,device前綴定義的函數只能在GPU上執行,所以device修飾的函數裏面不能調用一般常見的函數;global前綴,CUDA允許能夠在CPU,GPU兩個設備上運行,但是也不能運行CPU裏常見的函數;host前綴修飾的事普通函數,默認缺省,可以調用普通函數。

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