天天看點

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

高性能計算技術叢書 點選檢視第二章 點選檢視第三章

基于CUDA的GPU并行程式開發指南

GPU Parallel Program Development Using CUDA

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

[美]托爾加·索亞塔(Tolga Soyata) 著

唐 傑 譯

第1章

CPU并行程式設計概述

本書是一本适用于自學GPU和CUDA程式設計的教科書,我可以想象當讀者發現第1章叫“CPU并行程式設計概述”時的驚訝。我們的想法是,本書希望讀者具備較強的低級程式設計語言(如C語言)的程式設計能力,但并不需要具備CPU并行程式設計的能力。為了達到這個目标,本書不期望讀者有CPU并行程式設計經驗,但通過學習本書第一部分中的内容,獲得足夠多的CPU并行程式設計技巧并不困難。

不用擔心,最終目标是學會GPU程式設計,因而在學習CPU并行程式設計的這部分時,我們并不是在浪費時間,因為我在CPU世界中介紹的幾乎每一個概念都适用于GPU世界。如果你對此持懷疑态度,下面是一個例子:線程ID,或者稱之為tid,是多線程程式中一個正在執行的線程的辨別符,無論它是一個CPU線程還是GPU線程。我們編寫的所有CPU并行程式都将用到tid概念,這将使程式可以直接移植到GPU環境。如果你對線程不熟悉,請不要擔心。本書的一半内容是關于線程的,因為它是CPU或GPU如何同時執行多個任務的基礎。

1.1 并行程式設計的演化

一個自然而然的問題是:為什麼要用并行程式設計?在20世紀70年代、80年代甚至90年代的一部分時間裡,我們對單線程程式設計(或者稱為串行程式設計)非常滿意。你可以編寫一個程式來完成一項任務。執行結束後,它會給你一個結果。任務完成,每個人都會很開心!雖然任務已經完成,但是如果你正在做一個每秒需要數百萬甚至數十億次計算的粒子模拟,或者正在對具有成千上萬像素的圖像進行處理,你會希望程式運作得更快一些,這意味着你需要更快的CPU。

在2004年以前,CPU制造商IBM、英特爾和AMD都可以為你提供越來越快的處理器,處理器時鐘頻率從16 MHz、20 MHz、66 MHz、100 MHz,逐漸提高到200 MHz、333 MHz、466 MHz……看起來它們可以不斷地提高CPU的速度,也就是可以不斷地提高CPU的性能。但到2004年時,由于技術限制,CPU速度的提高不能持續下去的趨勢已經很明顯了。這就需要其他技術來繼續提供更高的性能。CPU制造商的解決方案是将兩個CPU放在一個CPU内,即使這兩個CPU的工作速度都低于單個CPU。例如,與工作在300 MHz速度上的單核CPU相比,以200 MHz速度工作的兩個CPU(制造商稱它們為核心)加在一起每秒可以執行更多的計算(也就是說,直覺上看2×200 > 300)。

聽上去像夢一樣的“單CPU多核心”的故事變成了現實,這意味着程式員現在必須學習并行程式設計方法來利用這兩個核心。如果一個CPU可以同時執行兩個程式,那麼程式員必須編寫這兩個程式。但是,這可以轉化為兩倍的程式運作速度嗎?如果不能,那我們的2×200 > 300的想法是有問題的。如果一個核心沒有足夠的工作會怎麼樣?也就是說,隻有一個核心是真正忙碌的,而另一個核心卻什麼都不做?這樣的話,還不如用一個300 MHz的單核。引入多核後,許多類似的問題就非常突出了,隻有通過程式設計才能高效地利用這些核心。

1.2 核心越多,并行性越高

程式員不能簡單地忽略CPU制造商每年推出的更多數量的核心。2015年,英特爾在市場上推出8核桌上型電腦處理器i7-5960X[11]和10核工作站處理器,如Xeon E7-8870 [14]。很明顯,這種多核狂熱在可預見的未來會持續下去。并行程式設計從2000年年初的一種奇異的程式設計模型轉變為2015年唯一被接受的程式設計模型。這種現象并不局限于台式電腦。在移動處理器方面,iPhone和Android手機都有2個或4個核。預計未來幾年,移動領域的核心數量将不斷增加。

那麼,什麼是線程?要回答這個問題,讓我們來看看8核INTEL CPU i7-5960X [11]。 INTEL的文檔說這是一個8C/16T CPU。換句話說,它有8個核心,但可以執行16個線程。你也許聽到過并行程式設計被錯誤地稱為多核程式設計。正确的術語應該是多線程程式設計。這是因為當CPU制造商開始設計多核架構時,他們很快意識到通過共享一些核心資源(如高速緩存)來實作在一個核心中同時執行兩項任務并不困難。

類比1.1:核心與線程

圖1-1顯示了兩個兄弟Fred和Jim,他們是擁有兩台拖拉機的農民。每天,他們開車從農舍到椰子樹所在的地方,收獲椰子并把它們帶回農舍。他們用拖拉機内的錘子來收獲(處理)椰子。整個收獲過程由兩個獨立但有序的任務組成,每個任務需要30秒:任務1是從拖拉機走向椰子樹,每次帶回1顆椰子。任務2是用錘子敲碎(處理)它們,并将它們存放在拖拉機内。Fred每分鐘可以處理1顆椰子,而Jim每分鐘也可以處理1顆椰子。綜合起來,他們倆每分鐘可以處理2顆椰子。

一天,Fred的拖拉機發生了故障。他把拖拉機留在修理廠,并把椰子錘忘在了拖拉機内。回到農舍的時候已經太遲了,但他們仍然有工作要做。隻使用Jim的拖拉機和裡面的1把椰子錘,他們還能每分鐘處理2顆椰子嗎?

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.3 核心與線程

讓我們來看看圖1-1中描述的類比1.1。如果收獲1顆椰子需要完成兩個連續的任務(我們将它們稱為線程):線程1從樹上摘取1顆椰子并花費30秒将它帶回拖拉機,線程2花費30秒用拖拉機内的錘子敲碎(處理)該椰子,這樣可以在60秒内收獲1顆椰子(每分鐘1顆椰子)。如果Jim和Fred各自都有自己的拖拉機,他們可以簡單地收獲兩倍多的椰子(每分鐘2顆椰子),因為在收獲每顆椰子時,他們可以共享從拖拉機到椰子樹的道路,并且他們各自擁有自己的錘子。

