最近在學習CUDA,感覺看完就忘,於是這裡寫一個導讀,整理一下重點
主要內容來源於NVIDIA的官方文檔《CUDA C Programming Guide》,結合了另一本書《CUDA並行程序設計 GPU編程指南》的知識。 因此在翻譯總結官方文檔的同時,會加一些評註,不一定對,望大家討論指出。
另外,我才不會老老實實的翻譯文檔,因此細節還是需要從文檔里看的。
看完兩份文檔總的來說,感覺《CUDA C Programming Guide》這本書作為一份官方文檔,知識細碎且全面,且是針對最新的Maxwell、Pascal、Volta架構的闡述。但相對來說不夠深入,且有關程序設計方面所述甚少。
而《CUDA並行程序設計 GPU編程指南》這本書,講解的比較深入,不僅闡述了NVIDIA GPU的特性,並且在程序設計方面有比較深入的見解。美中不足的是該書是針對老舊的Tesla、Fermi架構GPU,沒有涉及到新架構的新特性。
GPU是能夠高度並行化、具有很多處理器核心的器件,具有很強的計算能力和內存帶寬。下圖是CPU和GPU在浮點運算上的性能對比發展趨勢。
可以看到,NVIDIA的GPU在浮點運算能力上,吊打了Intel的CPU。其原因來自於CPU和GPU結構上的差異。
如下圖所示,CPU僅僅具有有限的核心數量。相比於GPU,CPU的核心屬於「少而精」的存在,核心數雖然很少,但是每個核心的性能很強,適合處理具有很多分支的複雜的邏輯。近些年來,CPU中集成了一些並行指令集,如SSE、AVX等,其中AVX可以同時處理256位(32個位元組),可以大大加速並行計算。但是相比於GPU,還是小巫見大巫。
GPU的設計理念與CPU不同,GPU具有龐大的核心數。以TITANX為例,流處理器(等同於CPU的核心)達到3072個之多。這意味著相比於8核的CPU處理器,TITANX可以同時並行處理384倍的任務。但是GPU的單個核心不如CPU的核心強大。因此相對於CPU,GPU更適合處理高度並行化的任務。
從另一個視角來看上圖,在CPU晶元中,運算單元(ALU)所佔的比例較小,CPU中更多的矽片被用來製作控制單元和緩存,以完成複雜的邏輯;而GPU的運算單元使用的矽片面積比例要大於CPU,以完成高強度的計算。因此CPU的側重點在於邏輯控制,而GPU的側重點在於計算。
深度學習,尤其是卷積神經網路中,有很多可以高度並行化的向量運算與矩陣運算。因此使用GPU進行深度學習運算,遠比CPU快速。
另外還需注意的是,由於GPU的核心數量太多,即使GPU的內存(也稱作顯存)優於CPU的內存,其內存仍然是瓶頸。因此,GPU希望程序是 計算密集型 而不是 內存密集型。
CUDA(Compute Unified Device Architecture),是NVIDIA推出的通用並行計算平台和編程模型。CUDA是在底層API的基礎上,封裝了一層,使得程序員可以使用C語言來方便的編程。
CUDA還支持C++/Python等更高級的語言編程;此外,NVIDIA還提供了CuDNN、TensorRT、NPP等更高級的庫函數。
從上圖中也可以看出各個系列的GPU屬於哪些架構、什麼定位。例如GeForece 1000系列,就是使用Pascal架構的消費顯卡。
CUDA的編程模型,使得同一個CUDA程序,可以在不同的顯卡上運行。
如上圖所示,CUDA程序一般會創建一些線程塊(Block),線程塊會被調度到空閑的流處理器簇(SM)上去。當線程塊執行完畢後,線程塊會退出SM,釋放出SM的資源,以供其他待執行線程塊調度進去。
因此,無論是只有2個SM的GPU,還是有4個SM的GPU,這些線程塊都會被調度執行,只不過是執行的時間有長有短。因此,同樣的程序,可以在具有不同SM數量上的GPU運行。
略
在講解內核函數前,先講解一下線程層級,不然有點難講。
CUDA編程是一個多線程編程,數個線程(Thread)組成一個線程塊(Block),所有線程塊組成一個線程網格(Grid),如下圖所示:
圖中的線程塊,以及線程塊中的線程,是按照2維的方式排布的。實際上,CUDA編程模型允許使用1維、2維、3維三種方式來排布。另外,即使線程塊使用的是1維排布,線程塊中的線程也不一定要按照1維排,而是可以任意排布。
目前的GPU限制一個線程塊中,最多可以安排1024個線程。
一個線程塊用多少線程,以及一個線程網格用多少線程塊,是程序員可以自由安排的。由於32個相鄰的線程會組成一個線程束(Thread Warp),而一個線程束中的線程會運行同樣的指令。因此一般線程塊中線程的數量被安排為32的倍數,選用256是比較合適的。
在線程數定下來之後,一般根據數據的排布情況來確定線程塊的個數。
例如:一個數組的長度為4096,安排每個線程處理一個元素。如果安排一個線程塊為256個線程,則需要4096/256=16個線程塊。
內核函數是CUDA 每個線程 執行的函數。CUDA使用擴展的C語言編寫內核函數,關鍵字為global。內核函數返回值只能是void。
下面是一段簡單的內核函數,用於求兩個數組的和:
// Kernel definition __global__ void VecAdd(float* A, float* B, float* C) { int i = threadIdx.x; C[i] = A[i] + B[i]; }
int main() { ... // Kernel invocation with N threads VecAdd<<<1, N>>>(A, B, C); ... }
threadIdx.x是線程在所處線程塊中的X方向的ID。由於本例中是定義的1維排布,因此X方向ID即為線程的ID。
threadIdx.x
由於GPU中的每個線程都會執行相同的VecAdd函數,因此不同的線程需要使用自己獨有的ID來區分彼此,來獲取不同的數據。這就是SIMT的概念,即「相同指令,不同線程」。
在main()函數中,我們注意到,VecAdd函數的調用使用了<<<blockPerGrid, threadsPerBlock>>>關鍵字。這是調用內核函數所獨有的。程序員通過該關鍵字,制定網格中線程塊和線程的排布方式。排布方式與數據息息相關。
<<<blockPerGrid, threadsPerBlock>>>
下面舉一個2維排布的例子,用於做矩陣加法:
// Kernel definition __global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) { int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; if (i < N && j < N) C[j][i] = A[j][i] + B[j][i]; }
int main() { ... // Kernel invocation dim3 threadsPerBlock(16, 16); dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y); MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C); ... }
首先看主函數,當排布不使用1維時,需要使用dim3數據類型。該程序每個線程塊中線程為16x16排布,而線程塊的排布依賴於數據的多少。
dim3
在內核函數中,i代表x方向上的ID,j代表y方向上的ID。blockDim代表當前線程塊的尺寸。從程序中可以看到,x方向為行方向,y方向為列方向。(注意,這裡官方文檔裡面寫的有些錯誤)
blockDim
每個線程讀取自己ID對應的數據A[j][i]和B[j][i],並將結果寫回C[j][i]。其中A、B、C都存儲在GPU的全局內存上(後面會提及)
同CPU一樣,GPU也有不同層級的內存。越靠近核心的內存速度越快,但容量越小;反之,越遠離核心的內存速度越慢,但容量較大。
上圖是NVIDIA設備的硬體示意圖。
這裡想強調一下共享內存。
以上是從硬體的角度解讀了一下GPU的內存層級。從編程角度來看,CUDA的線程網格、線程塊、線程與各個內存見的關係如下圖:
一種最簡單的CPU/GPU混合編程如下圖所示:
主機端(Host,即CPU)執行串列代碼,然後調用內核函數,讓設備端(Device,即GPU)執行並行代碼。如此交錯執行。
CPU和GPU的內存是獨立的。因此在運行內核函數前,主機端需要調用內存拷貝函數,將數據通過PCI-E匯流排拷貝到設備端。內核運行結束後,需要CPU再次調用內存拷貝函數,將數據拷回主機端內存。
另一種方式是使用統一編址,將設備端的內存和主機端內存編到一起。這樣主機就不需要顯式的調用函數將數據拷貝到設備端內存了。
除了CPU/GPU交錯執行代碼的方式外,還可以通過使用事件(event)和流(stream)等方式,讓CPU/GPU並行工作,提升整體的效率。
所謂的計算能力(Compute Capability),說白了就是GPU的版本號。有時也被稱作SM Version。
不同版本的GPU具有不同的特性,因此程序編寫也會有所差異。
計算能力為X.Y,其中主版本號X代表架構,各個架構如下表:
在CUDA的書籍文檔中,我們經常能看到"1.x"、"5.x"等這樣的字眼,代表第1代/第5代架構,也就是Tesla/Maxwell架構。
次版本號Y,代表在架構的基礎上,有一定改進,或者有一些新特性的引入。
最新的圖靈架構(Turing),實際上計算能力是7.5,也就是說還是屬於Volta架構。
CUDA是軟體平台,其版本(CUDA7.5 CUDA8.0 CUDA10.0)與計算能力基本沒有關係。不過最新的CUDA一般會支持最新的架構。
初學者的話,上面內容能看明白的都是勇士。這裡給一個例子,是我自己寫的BGR轉灰度圖的程序,希望能讓大家稍微明白一點CUDA程序如何寫。
/* main.cu */ #include <iostream> #include <time.h> #include "opencv2/highgui.hpp" //實際上在/usr/include下 #include "opencv2/opencv.hpp" using namespace cv; using namespace std;
//內核函數 __global__ void rgb2grayincuda(uchar3 * const d_in, unsigned char * const d_out, uint imgheight, uint imgwidth) { const unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x; const unsigned int idy = blockIdx.y * blockDim.y + threadIdx.y;
if (idx < imgwidth && idy < imgheight) //有的線程會跑到圖像外面去,不執行即可 { uchar3 rgb = d_in[idy * imgwidth + idx]; d_out[idy * imgwidth + idx] = 0.299f * rgb.x + 0.587f * rgb.y + 0.114f * rgb.z; } }
//用於對比的CPU串列代碼 void rgb2grayincpu(unsigned char * const d_in, unsigned char * const d_out, uint imgheight, uint imgwidth) { for(int i = 0; i < imgheight; i++) { for(int j = 0; j < imgwidth; j++) { d_out[i * imgwidth + j] = 0.299f * d_in[(i * imgwidth + j)*3] + 0.587f * d_in[(i * imgwidth + j)*3 + 1] + 0.114f * d_in[(i * imgwidth + j)*3 + 2]; } } }
int main(void) { Mat srcImage = imread("./test.jpg"); imshow("srcImage", srcImage); waitKey(0);
const uint imgheight = srcImage.rows; const uint imgwidth = srcImage.cols;
Mat grayImage(imgheight, imgwidth, CV_8UC1, Scalar(0));
uchar3 *d_in; //向量類型,3個uchar unsigned char *d_out;
//首先分配GPU上的內存 cudaMalloc((void**)&d_in, imgheight*imgwidth*sizeof(uchar3)); cudaMalloc((void**)&d_out, imgheight*imgwidth*sizeof(unsigned char));
//將主機端數據拷貝到GPU上 cudaMemcpy(d_in, srcImage.data, imgheight*imgwidth*sizeof(uchar3), cudaMemcpyHostToDevice);
//每個線程處理一個像素 dim3 threadsPerBlock(32, 32); dim3 blocksPerGrid((imgwidth + threadsPerBlock.x - 1) / threadsPerBlock.x, (imgheight + threadsPerBlock.y - 1) / threadsPerBlock.y);
clock_t start, end; start = clock();
//啟動內核 rgb2grayincuda<< <blocksPerGrid, threadsPerBlock>> >(d_in, d_out, imgheight, imgwidth);
//執行內核是一個非同步操作,因此需要同步以測量準確時間 cudaDeviceSynchronize(); end = clock();
printf("cuda exec time is %.8f ", (double)(end-start)/CLOCKS_PER_SEC);
//拷貝回來數據 cudaMemcpy(grayImage.data, d_out, imgheight*imgwidth*sizeof(unsigned char), cudaMemcpyDeviceToHost);
//釋放顯存 cudaFree(d_in); cudaFree(d_out);
imshow("grayImage", grayImage); waitKey(0);
return 0;
}
這裡我對比了CUDA、CPU、OPENCV三種實現方式的執行時間:
速度:CUDA>OPENCV>CPU。其中OPENCV快於CPU的主要原因是OPENCV調用了並行運算指令,但慢於CUDA。
水水的介紹到此結束,剩下的我就要放飛自我了,看不懂別怪我
CUDA的編程介面由一系列C語言的擴展和運行庫(runtime library)組成。
C語言的擴展在第二章「編程模型」中有所提及,如內核函數、線程網格和線程塊等;
本章講首先講解CUDA程序的編譯過程,之後會介紹CUDA運行庫,最後會介紹程序兼容性等問題。
CUDA程序使用NVCC編譯器。
NVCC進行離線編譯的操作流程是:
PTX是一個虛擬彙編文件。其形式雖然很像彙編,但裡面的每一條指令實際上是一個虛擬的指令,與機器碼無法對應。需要編譯器或設備驅動程序將其翻譯成對應平台的彙編/機器碼才能運行。
如果在編譯過程中,NVCC不將設備端代碼編譯為cubin文件,即二進位代碼,而是停在PTX代碼上。設備驅動(device driver)會負責在運行時,使用PTX代碼生成二進位代碼。這個過程被稱作在線編譯(JIT Compilation, Just-In-Time Compilation)。
在線編譯必然會使得程序啟動的時間延長,不過設備驅動程序會自動緩存編譯出來的二進位代碼(也被稱作compute cache)。
在線編譯一方面的優勢在於兼容性。另一方面的優勢在於,當設備驅動程序有關編譯的部分得到優化時,同樣的PTX編出來的cubin文件同樣會得到優化。也就是說,一段祖傳的PTX代碼,很有可能因為驅動程序不斷的優化,而躺著得到了優化。而如果直接離線編譯得到了cubin文件的話,則無法享受到這一優化。
二進位代碼cubin是受到GPU計算能力的限制的。在編譯時,需要使用-code來指定將代碼編譯到哪個計算能力平台上,如-code=sm_35代表生成的cubin代碼是運行在計算能力為3.5的平台上的。
-code
-code=sm_35
二進位代碼若要兼容,首先架構得一致。不同架構上的二進位代碼不能互相兼容,如在Maxwell架構上編譯出來的代碼,不能在其他架構上運行。
另外需要說明的是,上述二進位代碼的兼容性原則只限於桌面款顯卡。
PTX代碼的兼容性遠強於二進位代碼。只要不涉及到不同架構上的特性差異,PTX可以在任何架構上運行。
不過PTX代碼在兩種情況下其兼容性會受限:
在編譯時,可以通過-arch來指定生成的PTX代碼的版本,如-arch=compute_30。
-arch
-arch=compute_30
為了保證應用程序的兼容性,最好是將代碼編譯成PTX代碼,然後依靠各個計算能力的驅動程序在線編譯成對應平台的二進位代碼cubin。
除了使用-arch和-code來分別指定C->PTX和PTX->cubin的計算能力外,還可以用-gencode關鍵字來操作,如下例:
-gencode
nvcc x.cu -gencode arch=compute_35,code=sm_35 -gencode arch=compute_50,code=sm_50 -gencode arch=compute_60,code=compute_60,sm_60
使用上述編譯指令後,會生成3.5/5.0/6.0的cubin文件,以及6.0的PTX代碼。具體內容請參考nvcc user manual。
對於主機端代碼,會自動編譯,並在運行時決定調用哪一個版本的執行。對於上例,主機端代碼會編譯為:3.5/5.0/6.0的二進位文件,以及7.0的PTX文件。
另外,在程序中可以使用__CUDA_ARCH__宏來指定計算能力(只能用於修飾設備端代碼)。計算能力3.5在程序中對應的__CUDA_ARCH__為350。
__CUDA_ARCH__
有一點需要注意的是,7.0以前,都是以線程束為單位在調度,線程束內指令永遠是同步的。而Volta架構(計算能力7.x)引入了Independent Thread Scheduling,破壞了線程束內的隱式同步。因此,在不針對同步問題而修改代碼的前提下,要想使用7.x設備,可以指定-arch=compute_60 -code=sm_70,即將PTX編到Pascal架構下以禁用Independent Thread Scheduling特性。
-arch=compute_60 -code=sm_70
另外,版本相關編譯指令有縮寫的情況,具體看手冊。
對於主機端代碼,nvcc支持C++的全部特性;而對於設備端代碼,只支持C++的部分特性。具體查閱手冊。
當且僅當主機端代碼按照64位編譯時,設備端代碼才能編譯為64位。當主機端代碼編譯為32位時,設備端代碼只能編譯成32位。即設備端代碼的位數和主機端永遠保持一致。
具體編譯成32/64位的哪一種,取決於nvcc本身的版本。32位nvcc會自動編出32位的代碼,不過可以使用-m64來編出64位代碼。對於64位編譯器亦然。
-m64
運行庫實際上在cudart庫內,可以使靜態鏈接庫cudart.lib/libcudart.a,或者動態鏈接庫cudart.dll/cudart.so。
cudart.lib/libcudart.a
cudart.dll/cudart.so
所有程序的入口都是cuda。
cuda
CUDA運行庫沒有顯式的初始化函數,在調用第一個函數時會自動初始化(設備和版本管理函數不行)。初始化時,會產生一個全局可見的設備上下文(device context)。
當主機端代碼調用了cudaDeviceReset()函數,則會銷毀掉這個上下文。注意,銷毀的上下文是主機端正在操縱的設備。如要更換,需要使用cudaSetDevice()來進行切換。
cudaDeviceReset()
cudaSetDevice()
CUDA運行庫提供了函數以分配/釋放設備端的內存,以及與主機端內存傳輸數據。
這裡的設備內存,指的是全局內存+常量內存+紋理內存。
設備內存有兩種分配模式:線性存儲(linear memory)、CUDA arrays。 其中CUDA arrays與紋理內存有關,本導讀略去不談。
線性內存是我們常用的內存方式,在GPU上用40位的地址線定址。線性內存可以用cudaMalloc()分配,用cudaFree()釋放,用cudaMemcpy()複製數據,用cudaMemset()賦值。
cudaMalloc()
cudaFree()
cudaMemcpy()
cudaMemset()
對於2D或3D數組,可以使用cudaMallocPitch()和cudaMalloc3D()來分配內存。這兩個函數會自動padding,以滿足內存對齊的要求,提高內存讀寫效率。內存對齊的問題,會在第五章里詳細闡述。
cudaMallocPitch()
cudaMalloc3D()
另外,如果要在設備內存中定義全局變數,則需要使用使用__constant__或__device__來修飾,並使用cudaMemcpyToSymbol()和cudaMemcpyFromSymbol()來讀寫。如下例:
__constant__
__device__
cudaMemcpyToSymbol()
cudaMemcpyFromSymbol()
__constant__ float constData[256]; float data[256]; cudaMemcpyToSymbol(constData, data, sizeof(data)); cudaMemcpyFromSymbol(data, constData, sizeof(data));
__device__ float devData; float value = 3.14f; cudaMemcpyToSymbol(devData, &value, sizeof(float));
__device__ float* devPointer; float* ptr; cudaMalloc(&ptr, 256 * sizeof(float)); cudaMemcpyToSymbol(devPointer, &ptr, sizeof(ptr));
實際上,當使用__constant__關鍵字時,是申請了一塊常量內存;而使用__device__時,是普通的全局內存。因此__device__申請的內存需要申請,而__constant__不用。不管是全局內存,還是常量內存,都是只讀的,需要用帶有Symbol的函數拷貝。
Symbol
不管是全局變數還是局部變數,都需要使用__shared__來修飾。不過需要注意的是,即使定義為全局變數,共享內存依舊只能被同一線程塊內的線程可見。
__shared__
舉個例子,對於如下代碼,雖然是定義了一個全局的共享內存hist_shared,但實際上,在每一個線程塊被調度到SM上時,都會在SM的共享內存區開一塊內存。因此,每一個線程塊都有一個hist_shared,且之間無法互相訪問。
__shared__ unsigned int hist_shared[256]; //共享內存僅在線程塊內共享
__global__ void getGrayHistincuda_usesharemem(unsigned char * const grayData, unsigned int * const hist, uint imgheight, uint imgwidth) //使用共享內存加速 { const unsigned int idx = blockDim.x * blockIdx.x + threadIdx.x; const unsigned int idy = blockDim.y * blockIdx.y + threadIdx.y; const unsigned char inner_idx = threadIdx.y * blockDim.x + threadIdx.x;
hist_shared[inner_idx%256] = 0; //清空數據,由於每個塊的inner_idx可以超過256,所以這樣可以保證hist_shared被全部清零
__syncthreads(); //等待其他線程完成
if(idx < imgwidth && idy < imgheight) { const unsigned long pid = imgwidth * idy + idx; const unsigned char value = grayData[pid]; atomicAdd(&(hist_shared[value]), 1); }
__syncthreads();
if(threadIdx.y < 8) //每個線程塊將自己共享內存中的值合併到全局內存中去 { atomicAdd(&(hist[inner_idx]), hist_shared[inner_idx]); }
當然,共享內存的聲明放在內核函數裡面也是可以的,效果一致。
使用共享內存,可以獲得等同於L1 cache的訪存速度,其速度遠快於全局內存。
但是注意,並不是什麼時候都可以使用共享內存來獲取加速的。例如內核函數計算出來結果後,如果這個結果只需要傳輸回主機端,而不需要再次被用到時,直接寫回全局內存會比較快。如果先寫回共享內存,再寫回全局內存,反而會比較緩慢。
強調一下,共享內存只能為線程塊內的線程共享。如果需要整個線程網格中線程都能訪問,則需要全局內存或常量內存。
另外,共享內存是一個稀缺資源。有些架構可以通過配置,分配L1 cache和共享內存的比例。
鎖頁內存指的是主機端上不會被換出到虛擬內存(位於硬碟)上的內存。
鎖頁內存的分配與釋放:
cudaHostAlloc()
cudaFreeHost()
cudaHostRegister()
malloc()
NVIDIA官方給出的鎖頁內存相對於普通的內存的的好處是:
另一本書對於鎖頁內存之所以快的解釋是:
注意,鎖頁內存在 non I/O coherent Tegra 設備上不支持
NVIDIA官方文檔表示:上述所說的鎖頁內存的優點,只有在使用cudaHostAlloc()時,傳入cudaHostAllocPortable flag,或者在使用cudaHostRegister()時傳入cudaHostRegisterPortable flag,才能體現。否則鎖頁內存並不會有上述優點。
cudaHostAllocPortable
cudaHostRegisterPortable
《GPU編程指南》一書中是這麼描述的:如果傳入了cudaHostAllocPortable flag,則鎖頁內存在所有的CUDA上下文中變成鎖頁的和可見的。如果需要在CUDA上下文之間或者主機處理器的線程之間傳遞指針,則必須使用這個標誌。
(好吧,從編程指南一書中確實沒出來用Portable的必要性,不是很明白)
鎖頁內存默認是使用緩存的。如果將flag cudaHostAllocWriteCombined 傳入到 cudaHostAlloc(),則可以將這塊鎖頁內存指定為合併寫內存。
cudaHostAllocWriteCombined
合併寫內存不再使用主機端的L1&L2 cache,使得更多的cache可以供其他任務使用。
另外,對於通過PCI-E傳輸數據的情景,使用合併寫內存不會被snooped (是不是指的是不會被緩存管?不理解這個snooped什麼意思),可以提升40%的傳輸性能。
此外需要注意的是,由於合併寫內存不使用緩存,因此讀入CPU核的操作會非常的慢。因此合併寫內存最好只用作向GPU傳數據的內存,而不是傳回數據的內存。
CUDA中的內存映射,指的是將CPU端的鎖頁內存,映射到GPU端。
通過向cudaHostAlloc()傳入cudaHostAllocMapped flag,或向cudaHostRegister()傳入cudaHostAllocMapped flag,來將一塊內存指定為向GPU映射的內存。
cudaHostAllocMapped
映射的內存有兩個地址,一個是CPU端訪問的地址,一個是GPU端訪問的地址。
cudaHostGetDevicePointer()
使用內存映射有以下好處:
使用內存映射必須要注意的幾點:
cudaSetDeviceFlags()
cudaDeviceMapHost
canMapHostMemory
CUDA允許以下操作互相併行:
設備端的如下操作,可以跟主機端並行:
CUDA_LAUNCH_BLOCKING
Async
其中第3、4條說明,在使用cudaMemcpy()時,如果數據小於等於64KB,其實傳輸相對於CPU是非同步的。 如果數據多於64KB,則CPU會阻塞到數據傳輸完成。 這時使用帶Async的內存傳輸函數,會釋放CPU資源。
需要注意的是,如果沒有使用鎖頁內存,即使使用了Async函數,內存傳輸也不是並行的(和CPU?還是GPU?)。
計算能力2.x及以上的設備,支持多個內核函數同時執行。(可以通過檢查concurrentKernels來確定)
concurrentKernels
執行多個內核函數,需要主機端不同的線程啟動。如果一個線程依次啟動多個內核,則這些內核會串列執行。同一線程的內核函數返回時會觸發隱式的同步。
另外,多個內核函數必須位於同一個CUDA上下文(CUDA context)上。不同CUDA上下文上的內核不能並行。這意味著,啟動多個內核的多個線程必須使用相同的CUDA上下文。(如何傳遞CUDA上下文?)
一些設備支持數據傳輸(主機端/設備端、設備端/設備端)和內核執行並行,可通過檢查asyncEngineCount來確認。
asyncEngineCount
一些設備支持設備端內部數據傳輸和內核執行/數據傳輸並行,可通過檢查concurrentKernels來確認。
這一特性需要使用鎖頁內存。
計算能力2.x及以上的設備,支持數據傳入和傳出並行。
必須使用鎖頁內存。
在CUDA中,流(streams)指的是在GPU上一連串執行的命令。
不同的線程,可以向同一個流填入任務。
同一個流內的任務會按順序執行;同一設備上不同的流有可能並行,其執行順序不會有保證。
下述代碼是一個流的創建和銷毀的例子。該程序創建了兩個流,分配了兩個鎖頁內存傳輸數據,依次啟動了兩個內核,最後銷毀了這兩個流。
cudaStream_t stream[2]; for (int i = 0; i < 2; ++i) cudaStreamCreate(&stream[i]); float* hostPtr; cudaMallocHost(&hostPtr, 2 * size);
for (int i = 0; i < 2; ++i) { cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]); MyKernel <<<100, 512, 0, stream[i]>>> (outputDevPtr + i * size, inputDevPtr + i * size, size); cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size, size, cudaMemcpyDeviceToHost, stream[i]); }
for (int i = 0; i < 2; ++i) cudaStreamDestroy(stream[i]);
從上例中可以看到,流的創建需要定義cudaStream_t結構,並調用cudaStreamCreate()來初始化。
cudaStream_t
cudaStreamCreate()
cudaStreamDestroy()
當向流中添加內核函數任務時,<<<...>>>不再是<<<blocksPerGrid, threadsPerBlock>>>,而是<<<blocksPerGrid, threadsPerBlock, dynamic_shared_memory, stream>>>。
<<<...>>>
<<<blocksPerGrid, threadsPerBlock>>>
<<<blocksPerGrid, threadsPerBlock, dynamic_shared_memory, stream>>>
當設備還在執行流中的任務,而用戶調用cudaStreamDestroy()函數時,函數會立刻執行(不會阻塞)。之後,當流中的任務完成後,與流相關的資源會自動釋放。
另外需要注意的是,上例中主機端線程、數據拷貝和內核執行完全非同步,因此在"拷貝回主機端"這一操作完成之前,主機端的內存數據是不正確的。必須在數據返回的一步做同步操作,方能保證數據是正確的。
(需要了解一下流內部是如何實現的,為什麼內核執行和內存拷貝能夠非同步且重疊?什麼樣的操作又不能重疊?)
在調用內核函數時,不指定流或者將流指定為0,則代表使用了默認流(default stream)。
如果在編譯時使用了--default-stream per-thread,或是在include任何cuda頭文件前#define CUDA_API_PER_THREAD_DEFAULT_STREAM,則主機端的每一個線程都有自己專屬的默認流。
--default-stream per-thread
#define CUDA_API_PER_THREAD_DEFAULT_STREAM
--default-stream legacy
可以使用如下函數進行顯式同步:
cudaDeviceSynchronize()
cudaStreamSynchronize()
cudaStreamWaitEvent()
cudaStreamQuery()
注意,同步函數慎用,因為有可能會產生速度的下降。
一般來講,不同流內的命令可以並行。但是當任何一個流執行如下的命令時,情況例外,不能並行:
操作的重疊程度,一方面取決於各個操作的順序,另一方面取決於設備支持重疊的程度(是否支持內核執行並行/數據傳輸與內核執行並行/數據傳輸並行)
可以使用cudaStreamAddCallback()函數,向流中添加callback。該callback會在流中之前所有的任務完成後被調用。如果stream參數設為0,則代表之前的所有stream的任務執行完後就調用該callback。
cudaStreamAddCallback()
回調函數和cudaStreamWaitEvent()一樣,對於在加在callback之後的指令,必須等待callback執行完成後,才會繼續執行。
下例是一個使用回調的例子。該例中,兩個stream將數據拷回主機端後,會調用回調函數。
void CUDART_CB MyCallback(cudaStream_t stream, cudaError_t status, void *data){ printf("Inside callback %d ", (size_t)data); } ... for (size_t i = 0; i < 2; ++i) { cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]); MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size); cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost, stream[i]); cudaStreamAddCallback(stream[i], MyCallback, (void*)i, 0); }
回調函數中不能直接或間接的執行CUDA函數,否則會因為等待自己完成而造成死鎖。 (原因尚不太明白)
可以通過cudaStreamCreateWithPriority()來在創建流時指定流的優先順序。可以指定的優先順序可由cudaDeviceGetStreamPriorityRange()來獲得。
cudaStreamCreateWithPriority()
cudaDeviceGetStreamPriorityRange()
運行時,高優先順序stream中的線程塊不能打斷正在執行的低優先順序stream的線程塊(即不是搶佔式的)。但是當低優先順序stream的線程塊退出SM時,高優先順序stream中的線程塊會被優先調度進SM。
事件(Event)可以被壓入流中以監視流的運行情況,或者用於精確計時。
如果向stream 0壓入事件,則當壓入事件前向所有流壓入的任務完成後,事件才被觸發。
cudaEvent_t start, stop; //創建 cudaEventCreate(&start); cudaEventCreate(&stop); ... cudaEventDestroy(start); //銷毀 cudaEventDestroy(stop);
下例是一個使用Event計算時間的例子:
cudaEventRecord(start, 0); //記錄事件(將事件壓入流),流0則代表所有流完成任務後事件才會被觸發 for (int i = 0; i < 2; ++i) { cudaMemcpyAsync(inputDev + i * size, inputHost + i * size, size, cudaMemcpyHostToDevice, stream[i]); MyKernel<<<100, 512, 0, stream[i]>>>(outputDev + i * size, inputDev + i * size, size); cudaMemcpyAsync(outputHost + i * size, outputDev + i * size, size, cudaMemcpyDeviceToHost, stream[i]); } cudaEventRecord(stop, 0); cudaEventSynchronize(stop); float elapsedTime; cudaEventElapsedTime(&elapsedTime, start, stop); //獲取兩個事件發生的時間差(ms)
下例是如何枚舉設備,並獲取設備信息的例子:
int deviceCount; cudaGetDeviceCount(&deviceCount); //獲取設備數量 int device; for (device = 0; device < deviceCount; ++device) { cudaDeviceProp deviceProp; cudaGetDeviceProperties(&deviceProp, device); printf("Device %d has compute capability %d.%d. ", device, deviceProp.major, deviceProp.minor); }
使用cudaSetDevice()選擇設備,當不選擇時,默認使用設備0。
注意,所有的內存分配、內核函數啟動、流和事件的創建等,都是針對當前選擇的設備的。
下例是一個設備選擇的例子:
size_t size = 1024 * sizeof(float); cudaSetDevice(0); // Set device 0 as current float* p0; cudaMalloc(&p0, size); // Allocate memory on device 0 MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0 cudaSetDevice(1); // Set device 1 as current float* p1; cudaMalloc(&p1, size); // Allocate memory on device 1 MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1
下面將討論,如果對一個不屬於當前設備的流或事件進行操作,哪些操作會成功,哪些操作會失敗:
cudaSetDevice(0); // Set device 0 as current cudaStream_t s0; cudaStreamCreate(&s0); // Create stream s0 on device 0 MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 0 in s0 cudaSetDevice(1); // Set device 1 as current cudaStream_t s1; cudaStreamCreate(&s1); // Create stream s1 on device 1 MyKernel<<<100, 64, 0, s1>>>(); // Launch kernel on device 1 in s1
// This kernel launch will fail: MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0
另外需要注意,每個設備都有自己的默認流。因此在沒有指定流的情況下,向不同設備分派的任務,實際上是壓入了各個設備的默認流,他們之間是並行執行的。
計算能力2.0及以上的設備支持設備間對等內存訪問,這意味著兩個GPU之間的傳輸和訪問可以不經過主機端中轉,速度會有提升。查詢cudaDeviceCanAccessPeer()可以得知設備是否支持這一特性。(官方文檔說還需要一個條件:64位程序,存疑)
cudaDeviceCanAccessPeer()
需要使用cudaDeviceEnablePeerAccess()來使能這一特性。
cudaDeviceEnablePeerAccess()
對等設備的的地址是統一編址的,可用同一個指針訪問,如下例:
cudaSetDevice(0); // Set device 0 as current float* p0; size_t size = 1024 * sizeof(float); cudaMalloc(&p0, size); // Allocate memory on device 0 MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0 cudaSetDevice(1); // Set device 1 as current cudaDeviceEnablePeerAccess(0, 0); // Enable peer-to-peer access with device 0
// Launch kernel on device 1 // This kernel launch can access memory on device 0 at address p0 MyKernel<<<1000, 128>>>(p0);
對等設備的地址是統一編址的,可以使用cudaMemcpyPeer()、cudaMemcpyPeerAsync()、cudaMemcpy3DPeer、cudaMemcpy3DPeerAsync()來進行直接拷貝。無需先拷貝會主機端內存,再轉到另一塊卡上。如下例:
cudaMemcpyPeer()、cudaMemcpyPeerAsync()、cudaMemcpy3DPeer、cudaMemcpy3DPeerAsync()
cudaSetDevice(0); // Set device 0 as current float* p0; size_t size = 1024 * sizeof(float); cudaMalloc(&p0, size); // Allocate memory on device 0 cudaSetDevice(1); float* p1; cudaMalloc(&p1, size); // Allocate memory on device 1 cudaSetDevice(0); // Set Device 0 as Current MyKernel<<<1000, 128>>>(p0); // Launch Kernel on Device 0 cudaSetDevice(1); // Set Device 1 as Current cudaMemcpyPeer(p1, 1, p0, 0, size); // Copy p0 to p1 MyKernel<<<1000, 128>>>(p1); // Launch Kernel on Device 1
關於設備間的對等拷貝,如果使用的是NULL stream,則有如下性質:
(使用的如果不是NULL Stream,又會怎樣呢?)
當程序是64位程序時,所有主機端內存,以及計算能力≥2.0的設備的內存是統一編址的。所有通過CUDA API分配的主機內存和設備內存,都在統一編址的範圍內,有自己的虛擬地址。因此:
cudaPointerGetAttributes()
cudaMemcpy***()
cudaMemcpyKind
cudaMemcpyDefault
可以通過查詢unifiedAddressing來查看設備是否支持統一虛擬編址。
unifiedAddressing
線程間通訊,可以很方便的通過共享的變數來實現。然而進程間通訊不行。
為了在進程間共享設備端內存的指針或者事件,必須使用IPC(Inter Process Communication) API。IPC API只支持64位程序,並且要求設備計算能力≥2.0。
通過IPC中的cudaIpcGetMemHandle(),可以得到設備內存指針的IPC句柄。該句柄可以通過標準的IPC機制(interprocess shared memory or files)傳遞到另一個進程,再使用cudaIpcOpenMemHandle()解碼得到該進程可以使用的設備內存指針。
cudaIpcGetMemHandle()
cudaIpcOpenMemHandle()
所有的runtime function都會返回一個error code,可通過檢查error code判斷是否出錯。
但是對於非同步函數,由於在執行前就會返回,因此返回的error code僅僅代表函數啟動時的錯誤(如參數校驗);非同步函數不會返回運行時出現的錯誤。如果運行時出了錯,會被後面的某個函數捕獲並返回。
檢查非同步函數是否出錯的唯一方式,就是在非同步函數啟動後,進行同步。 如在非同步函數後,調用cudaDeviceSynchronize(),則非同步函數的錯誤會被cudaDeviceSynchronize()捕獲到。
事實上,除了runtime function會返回error code之外,每一個主機端線程都會有一個初始化為cudaSuccess的變數,用於指示錯誤。一旦發生了錯誤,該變數也會被設置為相應的error code。
cudaSuccess
該變數不會被直接調用,但可以被cudaPeekAtLastError()和cudaGetLastError()訪問到。不同的是,cudaGetLastError()在返回這一變數的同時,會把它重置為cudaSuccess。
cudaPeekAtLastError()
cudaGetLastError()
內核函數不會返回值,因此只能通過cudaPeekAtLastError()或cudaGetLastError()來知悉調用內核是否有錯誤。
另外需要注意的是,cudaStreamQuery()和cudaEventQuery()這類函數,有可能會返回cudaErrorNotReady。但這不被認為是錯誤,因此不會被cudaPeekAtLastError()和cudaGetLastError()捕獲到。
cudaEventQuery()
cudaErrorNotReady
對於計算能力≥2.0的設備,可以通過cudaDeviceGetLimit()/cudaDeviceSetLimit()來查詢/設置調用棧的大小。
cudaDeviceGetLimit()
cudaDeviceSetLimit()
有兩個版本需要注意:計算能力,以及CUDA driver API的版本。其中計算能力及其兼容性在前面已有闡述。
CUDA driver API的版本定義在驅動的頭文件中的CUDA_VERSION宏內。可以在程序中將該宏調出,以檢查程序是否可以在目標設備上運行。
CUDA_VERSION
CUDA driver API不是向前兼容的。也就是說,針對新版本的CUDA driver API編譯的程序、插件、庫,並不能在舊版本的驅動上運行。
關於CUDA driver API,有幾點需要注意:
NVIDIA的設備可以設置三種計算模式:
正常情況下,如果程序沒有調用cudaSetDevice(),則會默認使用0號設備。但是如果0號設備被置成禁止模式,亦或是被其他進程所專屬,則會在其他設備上創建上下文並使用。 可以向cudaSetValidDevices()函數輸入一個設備列表,函數會在第一個可以使用的設備上創建上下文。
cudaSetValidDevices()
Pascal及以上架構,支持指令級的優先順序調度。不再是以線程塊為SM的最小調度單位,而是以指令為最小調度單位,且具有優先順序。 這意味著具有冗長kernel的線程塊不再會佔據太多的計算資源,或是發生timeout。但是這也有缺點:當多個進程創建了上下文時,以往基於線程塊的調度不會造成太多的上下文切換,但現在的指令級調度則會造成很多的上下文切換,降低效率。(注意跟GPU內線程的上下文切換不同,GPU內線程上下文切換幾乎不浪費時間,直接換一個指針就好)。因此最好設置為Exclusive-process,這樣只有一個進程會使用設備。(如果線程很多的話,效果不是一樣嗎?)
(講道理好好看看上下文是什麼鬼)
(這段需要再看一下Pascal架構的說明,看看指令級的調度是如何實現的,寄存器等又是如何分配的)
設備處於哪種計算模式,可通過檢查computeMode來查看。
computeMode
GPU會將一些內存專門分配給primary surface,用於刷新顯示設備。 一旦用戶將顯示模式切換,如增加解析度或增加彩色的位數,會造成primary surface所需的內存變多。此時系統會把原來分配給CUDA運算的內存,調撥給primary surface,從而造成CUDA runtime產生錯誤,並返回invalid context error。
(言外之意是說,跑cuda的時候不要切解析度?)
這份官方文檔講的硬體內容太少了,從另一本書里補一點過來,可能內容有點老舊,見諒。
(在一本比較老舊的書上找到的結構圖)
下圖是一款比較老舊的顯卡(G80/GT200)的硬體結構圖:
從圖中可以看到,GPU由內存和一系列流處理器簇(Streaming Multiprocessors, SM)組成,不同GPU的具有不同的內存大小和SM數量。
流處理器簇(SM)內部結構如下圖所示:
SM由一系列流處理器(Streaming Processor,SP)、寄存器文件(Register File)、共享內存(Shared Memory, SMem)、SPU(特殊運算單元),以及紋理/常量/L1緩存組成。L2 cache是由所有SM共享的。
線程塊調度
另外需要強調一點的是,如果線程塊需要的寄存器或共享內存太多,以至於SM連一個線程塊都無法滿足的時候,內核會啟動失敗。
線程束調度
當調度器選擇調度線程束0時,第一個時鐘周期會將半個線程束調度到第一個16xSP上,然後下一個周期會將另外半個線程束調度到另一個16xSP上。因此,調度一個完整的線程束0的指令,需要2個時鐘周期。
線程束有時會處於等待狀態(等待內存,或等待特殊計算的結果)。此時調度器會調度其它線程束到SP上運行。當等待的線程束等到了所需的元素後,會再次處於就位狀態,等待調度器調度運行。
不同計算能力的平台,具有不同的調度器數量和SP數量。
NVIDIA GPU是典型的SIMT架構(Single-Instruction, Multiple-Thread Architecture,單指令多線程架構)。(半)線程束在同一時間內執行同樣的指令(相同的PC),但每個線程有自己的數據空間(寄存器);可以同時做同樣的事情,但是處理不同的數據。這樣可以有效節省指令帶寬(指令由線程束共享),實現高效並行。
當線程束中的線程,必須要執行不同的條件分支時,滿足分支條件的線程會被激活並執行分支內的內容;不滿足分支條件的線程會接收同樣的指令,但不會被激活,不會實際執行,但也不能跳過去執行其他指令。換言之,當線程束中的線程遇到分支時,不論線程是否需要執行分支,都會消耗執行該分支的時間,因為線程束中的線程執行同樣的指令,是高度同步的。
... if(條件) { 操作1 } else { 操作2 } ...
如果是CPU,如果不滿足條件,則會直接跳轉去執行操作2,不會執行操作1。 但是對於GPU同一個線程束內的線程,即使不滿足條件,依舊會去跟其他線程一起去執行操作1。等到操作1執行完畢後,該線程又會跟其他線程一起執行操作2。這也就意味著,不管線程實際上需不需要執行某一分支,它都要跟其他線程一起跑完這一分支。
另外,GPU不支持分支預測和推測執行,只會老老實實的一條條執行各個分支里的內容。因此,對於CUDA程序來說,分支實際上是一種低效的行為。
但是有兩種情況是例外:
但是從Volta架構開始,Independent Thread Scheduling被引入,線程束內的線程不再完全同步。每個線程都會有自己獨立的PC。遇到分支時,不再像之前的架構一樣,只有(半)線程束內的線程條件一致時,才會跳過分支;Volta架構的調度優化器會將線程束中的線程,按照分支條件是否滿足,重新組合成SIMT單元,從而跳過分支。
Volta架構的Independent Thread Scheduling無疑是高效的,但是這是一個跟舊架構完全不同的特性。在編寫舊架構的CUDA程序時,程序員會默認線程束內的線程一定會同步執行。Volta架構的新特性破壞了這一假設,無疑會給程序帶來一些問題,需要注意。
在一個線程束中,參與當前指令的線程稱為active threads,不參與的被稱為inactive threads。造成線程inactive的原因有:
當線程束中所有線程,向同一個內存地址寫數據時,不能保證哪個線程先寫,哪個線程後寫,即程序的正確性是無法保證的。 這時需要使用CUDA提供的原子操作(atomic)函數,如atomicAdd()。
atomicAdd()
原子操作可以保證程序的正確性,但是會造成線程束中線程的串列化(serialization),執行時間比並行執行要長。
另外需要注意的是,即使沒使用原子操作,向同一個內存地址寫數據,一樣也會產生一定程度的串列化,串列化程度依架構而定。
GPU線程束中的線程的上下文(寄存器&PC等)都存在片內空間龐大的寄存器文件中,直到線程束執行完畢才會被釋放(生命周期為整個線程束執行過程)。因此,不像CPU,GPU的線程束上下文切換十分迅速,沒有損耗。 線程束調度器(warp scheduler)會選擇ready狀態的線程束,將其調度到SP上執行。
CUDA程序性能優化有三個原則:
在性能優化前,需要先分析程序性能的瓶頸,再針對瓶頸優化,否則收益會很低。
最大化利用率的方法就是並行。
從程序最高層來看,應該儘可能讓主機端、設備端、PCI-E匯流排並行工作。對此可以使用非同步CUDA函數,以及流(Stream)來實現。
同步操作,以及內存的共享會影響程序的並行性。因此需要仔細設計演算法流程,盡量減少同步和內存共享。 如果一定需要同步和內存共享,盡量在線程塊內完成(線程塊同步——使用__syncthreads()涉及到的線程少,且可以通過SM內的共享內存共享數據。如果需要線程網格內同步,則需要兩個內核調用,且共享數據只能通過全局內存,速度慢)。
__syncthreads()
可以通過流的方式,儘可能的讓多個內核並行,提升利用率。
延遲(latency)指的是線程束(從上一個動作開始)到它處於ready狀態的時鐘數。 例如線程束先提交了一個內存訪問請求,然後等了400個時鐘周期,內存管理系統才返回數據,線程束可以繼續執行。這400個時鐘周期稱為延遲。
當一個線程束髮生延遲時,線程束調度器(warp scheduler)會將其他處於ready狀態的線程束調度到SP上。等到延遲結束後,再將該線程調度回SP繼續執行。這樣一來,前一個線程束的延遲,就被另一個線程束的執行所隱藏了。 這一過程被稱作延遲的隱藏(hidden latency)。
隱藏延遲是GPU編程的核心概念。由於GPU具有巨大的寄存器空間,線程的切換不存在損耗。因此,通過向GPU上分配足夠多的線程,可以讓這些線程延遲互相交錯,以起到隱藏延遲的作用,提高硬體利用率。
造成線程(束)產生延遲的原因有:
通過配置線程網格、線程塊、寄存器和共享內存用量,讓SM可以運行儘可能多的線程束,以隱藏延遲。例如對於計算能力3.x的設備,為了完全隱藏全局內存讀取的延遲(200-400時鐘),需要大概40個線程束。
舉個例子,設SM有32KB共享內存空間。程序每個線程需要32B共享內存,即一個線程束需要1KB共享內存,考慮下述兩種方案:
雖然方案2在一個線程塊上,有更多的線程束,但是實際上SM上運行的線程束減少了(32->18)。因此方案2隱藏延遲的能力弱於方案1,資源利用率較低。
此外,如果寄存器使用過多,超過了SM上的寄存器空間,則會使用本地內存作為寄存器。本地內存是存在在全局內存上的,速度很慢,會嚴重影響程序速度。因此需要嚴格考慮寄存器使用數量。 (這裡官方文檔和另一本書里說的有矛盾,難道是新的架構把本地內存給取消掉了?)
最後強調一點,線程塊中的線程數量,最好是32的整數倍。這樣,就不會有為了補齊線程束,而出現的永遠不會激活的線程。這些不激活的線程也會佔用SM的資源,降低資源利用率。
CUDA具有Occupancy Calculator,幫助程序員設計。
最大化內存吞吐,主要手段就是少用低帶寬的內存。這意味著首先要儘可能減少主機端和設備端間的設備傳輸(PCI-E,特別慢),其次要儘可能減少全局內存的讀寫(快於PCI-E,但是相對於片內內存來說,還是挺慢的);儘可能的使用片內的內存(寄存器、cache、共享內存)。
這裡需要強調一下cache和共享內存的事情。
共享內存是程序可控的高速緩存。一般情況下,共享內存的使用流程為: 將數據從全局內存拷貝到共享內存,或初始化共享內存 進行一個同步操作,確保共享內存全部被賦值 利用共享內存的數據,運行程序 如果出現了共享內存的寫操作,一般需要進行一個同步操作,確保寫操作全部完成後再進行下面的操作 * 將數據寫回全局內存
這裡有一點要強調,只有在數據需要反覆讀寫的時候,共享內存才有意義。如果數據只會被讀一次,處理完後又寫回並不再處理。則直接從全局內存讀出->寄存器運行->寫回全局內存是最快的。在共享內存中轉反而是慢的。
緩存(L1/L2 cache)是程序員無法顯式編程的。但是如果了解緩存的特性的話,可以通過合適的程序設計,增加緩存命中率。
由於PCI-E傳輸並不快,因此要盡量減少主機端和設備端間的數據傳輸: 一種方式是讓中間結果儘可能的在設備端產生,在設備端使用。 另一種方式是將很多小的數據,打包傳輸。 還有可以通過分配鎖頁內存來加快前端匯流排*系統的帶寬。
當使用內存映射時,需要注意,每次內存訪問都會啟動一次PCI-E傳輸。因此,盡量保證數據只被讀寫一次,且儘可能合併訪問以提升有效內存帶寬。
有些GPU設備,主機端和設備端內存,在物理上就是同一塊。這種情況下,主機端和設備端傳輸是不存在的。可通過標誌integrated來查看。
integrated
全局內存支持合併訪問,可以一次性傳輸連續的32、 64、 128位元組的數據。因此,在設計內核時,線程束內的線程盡量連續的訪問內存。
考慮如下兩個內核:
//假設gpuData是一個二維數組,尺寸為32x32 int gpuData[32][32]; //這樣是不合法的,因為這麼定義實際上是在主機端,還需要拷貝到設備端,這裡只是為了方便說明問題
__global__ void Kernel1(int gpuData[][32]) { const int tid = blockIdx.x * blockDim.x + threadIdx.x; int sum = 0; for(int i = 0; i < 32; i++) sum += gpuData[i][tid]; //行訪問 ... }
__global__ void Kernel2(int gpu[][32]) { const int tid = blockIdx.x * blockDim.x + threadIdx.x; int sum = 0; for(int i = 0; i < 32; i++) sum += gpuData[tid][i]; //列訪問 ... }
上例中,執行Kernel1的線程束中的線程,在一次循環中,32個線程依次訪問gpuData[0][0], gpuData[0][1], gpuData[0][2], ..., gpuData[0][31]。在內存中,這32個變數是連續存儲的,因此可以被合併訪問。這種訪問被稱為行訪問。
gpuData[0][0], gpuData[0][1], gpuData[0][2], ..., gpuData[0][31]
gpuData[0][0], gpuData[1][0], gpuData[2][0], ..., gpuData[31][0]
上例中,列訪問之所以效率低,原因有二:
因此,從上例中可以看到,好好安排內存排布,盡量使得內存訪問可以合併,可以加速全局內存的讀寫。
當變數的尺寸為1/2/4/8/16位元組時,變數會對齊。但如果不是的話,變數無法對齊,會產生額外的內存訪問。
C/C++內建的變數(int/float等),以及CUDA支持的向量(float2/float4等),是對齊的。
一些結構體可能會產生不對齊的情況,看下例:
struct struct1{ float x; float y; };
struct struct2{ float x; float y; float z; };
struct struct3 __align__(16){ float x; float y; float z; };
上例中,struct1是8位元組的結構體,自動會對齊; struct2具有12個位元組,無法對齊; struct3使用了__align__(16)關鍵字,顯式指定對齊到16。
__align__(16)
使用各類malloc分配的設備內存,一定是256位元組對齊的。
當使用了自動變數(不明白,再看)時,有可能會將數據放到本地內存上:
通過看PTX代碼,可以看到標記為.local的變數,就是本地內存。
.local
lmem
前面多次強調過了,一旦使用了本地內存,其速度會非常慢。不過本地內存在存儲的時候,是按照32個線程連續存儲的,因此可以合併訪問。
共享內存實際上是被分為多個存儲體(memory bank)。多個線程訪問同一個存儲體會造成串列化。
因此,編寫內核時,需要認真設計,以避免存儲體訪問的衝突。
可以使用如下方法來最大化指令吞吐:
__restrict__
指令吞吐的定義:每個SP在每個時鐘周期內執行的操作數。如果一個線程束在一個時鐘周期內執行了N個操作,則指令吞吐為N/32。
官方文檔這裡比較混亂,但主要有如下幾點: 不同架構的設備,不同指令有不同的指令吞吐,可以查表 有一些快速的內聯(inline)函數,如使用__fdividef()(快速浮點數除法)來代替普通的除法來加速 整形的除法和取余會比較慢,可能需要20個機器周期;因此對於n為2的冪次的情況,使用i>>log2(n)代替i/n,使用i&(n-1)來代替i%n 半精度(浮點數)運算(Half Precision Arithmetic):可以使用half2數據類型,並使用對應的運算指令(如__hadd2, __hsub2, __hmul2, __hfma2等),來讓一個周期內執行兩次運算,以節省指令帶寬。可以通過__halves2half2將兩個半精度浮點數合併為half2數據類型。 (半精度又是咋定義的?) * 數據類型轉換:當使用char或short,亦或是雙精度常量與單精度變數相互操作時,會觸發數據類型轉換,需要一定執行時間(實際上,char和short,不管是存儲在寄存器中,還是在運算時,都是以int型進行的)
__fdividef()
i>>log2(n)
i/n
i&(n-1)
i%n
half2
__hadd2, __hsub2, __hmul2, __hfma2
__halves2half2
盡量避免向線程束中引入分支。
此外,可以使用#pragma unroll宏,來進行循環展開,減少控制指令。
#pragma unroll
下表為不同計算能力的設備,同步指令__syncthreads()需要消耗的指令周期為:
__syncthreads()消耗的指令周期
注意,__syncthreads()會造成線程塊中的線程等待,影響內核執行效率。