天天看點

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

點選檢視第一章 點選檢視第三章

第2章

開發第一個CPU并行程式

本章主要關注的是了解第一個CPU并行程式imflipP.c。注意,檔案名末尾的“P”表示并行。開發平台對于CPU并行程式來說沒有任何差別。在本章中,我将逐漸介紹有關并行程式最主要的概念,當我們在第二部分開發GPU程式時,這些概念将很容易地應用于GPU程式設計。你可能已經注意到,我從不說GPU并行程式設計,而是GPU程式設計。這就像不需要說一輛帶輪子的汽車,說一輛車就足夠了。換句話說,根本沒有GPU串行程式設計,這意味着即使你有100 000個可用的GPU線程,但卻隻使用一個!是以,按照定義,GPU程式設計就意味着GPU并行程式設計。

2.1 第一個并行程式

現在是編寫第一個并行程式imflipP.c的時候了,它是我們在1.4節中介紹的串行程式imflip.c的并行版本。為了并行化imflip.c,我們需要在main()函數中建立多個線程并讓它們各自完成一部分工作後退出。在最簡單的情況下,如果我們嘗試運作一個雙線程的程式,main()将建立兩個線程,讓它們各自完成一半的工作,合并線程然後退出。在這種情況下,main()不過是各個事件的管理者,它沒有做實際的工作。

為了實作我們剛剛描述的内容,main()需要能夠建立、終止和管理線程并将任務配置設定給線程。Pthreads庫的部分函數可以幫助它完成這些任務。Pthreads隻能在符合POSIX标準的作業系統中工作。諷刺的是,Windows不符合POSIX标準!但是,在POSIX和Windows之間執行某種API到API的轉換後,Cygwin64允許Pthreads代碼在Windows中運作。這就是為什麼本書描述的所有東西都可以在Windows中使用,也是在你的計算機是一台Windows PC的情況下,我推薦Cygwin64的原因。以下是我們将要使用的一些Pthreads庫函數:

  1. pthread_create()用于建立一個線程。
  2. pthread_join()用于将任何給定的線程合并到最初建立它的線程中。你可以将“合并”過程想象成“毀滅”線程,或者父線程“吞食”剛剛建立的線程。
  3. pthread_attr()用于初始化線程的各項屬性。
  4. pthread_attr_setdetachstate()用于為剛剛初始化的線程設定屬性。

2.1.1 imflipP.c中的main()函數

我們的串行程式imflip.c(如代碼1.1所示)讀取一些指令行參數,并按照使用者的指令對輸入圖像進行垂直或水準翻轉。同樣的翻轉操作重複奇數次(例如129),用以改進由clock()擷取的系統時間的準确性。

代碼2.1和代碼2.2顯示的都是imflipP.c中的main()函數,不同之處在于:代碼2.1标注的是“main(){...”,這表示main()的“第一部分”,後面所跟的“...”進一步強調了這一點。這部分用于指令行參數解析和一些正常操作。在代碼2.2中,“main() ...}”後部的符号與2.1相反,“...”在前,表示這是main()函數的“第二部分”,該部分用于啟動線程和給線程配置設定任務。

為了提高可讀性,我可能在這兩部分代碼中重複一些代碼,例如使用gettimeofday()擷取時間戳,使用ReadBMP()進行圖像讀取等,稍後将詳細介紹ReadBMP()。這将使讀者能夠清楚地了解這兩個部分的開始和連接配接處。你可能已經注意到,如果完全列出一個函數的代碼,就會使用“func(){...}”來表示。當一個函數和它前後的代碼同時被列出時,用“... func(){...}”來表示,意思是“一些正常代碼...func()完整的代碼。”

下面是main()函數中指令行解析的部分,指令行參數在argv[]數組中給出(總共有argc個)。如果使用者輸入的參數個數不對時會報錯。使用者指定的翻轉方向存放在一個名為Flip的變量中備用。全局變量NumThreads也是基于使用者輸入确定的,稍後将在實際執行翻轉操作的函數中使用。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.2 運作時間

當有多個線程執行時,我們希望能夠量化加速倍數。在串行代碼中我們使用clock()函數,它包含在time.h頭檔案中,精度僅為毫秒級。

我們将在imflipP.c中使用的gettimeofday()函數能夠使精度達到μs。gettimeofday()需要包含sys/time.h頭檔案,并且給一個結構的兩個成員變量提供時間:一個是給.tv_sec成員變量設定以秒為機關的時間,另一個是給.tv_usec成員變量設定以微秒為機關的時間。這兩個成員變量都是int類型,在輸出之前聯合生成一個雙精度的時間值。

值得注意的是,計時的準确與否不取決于C函數本身,而取決于硬體。如果你的計算機的作業系統或硬體無法提供μs級的時間戳,gettimeofday()将隻提供從作業系統獲得的最佳結果(作業系統從硬體的時鐘單元獲得該值)。例如,即使使用gettimeofday()函數,由于Cygwin64依賴于Windows API,Cygwin64的精确度也不會達到μs。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.3 imflipP.c中main()函數代碼的劃分

我有意避免在一個代碼片段中列出長長的main()函數代碼。這是因為,從第一個示例中可以看出,代碼2.1和代碼2.2的功能完全不同:代碼2.1用于擷取指令行參數,解析它們以及向使用者發出警告。而代碼2.2用于建立與合并線程的“酷動作”。大多數情況下,我會按照類似的方法來安排我的代碼,并盡量關注代碼的重要部分。