在這個類比中,一台拖拉機就是一個核心,收獲一顆椰子就是針對一個資料單元的程式執行。椰子是資料單元,每個人(Jim、Fred)是一個執行線程,需要使用椰子錘。椰子錘是執行單元,就像核心中的ALU一樣。該程式由兩個互相依賴的線程組成:線上程1執行結束之前,你無法執行線程2。收獲的椰子數量意味着程式性能。性能越高,Jim和Fred銷售椰子掙的錢就越多。可以将椰子樹看作記憶體,你可以從中獲得一個資料單元(椰子),這樣線上程1中摘取一顆椰子的過程就類似于從記憶體中讀取資料單元。

1.3.1 并行化更多的是線程還是核心

現在,讓我們看看如果Fred的拖拉機發生故障後會發生什麼。過去他們每分鐘都能收獲兩顆椰子,但現在他們隻有一台拖拉機和一把椰子錘。他們把拖拉機開到椰子樹附近,并停在那兒。他們必須依次地執行線程1(Th1)和線程2(Th2)來收獲1顆椰子。他們都離開拖拉機,并在30秒内走到椰子樹那兒,進而完成了Th1。他們帶回挑好的椰子,現在,他們必須敲碎椰子。但因為隻有1把椰子錘,他們不能同時執行Th2。Fred不得不等Jim先敲碎他的椰子,并且在Jim敲碎後,他才開始敲。這需要另外的30+30秒,最終他們在90秒内收獲2顆椰子。雖然效率不如每分鐘2顆椰子,但他們的性能仍然從每分鐘1顆提升至每分鐘1.5顆椰子。

收獲一些椰子後,Jim問了自己一個問題:“為什麼我要等Fred敲碎椰子?當他敲椰子時,我可以立即走向椰子樹,并摘獲下1顆椰子,因為Th1和Th2需要的時間完全相同,我們肯定不會遇到需要等待椰子錘空閑的狀态。在Fred摘取1顆椰子回來的時候,我會敲碎我的椰子,這樣我們倆都可以是100%的忙碌。”這個天才的想法讓他們重新回到每分鐘2顆椰子的速度,甚至不需要額外的拖拉機。重要的是,Jim重新設計了程式,也就是線程執行的順序,讓所有的線程永遠都不會陷入等待核心内部共享資源(比如拖拉機内的椰子錘)的狀态。正如我們将很快看到的,核心内部的共享資源包括ALU、FPU、高速緩存等,現在,不要擔心這些。

我在這個類比中描述了兩個配置場景,一個是2個核心(2C),每個核心可以執行一個單線程(1T);另一個是能夠執行2個線程(2T)的單個核心(1C)。在CPU領域将兩種配置稱為2C/2T與lC/2T。換句話說,有兩種方法可以讓一個程式同時執行2個線程:2C/2T(2個核心,每個核心都可以執行1個線程—就像Jim和Fred的兩台單獨的拖拉機一樣)或者lC/2T(單個核心,能夠執行2個線程—就像Jim和Fred共享的單台拖拉機一樣)。盡管從程式員的角度來看,它們都意味着具有執行2個線程的能力,但從硬體的角度來看,它們是非常不同的,這要求程式員充分意識到需要共享資源的線程的含義。否則,線程數量的性能優勢可能會消失。再次提醒一下:全能的INTEL i7-5960X [11] CPU是8C/l6T,它有8個核心,每個核心能夠執行2個線程。

圖1-2顯示了三種情況:a)是具有2個獨立核心的2C/2T情況;b)是具有糟糕程式設計的1C/2T情況,每分鐘隻能收獲1.5顆椰子;c)是對椰子錘的需求永遠不會同時發生的順序正确版本,每分鐘可以收獲2顆椰子。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.3.2 核心資源共享的影響

Jim為自己的發現感到自豪,他們的速度提高到每分鐘2顆椰子,Jim希望繼續創造一些方法來用一台拖拉機完成更多的工作。一天,他對Fred說:“我買了一把新的自動椰子錘,它在10秒内就能敲碎1顆椰子。”他們對這一發現非常滿意,立即出發并将拖拉機停在椰子樹旁。這次他們知道在開始收獲前必須先做好計劃……

Fred問道:“如果我們的Th1需要30秒,而Th2需要10秒,并且我們唯一需要共享資源的任務是Th2(椰子錘),我們應該如何收獲椰子?”答案對他們來說很清楚:唯一重要的是線程的執行順序(即程式的設計),應確定他們永遠不會遇到兩人同時執行Th2并需要唯一的椰子錘(即共享核心資源)的情況。換句話說,它們的程式由兩個互相依賴的線程組成:Th1需要30秒,并且不需要共享(記憶體)資源,因為兩個人可以同時步行到椰子樹。Th2需要10秒并且不能同時執行,因為他們需要共享(核心)資源:椰子錘。由于每顆椰子需要30+10=40秒的總執行時間,他們能夠期望的最好結果是40秒收獲2顆椰子,如

圖1-2 d所示。如果每個人都按順序執行Th1和Th2,且不等待任何共享資源,則會發生這種情況。是以,他們的平均速度将是每分鐘3顆椰子(即每顆椰子平均20秒)。

1.3.3 記憶體資源共享的影響

用新的椰子錘實作了每分鐘收獲3顆椰子後,Jim和Fred第二天開始工作時看到了可怕的一幕。因為昨晚的一場大雨阻塞了半邊道路,從拖拉機到椰子樹的道路今天隻能由一個人通行。是以,他們再次制訂計劃……現在,他們有2個線程,每個線程都需要一個不能共享的資源。Th1(30秒—表示為30s)隻能由一人執行,而Th2(10s)也隻能由一人執行。怎麼辦?

考慮多種選擇後,他們意識到其速度的限制因素是Th1,他們能達到的最好目标是30秒收獲1顆椰子。當可以同時執行Th1(共享記憶體通路)時,每個人可以順序地執行10+30s,并且兩個人都可以持續運作而無須通路共享資源。但是現在沒有辦法對這些線程進行排序。他們能夠期望的最好結果是執行10+30s并等待20s,因為在此期間兩人都需要通路記憶體。他們的速度回到平均每分鐘2顆椰子,如圖1-2 e所示。

