天天看點

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

點選檢視第一章 點選檢視第二章

第3章

改進第一個CPU并行程式

我們并行化了第一個串行程式imflip.c,并在第2章中開發了它的并行版本imflipP.c。并行版本使用pthreads實作了合理的加速,如表2-1所示。當我們在具有4C/8T的i7-960 CPU上分别啟動2個和3個線程時,多線程将執行時間從131 ms(串行版本)分别降低到70 ms和46 ms。然而引入更多的線程(即≥4)并沒有幫助。在本章中,我們想讓讀者了解影響表2-1中結果資料的各種因素。我們可能無法改進它們,但我們必須能夠解釋為什麼無法改進它們。我們不想僅僅因為運氣而取得好的性能表現!

3.1 程式員對性能的影響

了解硬體和編譯器可以幫助程式員編寫好的代碼。多年來,CPU架構師和編譯器設計人員不斷改進其CPU架構和編譯器的優化功能。許多這些努力有助于減輕軟體程式員的負擔,是以,程式員在編寫代碼時不用擔心底層的硬體細節。但是,正如我們将在本章中看到的,了解底層硬體和高效利用硬體也許會讓程式員在某些情況下開發出性能提升10倍的代碼。

這種說法不僅對CPU來說是正确的,當硬體得到有效的利用時,潛在的GPU性能改進更加明顯,因為許多GPU性能的顯著提升來自軟體。本章将介紹所有與性能有關的因素及其互相之間的關系:程式員、編譯器、作業系統和硬體(以及某種程度上的使用者)。

  • 程式員擁有根本的智慧,應該了解其他部分的功能。沒有任何軟體或硬體可以與程式員所能做的相提并論,因為程式員具有最寶貴的資産:邏輯。良好的程式設計邏輯需要完全了解難題的所有方面。
  • 編譯器是一個龐大的軟體包,它的正常功能有兩個:編譯和優化。編譯是編譯器的工作,優化是編譯器在編譯時必須執行的額外工作,以優化程式員可能編寫的低效代碼。是以編譯器在進行編譯時是“組織者”。編譯時,時間是靜止的,這意味着編譯器可以仔細考慮在運作時可能發生的許多情況,并為運作時選擇最好的代碼。當我們運作程式時,時鐘開始滴答滴答。編譯器唯一無法知道的是資料,它們可能會完全改變程式的流程。隻有在作業系統和CPU工作時,才能在運作時知道資料的情況。
  • 在運作時,作業系統(OS)可以看作是硬體的“老闆”或“經理”。它的工作是在運作時有效地配置設定和映射硬體資源。硬體資源包括虛拟CPU(即線程)、記憶體、硬碟、閃存驅動器(通過通用串行總線[USB]端口)、網卡、鍵盤、顯示器、GPU(一定程度)等。好的作業系統知道它的資源以及如何很好地映射它們。為什麼這很重要?因為資源本身(例如CPU)不知道該怎麼做。它們隻是遵循指令。作業系統是司令,線程是士兵。
  • 硬體是CPU+記憶體+外圍裝置。作業系統接受編譯器生成的二進制代碼,并在運作時将它們配置設定給虛拟核心。虛拟核心在運作時盡可能快地執行它們。作業系統還要負責CPU與記憶體、磁盤、鍵盤、網卡等之間的資料傳輸。
  • 使用者是難題的最後一部分:了解使用者對編寫好的代碼也很重要。一個程式的使用者不是程式員,但程式員必須向使用者提出建議,并且必須與他們溝通。這不是一件容易的事情!

本書主要關注硬體,尤其是CPU和記憶體(以及在後面第二部分中要講的GPU和顯存)。了解硬體是開發高性能代碼的關鍵,無論是CPU還是GPU。在本章中,我們将發現是否有可能加速我們的第一個并行程式imflipP.c。如果可以的話,如何實作?唯一的問題是:我們不知道可以使用哪些硬體來更高效地提高性能。是以,我們會檢視所有可能。

3.2 CPU對性能的影響

在2.3.3節中,我解釋了當我們啟動多線程代碼時發生的事件序列。在2.4節中,我還列出了許多你可能會想到的如何解釋表2-1的問題。讓我們來回答第一類也是最明顯的一類問題:

  • 當CPU不同時,這些結果會如何變化?
  • 取決于CPU的速度,還是核心數量,線程數?
  • 或者是其他與CPU有關的屬性?比如高速緩存?

也許,回答這個問題最有趣的方法是在許多不同的CPU上運作相同的程式。一旦得到結果,我們可以嘗試從它們中發現點什麼。測試這些代碼的CPU越多,得到的答案可能也會越好。我将在表3-1中列出的6個不同的CPU上運作此程式。

表3-1列出了一些重要的CPU參數,如核心數和線程數(C/T),每個核心擁有的L1$和L2$高速緩存的大小,分别表示為L1$/C和L2$/C,共享的L3$高速緩存大小(由所有的4個、6個或8個核心共享)。表3-1還列出了每台計算機的記憶體大小和記憶體帶寬(BW),記憶體帶寬以千兆位元組每秒(GBps)為機關。在本節中,我們将重點關注CPU對性能的影響,但對記憶體在決定性能方面的作用的解釋将貫穿本書。我們也将在本章中介紹記憶體的操作。目前,為了防止性能名額與記憶體而不是CPU有關,記憶體參數也被列在

表3-1中。

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

在本節中,我們不會評估這6個CPU的性能結果如何不同。我們隻是想看CPU競賽來開心一下!當看到這些數值時,我們可以得出哪些才是決定程式整體性能最關鍵因素的結論。換句話說,我們正在從遠距離觀察事物。稍後我們會深入探讨細節,但收集的實驗資料将幫助我們找到一些方法來提高程式性能。

3.2.1 按序核心與亂序核心

除了CPU有多少個核心之外,還有另一個與核心相關的因素。幾乎每個CPU制造商開始時都是制造按序(In-Order,inO)核心,然後将其設計更新到更先進的系列産品中的亂序(Out-of-Order,OoO)核心。例如,MIPS R2000是一個inO型CPU,而更先進的R10000是OoO。同樣的,Intel 8086、80286、80386以及更新的Atom CPU都是inO,而Core i3、i5、i7以及Xeon都是OoO。

inO和OoO之間的差別在于CPU執行給定指令的方式,inO類型的CPU隻能按照二進制代碼中列出的順序來執行指令,而OoO類型的CPU可以按照操作數可用性的順序執行指令。換句話說,OoO型CPU可以在後面的指令中找到很多可做的工作。與之相對的是,當按照給定的指令順序執行時,如果下一條指令沒有可用的資料(可能是因為記憶體控制器還沒有從記憶體中讀取必要的資料),inO類型的CPU隻是處于空閑狀态。

OoO型CPU執行指令的速度更快,因為當下一條指令在操作數可用之前不能立即執行時,它可以避免被卡住。然而,這種奢侈的代價昂貴:inO型CPU需要的晶片面積更小,是以允許制造商在同一個內建電路晶片中安裝更多的inO核心。由于這個原因,每個inO核心的時鐘實際上可能會更快一些,因為它們更簡單。

