天天看點

《OpenACC并行程式設計實戰》—— 1.3 CUDA C

本節書摘來自華章出版社《openacc并行程式設計實戰》一 書中的第1章,第1.3節,作者何滄平,更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。

本節簡要介紹cuda c程式設計的相關概念,使讀者能夠看懂openacc編譯過程中出現的cuda内置變量,了解并行線程的組織方式。如果讀者已有cuda程式設計經驗,請跳過。

cpu用得好好的,為什麼要費心費力地改寫程式去到gpu上運作呢?隻有一個理由:跑得更快。小幅的性能提升吸引力不夠,必須有大幅提升才值得采購新裝置、學習新工具、設計新算法。從圖1.19可以看出,在雙精度浮點峰值和記憶體帶寬這兩個關鍵名額上,gpu的性能都達到同時期主力型号cpu的5~7倍。如果利用得當,可以預期獲得5~7的性能提升。以前隻在cpu上運作,計算方法的數學理論和程式代碼實作已經疊代發展多年,花很大力氣才能提速10%~20%,提速50%已經很厲害了。簡單粗暴地更換硬體裝置就能立刻提速幾倍,全世界的科學家、工程師一擁而上,gpu加速的應用遍地開花。注意,評價gpu應用性能的時候,至少要和2顆中高端cpu相對,并且兩種代碼都優化到最好。任何超過硬體潛能的加速結果都是有問題的。

那麼問題來了。gpu的晶片面積與cpu差不多,價格也接近,為什麼性能這麼強悍呢?圖1.20是cpu和gpu晶片的組成示意圖,左邊是一個單核超标量cpu,4個算術邏輯單元(alu)承擔着全部計算任務,卻隻占用一小部分晶片面積。“控制”是指分支預測、亂序執行等功能,占用晶片面積大而且很費電。伺服器cpu通常有三級緩存,占用的晶片面積最大,有的型号甚至高達70%。alu、控制、緩存都在cpu内部,大量記憶體條插在主機闆上,與cpu通過排線相連。gpu中絕大部分晶片面積都是計算核心(4行緊挨着的小方塊,每行12個),帶陰影的水準小塊是控制單元,控制單元上面的水準條是緩存。

通用cpu對追蹤連結清單這樣擁有複雜邏輯控制的程式運作得很好,但大規模的科學與工程計算程式的流程控制都比較簡單,cpu的長處難以施展。為了解釋gpu如何獲得極高的性能,需要先了解一下cpu中的控制、緩存、多線程的作用。

alu承擔最終的計算工作,越多越好。“控制”的目标是預取到正确的指令和資料以保證流水線不中斷,挖掘指令流裡的并行度,讓盡量多的部件都在忙碌工作,進而提高性能。緩存的作用是為了填補cpu頻率與記憶體條頻率的差距、減小cpu與記憶體條之間資料延時。目前中高端cpu的頻率在2.0~3.2ghz,而記憶體條的頻率還處于1600mhz、1866mhz、2133mhz,記憶體條供應、承接資料的速度趕不上cpu處理資料的速度。由于alu到主機闆上記憶體條的路徑較長,延時高,而如果需要的資料已經在緩存中,那麼就能有效降低延時,提高資料處理速度。緩存沒有命中怎麼辦?隻能到記憶體條上取,延時高。為了進一步降低延時,英特爾cpu有超線程功能,開啟後,一個cpu實體核心就變成了兩個邏輯核心,兩個邏輯核心分時間片輪流占用實體核心資源。當然了,按時間片切換是有代價的:換出時要保留正在運作的程式的現場,換入時再恢複現場以便接着上次繼續運作。在緩存命中率比較低的情況下,超線程功能能夠提高性能。

gpu天生是為并行計算設計的:處理圖像的大量像素,像素之間互相獨立,可以同時計算,而且沒有複雜的流程跳轉控制。正如圖1.19所示,gpu的大部分晶片面積都是計算核心,緩存和控制單元很小,那麼它是怎麼解決分支預測、亂序執行、資料供應速度、存取資料延時這些問題的呢?

gpu的設計目标是大批量的簡單計算,沒有複雜的跳轉,是以直接取消分支預測、亂序執行等進階功能。更進一步,多個計算核心(例如32個)共用一個控制單元再次削減控制單元占用的晶片面積。這樣做的效果就是:發射一條指令,例如加法,32個計算核心步調一緻地做加法,隻是每個計算核心操作不同的資料。如果隻讓第1個計算核心做加法,那麼在第1個計算核心做加法運算的時候,剩餘的計算核心空閑等待。這種情形下資源浪費,性能低下,要盡量避免。讓大量計算核心空轉的應用程式不适合gpu,用cpu計算性能更好。