這場大雨使他們的速度降低到每分鐘2顆椰子。Th2不再重要,因為一個人可以不慌不忙地敲椰子,而另一個人正在去摘取椰子的路上。Fred提出了這樣一個想法:他們應該從農舍再拿一把(較慢)椰子錘來幫忙。然而,這對于此時的情況絕對沒有幫助,因為收獲速度的限制因素是Th1。這種來自于某個資源的限制因素被稱為資源競争。這個例子展示了當通路記憶體是我們程式執行速度的限制因素時會發生什麼。處理資料的速度有多快(即核心運作速度)已無關緊要。我們将受到資料擷取速度的限制。即使Fred有一把可以在1秒鐘内敲碎椰子的椰子錘,但如果存在記憶體通路競争,他們仍然會被限制為每分鐘2顆椰子。在本書中,我們将區分兩種不同類型的程式:核心密集型,該類型不大依賴于記憶體通路速度;存儲密集型,該類型對記憶體通路速度高度敏感,正如我剛才提到的那樣。

1.4 第一個串行程式

我們已經了解了椰子世界中的并行程式設計,現在是時候将這些知識應用于真實計算機程式設計了。我會先介紹一個串行(即單線程)程式,然後将其并行化。我們的第一個串行程式imf?lip.c讀入圖1-3(左)中的小狗圖檔并将其水準(中)或垂直(右)翻轉。為了簡化程式的解釋,我們将使用Bitmap(BMP)圖像格式,并将結果也輸出為BMP格式。這是一種非常容易了解的圖像格式,可以讓我們專注于程式本身。不要擔心本章中的細節,它們很快就會被解釋清楚,目前可以隻關注高層的功能。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

imflip.c源檔案可以在Unix提示符下編譯和執行,如下所示:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

在指令行中用“H”指定水準翻轉圖像(圖1-3中),用“V”指定垂直翻轉(圖1-3右側)。你将看到如下所示的輸出(數字可能不同,取決于你電腦的速度):

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

運作該程式的CPU速度非常快,以緻我必須将原始的640×480的圖像dog.bmp擴充為3200×2400的dogL.bmp,這樣它的運作時間才能被測量出來;dogL.bmp的每個次元擴大到原來的5倍,是以比dog.bmp大25倍。統計時間時,我們必須在圖像翻轉開始和結束時記錄CPU的時鐘。

1.4.1  了解資料傳輸速度

從磁盤讀取圖像的過程(無論是SSD還是硬碟驅動器)應該從執行時間中扣除,這很重要。換句話說,我們從磁盤讀取圖像,并確定它位于記憶體中(在我們的數組中),然後隻統計翻轉操作所需的時間。由于不同硬體部件的資料傳輸速度存在巨大差異,我們需要分别分析在磁盤、記憶體和CPU上花費的時間。

在本書将要編寫的衆多并行程式中,我們重點關注CPU執行時間和記憶體通路時間,因為我們可以控制它們。磁盤通路時間(稱為I/O時間)通常在單線程中就達到極限,因而幾乎看不到多線程程式設計的好處。另外,請記住,當我們開始GPU程式設計時,較慢的I/O速度會嚴重困擾我們,因為I/O是計算機中速度最慢的部分,并且從CPU到GPU的資料傳輸要通過I/O子系統的PCI express總線進行,是以我們将面臨如何将資料更快地提供給GPU的挑戰。沒有人說GPU程式設計很容易!為了讓你了解不同硬體部件的傳輸速度,我在下面列舉了一些:

  • 典型的網卡(NIC)具有1 Gbps的傳輸速度(千兆比特每秒或一億比特每秒)。這些卡俗稱“千兆網卡”或“Gig網卡”。請注意,1 Gbps隻是“原始資料”的數量,其中包括大量的校驗碼和其他同步信号。傳輸的實際資料量少于此數量的一半。我的目的是給讀者一個大緻的概念,這個細節對我們來說并不重要。
  • 即使連接配接到具有6 Gbps峰值傳輸速度的SATA3接口,典型的硬碟驅動器(HDD)也幾乎無法達到1 Gbps~2 Gbps的傳輸速度。HDD的機械讀寫性質根本不能實作快速的資料通路。傳輸速度甚至不是硬碟的最大問題,最大問題是定位時間。HDD的機械磁頭需要一段時間在旋轉的金屬柱面上定位需要的資料,這迫使它在磁頭到達資料所在位置前必須等待。如果資料以不規則的方式分布(即碎片式的存放),則可能需要毫秒(ms)級的時間。是以,HDD的傳輸速度可能遠遠低于它所連接配接的SATA3總線的峰值速度。
  • 連接配接到USB 2.0端口的閃存磁盤的峰值傳輸速度為480 Mbps(兆比特每秒或百萬比特每秒)。但是,USB 3.0标準具有更快的5 Gbps傳輸速度。更新的USB 3.1可以達到10 Gbps左右的傳輸速率。由于閃存磁盤使用閃存建構,它不需要查找時間,隻需提供位址即可直接通路資料。
  • 典型的固态硬碟(SSD)可以連接配接在SATA3接口上,達到接近4 Gbps~5 Gbps的讀取速度。是以,實際上SSD是唯一可以達到SATA3接口峰值速度的裝置,即以預期的6 Gbps峰值速率傳輸資料。
  • 一旦資料從I/O(SDD、HDD或閃存磁盤)傳輸到CPU的記憶體中,傳輸速度就會大大提高。已發展到第6代的Core i7系列(i7-6xxx),更高端的Xeon CPU使用DDR2、DDR3和DDR4記憶體技術,記憶體到CPU的傳輸速度為20 GBps~60 GBps(千兆位元組每秒)。注意這個速度是千兆位元組。一個位元組有8個比特,為與其他較慢的裝置進行比較,轉換為存儲通路速度時為160 Gbps~480 Gbps(千兆比特每秒)。
  • 正如我們将在第二部分及以後所看到的,GPU内部存儲器子系統的傳輸速度可以達到100 GBps~1000 GBps。例如,新的Pascal系列GPU就具有接近後者的内部存儲傳輸速率。轉換後為8000 Gbps,比CPU内部存儲器快一個數量級,比閃存磁盤快3個數量級,比HDD快近4個數量級。

1.4.2 imflip.c中的main()函數

代碼1.1中所示的程式會讀取一些指令行參數,并按照指令行參數垂直或水準地翻轉輸入圖像。指令行參數由C放入argv數組中。

clock( )函數以毫秒為機關統計時間。重複執行奇數次(例如129次)操作可以提高時間統計的準确性,操作重複次數在"#define REPS 129"行中指定。該數字可以根據你的系統更改。