代碼2.1:imflipP.c ... main(){...

imflipP.c中main()函數的第一部分讀取和解析指令行選項。如有必要,輸出錯誤告警。 BMP圖像被讀入主存的數組中并啟動計時器。這部分決定多線程代碼是否會運作。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

代碼2.2:imflipP.c ... main() ...}

imflipP.c中main()函數的第二部分建立多個線程并為它們配置設定任務。每個線程執行其配置設定的任務并傳回。當每個線程完成後,main()會合并(即終止)線程并報告已用時間。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.4 線程初始化

以下代碼用于初始化線程并多次運作多線程代碼。為了初始化線程,通過應用程式接口pthread_attr_init()和pthread_attr_setdetachstate(),我們告訴作業系統準備啟動一系列線程,并且稍後将合并它們……将同樣的代碼重複執行129次隻是為了“減慢”時間!與計算執行一次需要多長時間相比,執行129次并将消耗的總時間除以129,結果并沒有什麼變化,除非你對Unix計時API的不準确性不在意。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.5 建立線程

這裡是好事情發生的地方:請看下面的代碼。每個線程都是通過使用API函數pthread_create()建立的,一旦建立就開始執行。 這個線程将做什麼?第三個參數将告訴線程要執行的任務:MTFlipFunc。就好像我們調用了一個名為MTFlipFunc()的函數,但它自己開始執行,也就是說,與我們并行執行。main()隻是建立了一個名為MTFlipFunc()的子線程,并且立即開始并行地執行。問題是,如果main()建立了2個、4個或8個線程,那麼每個線程如何知道它自己是誰?這個問題由第四個參數負責,經過一些指針操作後,該參數指向ThParam[i]。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

OS需要第一和第二個參數:第二個參數&ThAttr對于所有線程都相同,内容是線程屬性。第一個參數是每個線程的“句柄”,它對作業系統非常重要,使作業系統能夠跟蹤線程。如果作業系統無法建立線程,它将傳回NULL(即0),這意味着我們不能再建立線程。這是緻命的錯誤,是以程式将報告一個運作時錯誤并退出。

下面是一個有趣的問題:如果main()建立兩個線程,那麼我們的程式是一個雙線程的程式嗎?正如我們馬上就要看到的,當main()函數用pthread_create()建立2個線程時,我們可以期望的最好結果是程式提高2倍的運作速度。那麼main()本身呢?其實,main()本身也是一個線程。是以,當main()建立2個子線程後,程式中一共有3個線程。我們隻期望有2倍加速的原因是,main()隻做了一些微不足道的工作,而另外的兩個線程完成的是繁重的工作。

可以對上述情景進行量化分析:main()函數建立線程,為它們配置設定任務,然後合并它們,占大約1%的工作量,而其他99%的工作是由另外兩個線程執行的(各占49.5%)。在這種情況下,運作main()函數的第三個線程所花費的時間可以忽略不計。圖2-1所示為我電腦的Windows任務管理器,它顯示了1499個活躍線程。但是,CPU負載可以忽略不計(幾乎為0%)。這1499個線程是Windows作業系統建立的用于偵聽網絡資料包、鍵盤敲擊、其他中斷等事件的。例如,如果作業系統意識到一個網絡資料包已到達,它會喚醒相應的線程,立即用很短的時間處理該資料包。然後線程會回到睡眠狀态,盡管它仍然是一個活躍線程。請記住:CPU的速度比網絡資料包快得多。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.6  線程啟動/執行

圖1-2顯示,雖然作業系統中有1499個休眠的線程,但main()函數建立的線程具有完全不同的個性:一旦main()建立好2個子線程,線程總數就為1501。然而,這2個子線程執行了82 ms,在它們執行的過程中,Windows任務管理器中的兩個虛拟CPU将顯示100%的峰值,直到82 ms後被main()函數用pthread_join()吞噬。那時,系統又回到1499個線程。main()函數在運作到最後一行并輸出時間之前不會死亡。當main()退出後,線程數減少到1498。是以,如果你在代碼循環129次時檢視Windows任務管理器,其中有2個線程處于腎上腺素狀态下的急速增長狀态—從線程啟動到線程合并,你會看到8個CPU中的2個占用率為100%。我的電腦的CPU有4個核心,8個線程(4C/8T)。 Windows作業系統将此CPU視為“8個虛拟CPU”,這就是在任務管理器中看到8個“CPU”的原因。當你有一台Mac或一台Unix機器時,情況會很類似。總結一下當我們的2線程代碼運作時會發生什麼,請記住你鍵入的運作代碼的指令行:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

該指令行訓示作業系統加載并執行二進制代碼imf?lipP。執行過程包括建立第一個線程,為其配置設定函數main(),并将參數argc和argv[]傳遞給該線程。這聽起來與我們建立子線程非常相似。

當作業系統完成加載可執行二進制檔案imf?lipP後,将控制權交給main(),就像它調用了main()函數并将變量argc和argv[]數組傳遞給它一樣。main()開始運作……在執行過程中的某處,main()要求作業系統建立另外兩個線程……

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

作業系統設定好記憶體和棧區并将2個虛拟CPU配置設定給這兩個超級活躍線程。成功建立線程後,必須啟動它們。pthread_create()同時也意味着啟動一個剛剛建立的線程。啟動線程等同于調用以下函數:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

