前言
《并行編程》系列是學習《Intro to Parallel Programming》過程中所做的筆記記錄以及個人一些所思所想。
GPU 與 CPU
衡量一個高性能處理器的時候,采用兩個指標。
- 執行時間(Latency):執行一項任務所花時間,采用時間單位。
- 吞吐量(Throughput):單位時間完成的任務量。
而非常遺憾的是,這兩項指標并不總是一致的,它們通常是矛盾的。比如說:
A地和B地相距4500 KM,從A到B可以有兩種選擇。一種方法是開跑車,車上乘坐2個人,以200 KM/H的速度開到B地;另一種方法是乘坐客車,車上乘坐著40個人,以50 KM/H的速度開到B地。
方案 | Latency(hour) | Throughput(people/hour) |
---|---|---|
開跑車 | 4500 / 200 = 22.5 | 2 / 22.5 = 0.0889 |
客車 | 4500 / 50 = 90 | 40 / 90 = 0.444 |
雖然這個例子并不是很合理,但是它展示了 Latency 和 Throughput 的計算方式。
傳統的 CPU 設計就是嘗試去最優化執行時間,使其在每一項任務上的處理時間都能夠達到最優。而 GPU 的設計與 CPU 不同,它的目標最大化吞吐量。因為在計算機圖形學中,我們更加關心每秒能處理的像素數量,而不是每個像素需要花多少時間處理,甚至只要每秒能處理的像素數量只要能增加,即便單個像素處理的時間需要增加兩倍也是可以接受的。
GPU 設計原則
- GPU 有許多簡單的計算單元,它們組合在一塊可以執行大量的計算,GPU 通常會犧牲更多的控制力來換取更強大的計算能力。
- GPU 采用顯式并行編程模型(課程一大重點),即編程的時候就是按多處理器的思路進行,而不是假設只有單個處理器,然后將程序交給編譯器映射到多個處理上。
- GPU 設計的優化目標是 Throughput 而不是 Latency ,它可以接受單個任務執行時間延長,只要每秒能處理的任務總數能增加,也因此 GPU 適用于以 Throughput 為最重要衡量指標的應用程序中。
CUDA 編程模型
異構型計算機擁有兩種不同的處理器,它們是 CPU 和 GPU。如果只是簡單地寫一個 C 程序,那么它只使用到了 CPU,而如果想要使用 GPU 就要借助 CUDA。CUDA 編程模型允許我們通過一個程序同時對兩個處理器進行編程,另外雖然 CUDA 支持多門編程語言,但是本課程中主要使用 C 語言。
CUDA 中普通 C 語言部分的程序會運行在 CPU (也稱為"HOST")中,而另外一部分將在 GPU (相對于"HOST"被稱為"DEVICE")中運行。然后 CUDA 編譯器會將 CPU 部分的代碼和 GPU 部分的代碼分開編譯,為每個處理器生成各自的編譯結果。
CUDA 將 GPU 當做 CPU 的協處理器(co-processor)來對待,并且假設 HOST 和 DEVICE 各自擁有獨立的內存用于存儲數據,GPU 通常采用高性能的內存塊來作為內存。當談到 GPU 和 CPU 的關系時,CPU 則處于主導地位。CPU 負責運行主程序,并向 GPU 發送控操作指令。
操作內容包含有:
- 將數據從 CPU 內存中移動到 GPU 內存中。
- 將數據從 GPU 內存中移動到 CPU 內存中。
- 向 GPU 中的內存申請空間。
- 調用 GPU 中的程序,以并行的方式進行運算,這些程序也稱為內核,所以 HOST 能夠啟動 DEIVCE 中的內核。
操作1和2涉及的命令是 cudaMemcpy
,操作3涉及的指令是 cudaMalloc
。
CUDA 程序流程
一個典型的 CUDA 程序流程是:
- CPU 為 GPU 申請存儲內存空間(cudaMalloc)。
- CPU 將輸入數據復制到 GPU 內存中(cudaMemcpy)。
- CPU 啟動 GPU 內核處理數據(Kernel launch)。
- CPU 將結果從 GPU 內存中復制回來(cudaMemcpy)。
容易發現,步驟2與4屬于數據傳輸的過程。在程序中我們通常都希望能盡量減少數據傳輸所消耗的時間,而使更多時間花在計算上。所以對于 I/O 密集型的程序,便不適用于 CUDA 或者 GPU 編程。事實上,成功的 GPU 程序在計算時間與傳輸通信時間的比率上通常具有較高的值。
GPU 的優點
GPU 擅長處理以下兩個事項:
- 高效地啟動大量的線程
- 并行地運行大量的線程
舉個例子,比如說要對一個長64的數組進行求平方運算。
CPU 的做法
首先是只運行于 CPU 中的做法。
程序中對數組進行遍歷,然后依次對每一個元素都執行相同的乘積操作。這些操作是在一個線程中串行執行的,所以該線程將會執行循環64次。
注:此處的線程指的是執行完整代碼的一條獨立路徑。
GPU 的做法
理論知識
之前介紹過,CUDA 的代碼需要分成兩部分,一部分運行于 CPU,一部分運行于 GPU。GPU 部分所要實現的邏輯很簡單,這里是使得輸出等于輸入的平方,但是這部分并沒有說明并行運算的程度(或者是線程數量)。事實上,指明并行運算程度的任務將交給 CPU 進行。所以 CPU 需要為 GPU 分配內存空間,再將數據復制到 GPU 內存中,然后再啟動 GPU 計算平方數的內核(此處聲明了64個線程)。
同時創建64個線程用于執行平方運算的好處是,每個線程都有一個唯一的線程索引,所有就可以將數組的第 n 個元素分配給第 n 個線程進行處理。
代碼實踐
定義 GPU 內核代碼。
__global__ void square(float *d_out, float *d_in){
// 獲取線程索引,將線程索引也作為數組的元素索引
int idx = threadIdx.x;
float f = d_in[idx];
d_out[idx] = f * f;
}
然后通過內核啟動語句配置并啟動內核。
...
const int ARRAY_SIZE = 64;
...
square<<<1, ARRAY_SIZE>>>(d_out, d_in);
...
所以,這里啟動了一個含有64個線程的塊,每個線程各自負責計算數組中的一個元素。
配置啟動內核的參數
啟動內核的語句形式如:
kernel_function<<<blocks_number, thread_per_block>>>(d_out, d_in)
kernel_function
是自定義的內核函數名稱。<<<....>>>
則是 CUDA 定義的特殊符號,其中接受兩個參數,分別用于啟動的塊的數量以及每個塊的線程數。
例如,SQUARE<<<1, 64>>>(d_out, d_in)
語句啟動了一個內核,并指定了內核具有一個塊,每個塊存在64個線程。
如果需要使用到更多的計算資源,那么便可以通過 <<<...>>>
的參數進行配置。其中關于內核的配置具有兩個特點需要知道:
- 一個內核可以同時運行多個塊。
- 每個塊可以運行多個線程,不過線程具有上限,通常而言在較舊的 GPU 中這個上限為 512,在較新的 GPU 中上限是 1024。
所以當我們想要啟動128個線程計算128個數的平方時,代碼可以改為 SQUARE<<<1, 128>>>(...)
。那么如果想要想要啟動1280個線程呢?這時便有多種策略。比如 SQUARE<<<5, 256>>>
或者 SQUARE<<<10, 128>>>
,但是需要注意不能寫成 SQUARE<<<1, 1280>>>
,因為這樣超出了最大線程限制。
但是當前的塊和線程都是一維的,而如果我們需要處理二維或者是三維結構的數據,這樣顯然就不方便了,所以 CUDA 也支持二維和三維的塊與線程布局方式。
借助 dim3(x, y, z)
函數可以創建指定維度的布局方式。默認情況下,每個維度的值都為1,所以 dim3(w, 1, 1) == dim3(w) == w
。
多維內核
啟動內核的最一般形式是:
kernel<<<dim3(bx, by, dz), dim3(tx, ty, tz), shmem>>>(...)
dim3(bx, by, bz)
指定了塊的維度。dim3(tx, ty, tz)
指定了每個塊的線程維度。shmem
這個參數不太常用,默認為 0,它以字節為單位指定了每個線程塊分配的共享內存量,關于該參數的具體使用方法,后續會介紹到。
上圖展示了一個二維的布局,其中 thread 是最基本的單位,若干個 thread 組成了一個 block,而若干個 block 組合成一個 grid。CUDA 還提供了多個屬性用于實現獲取線程索引、塊索引和塊大小等。
-
threadIdx
:獲取塊內的線程索引,最多具有三個維度x, y, z
。 -
blockDim
:獲取塊大小,最多具有三個維度x, y, z
。 -
blockIdx
:獲取塊索引,與線程索引一樣,最多具有三個維度。 -
gridDim
:獲取 grid 大小,也是最多具有三個維度x, y, z
。
注意事項:這邊有一個在編程處理圖像的時候經常容易犯錯的坑。在處理圖像數據的時候,通常會將
grid of blocks
定義為與圖像大小一致,然后每個block
中的thread
數定義為1
。 這樣對于一個分辨率為n*m
的圖像,則使用了n*m
個塊,每個塊中只有一個線程負責處理當前像素值。但是需要注意的是,在定義gridSize
的時候需要定義成dim3(m, n)
。但是在編程時出于習慣,我們很可能會寫成dim3(n, m)
,然而這樣是錯誤的 。這是因為dim3()
接受的三個參數依次對應了x, y, z
的取值范圍,所以在使用圖像的寬高參數指定dim3
參數的時候應該是先制定寬度再指定高度。否則處理之后的圖像很可能出現有一半是黑色的情況。