CUDA學習日誌:線程協作與例程

接觸CUDA的時間並不長,最開始是在cuda-convnet的代碼中接觸CUDA代碼,當時確實看的比較痛苦。最近得空,在圖書館借了本《GPU高性能編程 CUDA實戰》來看看,同時也整理一些博客來加強學習效果。

Jeremy Lin

在上篇博文中,我們已經用CUDA C編寫了一個程序,知道了如何編寫在GPU上並行執行的代碼。但是對於並行編程來說,最重要的一個方面就是,並行執行的各個部分如何通過相互協作來解決問題。只有在極少數情況下,各個處理器纔不需要了解其他處理器的執行狀態而彼此獨立地計算出結果。即使對於一些成熟的算法,也仍然需要在代碼的各個並行副本之間進行通信和協作。因此,下面我們來講講不同線程之間的通信機制和並行執行線程的同步機制。


首先,我們來看一個線程塊的網格示意圖:



我們將並行線程塊的集合稱爲線程格(Grid),在上圖的Grid中總共有6個線程塊(block),每個線程塊有12個線程(thread)

硬件限制

  • 線程塊的數量限制爲不超過65 535;
  • 每個線程塊的線程數量限制爲不超過512。

解決線程塊數量的硬件限制的方法就是將線程塊分解爲線程。


共享內存

線程協作主要是通過共享內存實現的。CUDA C支持共享內存,我們可以將CUDA C的關鍵字__share__添加到變量聲明中,這將使這個變量駐留在共享內存中。

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

附加知識:

變量類型限定符

__device__

該限定符聲明位於設備上的變量。在接下來介紹的其他類型限定符中,最多隻能有一種可與__device__限定符一起使用,以更具體地指定變量屬於哪個存儲器空間。如果未出現其他限定符,則變量具有以下特徵:

    • 位於全局儲存器空間中;
    • 與應用程序具有相同的生命週期;
    • 可通過網格內的所有線程訪問,也可通過運行時庫從主機訪問。

__constant__

該限定符可選擇與__device__限定符一起使用,所聲明的變量具有以下特徵:

    • 位於固定存儲器空間中;
    • 與應用程序具有相同的生命週期;
    • 可通過網格內的所有線程訪問,也可通過運行時庫從主機訪問。

__shared__

該限定符可選擇與__device__限定符一起使用,所聲明的變量具有以下特徵:

    • 位於線程塊的共享存儲器空間中;
    • 與塊具有相同的生命週期;
    • 僅可通過塊內的所有線程訪問。

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

CUDA C編譯器對共享內存中的變量與普通變量將分別採取不同的處理方式。對於GPU上啓動的每個線程塊,CUDA C編譯器都將創建該變量的一個副本。線程塊中的每個線程都共享這塊內存,但線程卻無法看到也不能修改其他線程塊的變量副本。這就實現了一種非常好的方式,使得一個線程塊中的多個線程能夠在計算上進行通信和協作。而且,共享內存緩衝區駐留在物理GPU上,而不是駐留在GPU之外的系統內存中。因此,在訪問共享內存時的延遲要遠遠低於訪問普通緩衝區的延遲,使得共享內存像每個線程塊的高速緩存或者中間結果暫存器那樣高效。


不過,如果想要真正實現線程之間的通信,還需要一種機制來實現線程之間的同步。例如,如果線程A將一個值寫入到共享內存中,並且我們希望線程B對這個值進行一些操作,那麼只有當線程A的寫入操作完成之後,線程B才能執行它的操作。如果沒有同步,那麼將會發生競態條件(Race Condition),在這種情況下,代碼執行結果的正確性將取決於硬件的不確定性。這種同步方法就是:

__syncthreads()

這個函數調用將確保線程塊中的每個線程都執行完__syncthreads()前面的語句後,纔會執行下一條語句。


下面,我們通過一個內積運算來加深理解。

Code:

#include "cuda_runtime.h"
#include<stdlib.h>
#include<stdio.h>

#define imin(a,b) (a<b?a:b)
#define sum_square(x) (x*(x+1)*(2*x+1)/6)

const int N = 33*1024;

const int threadsPerBlock = 256;
const int blocksPerGrid = 
	            imin(32, (N+threadsPerBlock-1)/threadsPerBlock);

__global__ void dot_Jere(float *a, float *b, float *c)
{
	__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;

	// 對線程塊中的線程進行同步
	__syncthreads();

	// 對於歸約運算來說,以下代碼要求threadPerBlock必須是2的指數
	int i = blockDim.x / 2;
	while (i != 0)
	{
		if (cacheIndex < i)
		{
			cache[cacheIndex] += cache[cacheIndex + i];
		}
		__syncthreads();
		i /= 2;
	}
	if (cacheIndex == 0)
	{
		c[blockIdx.x] = cache[0];
	}
}

int main()
{
	float *a, *b, c, *partial_c;
	float *dev_a, *dev_b, *dev_partial_c;

	a = (float*)malloc(N*sizeof(float));
	b = (float*)malloc(N*sizeof(float));
	partial_c = (float*)malloc(blocksPerGrid*sizeof(float));

	cudaMalloc((void**)&dev_a, N*sizeof(float));
	cudaMalloc((void**)&dev_b, N*sizeof(float));
	cudaMalloc((void**)&dev_partial_c, blocksPerGrid*sizeof(float));

	for (int i = 0; i < N; i++)
	{
		a[i] = i;
		b[i] = 2*i;
	}

	cudaMemcpy(dev_a, a, N*sizeof(float), cudaMemcpyHostToDevice);
	cudaMemcpy(dev_b, b, N*sizeof(float), cudaMemcpyHostToDevice);

	dot_Jere<<<blocksPerGrid, threadsPerBlock>>>(dev_a, dev_b, dev_partial_c);

	cudaMemcpy(partial_c, dev_partial_c, blocksPerGrid*sizeof(float), cudaMemcpyDeviceToHost);

	c = 0;
	for (int i = 0; i < blocksPerGrid; i++)
	{
		c += partial_c[i];
	}

	printf("Does GPU value %.6g = %.6g?\n", c, 2*sum_square((float)(N-1)));

	cudaFree(dev_a);
	cudaFree(dev_b);
	cudaFree(dev_partial_c);

	free(a);
	free(b);
	free(partial_c);

	return 0;
}
結果