随後它們将進入水準或垂直翻轉函數,這由使用者在運作時輸入的參數決定。如果使用者選擇“H”作為翻轉選項,那麼啟動過程等同于:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.1.7 線程終止(合并)

線程啟動後,一股龍卷風會襲擊CPU!它們會瘋狂地使用這兩個虛拟CPU,并且最終會依次地調用return(),這會讓main()對每個線程逐一執行pthread_join():

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

執行第一個pthread_join()後,線程數減少到1500個。第一個子線程被main()吞噬了。 執行第二個pthread_join()後,線程數減少到1499個。第二個子線程也被吞噬了。這将讓龍卷風停止!幾毫秒後,main()報告時間并退出。正如我們将在代碼2.5中看到的,imageStuff.c中的部分代碼用來動态配置設定用于存放從磁盤讀取的圖像資料的存儲空間。malloc()函數用于動态(即在運作時)的記憶體配置設定。在main()退出之前,所有這些存儲空間都要用free()來釋放,如下所示。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

當main()函數退出時,它在作業系統中的父線程會結束運作main()的子線程。這些線程是一種有趣的生命形式,它們就像某種細菌一樣創造和吞噬其他線程!

2.1.8 線程任務和資料劃分

好了,這些被稱為線程的“細菌”的各種操作我們現在已經很清楚了。資料呢?建立多個線程的最終目的是更快地執行任務。根據定義,這意味着我們建立的線程越多,任務劃分也越多,我們處理的資料劃分也越多。要了解這一點,請看類比2.1。

類比2.1:多線程中的任務和資料劃分

Cocotown是椰子的主要生産地,每年收獲1800棵樹。樹木從0到1799編号。每年,整個城鎮都在收割椰子。由于參與收割椰子的人越來越多,該鎮制定了以下加快收割速度的政策:

願意幫助的農民需要出現在收割地點,并将獲得一頁指導手冊。該手冊分為上、下兩個部分。對于每位農民來說,上半部分都是一樣的:“敲碎外殼、剝皮……對後面的椰子樹做同樣的事。”

當隻有兩位農民時,手冊的下半部分寫道:第一位農民隻處理編号為[0 ... 899]的樹,第二位農民隻處理編号為[900 ... 1799]的樹。但是,如果有五位農民時,手冊的下半部分将會是以下數字:第一位農民[0 ... 359],第二位農民[360 ... 719],……,最後一位農民[1440 ... 1799]。

在類比2.1中,無論有多少農民參與,每個線程的任務都是收獲一部分椰子樹,這對每位農民來說都是一樣的。農民就是正在執行的線程。需要給每位農民一個唯一的ID,以知道他們應該收獲哪些椰子樹。這個唯一的ID類似于線程ID或tid。椰子樹的數量是1800棵,這是要處理的所有資料。最有意思的是,任務(即指導手冊的上半部分)可以與資料分離(即指導手冊的下半部分)。雖然每位農民的任務都是一樣的,但資料是完全不同的。是以,從某種意義上說,任務隻有一個,但要處理由tid确定的不同資料。

很顯然,這個任務在編譯時完全可以預先确定,也就是說,在準備指導手冊時。但是,看起來資料部分必須在運作時才能确定,即每個人都出現時,我們才能确切知道有多少農民參加。關鍵問題是資料部分是否也可以在編譯時确定。換句話說,鎮長隻能寫1份指導手冊,然後影印60份(即可預計的最多的農民數量),而且當不同數量的農民參加時不需要準備任何其他的東西。如果有2位農民出現,鎮長會發出2份指導手冊,并将tid=0和tid=1配置設定給他們。如果有5位農民出現,他會發出5份指導手冊,并指定tid=0、tid=1、tid=2、tid=3、tid=4。

更一般地說,唯一必須在運作時确定的是tid的指派,即tid=0,...,tid=N-1。其他的一切都是在編譯時确定的,包括任務參數。這可能嗎?事實證明,這是絕對可能的。最終,對于N位農民來說,我們清楚地知道資料劃分将會是什麼樣子:每位農民将配置設定1800/N棵椰子樹來收割,而第tid位農民必須收割如下範圍内的椰子樹:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

為了驗證這一點,讓我們計算tid=[0 ... 4](5位農民)的資料劃分。對于給定的tid,式2.1的結果是[360×tid,...,360×tid+359]。是以,對于5位農民來說,資料劃分結果為[0,...,359]、[360,...,719]、[720,...,1079]、[1080,...,1439]和[1440,...,1799]。這正是我們想要的。這意味着對于一個N線程程式,例如對圖像做水準翻轉,我們确實需要編寫一個函數,在運作時将其配置設定給啟動的線程。我們需要做的就是讓啟動的線程知道它的tid是什麼。然後,線程将能夠使用類似于式2.1的公式準确計算出在運作時要處理的資料部分。

重要的一點是,這裡的資料元素互相之間沒有依賴關系,即它們可以被獨立地、并行地處理。是以,我們預計,當我們啟動N個線程時,整個任務(即1800棵椰子樹)的執行速度可以提高N倍。換句話說,如果1800棵椰子樹需要1800小時才能收獲,那麼當5位農民出現時,我們預計需要360小時。正如我們将很快看到的,這種完美的結果是難以實作的。并行化任務存在隐含的開銷,稱為并行開銷。由于這種開銷的存在,5位農民可能需要400小時才能完成這項工作。其中的細節取決于硬體和我們為每個線程編寫的函數。在接下來的章節中,我們将更多地關注這個問題。