類比3.1:按序執行與亂序執行

Cocotown舉辦了一個比賽,兩隊農民比賽做椰子布丁。這是提供給他們的指導書:

(1)用自動破碎機敲碎椰子;(2)用研磨機研磨從破碎機敲碎的椰子;(3)煮牛奶;

(4)放入可可粉并繼續煮沸;(5)将研磨後的椰子粉放入混有可可的牛奶中,繼續煮沸。

每一步都要花費10分鐘。第一隊在50分鐘内完成了他們的布丁,而第二隊在30分鐘内完成,震驚了所有人。他們獲勝的秘密在賽後公開:他們同時開始敲椰子(步驟1)和煮牛奶(步驟3)。這兩項任務并不互相依賴,可以同時開始。10分鐘後,這兩項工作都完成了,可以開始研磨椰子(步驟2)。與此同時,将可可與牛奶混合并煮沸(步驟4)。是以,在20分鐘内,他們完成了步驟1~4。

不幸的是,步驟5必須等待步驟1~4完成後才能開始,使其總執行時間為30分鐘。是以,第二隊的秘訣就是亂序執行任務,而不是按照指定的順序執行。換句話說,他們可以執行任何不依賴于前一步結果的步驟。

類比3.1強調了OoO運作的性能優勢。一個OoO核心可以并行地執行獨立的依賴鍊(即互不依賴的CPU指令鍊),而無須等待下一條指令完成,實作了健康的加速。但是,采用兩種類型中的一種設計CPU時,還存在一些需要妥協的地方。人們想知道哪一種是更好的設計思路:(1)多一些inO核心;(2)少一些OoO核心。如果我們将這個想法推向極緻,将CPU中的60個核心都設計為inO核心會怎麼樣?這會比擁有8個OoO核心的CPU更快嗎?答案并不像選擇其中的一種那麼簡單。

下面是一些inO型與OoO型CPU比較的真實情況:

  • 這兩種設計思路都是可行的,也有一個真正按照inO類型設計的CPU,稱為Xeon Phi,由Intel制造。Xeon Phi 5110P在每個CPU中有60個inO核心,每個核心有4個線程,使其能夠執行240個線程。它被看成內建衆核(MIC)而非CPU,每個核心的工作速度都非常低,如1 GHz,但是它的核心和線程的數量很大,進而可以獲得計算優勢。inO核心的功耗非常低,60C/240T的Xeon Phi的功耗僅略高于差不多擁有6C/12T的Core i7 CPU。稍後,我将給出在Xeon 5110P上的執行時間。
  • inO類型CPU隻對某些特殊的應用程式有好處,并非每個應用程式都可以利用如此多的核心或線程。對于大多數應用程式來說,當核心或線程數量超過某一特定值後,我們獲得的回報就會不斷減少。一般來說,圖像和信号處理應用程式非常适合inO類型CPU或MIC。高性能科學計算類的應用程式通常也是使用inO類型CPU的候選對象。
  • inO核心的另一個優勢是低功耗。由于每個核心都簡單得多,它消耗的功率比同檔次的OoO核心要少。這就是當今大多數上網本都采用英特爾Atom處理器(具有inO核心)的原因。一個Atom CPU隻消耗2~10瓦。Xeon Phi MIC一般有60個Atom核心,每個核心有4個線程,全部封裝在一塊晶片中。
  • 如果擁有較多的核心和線程能夠使一些應用程式受益,那麼為什麼不讓這種想法更進一步,将數千個核心都放入計算單元中,同時讓每個核心可以執行超過4個的線程呢?事實證明,這種想法也是可行的。類似地,可以在大約數千個核心中執行數十萬個線程的處理器稱為GPU,也就是本書關注的對象!

3.2.2 瘦線程與胖線程

在執行多線程程式(如imflipP.c)時,可以在運作時給一個核心配置設定多個線程來執行。例如,在一個4C/8T的CPU上,兩個線程運作在兩個獨立的核心上,也可以運作在一個核心上,這有什麼差別嗎?答案是:當兩個線程共享一個核心時,它們必須共享所有的核心資源,例如高速緩存、整數計算單元和浮點計算單元。

如果需要大量高速緩存的兩個線程被配置設定給同一個核心,那它們會把時間浪費在将資料從高速緩存中移進或移出,進而無法從多線程中獲益。假設一個線程需要大量的高速緩存通路,而另一個線程隻需要整數計算單元而不需要高速緩存通路。這樣的兩個線程在執行期間是放在同一核心中運作的優秀候選者,因為它們在執行期間不需要相同的資源。

另一方面,如果程式員設計的一個線程對核心資源的需求較少,那麼它從多線程中的獲益就會很大。這樣的線程稱為瘦線程,而那些需要大量核心資源的線程稱為胖線程。程式員的責任是認真地設計這些線程以避免占用過多的核心資源,如果每個線程都是胖線程,增加線程數量就不會帶來什麼好處了。這就是為什麼像微軟這樣的作業系統設計人員在設計線程時要考慮避免影響多線程應用程式的性能。最後要說的是,作業系統是一個終極多線程應用程式。

3.3 imflipP的性能

表3-2列出了imflipP.c在一些CPU(表3-1中列出)上的執行時間(以毫秒為機關)。列出線程總數隻是為了顯示不同的數值。CPU2一欄的結果與表2-1中的一樣。每個CPU上的結果趨勢似乎非常相似:開始性能會有所提升,但線程數達到一定數量時,性能的提升就會遇到一堵牆!當超過由CPU決定的某個拐點後,啟動更多的線程對性能提升不會有幫助。

表3-2展現出了不少問題,比如:

  • 在已知最多能執行8個線程(.../8T)的4C/8T CPU上啟動9個線程意味着什麼?
  • 這個問題的正确問法也許是:啟動和執行一個線程有什麼不同?
  • 當我們将一個程式設計為“8線程”時,我們期待運作時會發生什麼?我們是否假設全部8個線程都會被執行?
  • 2.1.5節中提到:某計算機上啟動了1499個線程,但CPU使用率為0%。是以,不是每個線程都在并行地執行。否則,CPU使用率将達到峰值。如果一個線程沒有被執行,它在做什麼?運作時誰在管理這些線程?
帶你讀《基于CUDA的GPU并行程式開發指南》之三:改進第一個CPU并行程式第3章
  • 上面這些問題也許可以回答為什麼超過4個以上的線程不能幫助我們提高表3-2中的性能結果。
  • 還有一個問題是,胖線程或瘦線程否可以改變這些結果。
  • 很明顯的是:表3-1中的所有CPU都是OoO。

3.4 作業系統對性能的影響

