前言
《并行編程》系列是學習《Intro to Parallel Programming》過程中所做的筆記記錄以及個人一些所思所想。
并行通信
并行計算需要解決的一個問題就是,如何解決線程之間的協同工作(Working together)問題。而協同工作的關鍵則是通信(Communication)。
CUDA 的通信發生在內存當中,例如,多個線程可能需要從同一個內存地址中讀取數據;也可能出現多個線程需要同時向同一個地址寫入數據;可能出現多個線程之間需要交換數據。
并行通信模式(Parallel Communication Patterns)
并行通信存在多種模式,通信模式反映了線程所執行的任務與內存之間的映射關系。這里將分別介紹五種通信模式:
- Map
- Gather
- Scatter
- Stencil
- Transpose
Map
Map: Tasks read from and write to specific data elements.
Map 模式下,每個線程將從內存的特定地址中讀取數據進行處理,然后再寫入特定的地址中,它的輸入與輸出具有嚴格的一對一的關系。
Map 模式在 GPU 中非常高效,在 CUDA 中也能很容易通過有效的方式表達。
但是 Map 比較不靈活,能處理的問題有限。
Gather
現在假設需要求取3個數據的平均值,那么在 Gather 模式下,每個線程將從內存中的三個位置讀取數據,然后將這三個數取平均,寫入指定的內存中。
這一模式可用于涉及到濾波器的一系列操作。
Scatter
Scatter: Tasks compute where to write output.
與 Gather 模式下,多個輸入一個輸出相反,Scatter 模式是一個輸入多個輸出。
另外在同時寫入多個輸出的時候將出現沖突的問題,這將在后續進行討論。
Stencil
Stencil: Tasks read input from a fixed neighborhood in an array.
常用的模板有:
-
2D von Neumann
-
2D Moore
-
3D von Neumann
看到這里,可能有人會對 Stencil 和 Gather 產生疑惑。咋看之下,兩者確實非常相似,但是 Stencil 模式中,要求每個線程都嚴格執行相同的模板,但是 Gather 模式卻沒有這個限制,因此,比如說,在 Gather 模式中就可以按線程索引的奇偶不同,給線程分配不同的操作任務。
Transpose
Transpose: Tasks re-order data elements in memory.
對于一張圖像,其數據在內存中的存儲的方式如下:
但是在某些情況下,可能需要將圖像轉置。
通常在涉及到數組運算、矩陣運算和圖像操作的時候會需要使用到 Transpose,但是 Transpose 也適用于其它數據結構。
比如定義了一個結構體 foo
,然后創建一個該結構的結構數組(AoS),如果想將該結構數組變換成數組結構(SoA),也可以通過 Transpose 實現。
總結
上圖總結了并行計算的七種計算模式,除了之前介紹的五種模式以外,還有兩種更加基礎的模式將在接下來進行介紹。
GPU
程序員眼中的 GPU
程序員在并行編程中所要做的就是,創建內核(C/C++函數)用來處理具體的任務。內核由許多線程(完整執行一段處理程序的通路)組成,圖中的線程都采用曲線繪制,其原因是,每個線程的具體通路可能不相同(即每個線程所執行的運算不相同)。
多個線程將組成線程塊,一個線程塊內的多個線程負責協同處理一項任務或者子任務。
上圖中,程序首先啟動了一個內核 foo
,等到其中所有的線程都運行完了之后,結束內核。然后又啟動了內核 bar
,可以注意到,一個內核中所具有的線程塊,以及每個線程塊中的線程數是可以自己配置的參數。
線程塊與 GPU
GPU 中包含有許多的流處理器(Streaming Multiprocessor, SM),不同的 GPU 包含有不同數量的流處理器,并且流處理器數量也是衡量 GPU 性能的一項重要指標。
一個流處理器中包含有多個簡單的處理器和內存。
當你的程序創建了內核之后,GPU 將為內核中的線程塊分配流處理器,每個線程塊被分配給一個流處理器,然后這些流處理器以并行的方式進行運行。
注意:一個流處理器上允許運行多個線程塊,但是一個線程塊只允許被分配給一個流處理器運行。
CUDA 特征
CUDA 不具備的特征
CUDA 對于內核中的線程塊要何時運行、該如何分配流處理以及有多少線程塊需要同時運行等細節沒有進行任何的控制,這些分配問題都交給 GPU 進行控制。這么做的好處有:
- 硬件將可以更加高效地執行計算
- 當一個線程塊執行完成之后,當前的流處理器馬上又可以任意執行下一個線程塊
- 更高的擴展性。因為流處理器的分配交由硬件控制,所以程序可以很好地在具有不同流處理器數量的設備上進行移植。
但是 CUDA 的這種做法也將導致一些后果:
- 對于某一線程塊將在哪個流處理器上運行無法做出任何預測
- 線程塊之間沒有通信交流。如果線程塊 x 的輸入依賴于線程塊 y 的輸出,而 y 已經完成執行并且退出,這將導致 x 的計算出現問題。這種現象稱為“dead lock”
- 線程塊中的線程不能永遠執行(比如,死循環),因為它需要在執行完成之后釋放流處理器資源,以便于其它線程塊可以使用
CUDA 具備的特征
CUDA 在程序運行的時候,能夠保證兩點:
- 同一個線程塊上的所有線程將同時在同一個流處理器中運行。
- 下一個內核中的線程塊必須等待當前內核中的所有塊運行完成之后,才能運行。
- 比如說,程序依次定義了兩個內核
foo
和bar
,bar
中的線程塊只有等到foo
中的所有線程塊都運行完之后才能開始運行。
- 比如說,程序依次定義了兩個內核
GPU 內存模型
每個線程都擁有一個局部內存(Local memory),這就好像局部變量一樣,只有對應的線程才能訪問。
然后,線程塊也有一塊對應的共享內存(Shared memory)。共享內存只能被對應線程塊內的線程進行訪問。
另外還有具有全局內存(Global memory)。不僅內核中的所有線程可以訪問它,不同內核也可以進行訪問。
前邊介紹的局部內存、共享內存和全局內存都是屬于 GPU 內部的內存。上圖展示了,CPU 的線程啟動了 GPU ,然后將主機內存(Host memory)中的數據拷貝到 GPU 的全局內存中,以便于 GPU 內核線程可以訪問這些數據。另外 GPU 內核線程也可以直接訪問主機內存,這一點將在后邊介紹。
同步
通過共享內存和全局內存,線程之間可以互相訪問彼此的計算結果,這也意味著線程間可以進行協同計算。但是這樣也存在著風險, 如果一個線程在另一個線程寫入數據之前就讀取了數據怎么辦?因此線程之間需要同步的機制,來避免這種情形出現。
事實上,同步問題是并行計算的一個最基本的問題。而解決同步問題的一個最簡單方法則是屏障(Barrier)。
Barrier: Point in the program where threads stop and wait. When all threads have reached the barrier, they can proceed.
屏障語句是 __syncthreads()
。
編程模型
現在,可以重新構建一下編程模型。我們擁有線程和線程塊,并且在線程塊內,可以創建屏障用于同步線程。事實上,如果一個程序中創建了多個內核,內核之間默認具有隱性的屏障,這使得不會出現多個內核同時運行的情況。
然后再將之前介紹的內存模型添加進來,便得到了 CUDA 。
因此,CUDA 的核心就是層級計算結構。從線程到線程塊再到內核,對應著內存空間中的局部內存、共享內存和全局內存。
編寫高效的 CUDA 程序
這里將首先從頂層的策略上介紹如何編寫高效的 CUDA 程序。
首先需要知道的是 GPU 具有非常驚人的計算能力,一個高端的 GPU 可以實現每秒超過 3 萬億次的數學運算(3 TFLOPS/s)。但是如果一個 CUDA 程序的大多數時間都花費在了等待內存的讀取或寫入操作的話,這就相當浪費計算能力。所以要編寫高效的 CUDA 程序的第一點是——最大化計算強度。
計算強度表達為每個線程計算操作時間除以每個線程在的訪存時間。所以要最大化計算強度,就可以通過最大化分子和最小化分母來實現。然而由于計算操作時間主要受具體算法的計算量限制,所以為了最大化計算強度主要從最小化訪存時間入手。
最小化訪存時間
要最小化訪存時間的一種方式就是,將訪問頻率更高的數據移動到訪問速度更快的內存中。
在之前的介紹當中已經了解了 GPU 線程可以訪問四種類型的內存,其中最快就是局部內存。
局部內存
局部變量的定義是最簡單的。
對于上圖的內核代碼,變量 f
與參數 in
都將存儲于局部內存中。
共享內存
要定義存儲于共享內存中的變量,需要在變量定義語句前加一個 __shared__
關鍵字進行修飾。定義于共享內存中的變量可以被同一個線程塊中的所有線程所訪問,其生存時間為線程塊的生存時間。
全局內存
全局的內存訪問要稍微麻煩些,但是可以通過指針的機制來實現。
這里傳入內核的參數被定義成一個指針,而這個指針恰恰指向的是全局內存區域。
然后在 CPU 的代碼部分,首先創建了一個長度為 128 的浮點數數組 h_arr
,它將存儲于主機內存中(這里通過前綴 h_
表明當前變量運行于 HOST 中),然后定義了一個指向 GPU 全局內存的指針 d_arr
,并通過 cudaMalloc
函數為 d_arr
分配全局存儲區域。
最小化訪存時間的另一個方法是使用合并全局內存訪問(Coalesce global memory accesses)。
單一線程在訪問內存時具有一個特性,就是即使該線程只需要使用到內存中的一小部分,但是程序也會從內存中讀取一段連續的內存塊。因此,如果此時恰好有其它線程也在使用該內存塊中的數據,內存塊就得到復用,從而節省再次讀取內存的時間。
所以如果多個線程同時讀取或者寫入連續的全局內存位置,此時 GPU 的效率的是最高的,而這種訪問模式被稱為合并(Coalesced)。
但是當多個線程所訪問的全局內存位置不連續或者甚至隨機的時候,此時 GPU 便無法繼續保持高效,因為很可能需要分別讀取全局內存中的多個塊,這樣就增加了訪存時間。
相關性問題(Related problem)
Related problem: lots of threads reading and writing same memory locations
當多個線程同時參與到對同一塊內存地址的讀寫操作時,將引發沖突從而導致錯誤的計算結果,這便是相關性問題。
解決該相關性問題的一個方法是使用原子內存操作(Atomic memory operations)。
原子內存操作
CUDA 提供了若干個原子內存操作函數,通過這些函數可以以原子操作的方式訪問內存,也就是某一時刻內存中的特定地址只能被單一線程所讀寫,從而避免了相關性問題。
常見的原子內存操作:
-
atomicAdd()
,原子相加 -
atomicMin()
,原子最小值 -
atomicXOR()
,原子異或 -
atomicCAS()
,比較并且交換(Compare-and-Swap)
說明:這些原子內存操作函數的實現借助了硬件來實現原子操作,這里將不進行介紹。
但是這些原子操作也存在一些局限性。
- 只支持某些特定的操作(比如,支持加、減、最小值和異或等,不支持求余、求冪等操作)和數據類型(主要支持整數)。
- 沒有順序限制。盡管使用了原子操作,但是關于線程執行順序的問題依然沒有定義。
- 由于浮點數精度問題,這將導致浮點數運算出現非關聯現象(Non-associative)。具體來說就是可能出現
(a + b) + c != a + (b + c)
,比如,當a = 1, b = 10^99, c= 10^-99
時。
- 由于浮點數精度問題,這將導致浮點數運算出現非關聯現象(Non-associative)。具體來說就是可能出現
- 串行化線程內存訪問。原子操作的實現并沒有使用什么神奇的魔法,它僅僅只是串行化了線程對同一個內存地址的訪問,所以這將減慢整體的計算速度。
線程發散
前邊已經介紹過了,要使得 CUDA 程序高效的一個關鍵點是——最大化計算強度。然后另外一個關鍵點是——避免線程發散(Thread divergence)。
線程發散指的是,比如說當內核代碼中出現條件語句時,線程運行到條件語句處,可能有些線程符合條件,而有些線程不符合條件,此時它們就會發散開,形成兩條路徑,然后在條件語句塊結束之后再次聚合到同一條路徑上。
不僅僅只有條件語句才會導致線程發散,循環語句也可能導致。
舉個不太恰當的例子,在這個內核代碼中有一個循環,循環的次數是當前線程的索引。
所以線程的執行路徑如上圖,如果以時間為橫軸繪制線程運行圖則如下圖。
由于硬件傾向于同時執行完線程,所以當線程索引小的線程完成循環之后,它還會繼續等待其它線程完成循環,直至所有線程都完成循環之后,這些線程才會繼續執行循環塊之后的代碼。因此,這里除了最后一個線程充分利用了時間進行運算以外,其它線程均無法有效利用時間。而這也就是為什么要避免線程發散的原因。
總結
本節內容小結:
- 通信模式
- gather, scatter, stencil, transpose
- GPU 硬件與編程模型
- 流處理器,線程,線程塊
- 線程同步
- 內存模型(局部,共享,全局,主機),原子操作
- 高效 GPU 編程
- 減少訪存花銷(使用更快的內存,合并全局內存訪問)
- 避免線程發散
課堂作業
本次的課堂作業是實現圖像模糊,思路相對較簡單。唯一需要注意的是邊界情況的取值。因為當 filter 的中心位于圖像邊界的時候,它的周圍像素會出現超出圖像的現象,這里需要進行判斷。
課程作業完成代碼:
https://github.com/un-knight/cs344-parallel-programming