2.2 位圖檔案

了解了多線程程式必須進行任務劃分和資料劃分後,讓我們将這些知識應用到第一個并行程式imf?lipP.c中。在開始前,我們需要了解位圖(BMP)圖像檔案的格式以及如何讀取/寫入這些檔案。

2.2.1 BMP是一種無損/不壓縮的檔案格式

BMP檔案是一種不壓縮的圖像檔案。這意味着知道圖像大小,可以輕松确定存儲該圖像的檔案大小。例如,每像素24位的BMP圖像每個像素占用3個位元組(即R、G和B各一位元組)。這種格式還需要54個額外的位元組來存儲“頭”資訊。我将在2.2.2節給出并解釋相應的公式,但現在讓我們關注壓縮的概念。

類比2.2:資料壓縮

Cocotown的檔案管理部門想要儲存在2015年年初和年末收獲1800棵椰子樹的照片。辦公室職員将以下資訊存儲在一個名為1800trees.txt的檔案中。

2015年1月1日,共有1800棵相同的樹木,分布在一個寬40、長45的矩形中。我拍下一棵樹的圖檔,并将其儲存在名為OneTree.BMP的檔案中。用這張照片按40×45的方式平鋪複制。我注意到隻有在位置(30, 35)處有一棵不同的樹,并将其圖檔存儲在另一個圖檔檔案DifferentTree.BMP中。其他1799棵是相同的。

2015年12月31日,樹木看起來不同了,因為它們長大了。我拍下一棵樹的照片并儲存在GrownTree.BMP中。盡管它們長大了,但在2015年12月31日,其中的1798棵仍然相同,另2棵不同。用GrownTree.BMP檔案中的樹建立一個40×45的平鋪複制,并使用Grown3236.BMP和Grown3238.BMP檔案替換位置(32, 36)和(32, 38)處兩棵不同的樹。

如果你看看類比2.2就會發現,職員可以通過一棵椰子樹的照片(OneTree.BMP)和另一棵與其他1799棵稍有不同的椰子樹照片(DifferentTree.BMP)來獲得繪制整張40×45林場圖檔所需的所有資訊。假設每張這樣的圖檔都要占用1KB的存儲空間。包括職員提供的文本檔案,這些資訊在2015年1月1日約為3KB。如果我們要将整個40×45林場制作成一個BMP檔案,我們需要1800 KB,因為每棵樹需要1KB。重複的(即備援的)資料允許職員大大減少我們傳遞該資訊時所需檔案的大小。

這個概念稱為資料壓縮,可以應用于任何有備援的資料。這就是為什麼像BMP這樣未壓縮的圖像檔案大小會比采用JPEG(或JPG)格式的壓縮檔案大很多,JPEG格式會在存放圖像前先進行壓縮。壓縮技術包括頻率域分析等,抽象出的概念其實很簡單,就是類比2.2中給出的思路。

BMP檔案存儲“原始”圖像像素而不壓縮它們。因為沒有壓縮,是以在将每個像素存儲在BMP檔案中之前不需要額外的處理。這與JPEG檔案格式形成對比,JPEG格式首先需要進行像餘弦變換之類的頻域轉換。JPEG檔案的另一個有趣之處在于隻儲存了90%~99%的圖像資訊,這種丢失部分圖像資訊的概念—雖然我們的眼睛察覺不到—意味着JPEG檔案是一種有損圖像存儲格式,而BMP檔案中沒有資訊丢失,因為每個像素都是在沒有任何轉換的情況下存儲的。考慮到如果我們可以容忍1%的圖像資料損失,20 MB的BMP檔案可以存儲為1 MB的JPG檔案,這種妥協幾乎可以被任何使用者接受。這就是為什麼幾乎每部智能手機都以JPG格式存儲圖像以避免過快地占滿存儲空間。

2.2.2 BMP圖像檔案格式

雖然BMP檔案支援灰階和各種顔色深度(例如8位或24位),但在我們的程式中隻使用24位RGB檔案。該檔案有一個54位元組的檔案頭,接着存放每個像素的RGB顔色。與JPEG檔案不同,BMP檔案不進行壓縮,是以每個像素占用3個位元組,可以根據以下公式确定BMP檔案的确切大小:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

其中Vpixels和Hpixels是圖像的高度和寬度(例如,對于640×480的dog.bmp圖像檔案來說,Vpixels = 480,Hpixels = 640)。根據式2.2,dog.bmp占用54+3×640×480 = 921 654

個位元組,而3200×2400的dogL.bmp圖像檔案的大小為23 040 054個位元組(≈22 MB)。

從Hpixels到Hbytes位元組的轉換如下式所示,非常簡單:

Hbytes = 3×Hpixels

然而,Hbytes必須舍入為下一個可以被4整除的整數以確定BMP圖像大小是4的倍數。可以通過在公式2.2的第一行中加3并将結果的兩位最低有效位清零來實作(即,将最後2位與00進行與運算)。

下面是一些計算BMP大小的示例:

  • 24位1024×1024的BMP檔案需要3 145 782位元組的存儲空間(54+1024×1024×3)。
  • 24位321×127的BMP檔案需要122 482位元組(54+(321×3+1)×127)。