我們還可以提出很多其他問題,這些問題的本質都是一樣的:線程在運作時會發生什麼?換句話說,我們知道作業系統負責管理虛拟CPU(線程)的建立/關聯,但現在是了解細節的時候了。回到我們前面提及的與性能有關的因素清單:

  • 程式員通過為每個線程編寫函數來确定一個線程需要做什麼。這在編譯時就決定了,此時沒有任何運作時資訊可用。編寫該函數的語言比機器代碼進階得多,而機器代碼是CPU唯一能了解的語言。在過去,程式員直接編寫機器代碼,這使得程式開發可能困難100倍。現在我們有了進階語言和編譯器,是以我們可以将負擔轉移給編譯器。程式員的最終産品是一個程式,它是一組按指定順序執行的任務,包含各種假設場景。使用假設場景的目的是對運作時各種不同的事件做出很好的響應。
  • 編譯器在編譯時将線程建立函數編譯為機器代碼(CPU語言)。編譯器的最終産品是可執行指令序列或二進制可執行檔案。請注意,編譯器将程式設計語言編譯為機器代碼時基本上不知道運作時會發生什麼。
  • 作業系統負責運作時的問題。為什麼我們需要這樣的中間軟體?因為執行由編譯器生成的二進制檔案時會發生許多不同的事情。可能發生的不好的事情有以下幾種情況:(1)磁盤可能已滿;(2)記憶體可能已滿;(3)使用者的輸入可能導緻程式崩潰;(4)程式可能請求了過多的線程數目,但已沒有可用的線程句柄。另外,即使沒有出錯,也必須對資源效率負責,也就是說,要高效地運作程式,需要注意以下幾點:(1)誰獲得了虛拟CPU;(2)當程式申請記憶體時,是否應該獲得,如果是的話,指針是什麼;(3)如果一個程式想建立一個子線程,我們是否有足夠的資源來建立它?如果有,線程句柄是什麼;(4)通路磁盤資源;(5)網絡資源;(6)其他任何你可以想象的資源。資源是在運作時管理的,無法在編譯時精确地知道它們。
  • 硬體負責執行機器代碼。作業系統在運作時将需要執行的機器代碼配置設定給CPU。類似地,存儲器主要由在OS控制下的外圍裝置(例如,直接存儲器通路—DMA控制器)進行讀取和傳送。
  • 使用者負責享受這些程式,如果程式寫得很好并且運作時一切正常,将能夠産生出色的結果。

3.4.1 建立線程

作業系統知道它擁有哪些資源,因為一旦計算機打開,大多數資源都是确定的。虛拟CPU的數量就是其中之一,也是我們最感興趣的一個。如果作業系統确定它正在擁有8個虛拟CPU的處理器上運作(正如我們在4C/8T機器上的情況),它會給這些虛拟CPU配置設定名稱,如vCPU0、vCPU1、vCPU2、vCPU3、……、vCPU7。在這種情況下,作業系統擁有8個虛拟CPU的資源并負責管理它們。

當程式用pthread_create()啟動一個線程時,作業系統會為該線程配置設定一個線程句柄,比如1763。這樣,程式在運作時會看到ThHandle[1]=1763。該程式将此解釋為“tid=1被配置設定給了句柄ThHandle [1]=1763。”該程式隻關心tid=1,作業系統隻關心其句柄清單中的1763。盡管這樣,程式必須儲存該句柄(1763),因為這個句柄是告訴作業系統它正在和哪個線程進行對話的唯一方式,tid=1或ThHandle[]隻不過是一些程式變量,而且對作業系統的内部工作并不重要。

3.4.2 線程啟動和執行

作業系統在運作時将ThHandle[l]=1763配置設定給一個父線程後,父線程就會明白它獲得了使用該子線程執行某個函數的授權。它會使用在pthread_create()中設定的函數名來啟動相關代碼。這是告訴作業系統,除了建立該線程,現在父線程想要啟動該線程。建立一個線程需要一個線程句柄,啟動一個線程則需要配置設定一個虛拟CPU(即找到某人完成這項工作)。換句話說,父線程說:查找一個虛拟CPU,并在該CPU上運作此代碼。

在這之後,作業系統嘗試查找可用于執行此代碼的虛拟CPU資源。父線程不關心作業系統選擇哪個虛拟CPU,因為這是作業系統負責的資源管理問題。作業系統會在運作時将剛配置設定的線程句柄映射到一個可用的虛拟CPU上(例如,句柄1763→vCPU 4),如果虛拟CPU 4(vCPU4)在pthread_create()被調用時正好可用。

3.4.3 線程狀态

在2.1.5節提到過,計算機上啟動了1499個線程,但CPU使用率為0%。是以,不是每個線程都在并行地執行着。如果一個線程沒有執行,那麼它在做什麼?一般來說,如果一個CPU有8個虛拟CPU(如4C/8T處理器),那麼處于運作狀态的線程不會超過8個。正在執行的線程狀态是這樣的:作業系統不僅認為它是就緒的,并且該線程此刻正在CPU上執行(即正在運作)。除了運作,一個線程的狀态還可以是就緒、阻塞,或者在其作業完成時終止,如圖3-1所示。

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

當應用程式調用pthread_create()來啟動一個線程時,作業系統會立即确定一件事情:我是否擁有足夠的資源來配置設定句柄并建立此線程?如果答案為“是”,則為該線程配置設定一個線程句柄,并建立所有必需的記憶體和棧區。此時,線程的狀态為就緒,并被記錄在該句柄中。這意味着該線程可以運作,但尚未運作。通常它會進入可運作線程隊列并等待運作。在未來的某個時刻,作業系統會決定開始執行這個線程。為此,必須發生兩件事情:

(1)找到能夠執行該線程的虛拟CPU(vCPU);(2)線程的狀态更改為運作。

就緒→運作狀态的改變由稱為分發器的隸屬于作業系統的一個子產品來處理。作業系統将每個可用的CPU線程視為虛拟CPU(vCPU),例如,一個8C/16T的CPU有16個vCPU。在隊列中等待的線程可以在vCPU上開始運作。一個成熟的作業系統會關注在哪裡運作線程以優化性能。這種稱為核心親和性的分發政策實際上可以由使用者手動修改,以覆寫作業系統預設的不太優化的分發政策。

作業系統允許每個線程運作一段時間(稱為時間片),然後切換到另一個已處于就緒狀态下等待的線程。這樣做是為了避免饑餓現象,即一個線程永遠停留在就緒狀态。當一個線程從運作切換到就緒狀态時,它所有的寄存器資訊,甚至更多資訊,必須被儲存在某個地方,這些資訊稱為線程的上下文。同樣,從運作到就緒狀态的更改稱為上下文切換。上下文切換需要一定的時間才能完成,并對性能有影響,但這是不可避免的現實。

在執行過程中(處于運作狀态),線程可能會調用函數(如scanf())來讀取鍵盤輸入。讀取鍵盤比任何其他的CPU操作要慢得多。是以,沒有理由讓作業系統在等待鍵盤輸入時讓我們的線程保持運作狀态,這會使其他線程饑餓。在這種情況下,作業系統也無法将此線程切換為就緒狀态,因為就緒隊列存放的線程在時間允許時可立即切換到運作狀态。一個正在等待鍵盤輸入的線程可能會等待一段無法預知的時間,這個輸入可能會立刻發生,也可能在10分鐘後發生,因為使用者可能離開去喝咖啡!是以,把這種狀态稱為阻塞。

