本節書摘來自華章計算機《cuda c程式設計權威指南》一書中的第2章,第2.1節,作者 [美] 馬克斯·格羅斯曼(max grossman),譯 顔成鋼 殷建 李亮,更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。
本章内容:
寫一個cuda程式
執行一個核函數
用網格和線程塊組織線程
gpu性能測試
cuda是一種通用的并行計算平台和程式設計模型,是在c語言基礎上擴充的。借助于cuda,你可以像編寫c語言程式一樣實作并行算法。你可以在nvidia的gpu平台上用cuda為多種系統編寫應用程式,範圍從嵌入式裝置、平闆電腦、筆記本電腦、桌上型電腦、工作站到hpc叢集(高性能計算叢集)。熟悉c語言程式設計工具有助于在整個項目周期中編寫、調試和分析你的cuda程式。在本章中,我們将通過向量加法和矩陣加法這兩個簡單的例子來學習如何編寫一個cuda程式。
cuda程式設計模型提供了一個計算機架構抽象作為應用程式和其可用硬體之間的橋梁。圖2-1說明了程式和程式設計模型實作之間的抽象結構的重要。通信抽象是程式與程式設計模型實作之間的分界線,它通過專業的硬體原語和作業系統的編譯器或庫來實作。利用程式設計模型所編寫的程式指定了程式的各組成部分是如何共享資訊及互相協作的。程式設計模型從邏輯上提供了一個特定的計算機架構,通常它展現在程式設計語言或程式設計環境中。

