CUDA編程(三)
評估CUDA程序的表現
上一篇博客我們基本上搭建起來了CUDA程序的骨架,但是其中並沒有涉及到我們之前不斷提到的並行加速,畢竟只有當我們的程序高並行的運行在GPU上才能大大縮短運行時間。不過在加速之前我們還有一件非常重要的事情需要考慮,那就是我們的程序到底有沒有一個好的表現,也就是我們要準確計算程序的運行時間,這對之後的程序優化也有至關重要的作用,所以值得我們去仔細研究一下~
這裏所謂的計算運行時間也不是單純意義上的看運行時間,更重要的是我們要通過核函數的運行時間去計算程序實際上所使用的內存帶寬,與顯卡的性能進行比較,看看我們到底發揮了GPU的幾成功力,像上一篇博客裏的那個程序,其所使用的內存帶寬大概只有 5M/s,而我們之前也提到過了,像GeForce 8800GTX這樣比較老的顯卡,也具有超過50GB/s 的內存帶寬 。所以只有學會評估程序,才能不斷去優化程序,直到駕馭我們的顯卡。
計算核函數運行時間
clock函數
評估程序在GPU上的運行時間我們需要使用CUDA提供的一個Clock函數,這個函數將會返回GPU執行單元的頻率(timestamp),這十分適合用來判斷一段程序執行所花費的時間。
我們首先來看一下之前寫好的CUDA程序骨架,然後我們的任務就是加上計算程序運行時間的功能:
#include <stdio.h>
#include <stdlib.h>
//CUDA RunTime API
#include <cuda_runtime.h>
#define DATA_SIZE 1048576
int data[DATA_SIZE];
//產生大量0-9之間的隨機數
void GenerateNumbers(int *number, int size)
{
for (int i = 0; i < size; i++) {
number[i] = rand() % 10;
}
}
//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;
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)
{
int sum = 0;
int i;
for (i = 0; i < DATA_SIZE; i++) {
sum += num[i] * num[i] * num[i];
}
*result = sum;
}
int main()
{
//CUDA 初始化
if (!InitCUDA()) {
return 0;
}
//生成隨機數
GenerateNumbers(data, DATA_SIZE);
/*把數據複製到顯卡內存中*/
int* gpudata, *result;
//cudaMalloc 取得一塊顯卡內存 ( 其中result用來存儲計算結果 )
cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
cudaMalloc((void**)&result, sizeof(int));
//cudaMemcpy 將產生的隨機數複製到顯卡內存中
//cudaMemcpyHostToDevice - 從內存複製到顯卡內存
//cudaMemcpyDeviceToHost - 從顯卡內存複製到內存
cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);
// 在CUDA 中執行函數 語法:函數名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);
sumOfSquares << <1, 1, 0 >> >(gpudata, result);
/*把結果從顯示芯片複製回主內存*/
int sum;
//cudaMemcpy 將結果從顯存中複製回內存
cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
//Free
cudaFree(gpudata);
cudaFree(result);
printf("GPUsum: %d \n", sum);
sum = 0;
for (int i = 0; i < DATA_SIZE; i++) {
sum += data[i] * data[i] * data[i];
}
printf("CPUsum: %d \n", sum);
return 0;
}
首先我們需要先引入time.h,才能使用clock_t
#include <time.h>
然後我們需要先改動一下我們的核函數sumOfSquares,因爲之前提到過了,核函數是不能有返回值的,我們現在不僅需要返回計算結果,還需要一個返回運行時間的參數,同時調用clock函數獲取開始時間,通過做差計算出運行時間。
// __global__ 函數 (GPU上執行) 計算立方和
__global__ static void sumOfSquares(int *num, int* result, clock_t* time)
{
int sum = 0;
int i;
clock_t start = clock();
for (i = 0; i < DATA_SIZE; i++) {
sum += num[i] * num[i] * num[i];
}
*result = sum;
*time = clock() - start;
}
因爲需要記錄時間,我們也需要爲這個記錄時間的變量開闢一塊內存,所以開闢顯存的部分也需要進行更改
/*把數據複製到顯卡內存中*/
int* gpudata, *result;
clock_t* time;
//cudaMalloc 取得一塊顯卡內存 ( 其中result用來存儲計算結果,time用來存儲運行時間 )
cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
cudaMalloc((void**)&result, sizeof(int));
cudaMalloc((void**) &time, sizeof(clock_t));
調用核函數的部分也要加一個參數
sumOfSquares<<<1, 1, 0>>>(gpudata, result, time);
最後不要忘記從顯存拿回時間並且輸出出來
/*把結果從顯示芯片複製回主內存*/
int sum;
clock_t time_used;
//cudaMemcpy 將結果從顯存中複製回內存
cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
cudaMemcpy(&time_used, time, sizeof(clock_t), cudaMemcpyDeviceToHost);
//Free
cudaFree(gpudata);
cudaFree(result);
cudaFree(time);
printf("GPUsum: %d time: %d\n", sum, time_used);
經過以上改造我們就能成功的輸出clock函數的結果了~
完整程序:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
//CUDA RunTime API
#include <cuda_runtime.h>
#define DATA_SIZE 1048576
int data[DATA_SIZE];
//產生大量0-9之間的隨機數
void GenerateNumbers(int *number, int size)
{
for (int i = 0; i < size; i++) {
number[i] = rand() % 10;
}
}
//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;
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)
{
int sum = 0;
int i;
clock_t start = clock();
for (i = 0; i < DATA_SIZE; i++) {
sum += num[i] * num[i] * num[i];
}
*result = sum;
*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));
cudaMalloc((void**)&time, sizeof(clock_t));
//cudaMemcpy 將產生的隨機數複製到顯卡內存中
//cudaMemcpyHostToDevice - 從內存複製到顯卡內存
//cudaMemcpyDeviceToHost - 從顯卡內存複製到內存
cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);
// 在CUDA 中執行函數 語法:函數名稱<<<block 數目, thread 數目, shared memory 大小>>>(參數...);
sumOfSquares << <1, 1, 0 >> >(gpudata, result, time);
/*把結果從顯示芯片複製回主內存*/
int sum;
clock_t time_used;
//cudaMemcpy 將結果從顯存中複製回內存
cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
cudaMemcpy(&time_used, time, sizeof(clock_t), cudaMemcpyDeviceToHost);
//Free
cudaFree(gpudata);
cudaFree(result);
cudaFree(time);
printf("GPUsum: %d time: %d\n", sum, time_used);
sum = 0;
for (int i = 0; i < DATA_SIZE; i++) {
sum += data[i] * data[i] * data[i];
}
printf("CPUsum: %d \n", sum);
return 0;
}
運行結果:
(另外說一下我的環境,這裏用的是Debug,後面不說明的話也是Debug下的,Release的話還會快10倍左右。然後我的顯卡是NVIDIA GeForce GT 640
也夠老的,主要是因爲我另一臺電腦用戶文件夾是中文的,所以死活用不了CUDA,我又不想重裝系統,所以知道怎麼改用戶文件夾的同學一定要告訴我啊,555555555)
我們看到輸出的時間很奇怪:679743997,其實這個地方返回的是GPU執行單元的頻率,也就是GPU的時鐘週期(timestamp),需要除以GPU的運行頻率才能得到以秒爲單位的時間。那麼問題來了,我們怎麼去獲取準確的GPU信息呢,這對我們今後的優化也有着重大意義。
獲取GPU的詳細信息:
之前我們提到過CUDA的初始化過程我們要獲取 CUDA 的設備數,然後利用其支持CUDA版本的屬性來判斷是否是仿真器,最終判斷是否機器上具有完備的CUDA開發環境。其實在使用cudaGetDeviceProperties獲取設備屬性的時候,我們獲取的是一個關於設備的屬性集合,現在我們來具體的看一下這個函數:
函數說明:
以*prop形式返回設備dev的屬性。
返回值:
cudaSuccess、cudaErrorInvalidDevice,注,如果之前是異步啓動,該函數可能返回錯誤碼。
cudaDeviceProp 結構定義:
struct cudaDeviceProp {
char name [256];
size_t totalGlobalMem;
size_t sharedMemPerBlock;
int regsPerBlock;
int warpSize;
size_t memPitch;
int maxThreadsPerBlock;
int maxThreadsDim [3];
int maxGridSize [3];
size_t totalConstMem;
int major;
int minor;
int clockRate;
size_t textureAlignment;
int deviceOverlap;
int multiProcessorCount;
}
cudaDeviceProp 結構中的各個變量的意義:
name
用於標識設備的ASCII字符串;totalGlobalMem
設備上可用的全局存儲器的總量,以字節爲單位;sharedMemPerBlock
線程塊可以使用的共享存儲器的最大值,以字節爲單位;多處理器上的所有線程塊可以同時共享這些存儲器;regsPerBlock
線程塊可以使用的32位寄存器的最大值;多處理器上的所有線程塊可以同時共享這些寄存器;warpSize
按線程計算的warp塊大小;memPitch
允許通過cudaMallocPitch()爲包含存儲器區域的存儲器複製函數分配的最大間距(pitch),以字節爲單位;maxThreadsPerBlock
每個塊中的最大線程數maxThreadsDim[3]
塊各個維度的最大值:maxGridSize[3]
網格各個維度的最大值;totalConstMem
設備上可用的不變存儲器總量,以字節爲單位;major,minor
定義設備計算能力的主要修訂號和次要修訂號;clockRate
以千赫爲單位的時鐘頻率;textureAlignment
對齊要求;與textureAlignment字節對齊的紋理基址無需對紋理取樣應用偏移;deviceOverlap
如果設備可在主機和設備之間併發複製存儲器,同時又能執行內核,則此值爲 1;否則此值爲 0;multiProcessorCount
設備上多處理器的數量。
我們可以寫一個函數來把這些信息都輸出出來,這樣我們就能獲得我們GPU的全部信息了,更重要的是獲得我們所關心的時鐘頻率:
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的InitCUDA()函數中去使用,這樣就能把每個設備的信息打印出來。
//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;
}
運行結果:
在這裏我們就很清楚的看到了GPU的各項信息,包括最大的Thread數,Grid數等等,這對後面的並行優化也是很有價值的。然後我們看到我的GPU的時鐘頻率是797000千赫茲,於是我們就可以計算出這次運行核函數部分的時間約爲:
679680304 / (797000 * 1000) = 0.853S
計算使用的內存帶寬:
我們的數據量爲:DATA_SIZE 1048576,也就是1024*1024 也就是 1M
1M 個 32 bits 數字的數據量是 4MB。
因此,這個程序實際上使用的內存帶寬約爲:
4MB / 0.853S = 4.68MB/s
只有4.68MB/s 左右!這是非常糟糕的表現,因爲我們之前也提到過了,像GeForce 8800GTX這樣比較老的顯卡,也具有超過50GB/s 的內存帶寬,不過產生這種現象的原因和解決我們留到下次~
那麼我們爲什麼着呢在意內存帶寬呢,這裏給大家補充一下寫出一個優異的CUDA程序所要經過的步驟。
什麼是優秀的CUDA程序:
爲了短時間內完成計算,需要考慮算法、並行劃分、指令吞吐量、存儲器帶寬等多方面因素,總的來說一個優秀的CUDA程序應該具有下面這些特徵:
在給定的數據規模下,選用算法的計算複雜度不明顯高於最優的算法;
Active warp的數量能夠讓SM滿載,並且active block的數量大於2,能夠有效地隱藏訪存延遲(使用足夠大的內存帶寬);
當瓶頸出現在運算指令時,指令流的效率已經過了充分優化;
當瓶頸出現在訪問IO時,程序已經選用了恰當的存儲器來儲存數據,並使用了適當的存儲器訪問方式,以獲得最大帶寬;
CUDA程序編寫優化步驟:
如何完成一個優秀的CUDA程序呢?這裏有一份步驟給大家參考:
確定任務中的串行和並行的部分,選擇合適的算法(首先將問題分解爲幾個步驟,確定哪些步驟可以用並行實現,並確定合適的算法);
按照算法確定數據和任務的劃分方式,將每個需要實現的步驟映射爲一個滿足CUDA兩層並行模型的內核函數,讓每個SM上至少有6個活動warp和至少2個活動block;
編寫一個能正確運行的程序作爲優化的起點,要確保程序能穩定運行以及其正確性,在精度不足或者發生溢出時必須使用雙精度浮點或者更長的整數類型;
優化顯存訪問,避免顯存帶寬成爲瓶頸。在顯存帶寬得到完全優化前,其他優化不會產生明顯效果。
優化指令流,在誤差可接受的情況下,使用CUDA算術指令集中的快速指令;避免多餘的同步;在只需要少量線程進行操作的情況下,使用類似
“if threaded<N”
的方式,避免多個線程同時運行佔用更長時間或者產生錯誤結果;資源均衡,調整每個線程處理的數據量,shared memory和register和使用量;通過調整block大小,修改算法和指令以及動態分配shared memory,都可以提高shared的使用效率;register的多少是由內核程序中使用寄存器最多的時刻的用量決定的,因此減小register的使用相對困難;節約register方法是使用shared memory存儲變量;使用括號明確地表示每個變量的生存週期;使用佔用寄存器較小的等效指令代替原有指令;
與主機通信優化,儘量減少CPU與GPU間的傳輸,使用cudaMallocHost分配主機端存儲器,可以獲得更大帶寬;一次緩存較多的數據後再一次傳輸,可以獲得較高的帶寬;需要將結果顯示到屏幕的時候,直接使用與圖形學API互操作的功能;使用流和異步處理隱藏與主機的通信時間;使用zero-memory技術和Write-Combined memory提高可用帶寬;
由此我們可以看到我們的優化之路還很漫長,這個優化步驟中的每一步都對應了大量可以去做的優化,上面這個只是個概述,不過我們可以看到有一句非常重要的話:
在顯存帶寬得到完全優化前,其他優化不會產生明顯效果。
所以我們就先不要想其他的了,先完成最基本的優化,去儘可能的使用顯卡的內存帶寬~
總結:
這篇博客主要講解了怎麼去獲取核函數執行的準確時間,以及如何去根據這個時間評估CUDA程序的表現,也就是推算所謂的內存帶寬,總的來說有了這些準備,我們接下來就可以盡情去優化程序了~但是優化過程也是十分複雜與漫長的,我們首先需要解決內存帶寬問題。希望我的博客能幫助到大家~
參考資料:《深入淺出談CUDA》