ReadBMP( )函數從磁盤讀取源圖像,WriteBMP( )将處理後的(即翻轉的)圖像寫回磁盤。從磁盤讀取圖像和将圖像寫入磁盤的時間定義為I/O時間,我們從處理時間中去除它們。這就是為什麼我在實際的圖像翻轉代碼之間添加"start = clock()"和"stop = c1ock()"行,這些代碼對已在記憶體中的圖像進行翻轉操作,是以有意地排除了I/O時間。

在輸出所用時間之前,imflip.c程式會使用一些free( )函數釋放所有由ReadBMP( )配置設定的記憶體以避免記憶體洩漏。

代碼1.1:imflip.c的main( ){…}

imflip.c中的main()函數讀取3個指令行參數,用以确定輸入和輸出的BMP圖像檔案名以及翻轉方向(水準或垂直)。該操作會重複執行多次(REPS)以提高計時的準确性。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.4.3 垂直翻轉行:FlipImageV( )

代碼1.2中的FlipImageV( )周遊每一列,并交換該列中互為垂直鏡像的兩個像素的值。有關Bitmap(BMP)圖像的函數存放在另一個名為ImageStuff.c的檔案中,ImageStuff.h是對應的頭檔案,我們将在下一章詳細解釋它們。圖像的每個像素都以“struct Pixel”類型存儲,包含unsigned char類型的該像素的R、G和B顔色分量。由于unsigned char占用1個位元組,是以每個像素需要3個位元組來存儲。

ReadBMP( )函數将圖像的寬度和高度分别放在兩個變量ip.Hpixels和ip.Vpixels中。存儲一行圖像需要的位元組數在ip.Hbytes中。FlipImageV( )函數包含兩層循環:外層循環周遊圖像的ip.Hbytes,也就是每一列,内層循環一次交換一組對應的垂直翻轉像素。

代碼1.2:imflip.c …FlipImageV( ){…}

對圖像的行做垂直翻轉,每個像素都會被讀取并替換為鏡像行中的相應像素。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.4.4 水準翻轉列:FlipImageH( )

imf?lip.c的FlipImageH( )實作圖像的水準翻轉,如代碼1.3所示。除了内層循環相反,該函數與垂直翻轉的操作完全相同。每次交換使用“struct Pixel”類型的臨時像素變量pix。

由于每行像素以3個位元組存儲,即RGB、RGB、RGB……是以通路連續的像素需要一次讀取3個位元組。這些細節将在下一節介紹。現在我們需要知道的是,以下幾行代碼:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

隻是讀取位于垂直的第row行和水準的第col列處的一個像素。像素的藍色分量在位址imgrow處,綠色分量在位址imgrow處,紅色分量在imgrow處。在下一章中我們将看到,指向圖像起始位址的指針img由ReadBMP( )為其配置設定空間,然後由main(?)傳遞給FlipImageH( )函數。

代碼1.3:imflip.c FlipImageH(?){…}

進行水準翻轉時,每個像素都将被讀取并替換為鏡像列中相應的像素。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.5 程式的編輯、編譯、運作

在本節中,我們将學習如何在以下平台上開發程式:Windows、Mac或運作諸如Fedora、CentOS或Ubuntu等系統的Unix機器。讀者大多會側重選擇其中一個平台,并且能夠使用該平台完成第一部分剩餘的串行或并行程式的開發。

1.5.1 選擇編輯器和編譯器

要開發一個程式,你需要編輯、編譯和執行該程式。我在本書中使用的是普通的、簡單的C語言,而不是C++,因為它足夠好地展示CPU并行性或GPU程式設計。我不希望不必要的複雜性分散我們的注意力,使我們偏離CPU、GPU以及并行化等概念。

要編輯一個C程式,最簡單的方法就是使用編輯器,例如Notepad++[17]。你可以免費下載下傳,它适用于任何平台。它還能夠以不同的顔色顯示C語言的關鍵字。當然,還有更複雜的內建開發環境(IDE),如微軟的Visual Studio。但是,我們在第一部分中偏好簡單性。第一部分的結果都将輸出在Unix指令行中。你馬上就會看到,即使你是Windows 7系統也可以工作。為了編譯C程式,我們将在第一部分中使用g++編譯器,它也适用于任何平台。我将提供一個Makefile檔案,它将允許我們用适當的指令行參數編譯我們的程式。要執行編譯好的二進制代碼,隻需在編譯它的同一個平台環境中運作即可。

1.5.2 在Windows 7、8、10平台上開發

Cygwin64[5]可以免費下載下傳,它允許你在Windows中模拟Unix環境。簡而言之,Cygwin64是“Windows中的Unix”。請注意擷取Cygwin的64位版本(稱為Cygwin64),因為它含有最新的軟體包。你的計算機必須能夠支援64位x86。如果你有Mac或Unix系統,你可以跳過本節。如果你的計算機是Windows系統(最好是Windows x 專業版),你最好安裝Cygwin64,它有一個内置的g++編譯器。要安裝Cygwin64 [5],通路http//www.cygwin.com 并選擇64位安裝版本。如果你的Internet連接配接速度較慢,此過程需要幾個小時。是以,我強烈建議你将所有内容下載下傳到臨時目錄中,然後從本地目錄開始安裝。如果你直接進行Internet安裝,很可能會中斷,然後重新開始。不要安裝Cygwin的32位版本,因為它已經嚴重過時了。沒有Cygwin64,本書中的代碼都不能正常工作。此外,我們正在運作的最新的GPU程式也需要64位系統來執行。

在Cygwin64中,你将有兩種不同類型的shell:第一種是一個簡單的指令行(文本)shell,稱為“Cygwin64終端”。第二種是“xterm”,意思是“X Windows終端”,能夠顯示圖形。為了在不同類型的計算機上獲得最大的相容性,我将使用第一種:純文字終端,即“Cygwin64終端”,它也是一種Unix bash shell。使用文本shell還有以下理由:

  1. 由于我們将要編寫的每個程式都是對圖像進行操作,是以需要一種方法在終端外顯示圖像。在Cygwin64中,由于你在Cygwin64終端上浏覽的每個目錄都對應一個實際的Windows目錄,是以你隻需找到該Windows目錄并使用常用的程式(如mspaint或Internet Explorer浏覽器)顯示輸入和輸出圖像即可。這兩個應用程式都允許你将巨大的3200×2400圖像調整到任何你想要的大小,并舒适地顯示它。
  2. Cygwin指令ls、md和cd都在Windows目錄上工作。 cygwin64-Windows的目錄映射是:

    ~/Cyg64dir ←→ C:cygwin64homeTolgaCyg64dir