計算核心與顯存之間的頻率差異如何填補?特别簡單,降低計算核心的頻率。考慮到晶片功耗與頻率的平方近似成正比,降低頻率不但能解決資料供應速度問題,而且能降低gpu的功耗,一舉兩得。從表1.1可以看出gpu産品的頻率在562~875mhz,遠低于主力cpu的2.0ghz~3.2ghz。

最重要是延時,gpu的緩存那麼小,怎麼解決通路顯存的巨大延時呢?答案是多線程,每個計算核心分攤10個以上的線程。執行每條指令之前都要從就緒隊列中挑選出一組線程,每組線程每次隻執行一條指令,執行完畢立即到後面排隊。如果恰巧碰上了延時較多的訪存操作,那麼該線程進入等待隊列,訪存操作完成後再轉入就緒隊列。隻要線程足夠多,計算核心總是在忙碌,隐藏了訪存延時。有人立刻會問,這麼頻繁地切換線程、儲存現場、恢複現場也需消耗不少時間吧,會不會得不償失呢?實際上gpu線程切換瞬間完成,這是因為每個線程都有一份獨占資源(例如寄存器),不需要儲存、恢複現場,線程切換隻是計算核心使用權的轉移。

一塊gpu上有幾千個核心,每個核心都能運作10個以上線程,可見線程數量龐大,需要按照一定結構組織起來,友善使用和管理。所有的線程合在一起稱為一個網格(grid),網格再剖分成線程塊(block),線程塊包含若幹線程。圖1.21中的線程按照二維形式組織,網格包含2×3個線程塊,每個線程塊又包含3×4個線程。實際上,線程還可以按照一維、三維形式組織。

既然線程能夠以不同的形式組織起來,那麼每個線程都要有一個唯一的編号。為此cuda c引入了一個新的資料類型dim3。dim3相當于一個結構體,3個成員分别為:

        unsigned int x;

        unsigned int y;

        unsigned int z;

dim3類型變量的3個成員的預設值都是1。網格尺寸用内置變量griddim表示,griddim.x、griddim.y、griddim.z分别表示x、y、z方向上的線程塊數量;網格中每個線程塊的編号用内置變量blockidx表示,blockidx.x、blockidx.y、blockidx.z分别表示目前線程塊在x、y、z方向上的編号,從0開始編号;線程塊的尺寸用内置變量blockdim表示,blockdim.x、blockdim.y、blockdim.z分别表示目前線程塊在x、y、z方向上擁有的線程數量;任意一個線程塊内的線程編号用内置變量threadidx來表示,threadidx.x、threadidx.y、threadidx.z分别表示目前線程在x、y、z方向上的編号,從0開始編号。以圖1.21中的網格、線程塊(1,1)、線程塊(1,2)為例,這些内置變量的值如表1.3:

在gpu程式設計話語體系裡,稱cpu為主機,稱gpu為裝置。圖1.22示範了cuda c程式的執行過程:在帶有裝置的計算機上,與c語言程式一樣,從主機開始執行,主機上執行串行代碼,并為裝置上的并行計算做準備,包括資料初始化、開辟裝置記憶體、将資料複制到裝置記憶體中。準備工作完成之後,在主機上以特殊形式調用一個在裝置上執行的函數(稱為核心,調用時比c函數多了一對三尖号),然後裝置執行核心中的并行代碼。核心代碼執行完以後,控制權交還主機,主機從裝置上取回核心的并行計算結果,程式繼續向下執行。圖1.22中隻畫出一個核心,實際上一個cuda程式可以包含多個核心。

下面以實際例子示範cuda c代碼的編寫方法和執行過程。兩個長度為n的向量a和b對應元素相加,将結果存入向量c。從圖1.23可以看出,n個加法操作之間沒有依賴關系,可以并行計算。實作代碼見例1.1。

例1.1中第10行定義3個主機向量a、b、c,第11行定義3個指針用于存放裝置向量,第12~14行為3個裝置向量配置設定裝置記憶體空間。第15~19行的循環為主機向量a、b賦初值,第20~21行使用内置函數cudamemcpy将主機向量a和b中的元素值複制到裝置向量a_d和b_d之中,即從主機記憶體複制到裝置記憶體。第22行定義了2個dim3變量block和grid。block用于指定每個線程塊的形狀:一維,x方向長度為32;grid用于指定線程網格的形狀:一維,x方向的尺寸用block.x和n計算出來,以适應n不能被32整除的情形。至此,準備工作完畢。