當一個線程正在一段時間内請求某個無法使用的資源時,或者它必須等待某個不确定何時會發生的事件時,它會從就緒狀态切換到阻塞狀态。當所請求的資源(或資料)變得可用時,線程又會從阻塞狀态切換回就緒狀态,并被放入就緒線程的隊列中,即等待被再次執行。作業系統直接把這個線程切換到運作狀态是沒有意義的,因為這意味着另一個正在平靜地執行的線程被胡亂地排程,即将它踢出核心!是以,為了有序地運作,作業系統将已阻塞的線程放回就緒隊列中,并決定何時允許它再次執行。但是,作業系統可能會因為某個原因給該線程配置設定一個不同的優先級,以保證它能夠在其他線程之前被排程。

最後,當一個線程執行完成并調用pthread_join()函數後,作業系統會執行運作→終止的狀态切換,該線程被永久地排除在就緒隊列外。一旦該線程的存儲區域等被清除,該線程的句柄就會被釋放,并可用于其他pthread_create()。

3.4.4 将軟體線程映射到硬體線程

3.4.3節回答了關于圖2-1中的1499個線程的問題:我們知道在圖2-1中看到的1499個線程中,至少有1491個線程在4C/8T的CPU上處于就緒或阻塞狀态,因為處于運作狀态的線程不能超過8個。可以把1499看作要完成的任務數量,但一共隻有8個人來做!在任何時刻,作業系統都沒有足夠的實體資源來同時“做”(即執行)超過8件事情。它挑選1499個任務中的一個,并指定一個人來完成。如果另一項任務對于這個人來說更加緊迫(例如,如果網絡資料包到達,需要立即處理),則作業系統會切換為執行更緊急的任務并暫停他目前正在執行的任務。

我們很好奇這些狀态切換如何影響應用程式的性能。對于圖2-1中的1499個線程,其中的1495個線程很可能是阻塞或就緒的,它們在等待你敲擊鍵盤上的某個鍵,或者等待某個網絡包的到達,隻有4個線程正處于運作狀态,也許就是你的多線程應用程式代碼。下面是一個類比:

類比3.2:線程狀态

透過窗戶,你在路邊看到1499張紙片,上面寫着任務。你還看到外面有8名員工都坐在椅子上,等待經理給他們分派執行任務。在某個時刻,經理告訴 #1号員工去拿起 #1256号紙片。然後,#1号員工開始執行寫在 #1256号紙片上的任務。突然,經理告訴 #1号員工将 #1256号紙片放回原處,停止執行 #1256号任務并拿起 #867号紙片,開始執行 #867号紙片上的任務……

由于#1256号任務尚未執行完成,是以#1号員工執行#1256号任務的所有筆記都必須寫在經理的筆記本中的某個地方,以便 #1号員工稍後能回憶起它們。事實上,該任務甚至可能會由不同的員工來繼續完成。如果 #867号紙片上的任務已經完成,它可能會被揉成一團并扔進廢紙簍,表明該任務已完成。

在類比3.2中,坐在椅子上對應線程的就緒狀态,執行寫在紙上的任務對應線程的運作狀态,員工是虛拟CPU。準許員工切換狀态的經理是作業系統,而經理的筆記本是儲存線程上下文的地方,以供稍後在上下文切換期間使用。銷毀紙片(任務)等同于将線程切換到終止狀态。

所啟動的線程數量可以從1499增加到1505或減小到1365等,但是可用的虛拟CPU的數量不會改變(例如,在這個例子中為8),因為它們是“實體”實體。一種好的方式是将這1499個線程定義為軟體線程,即作業系統建立的線程。可用的實體線程(虛拟CPU)數量是硬體線程,即CPU制造商設計CPU能夠執行的最大線程數。這容易讓人有點困惑,因為它們都被稱為“線程”,因為軟體線程隻不過是一種資料結構,包含關于線程将執行的任務以及線程句柄、記憶體區等資訊,而硬體線程是正在執行機器代碼(即程式的編譯版本)的CPU的實體硬體部件。作業系統的工作是為每個軟體線程找到一個可用的硬體線程。作業系統負責管理硬體資源,如虛拟CPU、可用的記憶體等。

3.4.5  程式性能與啟動的線程

軟體線程的最大數量僅受内部作業系統的參數限制,而硬體線程的數量在CPU設計時就固定下來。當你啟動一個執行2個高度活躍線程的程式時,作業系統會盡可能快地使它們進入運作狀态。作業系統中執行線程排程程式的線程可能也是另一個非常活躍的線程,進而使高度活躍的線程數為3。

那麼,這如何幫助解釋表3-2中的結果?雖然準确的答案取決于CPU的架構,但有一些明顯的現象可以用我們剛剛學到的知識來解釋。讓我們選擇CPU2作為例子。雖然CPU2應該能夠并行執行8個線程(它是一個4C/8T),但程式啟動3個以上的線程後,性能會顯著下降。為什麼?我們先通過類比來進行推測:

  • 回憶類比1.1,兩位農民共用一台拖拉機。通過完美的任務安排,他們總共可以完成2倍的工作量。這是4C/8T能夠獲得8T性能的理論支援,否則,實際上你隻有4個實體核心(即拖拉機)。
  • 如果代碼2.1和代碼2.2中發生了這種最好的情況,我們應該期望性能提升現象能夠持續到8個線程,或者至少是6個或7個線程。但我們在表3-2中看到的不是這樣!
  • 那麼,如果其中一項任務要求其中一位農民以亂序的方式使用拖拉機的錘子和其他資源呢?此時,另一位農民不能做任何有用的事情,因為他們之間會不斷産生沖突并導緻效率持續下降。性能甚至不會接近2倍(即,1+1=2)!性能會更接近0.9倍!就效率而言,1+1=0.9聽起來很糟糕!換句話說,如果2個線程都是“胖線程”,它們并不會與同一個核心中的另一個線程同時工作,我的意思是,高效率地……這就是代碼2.1和代碼2.2所發生情況的原因,因為從每個核心運作雙線程中我們沒有獲得任何性能提升……
  • 記憶體又如何呢?我們将在第4章介紹完整的核心和記憶體的體系結構群組織。但是,現在可以說,無論CPU擁有多少核心/線程,所有線程隻能共享一個主存。是以,如果某個線程是記憶體不友好的,它會擾亂每個人的記憶體通路。這是另一種解釋,即為什麼線程數≥4時,性能提升會遇到一堵牆。
  • 假設我們解釋了為什麼我們無法在每個核心中使用雙線程(稱為超線程)的問題,但為什麼性能提升在4線程上停止?4線程的性能比3線程的性能低,這是違反直覺的。這些線程是否無法使用所有核心?幾乎每一個CPU都可以看到類似的情況,盡管确切的數字取決于最大可用線程數,并且因CPU而異。

3.5 改進imflipP

與其回答所有這些問題,不如看看在我們不知道答案,而隻是猜測問題原因的情況下,我們是否可以改程序式。畢竟,我們有足夠的直覺能夠做出有根據的猜測。在本節中,我們将分析代碼,嘗試确定代碼中可能導緻效率低下的部分,并提出修改建議。修改完成後,我們會看到它的效果如何,并将解釋它為什麼起作用(或不起作用)。