首先,我們來關注一下核函數dot_Jere()。在覈函數中,我們通過下面的語句:

       __shared__ float cache[threadsPerBlock];
定義一個共享內存 cache[ ],這個共享內存用來保存每個線程計算的乘積值。因爲對於共享變量,編譯器都將爲每個線程塊生成共享變量的一個副本,因此我們只需根據線程塊中線程的數量來分配內存,即將它的大小設置爲threadsPerBlock,這樣就可以使線程塊中的每個線程都能將它計算的臨時結果保存在某個位置上。

在分配了共享內存後,開始計算數據索引:

       int tid = threadIdx.x + blockIdx.x * blockDim.x;
       int cacheIndex = threadIdx.x;
這個tid每個線程都不一樣,在GPU中線程並行處理,tid表示着相應線程的ID。由上篇博文可知,blockIdx.x表示的是當前線程所在線程塊在grid的x方向的索引,而blockDim.x表示線程塊的大小。在上面的這個例子中,blockDim.x=256,blockIdx.x和threadIdx.x是變動的。

然後,在while循環中對tid有一個遞增:

	float temp = 0;
	while (tid < N)
	{
		temp += a[tid] * b[tid];
		tid  += blockDim.x * gridDim.x;
	}
一開始,我其實對這個tid的遞增值有點不太瞭解,在這個例子中gridDim.x=32,即tid每次遞增值爲256*32=8192,後來才知道,其實這個遞增值和多CPU的並行程序的遞增值是一個道理,在多CPU中遞增值是CPU的個數。而在這裏,這個遞增值表示的是當前全部運行的線程數。因爲內積的向量長度是33*1024=33792,大於當前運行的線程數,爲了能夠計算全部的內積,我們就引入while循環,多次運行,直到計算完全部向量對應位置的乘積。

當算法執行到現在後,我們需要對cache內的臨時乘積值進行求和,但是這是一種危險的操作,因爲我們需要確定所有對共享數組cache[ ]的寫入操作在讀取cache[]之前完成了。而這正是

	// 對線程塊中的線程進行同步
	__syncthreads();
完成的功能。這個函數調用將確保線程塊中的每個線程都執行完__syncthreads()前面的語句後,纔會執行下一條語句。因此,在__syncthreads()函數下面的歸約運算是在所有線程塊內的線程都執行完cache寫入操作後進行的。

歸約運算如下:

	// 對於歸約運算來說,以下代碼要求threadPerBlock必須是2的指數
	int i = blockDim.x / 2;
	while (i != 0)
	{
		if (cacheIndex < i)
		{
			cache[cacheIndex] += cache[cacheIndex + i];
		}
		__syncthreads();
		i /= 2;
	}
	if (cacheIndex == 0)
	{
		c[blockIdx.x] = cache[0];
	}
這個歸約運算的邏輯比較簡單,就是每個線程將cache[]中的兩個值相加起來,然後將結果保存回cache[]。由於每個線程都將兩個值合併爲一個值,那麼在完成這個步驟後,得到的結果就是計算開始時數值數量的一半。在下一個步驟中,我們對這一半數值執行相同的操作。
當然,這裏面也涉及到了同步問題。在對cache求和的迭代中,下一輪計算的啓動必須確保上一輪cache的計算已經完結。因此,我們需要在

if (cacheIndex < i)

{

cache[cacheIndex] += cache[cacheIndex + i];

}

__syncthreads();

i /= 2;

中加入__syncthreads()。

現在,我們來考慮,如果將__syncthreads()放入if{  }內會有什麼結果?在上面的代碼中,我們只有當cacheIndex小於 i 時才需要更新共享內存cache[ ]。由於cacheIndex實際上就等於threadIdx.x,因而這意味着只有一部分的線程會更新共享內存。那麼如果將__syncthreadx()放入if{  }內,即意味着只等待那些需要寫入共享內存的線程,那是不是就能獲得性能提升?


No,這隻會讓GPU停止響應。


Why!我們知道,線程塊中的每個線程依次通過代碼,每次一行。每個線程執行相同的指令,但對不同的數據進行計算。然而,當每個線程執行的指令放在一個條件語句中,這將意味着並不是每個線程都會執行這個指令,這種情況稱爲線程發散(Thread Divergence),在正常的環境下,發散的分支只會使得某些線程處於空閒狀態,而其他線程將執行分支中的代碼。但在__syncthread()情況中,線程發散的後果有點糟糕。CUDA架構將確保,除非線程塊中的每個線程都執行了__syncthread(),否則沒有任何線程能執行__syncthread()之後的指令。而當__syncthread()位於發散分支中,那麼一些線程將永遠都無法執行__syncthread()。因此,由於要確保在每個線程執行完__syncthread()後才能執行後面的語句,所以硬件將使這些線程保持等待。

最後,main()函數這一塊的cuda語法上一篇博文已經講了,它的邏輯也比較簡單,我就不再多說了。


更多資源請 關注博客:LinJM-機器視覺 微博:林建民-機器視覺



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