Tolga是我的登入名,也就是我的Cygwin64的根目錄名。每個cygwin64使用者的主目錄都位于相同的C:cygwin64home目錄下。在很多情況下,隻有一個使用者,這就是你的名字。

  1. 我們需要在Cygwin64終端外(即從Windows)運作Notepad++,方法是将C源檔案拖放到Notepad++中并進行編輯。編輯完成後,我們将在Cygwin64終端中對它們進行編譯,然後在終端外顯示它們。
  2. 還有另一種方式來運作Notepad++并在Cygwin64中顯示圖像,而無須轉到Windows。 鍵入以下指令行:
    帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

指令行cygstart notepad++ imflip.c如同你輕按兩下Notepad ++圖示運作它來編輯名為imflip.c的檔案。第二行将編譯imflip.c程式,第三行将運作它并顯示執行時間等。最後一行将運作預設的Windows程式來顯示圖像。Cygwin64中的cygstart指令基本上相當于“在Windows中輕按兩下”。最後一行指令的結果就像在Windows中輕按兩下圖像dogh.bmp一樣,這會告訴Windows打開照片檢視器。你可以通過更改Windows資料總管中的“檔案關聯”來更改預設檢視器。

有一件事看起來很神秘:為什麼我在程式名前面加上./而沒有為cygstart做同樣的事情?輸入以下指令:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

在初次安裝Cygwin64後,目前的PATH環境變量中不會有./,是以Cygwin64将不知道在目前目錄中搜尋你鍵入的任何指令。如果你的PATH中已經有./,則不必擔心這一點。如果沒有,你可以将它添加到.bash_profile檔案中的PATH中,現在它就會識别。該檔案位于你的主目錄中,要添加的行是:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

由于cygstart指令位于PATH環境變量中的某個路徑中,是以你不需要在它之前添加任何目錄名稱,例如表示目前目錄的./。

1.5.3 在Mac平台上開發

正如我們在1.5.2節中讨論過的,在本書第一部分,我們所說的執行程式并顯示結果就是如何在圖像存放目錄中顯示一張BMP圖像。對于Mac計算機也如此。Mac系統有一個内置的Unix終端,或者一個可下載下傳的iterm,是以它不需要Cygwin64之類的東西。換句話說,Mac就是一台Unix電腦。如果你在Mac中使用像Xcode這樣的IDE,那麼你可能會看到小差異。如果你使用Notepad ++,一切都應該和我前面描述的一樣。但是,如果需要開發大量的并行程式,Xcode非常棒,并且在Apple.com上建立開發人員賬戶後可以免費下載下傳。這值得嘗試。Mac系統有自己的顯示圖像的程式,是以隻需輕按兩下BMP圖像即可顯示它們。Mac也會為每個終端目錄設定相應的目錄。是以,在桌面上找到你正在開發的應用程式的目錄,然後輕按兩下BMP圖像。

1.5.4 在Unix平台上開發

如果你有一個運作圖形界面的Ubuntu、Fedora或CentOS的Unix機器,它們都有一個指令行終端。我使用術語“系列”來表示具有INTEL或AMD CPU的通用計算機或品牌計算機。Unix系統要麼有xterm,要麼有一個純文字終端,比如bash。這兩個都可以編譯和運作此處描述的程式。然後,你可以找出程式運作的目錄,然後輕按兩下BMP圖像以顯示它們。輕按兩下圖像而不是拖拽到程式中,就會要求作業系統運作預設程式來顯示它們。你可以通過系統設定更改這個預設程式。

1.6 Unix速成

在我過去5年的GPU教學中,幾乎每年都有一半的學生需要Unix入門課程。是以,我在本節中介紹這些内容。如果你對Unix很熟悉,可以跳過這一節。我們隻提供關鍵概念和指令,這些應該足以讓你完成本書中的所有内容。更全面地學習Unix需要大量的練習以及一本專門針對Unix的書。

1.6.1 與目錄相關的Unix指令

Unix目錄結構從你的主目錄開始,它由一個特殊的代字元(~)來表示。你建立的任何目錄都在你的“~/”主目錄下。例如,如果你在主目錄中建立了一個名為cuda的目錄,則表示此目錄的Unix方式是:~/cuda。你應該将檔案排列整齊,并在目錄下建立子目錄以使其階層化。例如,本書中的示例可以放在cuda目錄下,每章的示例都可以放在一個子目錄下,例如ch1、ch2、ch3……它們的目錄名稱為~/cuda/ch1等。

Unix中常用的建立/删除目錄指令有:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

無論你處于哪個目錄,都可以使用ls-al指令檢視目前目錄下包含的目錄和檔案(即詳細清單)的大小及權限。你還将看到Unix為你自動建立的兩個有特殊名字的目錄,.(意味着目前目錄)和..(意味着上一層目錄),這兩個目錄與你所處的位置有關。是以,指令./imflip...告訴Unix從目前目錄運作imflip。

用pwd指令尋找你的位置時,你會得到一個不是以波浪字元開頭的目錄,而是看起來像/home/Tolga/cuda這樣,為什麼?因為pwd輸出的是相對于Unix根目錄的路徑,而不是相對于你的主目錄/home/Tolga/或縮寫符号~/的路徑。cd指令會将你帶到你的主目錄,而cd /指令會将你帶到Unix根目錄,你将在其中看到名為home的目錄。可以使用cd home/Tolga指令進入home/Tolga目錄,也就是你的主目錄,但顯然,簡短的cd指令要友善得多。

當某個目錄為空時,rmdir指令可以删除該目錄。但如果該目錄中包含某個檔案或其他目錄(即子目錄),則會顯示一條錯誤消息,指出目錄不為空且不能删除。如果要删除包含檔案的目錄,請使用檔案删除指令rm和選項“-r”,這意味着“遞歸”。 rm -r dirname的含義是:從目錄dirname中删除所有的檔案及其子目錄。可能不需要強調這個指令有多危險。一旦你執行該指令,目錄就消失了,其中的全部内容也不見了,更不用說所有的子目錄了。是以,請謹慎使用此指令。

mv指令适用于檔案和目錄。例如,mv dir1 dir2将目錄dir1“移動”到dir2中。事實上,這是将目錄dir1重命名為dir2,且舊的目錄dir1不見了。當你執行ls,你隻會看到新的目錄dir2。

1.6.2 與檔案相關的Unix指令