2.2.3 頭檔案ImageStuff.h

由于我在本書第一部分使用完全相同的圖像格式,是以我将所有BMP圖像處理檔案和關聯的頭檔案放在實際代碼之外。 ImageStuff.h頭檔案包含與圖像相關的函數聲明和結構定義,需要被我們的所有程式包含,如代碼2.3所示。除了ImageStuff.h檔案,你還可以使用更多專業級圖像軟體包,如ImageMagick。但是,由于ImageStuff.h在某種意義上是“開源的”,我強烈建議讀者在開始使用ImageMagick或OpenCV之類的其他軟體包之前了解此檔案。這将使你能夠很好地掌握與圖像相關的底層概念。我們将在本書第二部分使用其他易用的軟體包。

在ImageStuff.h中,為圖像定義了一個結構(struct),成員變量包括前面提到的圖像的Hpixels和Vpixels。目前處理的圖像的檔案頭資訊儲存在HeaderInfo[54]中,以便翻轉操作後寫回圖像時被恢複。Hbytes是每行圖像資料在記憶體中占據的位元組數,舍入到下一個可以被4整除的整數。例如,如果一個BMP圖像具有640個水準像素,則Hbytes = 3×640 = 1920。然而,對于有201個水準像素的圖像,Hbytes=3×201=603→604。是以,每行将占用604位元組,将會有一個浪費的位元組。

ImageStuff.h檔案還包含ImageStuff.c檔案中提供的BMP圖像讀取和寫入函數Read-BMP()和WriteBMP()的聲明。C變量ip儲存的是目前圖像的屬性,也就是我們許多示例中的小狗圖檔。由于該變量是在本章的程式imf?lipP.c中定義的,是以它必須作為外部結構包含在ImageStuff.h中,這樣ReadBMP()和WriteBMP()函數才可以正确地引用它們。

代碼2.3:ImageStuff.h

頭檔案ImageStuff.h包含兩個BMP圖像處理函數的聲明以及用于表示圖像的結構定義。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.2.4 ImageStuff.c中的圖像操作函數

ImageStuff.c檔案包含兩個函數,分别負責讀取和寫入BMP檔案。将這兩個函數和相關的變量定義等封裝到ImageStuff.c和ImageStuff.h檔案中,這樣,在本書的第一部分,我們隻需要在此處關注這些代碼細節就可以了。無論開發CPU還是GPU程式,我們都會使用這些函數讀取BMP圖像。即使在開發GPU程式時,圖像也會被先讀入CPU,然後再傳送到GPU記憶體中,這些将在本書的第二部分詳細介紹。

代碼2.4顯示了WriteBMP()函數,它将處理後的BMP圖像寫回磁盤。該函數的輸入參數包括一個指向需要輸出的圖像結構的指針變量img,以及一個存放輸出檔案名的字元串變量filename。輸出BMP檔案的檔案頭為54位元組,在讀取BMP時被儲存起來。

代碼2.4:ImageStuff.c WriteBMP(){...}

WriteBMP()将一個處理後的BMP圖像寫入檔案。變量img是指向将要被寫入檔案的圖像結構的指針。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

代碼2.5中顯示的ReadBMP()函數使用關鍵字new每次一行地為圖像配置設定記憶體。在處理過程中,每個像素都是一個3位元組的結構,包含該像素的RGB值。但是,從磁盤讀取圖像時,我們可以一次讀取一整行的Hbytes位元組,并将其寫入長度為Hbytes的unsigned char數組中,不用考慮單個像素。

代碼2.5:ImageStuff.c ... ReadBMP(){...}

ReadBMP()函數讀取BMP圖像并為其配置設定記憶體。所需的圖像參數(如Hpixels和Vpixels)從BMP檔案中提取并寫入結構變量。Hbytes使用公式2.2進行計算。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

ReadBMP()函數從BMP檔案的檔案頭(即BMP檔案的前54個位元組)中提取Hpixels和Vpixels值,并用公式2.2計算Hbytes。它用malloc()函數為圖像動态配置設定足夠的記憶體,這些動态配置設定的記憶體将在main()的末尾用free()函數釋放。讀取的圖像檔案名由使用者指定,存放在傳遞給ReadBMP()的字元串參數變量filename中。BMP檔案的檔案頭儲存在HeaderInfo[]中,以便在将處理後的檔案寫回磁盤時使用。

ReadBMP()和WriteBMP()函數用C庫函數fopen()和“rb”或“wb”選項讀取或寫入二進制檔案。如果作業系統不能打開檔案,fopen()的傳回值為NULL,并向使用者報告錯誤。這種情況往往是由檔案名錯誤或檔案已存在引起的。fopen()為新檔案配置設定一個檔案句柄和一個讀/寫緩沖區并将其傳回給調用者。

根據fopen()參數的不同,還會對檔案進行鎖定,以防止多個程式因同時通路而破壞檔案。通過使用緩沖區,每個位元組資料可以一次一個地(即C變量類型unsigned char)從檔案讀取或寫入檔案。fclose()函數釋放配置設定的緩沖區并取消對該檔案的鎖定(如果有的話)。

2.3 執行線程任務

