CUDA演算法效率提升關鍵點概述
文章目錄
前言
CUDA演算法的效率總的來說,由存取效率和計算效率兩類決定,一個好的CUDA演算法必定會讓兩類效率都達到最優化,而其中任一類效率成為瓶頸,都會讓演算法的效能大打折扣。
存取效率
存取效率即GPU和視訊記憶體之間的資料交換效率,在上一篇部落格中,我們介紹了GPU的儲存結構,對GPU的各類儲存介質有了一個初步的瞭解,其中全域性記憶體具有最大的容量和最慢的訪問效率,且對是否對齊和連續訪問很敏感,這也是我們在前面推薦進行記憶體對齊的原因;共享記憶體訪問速度快,且對是否對齊和連續訪問不敏感,但是對Bank Conflict非常敏感,Bank Conflict的影響本文在後面會詳細介紹,靈活使用共享記憶體會獲得很高的存取效率,也是眾多優秀CUDA演算法替代全域性記憶體的不二選擇;暫存器具有最快的訪問速度,只對每個執行緒可見,執行緒內多使用暫存器是良好的習慣,但是需要注意一個SM內的暫存器數量有限,當單個執行緒的暫存器數量超過限制,會影響執行緒的實際佔用率,從而影響加速效果;其他儲存介質如紋理記憶體常量記憶體等較共享記憶體和暫存器,在速度並沒有太大優勢,但是其具有的一些特殊特性使其有時候在特定的情況下被使用以獲得更高的效率,比如紋理記憶體帶有的紋理快取具備硬體插值特性,可以實現最鄰近插值和線性插值,且針對二維空間的區域性性訪問進行了優化,所以通過紋理快取訪問二維矩陣的鄰域會獲得加速,這個特性使得紋理記憶體在一些影象處理演算法中具有一定的優勢。
計算效率
計算效率就是指除去記憶體交換過程以外的演算法計算部分的效率,GPU中主要有三類基礎運算:整數運算、單精度浮點數運算和雙精度浮點數運算,其中單精度浮點運算速度最快而雙精度浮點運算速度最慢,FLOPS(floating-point operations per second, 每秒執行的浮點運算次數)也是衡量GPU運算效能的關鍵指標,如果一個程式內只有單精度浮點數運算,將發揮硬體的最大功效,因此應該儘量多使用單精度浮點數運算,而避免使用雙精度浮點運算。實際上,GPU的單核運算效能遠不及CPU,因為單核運算速度取決於核心頻率,而GPU的核心頻率遠不及CPU,目前主流的英特爾第七代桌面級CPU的核心頻率都在3.5~4GHz左右,並支援超頻,而NVIDIA在2016年釋出的號稱地球最快顯示卡NVIDIA TITAN X的核心頻率也不過是1.4GHz,和CPU差距依然較大。但是GPU的核心數是CPU所完全無法比擬的,其平行計算效率一般情況下遠遠大於CPU的單核甚至多核計算效率,核心數的優勢讓GPU的浮點運算效率遠高於CPU,所以對GPU程式來說,讓GPU利用率達到100%,讓每個執行緒都處於活動狀態,對提高程式的效能有著至關重要的作用。此外,在提高CPU利用率的同時,還必須關注另一個因素:分支(if、else、for、while、do、switch等語句)對計算效率的影響,由於硬體每次只能為一個執行緒束獲取一條指令,若執行緒束中一半的執行緒要執行條件為真的程式碼段,一半執行緒要執行條件為假的程式碼段,這時有一半的執行緒會被阻塞,而另一半執行緒會執行滿足條件的那個分支,如此,硬體的利用率只達到了50%,大大影響並行效能。
效能優化要點
在基於CUDA優化演算法設計過程中,除了使演算法能夠執行得到正確結果之外,更重要的是演算法效率能達到理想的水平,而從上面的描述來看,要發揮CUDA演算法的效能優勢必須考慮全面,留意一些效能陷阱,採用合理的演算法設計方案。一般來說,優化一個CUDA演算法的效能需要專注三個方面,按照重要性排序為:
展現足夠的並行性
為了最大程度的利用GPU多執行緒的優勢,應該在GPU上安排儘量多的併發任務,以使指令頻寬和記憶體頻寬都達到飽和,在一個SM(流處理器)中保證有足夠多的併發執行緒束,這不單單是要為GPU每個執行緒都安排任務,還需要檢查SM資源佔用率的限制因素(共享記憶體、暫存器以及計算週期等)以找到達到最佳效能的平衡點,因為GPU的記憶體資源是有限的,為每個執行緒分配的資源也是有限的,如果演算法設計者在一個執行緒中使用了過多的共享記憶體或者暫存器,那麼併發執行的執行緒數必然會減少,使得SM資源的實際佔用率小於理論佔用率;另一方面可以為每個執行緒/執行緒束分配更多獨立的工作。
優化記憶體訪問
大部分GPU演算法的效能瓶頸都在於記憶體訪問速度,由於視訊記憶體訪問的高延遲和低效率,記憶體訪問模式對核心效能有著顯著的影響。記憶體訪問優化的目標是最大限度地提高記憶體頻寬的利用率,重點在於優化記憶體訪問模式和保證充足的併發記憶體訪問。在GPU中,執行緒是以執行緒束為單位執行的,一個執行緒束包含32個執行緒,所以一方面我們最好將併發執行緒數設定為32的倍數,另一方面當一個執行緒束髮送記憶體請求(載入或儲存)時,都是32個執行緒一起訪問一個裝置記憶體塊,因此對於全域性記憶體來說,最好的訪問模式就是對齊和合並訪問,對齊記憶體訪問要求所需的裝置記憶體的第一個地址是32位元組的倍數,合併記憶體訪問指的是通過執行緒束中的32個執行緒來訪問一個連續的記憶體塊。這表示在演算法設計中一定要儘量為一個執行緒束的執行緒分配連續的記憶體塊,比如0~31號執行緒(同一個執行緒束)訪問影像中連續儲存的31個畫素,而不是訪問不連續的31個畫素,由於合併訪問對記憶體訪問效率影像非常大,所以我們在演算法設計中建議嚴格遵守該要求。
共享記憶體因為是片上記憶體,所以比本地和裝置的全域性記憶體具有更高的頻寬和更低的延遲,使用共享記憶體有兩個主要原因:①減少全域性記憶體的訪問次數;②通過重新安排資料佈局避免未合併的全域性記憶體的訪問。在物理角度上,共享記憶體通過一種線性方式排列,通過32個儲存體(bank)進行訪問。Fermi和Kepler架構各有不同的預設儲存體模式:4位元組儲存體模式和8位元組儲存體模式,共享記憶體地址到儲存體的對映關係隨著訪問模式的不同而不同,當執行緒束中的多個執行緒在同一儲存體中訪問不同位元組時,會發生儲存體衝突(Bank Conflict),由於共享記憶體重複請求,所以多路儲存體衝突可能要付出很大的代價,應該儘量避免儲存體衝突,每個儲存體(Bank)每個週期只能指向一次操作(一個32bit 的整數或者一個單精度的浮點型資料),一次讀或者一次寫,也就是說每個儲存體(Bank)的頻寬為每週期 32bit,比如一個32*32的二維單精度浮點陣列,每一列屬於一個Bank,如果一個執行緒束裡的不同執行緒訪問該數組裡同一列的不同資料,則會發生Bank Conflict,解決或減少儲存體衝突的一個非常簡單有效的方法是填充陣列,在合適的位置新增填充字,可以使其跨不同儲存體進行訪問,從而減少延遲並提高了吞吐量。
暫存器是GPU上最快的儲存機制,但是數量非常有限,如果一個執行緒使用過多的暫存器,會導致SM能夠同時啟動的執行緒數變少,實際上很多情況下暫存器都成為了資源佔用率無法達到100%的主要限制條件,所以往往要注意監控暫存器的數量,當數量沒有超標時,適當的增加數量可以提升效能,而一旦數量超標,最好還是將暫存器的數量減少以保證100%的資源佔用率,這可以通過重新排列程式碼的順序來實現,比如當變數的賦值和使用靠的很近時,編譯器會重複使用少量暫存器以達到減少暫存器數量的目的。
優化指令執行
GPU屬於單指令多資料流架構,每個執行緒束中的所有執行緒在每一步都執行相同的指令,如果每個指令都能夠得到對結果有效的運算值,就能夠避免執行緒的浪費,而如果由於條件分支造成執行緒束內有不同的控制流路徑,則執行緒執行可能出現分化,這時執行緒束必須順序執行每個分支路徑,並禁用不在此執行路徑上的執行緒,而如果演算法的大部分時間都耗在分支程式碼中,必然顯著的影響核心效能,所以儘量避免使用分支是很關鍵的,或者儘量使分支有非常大的概率執行對結果有效的哪一個路徑。