一旦建立了目錄(又名檔案夾),你可以在其中建立或删除檔案。這些檔案包括你的程式,程式所需要的輸入檔案以及程式生成的檔案。例如,要運作在1.4節提到的串行圖像翻轉程式imflip.c,你需要程式本身并編譯它,并且當程式輸出BMP圖檔時,你需要能夠檢視那張圖檔。你還需要将圖檔帶到(複制到)此目錄中。該目錄下還有一個我建立的用于編譯的Makefile檔案。以下是用于檔案操作的常用Unix指令:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
  • #(井号)是注釋符号,它之後的任何内容都會被忽略。
  • clear指令清除終端螢幕。
  • cat Makefile以指令行顯示Makefile的内容,而不必使用Notepad ++之類的其他外部程式。
  • more Makefile顯示Makefile更多的内容,并且還可以逐個滾動頁面。這對于多頁檔案非常有用。
  • cat > filename是建立名為filename的文本檔案的最快方式。這使Unix進入文本輸入模式。文本輸入模式将你輸入的所有内容發送到你在 > 之後輸入的檔案(例如mytest.c)中。輸入CTRL-D(同時按住CTRL鍵和D鍵,這是EOT字元,ASCII碼為4,表示傳輸結束)可以退出文本輸入模式。如果你不想使用像Notepad ++這樣的編輯器,那麼這種輸入文本的方法非常棒。對于隻有幾行的程式來說,它是完美的,盡管沒有什麼能夠阻止你使用這種方法輸入整個程式!
  • | 是“管道”指令,它将一個Unix指令或程式的輸出通道(即管道)轉換為另一個指令。這允許使用者僅使用一個指令行來運作兩個單獨的指令。第二個指令接受第一個指令的輸出作為其輸入。管道可以被建立多次,但這不常見。
  • cat Makefile | grep imflip将cat指令的輸出傳遞給另一個指令grep,該指令查找并列出包含關鍵字imf?lip的行。grep非常适合在文本檔案中搜尋一些字元串。任何Unix指令的輸出都可以重定向輸入到grep中。
  • ls -al grep imflip将ls指令的輸出傳遞給grep imflip。實際上這是在ls指令的輸出中查找字元串imflip。這在确定包含特定字元串的檔案名時非常有用。
  • make imflip 在Makefile中尋找imflip: file1 file2 file3 …,如果某個檔案已被修改,則重新生成imflip。
  • cp imflip if1将剛建立的可執行檔案imflip複制為另一個名為if1的檔案,這樣你不會丢失它。
  1. cp顯示cp指令的幫助檔案。能夠顯示任意一條Unix指令的詳細資訊,這非常棒。
  • ls -al可以用來顯示源檔案和輸入/輸出檔案的權限和檔案大小。例如,檢查輸入和輸出BMP檔案dogL.bmp和dogH.bmp的大小是否完全相同。如果不是,這是一個錯誤的早期迹象!
  • ls imf *列出名稱以imf開頭的所有檔案。這對于列出你知道的包含imf字首的檔案很有用,就像我們在本書中建立的名為imflip、imflipP……(*)是一個通配符,意思是“任何東西”。當然,你可能更喜歡這樣使用*,如:Is imf *12是指以imf開始并以12結尾的檔案。另一個例子是ls imf *12*,意思是以imf開頭并且在檔案名中間有12的檔案。
  • diff file1 file2顯示兩個文本檔案之間的差異。這對确定檔案是否發生變化很有用。它也可以用于二進制檔案。
  • imflip或imflip dog … 如果./在$PATH中,則啟動該程式。否則,你必須使用./imflip dog。
  • touch imflip更新檔案imflip的“上次通路時間”。
  • rm imflip删除imflip可執行檔案。
  • mv指令,就像重命名目錄一樣,也可以用來重命名檔案并真正移動它們。mv file1 file2将file1重命名為file2并保留在同一目錄中。如果你想将檔案從一個目錄移動到另一個目錄,在檔案名之前加上目錄名,就會移動到該目錄。你也可以移動檔案而無須重命名它們。大多數Unix指令都具有這種多功能性。例如,可以像使用mv指令将檔案從一個目錄複制到另一個目錄一樣來使用cp指令。
  • history列出你打開終端後使用過的指令。

如下所示為編譯本書第一個串行程式imflip.c并将其轉換為可執行的imflip(或Windows中的imflip.exe)的Unix指令。使用者輸入的重要指令顯示在左側,Unix的輸出顯示向右側縮進了一段距離:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

在上述的輸出結果中,每個檔案的權限顯示為-rwxr-x等。根據你運作這些指令的計算機不同,輸出可能會略有不同。 可以用Unix指令chmod更改這些權限,使其成為隻讀等。

Unix的make工具使我們能夠自動執行若幹常用的指令,友善地編譯檔案。在我們的例子中,“make imflip”要求Unix檢視Makefile檔案并執行“gcc imflip.c ImageStuff.c -o imflip”這行指令,它将調用gcc編譯器編譯imflip.c和ImageStuff.c源檔案,并生成一個名為imflip的可執行檔案。在我們的Makefile中,第一行顯示了檔案依賴關系:它告訴make,隻有當列出的源檔案imflip.c、ImageStuff.c或ImageStuff.h發生更改時才重新生成可執行檔案imflip。要想強制編譯,可以先使用Unix的touch指令。

1.7 調試程式

調試代碼是你不得不做的事情。有時,你認為編寫的代碼應該可以正常工作,但卻抛出了一個段錯誤或一些從未見過的錯誤。這個過程可能會令人非常沮喪,常常是由很難發現的簡單的輸入錯誤或邏輯錯誤造成的。還有一些代碼錯誤甚至可能在運作時也不總是發生,乍一看你不會發現它的影響。這是最糟糕的錯誤,因為編譯器沒有發現它們,在運作時也不明顯。例如像記憶體洩漏這樣的錯誤并不會在運作時馬上顯現出來。在代碼開發過程中,一個好的做法是定期運作gdb和valgrind等調試工具來尋找潛在的發生段錯誤的位置。要在調試器中運作代碼,你需要在編譯時設定調試标志,通常為“-g”。這會告訴編譯器包含調試符号(包括行号等),以告訴你代碼出錯的位置。如下所示為一個例子:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.7.1 gdb

為了說明當你的代碼一團糟時會發生什麼,我在imflip.c中的資料變量使用完成前的某個位置插入了一條記憶體free()語句。顯然這将引起一個段錯誤,如下所示:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

由于imflip是用調試模式編譯的,是以可以用GNU的調試器gdb來找出段錯誤發生的位置。gdb的輸出在圖1-4中給出。執行下述指令可以啟動gdb:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