現在我們已經知道了如何計算CPU代碼運作的時間以及如何讀取/寫入BMP圖像,讓我們使用多線程來實作圖像的翻轉吧。多線程程式中各部分責任如下:

  • main()負責建立線程并在運作時為每個線程配置設定唯一的tid(例如,下面顯示的ThParam [i])。
  • main()為每個線程調用一個函數(函數指針MTFlipFunc)。
  • main()還必須将其他必要的參數(如果有的話)傳遞給線程(也是通過ThParam[i]傳遞)。
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
  • main()還負責讓作業系統知道它正在建立什麼類型的線程(即線程屬性,由&ThAttr傳入)。最後,main()隻不過是另一個線程,代表它将建立的子線程發言。
  • 作業系統決定是否可以建立一個線程。線程其實是作業系統管理的資源。如果可以建立線程,作業系統就負責為該線程配置設定句柄(ThHandle[i])。如果不能,作業系統傳回NULL(ThErr)。
  • 如果作業系統不能建立某個線程,main()負責退出或實施其他操作。
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
  • 每個線程的職責是接收tid,執行任務MTFlipFunc(),處理需要自己處理的那部分資料。在這個方面我們會多做一些介紹。
  • main()最後的任務是等待線程完成并合并它們。這将告訴作業系統釋放配置設定給線程的資源。
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.3.1 啟動線程

讓我們看看函數指針是如何啟動線程的。pthread_create()函數期望一個函數指針作為其第三個參數,即MTFlipFunc。這個指針從何而來?為了能夠确定這一點,讓我們列出參與“計算”變量MTFlipFunc的imflipP.c中的所有代碼。代碼2.6中列出了它們。我們的目标是為main()提供足夠的靈活性,這樣可以使用任何我們想要的函數來啟動線程。 代碼2.6列出了四種不同的函數:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

前兩個函數正是我們在1.1節中介紹的。它們是在垂直或水準方向上對圖像進行翻轉的串行函數。剛剛我們介紹了它們的多線程版本(上述代碼的後兩行),它将完成與串行版本相同的工作,除了因為使用多線程而變得更快(希望如此)!請注意,多線程版本将需要我們之前描述的tid,而串行版本不需要。

現在,我們的目标是了解如何将函數指針和資料傳遞給每個啟動的線程。該函數的串行版本被稍作修改以消除傳回值(即void),這樣與沒有傳回值的多線程版本保持一緻。這四個函數都隻是對指針TheImage指向的圖像稍作修改。事實證明,我們并不需要将函數指針傳遞給線程。相反,我們必須調用函數指針指向的函數。這個過程稱為線程啟動。

傳遞資料并啟動線程的方式根據啟動的是串行函數還是多線程版本的函數而有所不同。我設計的imf?lipP.c能夠根據使用者指令行參數運作舊版本的代碼或新的多線程版本。由于兩個函數的輸入變量略有不同,是以定義兩個單獨的函數指針FlipFunc和MTFlipFunc會更容易,它們分别負責啟動串行函數和多線程版本的函數。我使用了兩個函數指針,如下所示:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

讓我們來澄清建立和啟動一個線程之間的差別,兩者都隐含在pthread_create()中。建立一個線程涉及父線程main()和作業系統之間的請求/授權機制。如果作業系統說不,什麼都不會發生。是以,正是作業系統實際建立了一個線程,并為它建立了記憶體空間、句柄、虛拟CPU和棧區,還将一個非零的線程句柄傳回給父線程,進而授權父線程啟動(又名運作)另一個并行線程。

代碼2.6:imflipP.c 線程函數指針

定義作為參數傳遞到啟動線程的函數指針的代碼。這是線程知道要執行什麼的方式。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

注意,雖然父線程現在已被許可運作一個子線程,但還沒有發生任何事情。啟動一個線程實際上是一個并行函數調用。換句話說,main()知道另一個子線程在啟動後正在運作,并且可以在需要時與它通信。

main()函數也許永遠不會與它的子線程通信(例如來回傳遞資料),如代碼2.2所示,因為它不需要。子線程修改所需的記憶體區域并傳回。在這種特定情況下,配置設定給子線程的任務隻需要main()負責一件事情:等待子線程完成并終止(合并)它們。是以,main()唯一關心的事情是:它有新線程的句柄,并且可以通過使用pthread_join()來确定該線程何時完成執行(即傳回)。是以,實際上,pthread_join(x)意味着等待句柄為x的線程運作結束。當該線程完成時,意味着它執行了return并完成了它的工作。沒有理由讓它繼續

存在。

當線程(帶有句柄x)與main()合并時,作業系統釋放配置設定給該線程的所有記憶體、虛拟CPU和棧,然後該線程消失。然而,main()仍然活着,直到它到達最後一行代碼并傳回(代碼2.2中的最後一行)。當main()執行傳回操作時,作業系統将釋放配置設定給main()(即imflipP程式)的所有資源。程式完成運作。然後,你将在Unix中獲得提示符,等待下一個Unix指令,因為imflipP的執行剛剛完成。

2.3.2 多線程垂直翻轉函數MTFlipV()

