并行編程——Lesson 1:GPU 編程模型

前言

《并行編程》系列是學習《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 發送控操作指令。

操作內容包含有:

  1. 將數據從 CPU 內存中移動到 GPU 內存中。
  2. 將數據從 GPU 內存中移動到 CPU 內存中。
  3. 向 GPU 中的內存申請空間。
  4. 調用 GPU 中的程序,以并行的方式進行運算,這些程序也稱為內核,所以 HOST 能夠啟動 DEIVCE 中的內核。

操作1和2涉及的命令是 cudaMemcpy ,操作3涉及的指令是 cudaMalloc

CUDA 程序流程

一個典型的 CUDA 程序流程是:

  1. CPU 為 GPU 申請存儲內存空間(cudaMalloc)。
  2. CPU 將輸入數據復制到 GPU 內存中(cudaMemcpy)。
  3. CPU 啟動 GPU 內核處理數據(Kernel launch)。
  4. CPU 將結果從 GPU 內存中復制回來(cudaMemcpy)。

容易發現,步驟2與4屬于數據傳輸的過程。在程序中我們通常都希望能盡量減少數據傳輸所消耗的時間,而使更多時間花在計算上。所以對于 I/O 密集型的程序,便不適用于 CUDA 或者 GPU 編程。事實上,成功的 GPU 程序在計算時間與傳輸通信時間的比率上通常具有較高的值。

GPU 的優點

GPU 擅長處理以下兩個事項:

  1. 高效地啟動大量的線程
  2. 并行地運行大量的線程

舉個例子,比如說要對一個長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個線程。
如果需要使用到更多的計算資源,那么便可以通過 <<<...>>> 的參數進行配置。其中關于內核的配置具有兩個特點需要知道:

  1. 一個內核可以同時運行多個塊。
  2. 每個塊可以運行多個線程,不過線程具有上限,通常而言在較舊的 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 參數的時候應該是先制定寬度再指定高度。否則處理之后的圖像很可能出現有一半是黑色的情況。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容