從什麼地方開始最好呢?如果你想提高計算機程式的性能,最好從最内層循環開始。讓我們從代碼2.8中的MTFlipH()函數開始。該函數讀入一個像素并将其移動到另一個存儲位置,一次一個位元組。代碼2.7中顯示的MTFlipV()函數也非常相似。對于每個像素,這兩個函數需要一次移動一個位元組的R、G和B值。這張照片有什麼問題?太多問題了!當我們在第4章中詳細讨論CPU和記憶體架構時,你會對代碼2.7和代碼2.8的效率低下感到驚訝。但是現在,我們隻是想找到明顯的修改方法,并定量地分析這些改進。在我們在第4章中更多地了解記憶體/核心架構之前,我們不會對它們發表評論。

3.5.1  分析MTFlipH()中的記憶體通路模式

MTFlipH()函數顯然是一個“存儲密集型”函數。對于每個像素來說,确實沒有進行“計算”,而隻是将一個位元組從一個存儲位置移動到另一個存儲位置。當我說“計算”時,我的意思是通過減小RGB的值使每個像素值變暗,或者通過重新計算每個像素的新值将圖像變成B&W圖像等。這裡沒有執行這些類似的計算。MTFlipH()的最内層循環如下所示:

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

是以,要改進這個程式,我們必須仔細地分析記憶體通路模式。圖3-2顯示了在處理22 MB圖像dogL.bmp期間MTFlipH()函數的記憶體通路模式。這張小狗圖檔由2400行和3200列組成。當水準翻轉第42行(選擇這個數字沒有什麼特定的原因)時,像素的交換模式如下所示(如圖3-2所示):

42↔42,42↔42 ... 42↔42,42↔42

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

3.5.2  MTFlipH()的多線程記憶體通路

不僅是MTFlipH()的逐位元組記憶體通路聽起來很糟糕,還要記住這個函數是在多線程環境中運作的。首先,如果我們隻啟動一個線程,讓我們來看看單線程的記憶體通路模式是怎樣的:這個單線程會自行翻轉所有2400行,從第0行開始,繼續執行第1、2、…、2399行。在這個循環中,當它處理第42行時,MTHFlip()真正交換的是哪個“位元組”?我們以第一個像素交換為例。它涉及以下操作:交換pixel42和pixel42,也就是依次交換第42行的bytes[0..2]和第42行的bytes [9597..9599]。

在圖3-2中,請注意每個像素對應儲存該像素RGB值的3個連續位元組。在一次像素交換過程中,MTFlipH()函數請求了6次記憶體通路,3次讀取位元組[0..2]和3次将它們寫入在位元組[9597..9599]處翻轉後的像素位置。這意味着,僅為了翻轉一行資料,我們的MTFlipH()函數請求了3200×6=19 200次記憶體通路,包括讀取和寫入。現在,讓我們看看當4個線程啟動時會發生什麼。每個線程都努力完成600行資料的翻轉任務。

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

請注意,這4個線程中的每個線程請求記憶體通路的頻率都和單線程一樣。如果每個線程的設計不當,導緻混亂的記憶體通路請求,那它們将會産生4倍的混亂!讓我們看一下執行的最初部分,main()啟動所有4個線程并把MTHFlip()函數配置設定給它們來執行。如果我們假設這4個線程在同一時間開始執行,這就是所有4個線程在處理最初幾個位元組時試圖同時執行的操作:

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

雖然每個線程的執行會有細微的變化,但并不會改變故事。當你檢視這些記憶體通路模式時,你會看到什麼?第一個線程tid=0正在嘗試讀取pixel0,它的值位于記憶體位址mem(00000000..00000002)。這是任務tid = 0的開始,處理完第0行後,它将繼續處理

第1行。

當tid=0正在等待它的3個位元組從存儲器中讀入時,恰好在同一時間,tid=1正試圖讀取位于存儲器中第600行的第一個像素pixel600,記憶體位址是mem(05760000 .. 05760002),即距離第一個請求5.5 MB(兆位元組)處。等一下,tid=2也沒閑着。它也在努力完成自己的工作,也就是交換第1200行的整行資料。第一個要讀取的像素是pixel1200,位于記憶體位址mem(11520000..11520002)的3個連續位元組,即距離tid=0讀取的3個位元組11 MB處。類似地,tid=3正在嘗試讀取距離前3個位元組16.5 MB處的3個位元組……請記住總圖像為22 MB,并将其處理為4個線程,每個線程負責5.5 MB的資料塊

(即600行)。

當我們在第4章中學習DRAM(動态随機存取存儲器)的詳細内部工作機制時,我們将了解為什麼這種存儲器通路模式是一場災難,但現在針對這個問題,我們可以找到一個非常簡單的修複方法。對于那些渴望進入GPU世界的人來說,請允許我在這裡發表一個看法,CPU和GPU中的DRAM在操作上幾乎相同。是以,我們在這裡學到的任何東西都可以很容易地應用到GPU記憶體上,但也會由于GPU的大規模并行性而導緻一些例外。一個相同的“災難性的記憶體通路”示例可以應用到GPU上,并且你将能夠依靠在CPU世界中學到的知識立即猜出問題所在。

3.5.3  DRAM通路的規則

雖然第4章的很大一部分會用來解釋為什麼這些不連續的記憶體通路對DRAM性能不利,但解決這個問題的方法卻非常簡單和直覺。所有你需要知道的就是表3-3中的規則,它們對于獲得良好的DRAM性能是很好的指導。讓我們看看這張表格并從中了解些什麼。這些規則基于DRAM架構,該架構旨在允許每個CPU核心都能共享資料,并且它們都以這樣或那樣的方式表達同樣的觀點:

當你通路DRAM時,應該通路大塊連續資料,例如1 KB、4 KB,而不是隻通路很小的1個或2個位元組……

雖然這是改進第一個并行程式imflipP.c的非常好的指導,但我們首先檢查一下原來的代碼是否遵守這些規則。以下是MTFlipH()函數的記憶體通路模式(代碼2.8)的總結:

  • 明顯違反粒度規則,因為我們試圖一次通路一個位元組。
  • 如果隻有一個線程,則不會違反局部性規則。但是,若有多個不同線程同時(和遠端)通路則會導緻違規。
  • L1、L2、L3高速緩存對我們根本沒有幫助,因為該場景下沒有好的 “資料重用”。這是因為我們不需要多次使用某個資料元素。

幾乎所有的規則都被違反了,是以不難了解imflipP.c的性能為什麼這麼糟糕了。除非我們遵守DRAM的通路規則,否則我們隻會建立大量低效的記憶體通路,進而影響整體

性能。

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

3.6 imflipPM:遵循DRAM的規則

現在是通過遵循表3-3中的規則來改進imflipP.c的時候了。改進的程式名為imflipPM.c(“M”表示“記憶體友好”)。

3.6.1 imflipP的混亂記憶體通路模式

我們再來分析一下MTFlipH(),它是imflipP.c中一個記憶體不友好的函數。當我們讀取位元組并用其他位元組替換時,每個像素都會單獨地通路DRAM來讀取它的每個位元組,如下所示:

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

關鍵一點在于,由于小狗圖檔位于主存儲器(即DRAM)中,是以每個單獨的像素讀取都會觸發對DRAM的通路。根據表3-3,我們知道DRAM不喜歡被頻繁地打擾。