一旦gdb啟動,程式參數由以下指令設定:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

在此之後,程式使用簡單的run指令來運作。gdb會輸出一堆錯誤,說你的代碼全部搞砸了,where指令可以提供代碼出錯位置的資訊。最初,gdb認為錯誤出現在ImageStuff.c

中第73行的WriteBMP(?)函數中,但where指令将範圍縮小到imf?lip.c中的第98行。進一步檢查imf?lip.c代碼後發現,在用WriteBMP(?)函數将資料寫入BMP圖像之前調用了free(data)語句。這隻是一個簡單的例子,gdb的功能包括添加斷點,檢視變量值以及其他一些選項。表1-1中列出了一些常用指令。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章
帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

大多數內建開發環境(IDE)都有一個内置的調試子產品,這使得調試過程非常容易。通常它們的後端仍然是gdb或一些專有的調試引擎。無論你是否使用IDE,都可從指令行使用gdb,并且與你的IDE(取決于你選擇的IDE)相比,它包含的功能就算不是更多,也基本一樣。

1.7.2 古典調試方法

這可能是最能展現程式員在過去的年代—40年代、50年代、60年代、70年代—使用的調試類型的名詞了,這些調試方式至今仍被使用。我看不出古典調試方法在可預見的将來會消失。畢竟,我們用來調試代碼的“真實”調試器,比如gdb,隻不過是古典調試方法的自動執行版本。我将在7.9節的GPU程式設計環境中詳細介紹古典調試方法。這些内容也适用于CPU。是以,你可以選擇繼續閱讀本章或者馬上跳到7.9節。

每個調試器的主要思想都是在代碼中插入斷點,以列印/顯示在該斷點處與系統狀态有關的各種數值。所謂狀态包括變量值或外設的狀态,你可以自己定義它們。可以在斷點處中止或繼續一個程式的執行,同時輸出多個狀态值。

訓示燈:在早期階段,編寫機器指令的程式員通過撥動各種開關來逐位編寫程式,斷點可能是顯示某一位值的一個訓示燈。今天,FPGA程式員使用8個LED顯示一個8位Verilog變量的值(注意:Verilog是一種硬體描述語言)。但是,從幾個比特的顯示值推斷系統狀态需要程式員具備非常豐富的經驗。

printf:在一個C程式中,程式員通常會插入一堆printf()語句來輸出程式運作到某些位置時相關變量的值。這其實同手動設定斷點差不多。正如我在1.7.1節中所述,如果你覺得很容易發現代碼中的錯誤,那就沒有必要使用繁雜的gdb操作。在代碼中粘貼一堆printf(),它們會告訴你發生了什麼。一個printf()可以顯示大量關于變量的資訊,顯然比幾個LED的功能更強大。

assert:除非違反了你指定的條件,否則assert語句不會執行任何操作。這與printf()相反,printf總是輸出某些内容。例如,如果你的代碼有以下幾行:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

此時,你隻是試圖確定拿到的指針不是NULL,這是對記憶體配置設定的最嚴重問題的告警。盡管assert()在正常情況下不會執行任何操作,但如果違反了指定的條件,它将發出如下所示的錯誤:

Assertion violation: file mycode.c, line 36: ImgPtr != NULL

注釋行:令人驚訝的是,還有比在代碼中添加一堆printf()更容易的方法。雖然C語言并不要求代碼按“行”編寫,但C程式員偏好一行一行地編寫代碼,這很常見,就像Python。這也是為什麼Python受到一些批評,因為它讓逐行式的語言成為實際文法,而不是C中的可選形式。在注釋驅動的調試中,你隻需注釋掉一條可疑的行,重新編譯,重新執行以檢視問題是否消失,盡管結果肯定不再正确。這在出現重大錯誤時是非常有效的。在下面的例子中,如果使用者輸入速度為0,你的程式會給你一個除0錯誤。你可以在那裡插入printf()語句來看看它會在哪裡崩潰,但用assert()語句就友善得多,因為assert()在正常情況下不會做任何事情,這可以避免調試過程中螢幕上出現混亂。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

注釋非常實用,如果代碼中存在多條C語句,你可以将它們插入代碼中間,如下所示:

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

1.7.3 valgrind

另一種非常有用的調試工具是valgrind。 一旦代碼用調試模式編譯,valgrind就很容易運作。它可以設定許多選項,類似于GDB,但基本用法很簡單。圖1-5顯示了在valgrind中運作具有記憶體錯誤的imflip代碼的輸出。它會捕獲更多的錯誤,甚至可以定位imflip.c中發生錯誤的第96行,也就是不合适的free()指令所在的行。

帶你讀《基于CUDA的GPU并行程式開發指南》之一:CPU并行程式設計概述第1章

valgrind還擅長查找運作時不顯現的記憶體錯誤。通常記憶體洩漏很難用簡單的列印語句或像gdb這樣的調試器發現。例如,如果最後imflip沒有釋放任何記憶體,則會出現記憶體洩漏,而valgrind會發現它們。valgrind還有一個名為cachegrind的子產品,可以用來模拟代碼如何與CPU的緩存系統互動。cachegrind子產品可以用-tool=cachegrind指令選項來調用。更多的選項和文檔可以參考

http://valgrind.org

1.8 第一個串行程式的性能

我們先來看看我們的第一個串行程式imflip.c的性能。由于作業系統(OS)會執行許多随機事件,是以一個好辦法是多運作幾次相同的程式以確定獲得一緻的結果。是以,通過指令行多運作幾次imflip.c。這樣做後,我們得到的結果有81.022 ms、82.7132 ms、81.9845 ms……我們可以說它大約為82 ms。非常好,這相當于10.724 ns/像素,因為這個擴充的dogL.bmp圖像有3200×2400像素。

為了能夠更準确地測量程式的性能,我在代碼中增加了一個for(?)循環來重複(例如129次)執行相同的代碼,并将執行時間除以相同的數值129。這将使我們比正常情況多花費129倍的時間,進而能使用不太準确的UNIX系統定時來實作更準确的計時。大多數機器提供的硬體時鐘精度不會好于10 ms,甚至更糟糕。如果一個程式隻需要執行50 ms,那麼即使你按照上述方法簡單地多次重複測量,你也會得到非常不準确的性能結果(在時鐘精度為10 ms的情況下)。但是,如果你将同一個程式重複129次,在129次循環的開始和結束時測量時間,并将其除以129,則10 ms實際上變為10/129 ms的精度,這對我們的目标來說已經足夠了。請注意,循環次數必須是奇數,否則,最終的圖檔将不會被翻轉!

