《GPU高效能程式設計 CUDA實戰》(CUDA By Example)讀書筆記
寫在最前 這本書是2011年出版的,按照計算機的發展速度來說已經算是上古書籍了,不過由於其簡單易懂,仍舊被推薦為入門神書。先上封面: 由於書比較老,而且由於學習的目的不同,這裡只介紹了基礎程式碼相關的內容,跳過了那些影象處理的內容。 另外這本書的程式碼這裡:csdn資源
前兩章 科普 就各種講CUDA的變遷,然後第二章講如何安裝CUDA。不會安裝的請移步這裡:安裝CUDA.
第三章 CUDA C簡介 輸出hello world
#include<stdio.h>
__global__ void kernel() { printf("hello world"); }
int main() { kernel<<<1, 1>>>(); return 0; } 1 2 3 4 5 6 7 8 9 10 11 12 這個程式和普通的C程式的區別值得注意
函式的定義帶有了__global__這個標籤,表示這個函式是在GPU上執行 函式的呼叫除了常規的引數之外,還增加了<<<>>>修飾。而其中的數字將傳遞個CUDA的執行時系統,至於能幹啥,下一章會講。 進階版
#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; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 這裡就涉及了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; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 重點也是對於初學者最難理解的就是kernel函數了:
__global__ void add( int *a, int *b, int *c ) { int tid = blockIdx.x; if (tid < N) c[tid] = a[tid] + b[tid]; } 1 2 3 4 5 GPU程式設計和CPU程式設計的最大區別也就在這裡體現出來了,就是陣列求和竟然不要迴圈!為什麼不要迴圈,就是因為這裡的tid可以把整個迴圈的工作做了。這裡的tid也就是thread的id,每個thread負責陣列一個數的操作,所以將10個迴圈操作拆分成了十個執行緒同時搞定。這裡的kernel函式也就是可以同時併發執行,而裡面的tid的數值是不一樣的。
第五章 執行緒協作 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; } } 1 2 3 4 5 6 7 嗯,理解透了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]; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 其中這個陣列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; } } 1 2 3 4 5 6 7 8 9 這裡的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 3 4 5 6 7 8 9 10 11 12 13 14 15 16 第十章 流 頁鎖定記憶體 這種記憶體就是在你申請之後,鎖定到了主機記憶體裡,它的實體地址就固定不變了。這樣訪問起來會讓效率增加。 CUDA流 流的概念就如同java裡多執行緒的概念一樣,你可以把不同的工作放入不同的流當中,這樣可以併發執行一些操作,比如在記憶體複製的時候執行kernel: 文後講了一些優化的方法,但是親測無效啊,可能是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字首修飾的事普通函式,預設預設,可以呼叫普通函式。 --------------------- 作者:FishSeeker 來源:CSDN 原文:https://blog.csdn.net/fishseeker/article/details/75093166 版權宣告:本文為博主原創文章,轉載請附上博文連結!