3.6.2 改進imflipP的記憶體通路模式

如果我們将整行圖像(全部3200個像素,總計9600個位元組)讀入一個臨時區域(DRAM以外的某個區域),然後在該區域内處理它,在處 理該行期間不再打擾DRAM,這會怎樣?我們将這個區域稱為緩沖區。因為這個緩沖區很小,它将被高速緩存在L1$内,可以讓我們很好地利用L1高速緩存。這樣,至少我們現在正在使用高速緩存并遵守了表3-3中的高速緩存友好規則。

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

當我們将9600 B從主存傳輸到Buffer時,我們依賴memcpy()函數的效率,該函數由标準C語言庫提供。在執行memcpy()期間,9600位元組從主存儲器傳輸到我們稱為Buffer的存儲區。這種通路是非常高效的,因為它隻有一個連續的記憶體傳輸,遵循表3-3中的每個規則。

我們不要自欺欺人:Buffer也位于主存。然而,使用這9600個位元組的方式存在巨大的差異。由于我們将連續地通路它們,是以它們将被高速緩存且不再打擾DRAM。這就是為什麼通路Buffer的效率能夠顯著提升,并符合表3-3中的大部分規則的原因。現在讓我們重新設計代碼來使用Buffer。

3.6.3 MTFlipHM():記憶體友好的MTFlipH()

MTFlipH()函數(代碼2.8中)的記憶體友好版本是代碼3.1中的MTFlipHM()函數。除了一個較為明顯的不同,它們基本一樣:在對每行像素進行翻轉操作時,MTFlipHM()隻通路一次DRAM,它使用memcpy()函數讀取大塊資料(圖像的一整行,比如dogL.bmp圖像中的9600 B)。定義了一個16 KB的緩沖區存儲數組作為局部數組變量,在代碼運作到最内層循環開始進行交換像素操作之前,整行資料會被複制到該緩沖區中。我們也可以隻定義9600 B的緩沖區,因為這是dogL.bmp圖像需要的緩沖區大小,但較大的緩沖區可以滿足其他較大圖像的需要。

代碼3.1:imflipPM.c MTFlipHM(){ ... }

記憶體友好版本且符合表3-3中各項規則的MTFlipH()(代碼2.8)。

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

盡管兩個函數中的最内層循環都是相同的,但請注意,MTFlipHM()的while循環體隻通路Buffer[]數組。我們知道作業系統會在棧區為所有的局部變量配置設定一塊區域,我将在第5章詳細介紹這一點。但是現在需要注意的是該函數定義了一個16 KB大小的局部存儲區域,這将使MTFlipHM()函數符合表3-3中的L1緩存規則。

以下是代碼3.1的部分代碼,主要顯示了MTFlipHM()中的緩沖區操作。請注意,全局數組TheImage[]在DRAM中,因為它是通過ReadBMP()函數讀入DRAM的(見代碼2.5)。該變量應嚴格遵守表3-3中的DRAM規則。我認為最好的方法是一次性讀取9600 B資料并将這些資料複制到本地存儲區域。這使其100%的DRAM友好。

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

最大的問題是:為什麼局部變量Buffer []能起作用?我們修改了最内層的循環,并使其按照之前通路TheImage[]的方式來通路Buffer[]數組。Buffer[]數組到底有什麼不同?此外,另一個令人費解的問題是Buffer[]數組的内容将被“高速緩存”,這是從哪裡展現出來的?代碼中并沒有暗示“将這9600個位元組放入高速緩存”,我們如何确信它會進入高速緩存?答案實際上非常簡單:與CPU的架構設計有關。

CPU高速緩存算法可以預測DRAM(“不好的位置”)中的哪些部分應該暫時進入高速緩存(“較好的位置”)。這些預測不需要是100%準确的,因為如果某次預測不準确,總是可以稍後再對其進行糾正。後果隻不過是效率上的懲罰,而不至于造成系統崩潰或其他什麼。将“最近使用的DRAM内容”引入高速緩沖存儲器的這個過程稱為緩存。理論上,CPU可以偷懶,将所有内容都放入緩存中,但實際上這是不可能的,因為隻有少量的高速緩存可用。在i7系列處理器中,L1高速緩存為32 KB,L2高速緩存為256 KB。L1的通路速度比L2快。緩存有益于性能主要有以下三個原因:

  • 通路模式:高速緩沖存儲器是SRAM(靜态随機存取存儲器),而不是像主存儲器那樣的DRAM。與表3-3中列出的DRAM效率規則相比,主導SRAM通路模式的規則要寬松得多。
  • 速度:由于SRAM比DRAM快得多,一旦某些内容被緩存,通路它們的速度就會快很多。
  • 隔離:每個核心都有自己的緩存(L1$和L2$)。是以,如果每個線程頻繁地通路不多于256 KB的資料,這些資料将非常有效地緩存在核心的高速緩存中,不會再去麻煩DRAM。

我們将在第4章中詳細介紹CPU核心和記憶體如何協同工作。但是,我們已經學到了很多關于緩沖的知識,現在可以開始改進我們的代碼了。請注意,緩存對于CPU和GPU都很重要,對GPU更為突出一些。是以,了解緩沖區概念,也就是資料被緩存非常重要。盡管有一些理論研究,但目前沒有辦法顯式地讓CPU将某些指定内容載入高速緩存。它完全由CPU自動完成。但是,程式員可以通過代碼的記憶體通路模式來影響緩存過程。在代碼2.7和代碼2.8中,我們親身體驗到了當記憶體通路模式混亂時會發生什麼情況。CPU的緩存算法根本無力糾正這些混亂的模式,因為它們簡單的緩存/替換算法已經徹底投降。編譯器也無法糾正這些問題,因為在很多情況下,它需要編譯器了解程式員的想法!唯一對性能有幫助的是程式員的邏輯。

3.6.4 MTFlipVM():記憶體友好的MTFlipV()

現在,讓我們看一下代碼3.2中重新設計的MTFlipVM()函數。我們可以看到此代碼與代碼2.7中低效率的MTFlipV()函數之間的一些主要差異。下面是MTFlipVM()和MTFlipV()之間的差別:

  • 改進版中使用了兩個緩沖區:每個緩沖區16 KB。
  • 在最外層循環中,第一個緩沖區用于讀取圖像起始行的整行資料,第二個緩沖區用于讀取圖像終止行的整行資料。随後這兩行資料被交換。
  • 盡管最外層的循環完全相同,但最内層的循環被删除,改為使用緩沖區的批量記憶體傳輸。

代碼3.2:imflipPM.c MTFlipHM(){ ... }

記憶體友好版本且符合表3-3中各項規則的MTFlipH()(代碼2.7)。

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

3.7 imflipPM.C的性能

使用以下指令行運作改進的程式imflipPM.c:

imflipPM InputfileName OutputfileName [v/V/h/H/w/W/i/I] [1-128]

新添加的指令行選項W和I分别用于選擇使用記憶體友好的MTFlipVM()和MTFlipHM()函數。大寫或小寫無關緊要,是以選項列出W/w和I/i。原有的V和H選項仍然有效,并且分别表示調用記憶體不友好的函數MTFlipV()和MTFlipH()。這讓我們運作一個程式就可以将兩個系列的函數進行比較。