除了與其他并行程式設計模型共有的抽象外,cuda程式設計模型還利用gpu架構的計算能力提供了以下幾個特有功能。
一種通過層次結構在gpu中組織線程的方法
一種通過層次結構在gpu中通路記憶體的方法
在本章和下一章你将重點學習第一個主題,而在第4章和第5章将學習第二個主題。
以程式員的角度可以從以下幾個不同的層面來看待并行計算。
領域層
邏輯層
硬體層
在程式設計與算法設計的過程中,你最關心的應是在領域層如何解析資料和函數,以便在并行運作環境中能正确、高效地解決問題。當進入程式設計階段,你的關注點應轉向如何組織并發線程。在這個階段,你需要從邏輯層面來思考,以確定你的線程和計算能正确地解決問題。在c語言并行程式設計中,需要使用pthreads或openmp技術來顯式地管理線程。cuda提出了一個線程層次結構抽象的概念,以允許控制線程行為。在閱讀本書中的示例時,你會發現這個抽象為并行程式設計提供了良好的可擴充性。在硬體層,通過了解線程是如何映射到核心可以幫助提高其性能。cuda線程模型在不強調較低級别細節的情況下提供了充足的資訊,具體内容詳見第3章。
cuda程式設計模型使用由c語言擴充生成的注釋代碼在異構計算系統中執行應用程式。在一個異構環境中包含多個cpu和gpu,每個gpu和cpu的記憶體都由一條pci-express總線分隔開。是以,需要注意區分以下内容。
主機:cpu及其記憶體(主機記憶體)
裝置:gpu及其記憶體(裝置記憶體)
為了清楚地指明不同的記憶體空間,在本書的示例代碼中,主機記憶體中的變量名以h_為字首,裝置記憶體中的變量名以d_為字首。
從cuda 6.0開始,nvidia提出了名為“統一尋址”(unified memory)的程式設計模型的改進,它連接配接了主機記憶體和裝置記憶體空間,可使用單個指針通路cpu和gpu記憶體,無須彼此之間手動拷貝資料。更多細節詳見第4章。現在,重要的是應學會如何為主機和裝置配置設定記憶體空間以及如何在cpu和gpu之間拷貝共享資料。這種程式員管理模式控制下的記憶體和資料可以優化應用程式并實作硬體系統使用率的最大化。
核心(kernel)是cuda程式設計模型的一個重要組成部分,其代碼在gpu上運作。作為一個開發人員,你可以串行執行核函數。在此背景下,cuda的排程管理程式員在gpu線程上編寫核函數。在主機上,基于應用程式資料以及gpu的性能定義如何讓裝置實作算法功能。這樣做的目的是使你專注于算法的邏輯(通過編寫串行代碼),且在建立和管理大量的gpu線程時不必拘泥于細節。
多數情況下,主機可以獨立地對裝置進行操作。核心一旦被啟動,管理權立刻傳回給主機,釋放cpu來執行由裝置上運作的并行代碼實作的額外的任務。cuda程式設計模型主要是異步的,是以在gpu上進行的運算可以與主機-裝置通信重疊。一個典型的cuda程式包括由并行代碼互補的串行代碼。如圖2-2所示,串行代碼(及任務并行代碼)在主機cpu上執行,而并行代碼在gpu上執行。主機代碼按照ansi c标準進行編寫,而裝置代碼使用cuda c進行編寫。你可以将所有的代碼統一放在一個源檔案中,也可以使用多個源檔案來建構應用程式和庫。nvidia 的c編譯器(nvcc)為主機和裝置生成可執行代碼。
一個典型的cuda程式實作流程遵循以下模式。
把資料從cpu記憶體拷貝到gpu記憶體。
調用核函數對存儲在gpu記憶體中的資料進行操作。
将資料從gpu記憶體傳送回到cpu記憶體。
首先,你要學習的是記憶體管理及主機和裝置之間的資料傳輸。在本章後面你将學到更多gpu核函數執行的細節内容。
cuda程式設計模型假設系統是由一個主機和一個裝置組成的,而且各自擁有獨立的記憶體。核函數是在裝置上運作的。為使你擁有充分的控制權并使系統達到最佳性能,cuda運作時負責配置設定與釋放裝置記憶體,并且在主機記憶體和裝置記憶體之間傳輸資料。表2-1列出了标準的c函數以及相應地針對記憶體操作的cuda c函數。
用于執行gpu記憶體配置設定的是cudamalloc函數,其函數原型為:
該函數負責向裝置配置設定一定位元組的線性記憶體,并以devptr的形式傳回指向所配置設定記憶體的指針。cudamalloc與标準c語言中的malloc函數幾乎一樣,隻是此函數在gpu的記憶體裡配置設定記憶體。通過充分保持與标準c語言運作庫中的接口一緻性,可以實作cuda應用程式的輕松接入。
cudamemcpy函數負責主機和裝置之間的資料傳輸,其函數原型為:
此函數從src指向的源存儲區複制一定數量的位元組到dst指向的目标存儲區。複制方向由kind指定,其中的kind有以下幾種。
這個函數以同步方式執行,因為在cudamemcpy函數傳回以及傳輸操作完成之前主機應用程式是阻塞的。除了核心啟動之外的cuda調用都會傳回一個錯誤的枚舉類型cuda error_t。如果gpu記憶體配置設定成功,函數傳回:
否則傳回:
可以使用以下cuda運作時函數将錯誤代碼轉化為可讀的錯誤消息:
cudageterrorstring函數和c語言中的strerror函數類似。
cuda程式設計模型從gpu架構中抽象出一個記憶體層次結構。圖2-3所示的是一個簡化的gpu記憶體結構,它主要包含兩部分:全局記憶體和共享記憶體。第4章和第5章詳細介紹了gpu記憶體層次結構的内容。
這是一個純c語言編寫的程式,你可以用c語言編譯器進行編譯,也可以像下面這樣用nvcc進行編譯。
nvcc封裝了幾種内部編譯工具,cuda編譯器允許通過指令行選項在不同階段啟動不同的工具完成編譯工作。-xcompiler用于指定指令行選項是指向c編譯器還是預處理器。在前面的例子中,将-std=c99傳遞給編譯器,因為這裡的c程式是按照c99标準編寫的。你可以在cuda編譯器檔案中找到編譯器選項(http://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/index.html)。
現在,你可以在gpu上修改代碼來進行數組加法運算,用cudamalloc在gpu上申請記憶體。
使用cudamemcpy函數把資料從主機記憶體拷貝到gpu的全局記憶體中,參數cudamemc-pyhosttodevice指定資料拷貝方向。
當資料被轉移到gpu的全局記憶體後,主機端調用核函數在gpu上進行數組求和。一旦核心被調用,控制權立刻被傳回主機,這樣的話,當核函數在gpu上運作時,主機可以執行其他函數。是以,核心與主機是異步的。
當核心在gpu上完成了對所有數組元素的處理後,其結果将以數組d_c的形式存儲在gpu的全局記憶體中,然後用cudamemcpy函數把結果從gpu複制回到主機的數組gpuref中。
cudamemcpy的調用會導緻主機運作阻塞。cudamemcpydevicetohost的作用就是将存儲在gpu上的數組d_c中的結果複制到gpuref中。最後,調用cudafree釋放gpu的記憶體。
當核函數在主機端啟動時,它的執行會移動到裝置上,此時裝置中會産生大量的線程并且每個線程都執行由核函數指定的語句。了解如何組織線程是cuda程式設計的一個關鍵部分。cuda明确了線程層次抽象的概念以便于你組織線程。這是一個兩層的線程層次結構,由線程塊和線程塊網格構成,如圖2-5所示。
由一個核心啟動所産生的所有線程統稱為一個網格。同一網格中的所有線程共享相同的全局記憶體空間。一個網格由多個線程塊構成,一個線程塊包含一組線程,同一線程塊内的線程協作可以通過以下方式來實作。
同步
共享記憶體
不同塊内的線程不能協作。
線程依靠以下兩個坐标變量來區分彼此。
blockidx(線程塊線上程格内的索引)
threadidx(塊内的線程索引)
這些變量是核函數中需要預初始化的内置變量。當執行一個核函數時,cuda運作時為每個線程配置設定坐标變量blockidx和threadidx。基于這些坐标,你可以将部分資料配置設定給不同的線程。
該坐标變量是基于uint3定義的cuda内置的向量類型,是一個包含3個無符号整數的結構,可以通過x、y、z三個字段來指定。
cuda可以組織三維的網格和塊。圖2-5展示了一個線程層次結構的示例,其結構是一個包含二維塊的二維網格。網格和塊的次元由下列兩個内置變量指定。
blockdim(線程塊的次元,用每個線程塊中的線程數來表示)
griddim(線程格的次元,用每個線程格中的線程數來表示)
它們是dim3類型的變量,是基于uint3定義的整數型向量,用來表示次元。當定義一個dim3類型的變量時,所有未指定的元素都被初始化為1。dim3類型變量中的每個元件可以通過它的x、y、z字段獲得。如下所示。
在cuda程式中有兩組不同的網格和塊變量:手動定義的dim3資料類型和預定義的uint3資料類型。在主機端,作為核心調用的一部分,你可以使用dim3資料類型定義一個網格和塊的次元。當執行核函數時,cuda運作時會生成相應的内置預初始化的網格、塊和線程變量,它們在核函數内均可被通路到且為unit3類型。手動定義的dim3類型的網格和塊變量僅在主機端可見,而unit3類型的内置預初始化的網格和塊變量僅在裝置端可見。
你可以通過代碼清單2-2來驗證這些變量如何使用。首先,定義程式所用的資料大小,為了對此進行說明,我們定義一個較小的資料。
接下來,定義塊的尺寸并基于塊和資料的大小計算網格尺寸。在下面的例子中,定義了一個包含3個線程的一維線程塊,以及一個基于塊和資料大小定義的一定數量線程塊的一維線程網格。
你會發現網格大小是塊大小的倍數。在下一章中你會了解必須這樣計算網格大小的原因。以下主機端上的程式段用來檢查網格和塊次元。
在核函數中,每個線程都輸出自己的線程索引、塊索引、塊次元和網格次元。
把代碼合并儲存成名為checkdimension.cu的檔案,如代碼清單2-2所示。
現在開始編譯和運作這段程式:
因為printf函數隻支援fermi及以上版本的gpu架構,是以必須添加-arch=sm_20編譯器選項。預設情況下,nvcc會産生支援最低版本gpu架構的代碼。這個應用程式的運作結果如下。可以看到,每個線程都有自己的坐标,所有的線程都有相同的塊次元和網格次元。
對于一個給定的資料大小,确定網格和塊尺寸的一般步驟為:
确定塊的大小
在已知資料大小和塊大小的基礎上計算網格次元
要确定塊尺寸,通常需要考慮:
核心的性能特性
gpu資源的限制
本書的後續章節會對以上幾點因素進行詳細介紹。代碼清單2-3使用了一個一維網格和一個一維塊來說明當塊的大小改變時,網格的尺寸也會随之改變。
用下列指令編譯和運作這段程式:
下面是一個輸出示例。由于應用程式中的資料大小是固定的,是以當塊的大小發生改變時,相應的網格尺寸也會發生改變。
你應該對下列c語言函數調用語句很熟悉:
cuda核心調用是對c語言函數調用語句的延伸,<<<>>>運算符内是核函數的執行配置。
正如上一節所述,cuda程式設計模型揭示了線程層次結構。利用執行配置可以指定線程在gpu上排程運作的方式。執行配置的第一個值是網格次元,也就是啟動塊的數目。第二個值是塊次元,也就是每個塊中線程的數目。通過指定網格和塊的次元,你可以進行以下配置:
核心中線程的數目
核心中使用的線程布局
同一個塊中的線程之間可以互相協作,不同塊内的線程不能協作。對于一個給定的問題,可以使用不同的網格和塊布局來組織你的線程。例如,假設你有32個資料元素用于計算,每8個元素一個塊,需要啟動4個塊:
圖2-6表明了上述配置下的線程布局。
由于資料在全局記憶體中是線性存儲的,是以可以用變量blockidx.x和threadid.x來進行以下操作。
在網格中辨別一個唯一的線程
建立線程和資料元素之間的映射關系
如果把所有32個元素放到一個塊裡,那麼隻會得到一個塊:
如果每個塊隻含有一個元素,那麼會有32個塊:
核函數的調用與主機線程是異步的。核函數調用結束後,控制權立刻傳回給主機端。你可以調用以下函數來強制主機端程式等待所有的核函數執行結束:
一些cuda運作時api在主機和裝置之間是隐式同步的。當使用cudamemcpy函數在主機和裝置之間拷貝資料時,主機端隐式同步,即主機端程式必須等待資料拷貝完成後才能繼續執行程式。
之前所有的核函數調用完成後開始拷貝資料。當拷貝完成後,控制權立刻傳回給主機端。
核函數是在裝置端執行的代碼。在核函數中,需要為一個線程規定要進行的計算以及要進行的資料通路。當核函數被調用時,許多不同的cuda線程并行執行同一個計算任務。以下是用__global__聲明定義核函數:
核函數必須有一個void傳回類型。
表2-2總結了cuda c程式中的函數類型限定符。函數類型限定符指定一個函數在主機上執行還是在裝置上執行,以及可被主機調用還是被裝置調用。
__device__和__host__限定符可以一齊使用,這樣函數可以同時在主機和裝置端進行編譯。
考慮一個簡單的例子:将兩個大小為n的向量a和b相加,主機端的向量加法的c代碼如下:
這是一個疊代n次的串行程式,循環結束後将産生以下核函數:
c函數和核函數之間有什麼不同?你可能已經注意到循環體消失了,内置的線程坐标變量替換了數組索引,由于n是被隐式定義用來啟動n個線程的,是以n沒有什麼參考價值。
假設有一個長度為32個元素的向量,你可以按以下方法用32個線程來調用核函數:
既然你已經編寫了核函數,你如何能知道它是否正确運作?你需要一個主機函數來驗證核函數的結果。
由于許多cuda調用是異步的,是以有時可能很難确定某個錯誤是由哪一步程式引起的。定義一個錯誤處理宏封裝所有的cuda api調用,這簡化了錯誤檢查過程:
例如,你可以在以下代碼中使用宏:
如果記憶體拷貝或之前的異步操作産生了錯誤,這個宏會報告錯誤代碼,并輸出一個可讀資訊,然後停止程式。也可以用下述方法,在核函數調用後檢查核函數錯誤:
check(cudadevicesynchronize())會阻塞主機端線程的運作直到裝置端所有的請求任務都結束,并確定最後的核函數啟動部分不會出錯。以上僅是以調試為目的的,因為在核函數啟動後添加這個檢查點會阻塞主機端線程,使該檢查點成為全局屏障。
現在把所有的代碼放在一個檔案名為sumarraysongpu-small-case.cu的檔案中,如代碼清單2-4所示。
在這段代碼中,向量大小被設定為32,如下所示:
執行配置被放入一個塊内,其中包含32個元素:
使用以下指令編譯和執行該代碼:
系統報告結果如下:
如果你将執行配置重新定義為32個塊,每個塊隻有一個元素,如下所示:
那麼就需要在代碼清單2-4中對核函數sumarraysongpu進行修改:
一般情況下,可以基于給定的一維網格和塊的資訊來計算全局資料通路的唯一索引:
你需要確定一般情況下進行更改所産生結果的正确性。