我們已經知道多線程程式應該如何工作了,現在來看一看每個線程在多線程版本的程式中執行的任務。這就像類比2.1,如果是獨自一人,一位農民必須收獲所有的1800棵樹(從0到1799),而如果有兩位農民來收獲,他們就可以分成兩部分[0 ... 899 ]和[900 ... 1799],随着農民人數的增加,每個區域的椰子樹會越來越少(資料範圍)。神奇的公式是公式2.1,它僅基于名為tid的單一參數來指定這些劃分範圍。是以,為每個單獨的線程配置設定相同的任務(即編寫每個線程将在運作時執行的函數),并在運作時為每個線程配置設定唯一的tid,這對于編寫多線程程式足夠了。

如果我們記得代碼1.2中顯示的垂直翻轉代碼的串行版本,它會逐列周遊,并将每列中的每個像素與其垂直鏡像的像素交換。例如,在名為dog.bmp的640×480的圖像中,行0(第一行)包含水準像素0,其垂直鏡像行479(最後一行)包含像素479。是以,為了垂直翻轉圖像,我們的串行函數FliplmageV()必須按照以下方式逐一交換每行的像素。←→符号表示交換。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

代碼1.2用于更新記憶體,交換像素的FlipImageV()函數看起來像下面這樣。注意:傳回值類型被改為void,與該程式的多線程版本保持一緻。除此之外,下面列出的代碼的剩餘部分看起來和代碼1.2完全一樣。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

問題是:如何修改FlipImageV()函數使它能夠以多線程運作?正如我們之前強調的那樣,該函數的多線程版本MTFlipV()将接收一個名為tid的參數。它将處理的圖像儲存在全局變量TheImage中,是以不需要作為額外的輸入參數進行傳遞。由于我們的老朋友pthread_create()期望我們給它一個函數指針,是以我們将這樣定義MTFlipV():

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

在本書中,我們會遇到一些不适合并行化的函數。不容易并行化的函數通常稱為不易線程化函數。在這一點上,任何讀者都不應該懷疑,如果一個函數不易線程化,那麼它很可能是GPU不友好的。在本節中,我認為這樣的函數可能也是CPU多線程不友好的。

那麼,當一項任務是“天生的串行”時,我們該怎麼做?顯然你不會在GPU上運作此任務。你應該把它放在CPU上,保持串行,讓它快速地運作。大多數現代CPU,比如我在1.1節中提到的i7-5960x[11],都有一個稱為Turbo Boost的特性,它允許CPU在運作串行(單線程)代碼時在單線程上實作非常高的性能。為了實作這一目标,CPU可以将其中一個核心的時鐘頻率設為4 GHz,而其他核心的頻率為3 GHz,進而大大提高單線程代碼的性能。這使得現代和老式的串行代碼都可以在CPU上獲得良好的性能。

代碼2.7給出了MTFlipV( )的完整代碼清單。與代碼1.2給出的該函數的串行版本相比,除了充當資料分塊代理的tid外,沒有太多差别。請注意,這段代碼是一段非常簡單的多線程代碼。通常,每個線程的工作完全取決于程式員的邏輯。就我們的目的而言,這個簡單的例子非常适合展示基本的思想。此外,FlipImageV( )函數是一個非常友好且适合多線程的函數。

代碼2.7:imflipP.c ... MTflipV( ){...}

FlipImageV( )在代碼1.2中,它的多線程版本需要提供tid。它和串行版本之間的唯一差別在于它處理的是部分資料,而不是全部資料。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.3.3 FlipImageV( )和MTFlipV( )的比較

以下是串行垂直翻轉函數FlipImageV( )和其并行版本MTFlipV( )之間的主要差別:

  • FlipImageV( )定義為函數,而MTFlipV( )定義為函數指針。這是為了讓我們在使用pthread_create()啟動線程時更加容易地使用這個指針。
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
  • FlipImageV( )處理全部圖像資料,而并行版本的MTFlipV( )僅處理通過與式2.1類似的公式計算出的部分圖像資料。是以,MTFlipV( )需要一個傳遞給它的變量tid以知道它是誰。這在用pthread_create( )啟動線程時完成。
  • 除了在pthread_create( )啟動線程函數中使用MTFlipFunc函數指針,我們還可以通過MTFlipFunc函數指針(及其串行版本FlipFunc)自己調用該函數。要調用這些指針所指向的函數,必須使用以下表示法:
帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
  • 圖像的每行占用ip.Hbytes位元組。例如,根據公式2.2,對于640×480的圖像dog.bmp,

    ip.Hbytes=1920位元組。串行函數FlipImageV()顯然必須周遊範圍[0 ... 1919]中的每個位元組。但多線程版本MTFlipV( )會根據tid對這些水準的1920位元組進行分塊。如果啟動了4個線程,則每個線程需要處理的位元組(和像素)範圍為:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章
  • 多線程函數的第一個任務是計算它必須處理的資料範圍。如果每個線程都這樣做,那麼上面顯示的4個像素範圍就可以并行處理了。以下是每個線程如何計算其自己的範圍:
    帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

線程的第一個任務是計算它的ts值和te值(線程開始和線程結束)。這是Hbytes中的範圍,與上面列出的類似,基于公式2.1計算分塊。由于每個像素占用3個位元組(每個RGB顔色分量需要一個位元組),是以函數将for循環中的col變量加3。FlipImageV( )函數不需要做這樣的計算,因為它需要處理所有的資料,即Hbytes的範圍是0到1919。

  • 在串行的FliplmageV( )函數中,待處理的圖像通過局部變量img傳遞,與1.1節中介紹的版本相容,而在MTFlipV( )中則使用全局變量(TheImage),原因将在後面的章節中介紹。
  • 多線程函數執行pthread_exit( )讓main( )知道它已經完成。此時,pthread_join()函數才會繼續執行下一行,處理已完成的線程。