表3-4所示為改進後的程式imflipPM.c的運作時間。當我們将這些結果與相應的“記憶體不友好”的imflipP.c(表3-2中列出)進行比較時,我們發現所有的性能均有顯著的改進。這對讀者來說不足為奇,因為用一整章的内容隻展現一些微小的改進,這不會使讀者開心!

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

除了改進效果很明顯,另一個很明顯的地方是改進的效果會基于是垂直翻轉還是水準翻轉而大不相同。是以,我們不做泛泛的評論,而是選擇一個示例CPU,并列出記憶體友好和記憶體不友好的結果來進行深入的研究。由于幾乎每個CPU都展現出了相同的性能改進模式,是以讨論一個具有代表性的CPU不會産生誤導。最好的選擇是CPU5,因為它的結果更豐富并可以将分析進行擴充,而非隻針對幾個核心。

3.7.1 imflipP.c和imflipPM.c的性能比較

表3-5列出了imflipP.c和imflipPM.c在CPU5上的實驗結果。記憶體友好的函數MTFlipVM()和MTFlipHM()與記憶體不友好的函數MTFlipV()和MTFlipH()之間的加速比(即“加速”)在新增加的一列中給出。很難做出類似“全面改善”的評論,因為這不是我們在這裡看到的情況。水準方向和垂直方向的加速趨勢差别很大,需要單獨對它們進行評論。

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

3.7.2 速度提升:MTFlipV()與MTFlipVM()

首先我們來看垂直翻轉函數MTFlipVM()。從表3-5中的MTFlipV()函數(“V”列)轉化到MTFlipVM()函數(“W”列)時有幾點值得注意:

  • 加速比會随着啟動線程的數量而變化。
  • 線程數越多,加速比可能會下降(34倍降至16倍)。
  • 即使線程數超過了CPU實體上可支援的線程數(例如,9、10),加速仍會繼續。

3.7.3 速度提升:MTFlipH()與MTFlipHM()

接下來,我們來看看水準翻轉函數MTFlipHM()。以下是從表3-5中的MTFlipH()函數(“H”列)變化到MTFlipHM()函數(“I”列)時的觀察結果:

  • 與垂直系列相比,加速比的變化要小得多。
  • 啟動更多線程會稍微改變加速比,但确切的趨勢很難量化。
  • 幾乎可以将加速比确定為“固定值1.6”,稍微有一些小的波動。

3.7.4 了解加速:MTFlipH()與MTFlipHM()

表3-5中的内容需要一點時間來消化。隻有仔細閱讀第4章後才能了解正在發生的事情。但是,在本章我們可以先做一些猜測。為了能夠得到有根據的猜測,我們先來看看實際情況。首先,讓我們解釋一下為什麼垂直和水準翻轉系列會有不同,盡管這兩個函數最終翻轉了數量完全相同的像素。

比較代碼2.8中的MTFlipH()和代碼3.1中的記憶體友好版本MTFlipHM(),我們看到的唯一差別是本地緩沖,其他的代碼是相同的。換句話說,如果這兩個函數之間有任何的加速,則肯定是由緩沖引起的。是以,下面這種說法是很公正的:

本地緩沖使我們能夠充分利用高速緩存,這導緻了1.6倍的加速。

這個數字随線程數的增加而輕微地波動。

另一方面,将代碼2.7中的MTFlipV()與代碼3.2中的記憶體友好版本MTFlipVM()進行比較,我們看到函數從核心密集型轉換為存儲密集型。MTFlipV()一次隻處理一個位元組的資料,并且保持核心的内部資源處于忙碌狀态,而MTFlipVM()使用memcpy()的批量記憶體複制函數,并通過批量記憶體資料傳輸完成所有操作,這樣有可能完全避免核心的參與。當你讀取大塊資料時,神奇的memcpy()函數可以從DRAM中非常高效地複制某些内容,就像我們在這裡一樣。這也符合表3-3中的DRAM效率規則。

如果這些說法是正确的,為什麼提速現象會飽和?換句話說,當我們啟動更多的線程時,為什麼會得到更低的加速比?看起來不管線程數是多少,程式執行時間不會低于大約1.5倍的加速比。直覺上,這可以解釋如下:

  • 當程式是存儲密集型時,其性能将嚴格地由記憶體的帶寬決定。
  • 我們似乎在大約4個線程時就達到了記憶體帶寬的極限。

3.8 程序記憶體映像

在指令行提示符啟動以下程式會發生什麼?

imflipPM dogL.bmp Output.bmp V 4

首先,我們請求啟動可執行程式imflipPM(或Windows中的imflipPM.exe)。為了啟動這個程式(即開始執行),作業系統建立一個程序并為其配置設定一個程序ID。當這個程式執行時,它需要三個不同的記憶體區域:

  • 棧,用于存儲函數調用時的傳回位址和參數,包括傳遞給函數的參數,或從函數傳回的參數。該區域自上而下(從高位址到低位址)增長,這是所有微處理器使用棧的方式。
  • 堆,用于存放使用malloc()函數動态配置設定的記憶體内容。該記憶體區域沿着與棧相反的方向增長,以便作業系統使用每個可能的記憶體位元組而不會與棧區沖突。
  • 代碼區,用于存儲程式代碼和程式中聲明的常量。代碼區是不能被修改的。程式中的常量存儲在這裡,因為它們也不需要被修改。

作業系統建立的程序記憶體映像如圖3-3所示:首先,由于程式隻啟動了運作main()的單個線程,是以記憶體映像如圖3-3(左)所示。當用pthread_create()啟動了四個線程後,記憶體映像如圖3-3(右)所示。即使作業系統決定将某個線程替換出去以允許另一個線程運作(即上下文切換),該線程的棧也會被儲存。線程的上下文資訊儲存在同一片記憶體區域。此外,代碼位于記憶體空間的底部,所有線程共享的堆區位于代碼區之上。當線程經過排程得以重新運作,并完成上下文切換後,這些是它恢複工作所需要的全部内容。

第一次啟動imflipPM.c時,作業系統不知道棧和堆的大小。這些都有預設的設定,你也可以修改這些預設設定。Unix和Mac OS在指令提示符下使用參數來設定,而Windows通過單擊右鍵修改應用程式屬性來更改。由于程式員是最了解一個程式需要多少堆和棧的,應該給應用程式配置設定足夠多的棧和堆區,以避免因無效記憶體位址通路而發生核心崩潰,這種情況大多由于記憶體的不同區域産生沖突而導緻通路無效的記憶體位址。

讓我們再看看我們最喜歡的圖2-1,它顯示了1499個啟動的線程和98個程序。這意味着作業系統内部啟動的許多程序甚至所有線程都是多線程的,此時的記憶體映像類似于圖3-3。每個程序平均啟動了15個線程,這些線程的活躍度都比較低。我們看到當5、6個線程在一段時間内超級活躍時會發生什麼。在圖2-1中,如果所有的1499個線程的活躍度都像迄今為止我們所寫的線程那樣高,那麼你的CPU可能會窒息,你甚至無法在計算機上移動滑鼠。

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