1.8.1 可以估計執行時間嗎

經過這一改動後,執行水準翻轉小狗圖檔的imflip.c程式獲得了81~82 ms的結果。我們想知道在較小的dog.bmp圖檔上運作該程式時會發生什麼情況,如果在一張901 KB的位圖圖檔上運作完全相同的程式,輸入為原始的640×480的dog.bmp檔案,我們獲得了3.2636 ms的運作時間,即10.624 ns/像素。換句話說,當圖檔縮小到1/25時,運作時間也幾乎減少到了那麼多。然而,一件奇怪的事情是:每次我們都會得到完全相同的執行時間。雖然這表明我們能夠以令人難以置信的準确度計算執行時間,但不要高興太早,因為我們會遇到一個動搖我們世界的複雜情況!

事實上,第一個奇怪的地方已經出現了。你能回答這個問題嗎:盡管我們獲得了幾乎相同的(每像素)執行時間,但為什麼處理較小圖像的執行時間不會改變?都是完全相同的4位十進制小數,而處理較大圖像的執行時間會在1%~2%内變化。雖然這看起來可能像一個統計上的随機性,但事實并非如此!它有一個非常明确的解釋。為防止你睡不着覺,我會馬上公布答案,不讓你等到下一章了:當我們處理22 MB的dogL.bmp圖像時,與原來的901 KB的dog.bmp相比,哪些東西改變了?答案是:在處理dogL.bmp的過程中,CPU無法将整個圖像儲存在最後一級的L3緩存(L3$)中,該級緩存的大小為8 MB。這意味着,要通路該圖像,在執行期間它需要不斷地清空和填充L3$。與之對應的是,在處理901 KB的dog.bmp圖像時,隻需要一輪處理即可将資料完全裝入L3$,并且在所有129個執行循環中CPU都擁有該資料。請注意,我将用符号L3$來表示L3緩存。

1.8.2 代碼執行時OS在做什麼

較大圖像的處理時間變化較大的原因是通路記憶體的不确定性比通路片内資料更大。由于imflip.c是一個串行程式,我們确實需要一個“1T”來執行我們的程式。另一方面,我們的CPU擁有諸如4C/8T的豪華資源。這意味着,一旦開始運作,我們隻有一個活躍線程的程式,作業系統幾乎能立即意識到給我們一個完全專用的CPU線程(甚至是核心),以符合所有人的最大利益,是以我們的應用程式可以充分利用此資源。總之,這是作業系統的工作:智能地配置設定資源。無論是Windows系統還是Unix系統,當今所有的作業系統代碼在了解程式執行中的這些模式時都非常聰明。如果一個程式熱衷于尋求一個單獨的線程而沒有其他要求,除非你正在運作許多其他程式,否則作業系統的最佳操作就是讓你像VIP一樣地通路單個線程(甚至可能是一個完整的核心)。

然而,對于主存儲器來說,這個故事是完全不同的,作業系統中的每個活動線程都可以通路主存。想象它隻有1 M!沒有2 M!是以,作業系統必須在每個線程中共享它。主存是所有作業系統資料,以及每個線程的資料所在的地方,主存是所有椰子(即圖像資料)所在的地方。是以,作業系統不僅必須弄清楚你的imflip.c如何從主存通路圖像資料,甚至得弄清楚它自己如何通路資料。作業系統的另一項重要工作是確定公平性。如果一個線程缺少資料,而另一個線程卻綽綽有餘,那麼作業系統并沒有很好地完成它的工作。它必須公平對待每個人,包括它自己。當你在主存通路中有如此多的内容需要傳輸時,你就會知道為什麼主存通路時間具有不确定性。相反,當我們在處理較小圖像時擁有一個幾乎完全專屬的核,就可以在該核中運作程式而無須通路主存。我們沒有與其他人分享這個核。是以,在确定執行時間方面幾乎沒有不确定性。如果你對這些概念有些模糊,不要擔心,後面會有一整章解釋CPU架構。以下是C/T(核心/線程)符号的含義:

  • C/T(核心/線程)符号表示:

    例如,4C/8T表示4個核心,8個線程,

4C意味着處理器有4個核心,

8T意味着每個核心可以執行2個線程。

  • 是以,4C/8T處理器可以同時執行8個線程。

    然而,每個線程對必須共享内部核心的資源。

1.8.3 如何并行化

即使在運作串行版本的代碼時,仍然需要了解很多細節。我甯願将并行版本的代碼擴充為完整的一章内容,而不是壓縮到本小節中。事實上,接下來的幾章将完全緻力于代碼的并行化以及對其性能的深入分析。現在,讓我們從椰子這個類比開始,請回答下列問題:

在類比1.1中,如果我們擁有兩台拖拉機且每台拖拉機中有2位農民時會發生什麼情況?此時,你有4個線程在運作……因為有2台實體上獨立的拖拉機,拖拉機(即核心)内部的一切都很舒适。然而,現在需要分享從拖拉機到椰子樹的道路(即多個線程需要通路主存儲器)是4個人而不是2個人。繼續……,如果有8位農民去收獲椰子怎麼辦?有16位農民又怎麼辦?換句話說,即使在8C/16T的情況下,也就是你有8個核心和16個線程,相當于你有8台拖拉機可以滿足農民的需求,其中每2位農民需要共享一把椰子錘。但是,主存儲器的通路又如何呢?參加收獲的農民越多,他們等待通過那條道路擷取椰子的時間就越長。在CPU方面,記憶體帶寬遲早會飽和。事實上,在下一章中,我會給出一個發生該情況的程式。這意味着,在我們開始對程式進行并行化之前,必須考慮線程在執行期間會通路哪些資源。

1.8.4 關于資源的思考

即使你知道上述問題的答案,還有另一個問題:針對不同的資源,并行性的魔法都會起到同樣的作用嗎?換句話說,并行性的概念與它所應用的資源是獨立的嗎?舉例來說,無論記憶體帶寬如何,2C/4T核心配置總能讓我們獲得相同的性能改進嗎?或者說如果記憶體帶寬非常糟糕,那麼額外增加核心數量所獲得的性能增益是否會消失?現在隻是思考一下,本書的第一部分将回答這些問題。是以,現在不要過度強調它們。

好吧,這已經足夠讓大腦熱身了……讓我們編寫第一個并行程式吧。

繼續閱讀