第24行從主機調用核心add,三尖号<<<>>>裡的參數稱為執行配置,第1個參數指定線程網格的形狀,第2個參數指定線程塊的形狀,緊跟着的圓括号裡面是和c函數一樣的實參。執行配置參數要求啟動2個線程塊共64個線程來執行核心add。核心add在裝置上運作,它将裝置向量a_d和b_d并行相加,結果存入裝置向量c_d。核心add的定義在第4~7行,第4行上的修飾符__global__表示該函數需要在主機上調用且在裝置上執行。第5行計算線程的全局編号,n為64,每個線程塊有32個線程,是以網格中有2個線程塊。在每個線程塊中,線程的本地編号threadidx.x分别是0,1,2,…,31,blockdim.x的值為32,是以執行核心的64個線程的tid分别為0,1,2,...,63,見圖1.24。第6行也被64個線程同時執行,每個線程執行1次加法,共同完成兩個向量的對應相加。

第25行将裝置上的計算結果複制回主機記憶體,即把向量c_d的元素值複制到向量c中。第27~28行輸出計算結果以便檢驗正确性,可以預見是64行1+2=3。第29~31行釋放裝置記憶體。

在已經部署cuda c開發工具的linux環境上編譯、運作:

        $ nvcc -o addvec.exe addvec.cu

        $ ./addvec.exe

         1 + 2 = 3

    【共64行,後面省略】

從1.1.1節的硬體架構圖中已經看到,gpu中有多種記憶體:處于晶片外部的全局記憶體(global memory),晶片内部的共享記憶體(shared meory)、寄存器(register)、紋理記憶體、常量記憶體、l1緩存、l2緩存。每種記憶體都有不同的特性,有不同的使用技巧。對開發cuda程式最重要的三種記憶體分别是寄存器、共享記憶體和全局記憶體。

如圖1.25所示,每個線程都有自己專用的寄存器,從核心開始時,一旦擁有某個寄存器的使用權,就一直獨占,直到核心結束才釋放,進而線程之間無法通過寄存器交換資料。雖然有大量的寄存器,但也有大量的線程,平均下來每個線程隻能配置設定到幾十個至幾百個寄存器,複雜程式仍然要控制線程消耗的寄存器數量。每個線程塊都能配置設定一塊共享記憶體,本塊内的線程可以通路這塊共享記憶體的任意位置,是以可以用共享記憶體來交換資料。一個線程塊不能通路其他線程塊的共享記憶體,因而線程塊之間不能用共享記憶體交換資料。共享記憶體容量比寄存器要大,例如tesla p100的每個流式多處理器擁有64kb共享記憶體,每個線程塊最多可以擁有32kb。所有的線程塊、線程網格都能通路全局記憶體,隻要不顯式地釋放或者程式結束,全局記憶體中的資料會一直存在,是以可以用于線程塊之間、線程網格之間的資料交換。全局記憶體更大,以gb為機關。

不同記憶體的通路延時差别很大,寄存延時最小,共享記憶體次之,全局記憶體最大。對pascal之前的架構,全局記憶體與gpu晶片互相分離,通過闆卡上的排線相連,通路延時達到幾百個時鐘周期。pascal架構中,全局記憶體與gpu晶片距離很近,延時應該有大幅減小,

具體數值還需要等待官方公布。

不同構件下的記憶體層級多少都有些變化,要想使cuda程式達到最好性能,必須做針對性優化。

cuda程式編寫容易,調優不易。程式員能夠掌控很多事情,包括但不限于配置設定全局記憶體:全局記憶體中的資料對齊、維數,為每個線程塊配置設定的共享記憶體大小,将哪些資料以什麼樣的組織方式放入共享記憶體,哪些資料放入紋理記憶體,哪些資料放入常量記憶體,線程網格如何劃分,線程塊是一維、二維還是三維,線程塊每個次元的大小是多少,線程與資料元素的對應關系,不同線程通路的資料是否有沖突,不同線程同時通路的資料是否會走相同的通道;單個核心是否能夠用滿資源,如何同時運作多個核心以提高裝置使用率,有幾個資料複制引擎,如何安排異步隊列來重疊資料的來往傳輸,如何重疊資料傳輸與計算,如何填補pcie帶寬與全局記憶體帶寬之間的差異,資料複制操作是否需要錨定主機記憶體;計算密度夠不夠大,計算核心要等待資料多久,一個warp内的線程的流程分支有多少,多少個線程才能隐藏延時;gpu上的算術指令與cpu上對應指令的差異,雙精度操作、單精度操作、半精度操作、三角函數等特殊操作的計算資源配置設定。

管事多,操心就多。每個問題都有相應的優化方法和一定的限制條件,具體技巧請參考英偉達官方文檔《cuda c best practices guide》。需要注意,不同架構下的優化技術會有一些差别。

影響最大的優化技巧是主機與裝置間的資料傳輸。從圖1.4可以看出,裝置與主機通過pcie×16通道相連,在采用2016年釋出的最新cpu的伺服器上,pcie 3.0×16的理論帶寬為16gb/s,與表1.1中幾百gb/s的顯存(全局記憶體)帶寬差别可達30倍,與tesla p100的差别會更大。是以,應盡量減少主機與裝置間的資料傳輸量與傳輸次數。

繼續閱讀