當涉及1499個線程時,還有一點需要注意:作業系統編寫人員必須盡可能地将他們的線程設計得“瘦”一些,以避免作業系統影響應用程式的性能。換句話說,如果任何一個作業系統的線程在從就緒狀态變為運作狀态時産生過多的幹擾,那麼它們将使一些核心資源的負擔過重,當你的線程與作業系統線程同時處于運作狀态時,超線程機制将不能有效地工作。當然,并不是每項任務都可以被設計得非常“瘦”,剛才我對作業系統的描述也有一定的局限性。另一方面,應用程式的設計者也應該注意盡量使應用程式的線程“瘦”一些。我們隻會簡單地介紹這一點,因為本書不是一本關于CPU并行程式設計的書,而是一本關于GPU的書。當然,當我有機會介紹如何使CPU線程變得更“瘦”時,我會在書中闡述。

3.9 英特爾MIC架構:Xeon Phi

內建衆核(Many Integrated Core,MIC)是一個與GPU類似的非常有趣的并行計算平台,它是英特爾為了與Nvidia和AMD GPU架構競争而推出的一種架構。型号名為Xeon Phi的MIC體系結構包含很多與x86相容的inO核心,這些核心可以運作的線程比Intel Core i7 OoO體系結構中标準的每核心兩線程更多。例如,我将要測試的Xeon Phi 5110P處理器包含60個核心和4個線程/核心。是以,它能夠執行240個并發線程。

與Core i7 CPU核心的工作頻率接近4 GHz相比,每個Xeon Phi核心僅工作在1.053 GHz,大約慢了近4倍。為了彌補這個不足,Xeon Phi架構采用了30 MB的高速緩存,它有16個記憶體通道,而不是現代core i7處理器中的4個,并且它引入了320 GBps的記憶體帶寬,比Core i7的記憶體帶寬高出5~10倍。此外,它還有一個512位的向量引擎,每個時鐘周期能夠執行8個雙精度浮點運算。是以,它具有非常高的TFLOP(Tera-Floating Point Operating)處理能力。與其将Xeon Phi看作CPU,将其歸類為吞吐量引擎更為合适,該引擎旨在以非常高的速度處理大量資料(特别是科學資料)。

Xeon Phi裝置的使用方式通常有以下兩種:

  • 當使用OpenCL語言時,它“幾乎”可以被當作GPU來使用。在這種操作模式下,Xeon Phi将被視為CPU的外部裝置,即一個通過I/O總線(在我們的例子中是PCI Express)連接配接到CPU的裝置。
  • 當使用自己的編譯器icc時,它“幾乎”可以被當作CPU來使用。編譯完成後,你可以遠端連接配接到mic0(即連接配接到Xeon Phi中輕量級作業系統),然後在mic0中運作代碼。在這種操作模式下,Xeon Phi仍然是一種擁有自己的作業系統的裝置,是以必須将資料從CPU傳輸到Xeon的工作區。該傳輸使用Unix指令完成,scp(安全複制)指令将資料從主機傳輸到Xeon Phi。

以下是在Xeon Phi上編譯和執行imflipPM.c以獲得表3-6中的性能資料的指令:

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

imflipPM.c程式在Xeon Phi 5110P上運作的性能結果如表3-6所示。雖然從幾個線程到多達16或32個線程,性能都有較好的提升,但性能提升的上限為32個線程。啟動64個線程不會提供額外的性能改進。主要是因為我們的imflipPM.c程式中的線程太“胖”,以緻無法充分利用每個核心中的多個線程。

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

3.10 GPU是怎樣的

現在我們已經了解了CPU并行程式設計的故事,GPU又是怎樣的呢?我可以保證我們在CPU世界中學到的所有東西都适用于GPU世界。現在,想象你有一個可以運作1000個或更多核心/線程的CPU。這就是最簡單化的GPU故事。但是,如你所見,繼續增加線程數并不容易,因為性能最終會在某個點之後停止提升。是以,GPU不僅僅是一個擁有數千核心的CPU。GPU内部必須進行重大的架構改進,以消除本章剛剛讨論的各種核心和記憶體瓶頸問題。此外,即使在架構改進之後,GPU程式員也需要承擔更多的責任以確定程式不會遇到這些瓶頸。

本書的第一部分緻力于了解什麼是“并行思維”,實際上這還不夠。你必須開始思考“大規模并行”。當我們在前面所示的例子中有2個、4個或8個線程運作時,調整執行的順序以便每個線程都能發揮作用并不是一件難事。但是,在GPU世界中,你将處理數千個線程。要了解如何在瘋狂的并行世界中思考問題應該首先學習如何合理地排程2個線程!這就是為什麼講解CPU環境非常适合用來對學習并行性進行熱身的原因,也是本書的理念。當你完成本書的第一部分時,你不僅學會了CPU的并行,而且也完全準備好去接受本書第二部分中介紹的GPU大規模并行。

如果你仍然沒有信服,那麼我可以告訴你:GPU實際上可以支援數十萬個線程,而不僅僅是數千個線程!相不相信?就好比IBM這樣擁有數十萬名員工的公司可以像隻有1或2名員工的公司一樣運作,而且IBM能夠從中獲益。但是,大規模并行程式需要極端嚴格的紀律和系統論方法。這就是GPU程式設計的全部内容。如果你已迫不及待地想去第二部分學習GPU程式設計,那麼現在你就可以開始嘗試。但是,除非你已經了解了第一部分介紹的概念,否則總會錯過某些東西。

GPU的存在給我們提供了強大的計算能力。比同類CPU程式速度快10倍的GPU程式要比僅快5倍的GPU程式好。如果有人可以重寫這個GPU程式,并把速度提高20倍,那麼這個人就是國王(或女王)。編寫GPU程式的目标就是高速,否則沒有任何意義。GPU程式中有三件事很重要:速度,速度,還是速度!是以,本書的目标是讓你成為編寫超快GPU代碼的GPU程式員。實作這個目标很難,除非我們系統地學習每一個重要的概念,這樣當我們在程式中遇到一些奇怪的瓶頸問題時,可以解釋并解決這些瓶頸問題。否則,如果你打算編寫較慢的GPU代碼,那麼不妨花時間學習更好的CPU多線程技術,因為除非你的代碼希望竭盡所能地提高速度,否則使用GPU沒有任何意義。這就是為什麼在整本書中我們都會統計代碼運作的時間,并且找到讓GPU代碼更快的方法。

3.11 本章小結

現在我們基本了解了如何編寫高效的多線程代碼。下一步該做什麼呢?首先,我們需要能夠量化本章中讨論的所有内容:什麼是記憶體帶寬?核心如何真正運作?核心如何從記憶體中擷取資料?線程如何共享資料?如果不了解這些問題,我們隻能猜測為什麼速度會有提升。

現在是量化所有這些問題并全面了解架構的時候了。這就是第4章将要介紹的内容。同樣,盡管會有明顯的不同,我們學到的所有CPU内容都可以很容易地應用到GPU世界,我将在不同之處出現時加以說明。

繼續閱讀