一個有趣的情況是,如果我們用pthread_create( )隻啟動一個線程,那麼技術上我們正在運作一個多線程程式,其中tid的範圍是[0 ... 0]。這個線程仍然會計算它的資料範圍,但它發現它必須處理整個範圍。在imf?lipP.c程式中,FlipImageV()函數被稱為串行版本,而使用1個線程的多線程版本是允許的,這被稱為l線程版本。

通過比較串行代碼1.2和其并行版本代碼2.7,很容易看出,隻要一開始就小心編寫函數,通過一些小改動就能很容易地對它進行并行化。當我們對某些串行CPU代碼實施GPU并行化時,這種思想非常有用。根據定義,GPU代碼意味着并行代碼,是以這種思想允許我們以最小的努力将CPU代碼移植到GPU環境下。當然,這種情況隻在某些時候成立,并不總是成立的!

2.3.4 多線程水準翻轉函數MTFlipH()

代碼2.8中所示為代碼1.3中的串行函數FlipImageH()的多線程并行化版本MTFlipH()。與垂直翻轉函數類似,多線程的水準翻轉函數也需要檢視tid以确定它必須處理哪部分資料。對于使用4個線程的640×480圖像(480行),像素分塊為:

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

對于線程負責的每一行資料來說,每個像素3個位元組的RGB值都會與其水準鏡像的像素進行交換。該交換從存放第0個像素RGB值的col = [0 ... 2]開始,并一直持續到最後的RGB(3位元組)值被交換。對于640×480的圖像來說,由于Hbytes=1920,并且沒有浪費的位元組,是以最後一個像素(即像素639)在col = [1917 ... 1919]處。

代碼2.8:imflipP.c ... MTFlipH(){...}

代碼1.3中FliplmageH()函數的多線程版本。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

2.4 多線程代碼的測試/計時

我們已經知道了imf?lipP程式是如何工作的,現在是時候測試它了。程式的指令行文法是通過main()的解析部分來确定的,如代碼2.l所示。要運作imf?lipP,一般的指令行語

法是:

imflipP InputfileName OutputfileName [v/h/V/H] [1-128]

其中,InputFileName和OutputFileName分别是要讀取和寫入的BMP檔案名。可選的指令行參數[v/h/V/H]用于指定翻轉方向(預設值是'V')。下一個可選參數是線程數,可以在1和MAXTHREADS(128)之間指定,預設值為1(串行)。

表2-1顯示了同一程式在擁有4C/8T(4個核心/8個線程)的Intel i7-960 CPU上使用1到10個線程時的運作時間。我們一直測試到10個線程,并不是希望超過8個線程後,程式還能提速,而是作為完備性檢查。這些檢查有助于快速發現潛在的錯誤。功能性測試可以通過檢視輸出圖檔,檢查檔案大小以及運作比較兩個二進制檔案的比較程式(Unix diff指令)來完成。

帶你讀《基于CUDA的GPU并行程式開發指南》之二:開發第一個CPU并行程式第2章

那麼,這些結果告訴我們什麼?首先,在垂直和水準翻轉的情況下,使用多個線程很明顯是有幫助的。是以,我們對該程式進行并行化并非沒有用處。然而,令人不安的消息是,超過3個線程後,水準和垂直翻轉似乎都沒有性能的提升。對于≥4個線程的情況,你可以将結果資料簡單地視為噪聲!

表2-1清楚地表明,在小于3個線程時,多線程是有效果的。當然,這個結論不具普遍性。當我在i7-960 CPU(4C/8T)上運作代碼2.7和代碼2.8中所示的代碼時,該結論是滿足的,代碼2.7和代碼2.8是imflipP.c的核心代碼。現在,你心裡應該有一千個問題。下面也許是其中的一些:

  • 在2C/2T這樣功能較弱的CPU上,結果會不同嗎?
  • 在功能更強大的CPU上,如6C/12T,結果會怎樣?
  • 在表2-1中,考慮到我們是在4C/8T的CPU上進行測試,在8個線程的配置上不是應該獲得更好的結果嗎?或者,至少在6個線程上獲得最好的結果?為什麼超過3個線程時,性能會退化?
  • 如果我們處理較小的640×480圖像(如dog.bmp),而不是巨大的3200×2400圖像dogL.bmp,結果會怎樣?性能增長的拐點會是一個不同的線程數嗎?
  • 或者,對于較小的圖像,是否會出現拐點?
  • 同樣是處理相同數量的3200×2400個像素為什麼水準翻轉比垂直翻轉操作更快?
  • ……

上述清單還可以繼續。不要因為表2-1失眠。對于本章,我們已經實作了我們的目标。我們知道了如何編寫多線程程式,并且使程式獲得了一些加速。在我們的并行程式設計之旅中,這已經足夠了。我可以保證你會想到1000個關于為什麼這個程式沒有我們所希望的那麼快的問題。我也可以保證,你不會想到實際上導緻這種平淡表現的關鍵問題。回答這些問題需要一整章的内容,這就是我将要做的。在第3章中,我将回答上述所有問題以及更多你沒有問的問題。現在,請你思考一下你可能不會問的問題……

繼續閱讀