這篇文章是對這段時間學習並行編程中的設計模式的一個總結。有不當之處,希望得到大家的批評、指正。
首先,所謂“並行編程中的設計模式”(patterns in parallel programming)仍處於不斷的被發現、發掘的階段。當前已經有各路人馬對這一領域進行了研究,但遠遠沒有達到統一認識的高度。也沒有一套業界普遍認同的體系或者描述。這就造成了當前這一領域的現狀:從事研究的人有不同的背景,他們各自總結出的模式種類不盡相同。即使是同一個模式,不同的團隊給它們取得名字也可能不一樣。不過這並不妨礙我們對“並行編程中的設計模式”進行一定的了解,並在實際的軟件開發工作中有所借鑒。
本文分兩部分,第一部分會簡單介紹這一領域的發展現狀:首先是進行研究的主要團體及其代表人物,然后介紹一下他們各自總結的模式體系,最后着重介紹一下加州大學伯克利分校的體系,A pattern language for parallel programming。第二部分則從該體系中挑出八個常用的設計模式作稍微詳細一點的介紹。每個設計模式都附有實際的應用例子來幫助理解。我始終相信,通過例子的學習是最容易理解的一種方式。
1. 並行編程模式的發展現狀
在這一領域比較活躍的研究團體包括:
1. 加州大學伯克利分校。其代表人物是Kurt Keutzer教授,他曾是Synopsys公司的CTO。
2. Intel公司。代表人物是Tim Mattson,他是Intel的Principle Engineer和並行計算的布道師(evangelist),是“Patterns for Parallel Programming”一書的作者。
3. 伊利諾伊大學。代表人物是Ralph Johnson教授。他是著名的設計模式“Gang of Four”中的一員。
4. 其他團體:包括微軟、麻省理工大學… 等等。
他們總結出的並行編程模式體系不盡相同,互相之間又有着緊密的聯系。主要有:
1. 加州大學伯克利分校的 A Pattern Language for Parallel Programming ver2.0
2. 伊利諾伊大學的 Parallel Programming Patterns
3. Tim Mattson 的著作 Patterns for Parallel Programming
這其中,我最為喜歡加州大學伯克利分校的體系:
伯克利的研究人員認為,寫出高質量並行軟件的關鍵在於擁有好的軟件設計:從軟件的總體架構,一直到系統的底層實現。將這些“好的設計”以一種系統的方式描述出來,並在各個不同的軟件項目當中重用是解決構建高質量並行軟件問題的關鍵。這遠比各種具體的編程模型以及其支撐環境來得重要。因為擁有了一個好的設計之后,我們可以很容易的在各種開發平台上編寫源代碼。
伯克利的模式體系包含了幾個不同的層次,上自軟件架構,下至程序指令執行的策略。最上面的兩塊分別是“架構模式”(Structural patterns) 和“計算模式”(Computational patterns)。這兩塊並不單單包括並行軟件,也包括傳統的串行軟件模式。用我們常用來表達系統架構的線框圖打比方,“架構模式”指的就是那些箭頭和框框,它們表示的是程序總體的組織結構,以及各部分之間是怎么交互的。“計算模式”指的是框框里面的計算類型。例如,是基於圖的算法,還是有限狀態機,還是線性代數運算,等等。程序設計師通常需要在這兩大類模式之間翻來覆去的進行選擇。例如,我們可能先選擇了一種架構模式,然后考慮使用某些合適的計算模式。但是選擇的計算模式可能更適合於在另一種架構模式下運行,所以我們又得重新選擇一種架構模式… …如此反復。上面這張圖在“架構模式”和“計算模式”這兩大塊之間畫了兩個反復的箭頭,表達的就是這個意思。
這張圖下方的三大塊就專指並行編程當中的模式了。它們包括“算法模式”(Algorithm strategy patterns),“實現模式”(Implementation strategy patterns)和“並行執行模式”(Parallel execution patterns)。顧名思義,“算法模式”指的是程序算法層面的模式。它們表達的是,為了解決某一類實際問題,怎么樣在較高的算法層面實現並行。“實現模式”指的是我們在編寫源代碼的時候,利用什么樣的一些程序控制結構和數據結構來實現並行。例如“parallel_for”,例如並行容器,等等。最后一個“並行執行模式”指的是為了在計算機中有效的執行一個並行程序,我們應該采取哪些方法。這包括指令的執行模式,例如是MIMD還是SIMD,也包括一些任務調度的策略。這三類模式是緊密聯系在一起的。例如,為了解決一個問題,我們可能使用“recursive splitting”的算法模式。而為了實現這個算法,我們很有可能使用“fork-join”的實現模式。在執行層面,並行程序庫則通常會用“thread pool”的並行執行模式來支持“fork-join”的運行。
由於我們在編寫程序時,“並行執行模式”往往由編譯器或並行程序庫例如TBB的編寫人員考慮,我們並不需要關心;“實現模式”和我們選擇的具體並行運算平台有關(OpenMP, TBB,MPI,…,,等等),不同的平台有不同的API;“計算模式”則和具體的問題域聯接緊密。而且,看上去伯克利所總結的“計算模式”大部分和高性能計算有關,普通的應用程序員並不熟悉。所以在本文,我將只挑選和並行計算密切相關的兩個“架構模式”和六個常見的“算法模式”舉例進行說明。
2. 八個常用的並行編程模式
2.1 Agent and Repository
這是一個“架構模式”,它針對這樣一類問題:我們有一組數據,它們會隨機的被一些不同的對象進行修改。解決這一類問題的方案是,創建一個集中管理的數據倉庫(data repository),然后定義一組自治的agent來操作這些數據,可能還有一個manager來對agent的操作進行協調,並保證數據倉庫中數據的一致性。我們常見的源代碼版本控制軟件例如Perforce就是實現這種架構的典型代表:源代碼都存放在一個統一的服務器中(或是一組服務器中,但對client而言是透明的),不同的程序員們使用各自的客戶端對源代碼文件進行讀,寫,加,刪的操作。由Perforce負責保證源代碼數據的一致性。
2.2 Map Reduce
Map Reduce這個名詞原來是函數式編程里面的一個概念,但是自從Google於2004年推出同名的並行計算程序庫后,提到這個名詞大家大多想到的是Google的這個Framework。在這里,Map Reduce是一個“架構模式”的名稱。當然,我們這里的Map Reduce指的就是類似Google Map Reduce工作原理的一類模式。
那么什么是Map Reduce的模式呢?用較為簡單的語言描述,它指的是這樣一類問題的解決方案:我們可以分兩步來解決這類問題。第一步,使用一個串行的Mapper函數分別處理一組不同的數據,生成一個中間結果。第二步,將第一步的處理結果用一個Reducer函數進行處理(例如,歸並操作),生成最后的結果。從使用Google的Map Reduce程序庫的角度而言,作為應用程序員,我們只需提供一組輸入數據,和兩個普通的串行函數(Mapper和Reducer),Google的Map Reduce框架就會接管一切,保證輸入數據有效的在一個分布式的計算機集群里面分配,然后Mapper和Reducer函數在其上有效的運行、處理,並最后匯總生成我們想要的處理結果。所有一切的細節,例如並行化、數據的分配、不同機器之間的計算誤差,通通被隱藏在程序庫內。
那么Map Reduce到底是什么樣的一個過程呢?
我們講過,使用Map Reduce,程序員必須提供一組輸入數據,以及一個Mapper和一個Reducer函數。在這里,輸入數據必須是一個按(input_key,input_value)方式組織的列表。
mapper函數的任務是處理輸入列表中的某一個單元數據:mapper(input_key,input_value),並產生如下輸出結果:
接下來,把對所有單元數據的處理結果按照intermediate_key歸類:同樣的intermediate_key放在一起,它們的intermediate_value簡單的串接起來,得到:
Reducer函數的任務是對上述的中間結果進行處理:reducer(intermediate_key, intermediate_value_list),並產生如下最終輸出結果:
我們會舉兩個例子說明這一過程。第一個例子是一個簡單的統計單詞出現次數的小程序。第二個例子是Google曾經怎樣使用Map Reduce FrameWork來計算Page Rank。
第一個例子,假設我們要寫一個小程序,來統計在幾篇不同文章里所有出現過的單詞各自總共出現的次數。我們應該怎么做呢?下面描述的利用Map Reduce的方法肯定不是大多數程序員第一感會想到的方法。但這種方法非常好的揭示了Map Reduce的基本思想。並且,這種方法很容易被擴展到處理上千萬甚至是上億的文件數據,並且能夠在一個分布的計算機集群里面運行。這可不是傳統的方法能夠輕易做到的。
具體而言,假設我們有如下三個文本文件,a.txt, b.txt和c.txt:
對於輸入數據而言,input_key就是文件名,input_value就是一個大的string,包含的是文件內容。所以我們的輸入數據看上去會是這樣的:
我們會寫一個簡單的串行的mapper(fileName, fileContent)函數。這個函數做的事情很簡單,讀入一個文本文件,把每一個遇到的單詞當作一個新的intermediate_key,並賦其intermediate_value為1。將mapper函數處理文件a,我們會得到如下結果:
將所有三個文件的處理結果放在一起,我們得到:
然后將中間結果按intermediate_key歸類:
最后,由reducer(intermediate_key, intermediate_value_list)對中間結果進行處理。它做的事也很簡單,僅僅是把某intermediate_key對應的所有intermediate_value相加。我們於是得到最終結果:
第二個例子,怎樣使用Map Reduce計算PageRank。什么是PageRank?可能大家都有所了解,這是Google用來量度一個網頁的重要性的值。簡單而言,有越多的其它網頁鏈接到這個網頁,這個網頁的PageRank越高。鏈接到這個網頁的網頁PageRank越高,這個網頁的PageRank也越高。假設我們一共有n個網頁0, 1, …, n-1。對第j個網頁我們給它賦一個PageRank值qj。所有的qj組合起來成為一個向量q = (q0,q1, …qn-1)。這個向量滿足概率分布。即qj的值都在0和1之間,並且所有的qj加起來等於1。qj越大,網頁的重要性越高。那么q是怎么計算出來的呢?答案是使用迭代的方法:
我們從一個初始的PageRank向量分布P開始,乘以一個n*n的矩陣M,得到一個新的PageRank向量。把新的PageRank向量繼續乘以M得到下一步的PageRank… … 如此迭代有限步后,PageRank向量的值會趨於收斂,於是我們得到最終的PageRank。
這里需要回答兩個問題:1. 如何確定初始的PageRank,即迭代的起點?答案是任意選擇一個概率分布就可以,無論你選擇什么初始值,都不影響其收斂到最終的結果。我們通常使用均勻概率分布,即。2. 如何定義M?這個問題稍顯復雜,有興趣的讀者可以參見Michael Nielsen 的博文Using MapReduce to compute PageRank了解更詳細的內容。在這里,我們將其簡化的定義為一個描述網頁間互相鏈接結構的超大矩陣。假設網絡里有n個網頁,那么我們這個矩陣就是一個n*n的方陣。矩陣的每一列代表一個網頁對外的超鏈接情況。例如,我們定義#(j)為第j個網頁對外的所有超鏈接的數量。那么對於矩陣M的第j列而言,如果網頁j對網頁k沒有超鏈接,那么第k行元素Mkj=0,否則Mkj=1/#(j)。這里隱含的意思是當一個讀者在瀏覽網頁j時,有1/#(j)的可能性跳轉到網頁k。
那么如何使用Map Reduce來計算PageRank呢?雖然整個迭代的過程必須是串行的,迭代的每一步我們還是可以用Map Reduce來並行的計算的。這里也必須並行的計算因為這個矩陣和向量的規模是超大的(想象一下整個互聯網的網頁數量)。使用Map Reduce來計算迭代的一步實際上是用Map Reduce來計算矩陣和向量的乘法。假設我們要計算如下一個方陣和向量的乘法。其實質是將第i個向量元素的值pi乘以矩陣第i列的每一元素,然后放在矩陣元素原來的位置。最后,把矩陣第i行的所有元素相加,得到結果向量的第i個元素的值。
類似的,我們可以得到用MapReduce計算PageRank的方法:
第一步,輸入的(input_key, input_value)。input_key是某個網頁的編號,如j。input_value是一個列表,元素值是M矩陣的第j列元素,最后再加上一個pj,就是當前網頁j的PageRank值。
第二步,Map。Mapper(input_key, input_value)所做的事情很簡單,就是把pj乘以列表元素的每一個值,然后輸出一組(intermediate_key, intermediate_value)。intermediate_key就是矩陣的行號,k。intermediate_value就是pj列表元素的值,即pj乘以矩陣第k行第j列的元素的值。
第三步,匯總。把所有intermediate_key相同的中間結果放到一起。即是把第k行所有的intermediate_value放在一個列表intermediate_value_list內。
第四步,Reduce。Reducer(intermediate_key, intermediate_value_list)做的事也很簡單,就是把intermediate_value_list內所有的值相加。最后形成的(output_key, output_value)就是結果向量第k行的元素值。
以上就是利用Map Reduce計算PageRank的簡略過程。這個過程相當粗略和不精確,只是為了揭示Map Reduce的工作過程和Google曾經用來計算PageRank的大致方法。認真的讀者應該查閱其它更嚴謹的著作。
最后,和上述計算矩陣和向量乘法的例子相似,Map Reduce也可以用來計算兩個向量的點乘。具體怎么做留給讀者自己去思考,一個提示是我們所有的intermediate_key都是相同的,可以取同一個值例如1。
2.3 Data Parallelism
這是一個“算法模式”。事實上,把Data Parallelism和下節將要提到的Task Parallelism都稱之為一種“算法模式”我覺得有過於籠統之嫌。到最后,哪一種並行算法不是被分解為並行執行的task呢(task parallelism)? 而並行執行的task不都是處理着各自的那份數據嗎(data parallelism)?所以如果硬要把Data Parallelism和Task Parallelism稱為兩種算法模式,我只能說它們的地位要高於其它的算法模式。它們是其它算法模式的基礎。只不過對於有些問題而言,比較明顯的我們可以把它看成是Data Parallelism的或是Task Parallelism的。也許Data Parallelism模式和Task Parallelism模式特指的就是這類比較明顯的問題。
那么什么是Data Parallelism? 顧名思義,就是這類問題可以表達為同樣的一組操作被施加在不同的相互獨立的數據上。
一個比較典型的例子就是計算機圖形學里面的Ray tracing算法。Ray tracing算法可以大致描述為從一個虛擬相機的光心射出一條射線,透過屏幕的某個像素點,投射在要渲染的幾何模型上。找到射線和物體的交點后,再根據該點的材料屬性、光照條件等,算出該像素點的顏色值,賦給屏幕上的像素點。由於物體的幾何模型很多個小的三角面片表示,算法的第一步就是要求出射線與哪個三角面片有交點。射線與各個單獨的三角面片求交顯然是相互獨立的,所以這可以看做是Data Parallelism的例子。
2.4 Task Parallelism
Task Parallelism的算法模式可以表述為,一組互相獨立的Task各自處理自己的數據。和Data Parallelism不同,這里關注的重點不是數據的划分,而是Task的划分。
如前所述,Task Parallelism和Data Parallelism是密不可分的。互相獨立的Task肯定也是運行在互相獨立的數據上。這主要是看我們以什么樣的視角去看問題。例如,上一節RayTracing的例子中,我們也可以把射線和一個獨立的三角面片求交看作是一個獨立的Task。這樣就也可以當它做是Task Parallelism的例子。然而,咬文嚼字的去區分到底是Task Parallelism還是Data Parallelism不是我們的目的,我們關注的應該是問題本身。對於某一個具體問題,從Data Parallelism出發考慮方便還是從Task Parallelism出發考慮方便,完全取決於問題本身的應用場景以及設計人員自身的經驗、背景。事實上,很多時候,不管你是從Task Parallelism出發還是從Data Parallelism出發,經過不斷的優化,最終的解決方案可能是趨同的。
下面一個矩陣乘法的例子很好的說明了這個問題。我們都知道矩陣乘法的定義:假如有n行k列的矩陣A乘以k行m列的矩陣B,那么我們可以得到一個n行m列的的矩陣C。矩陣C的第n行第m列的元素的值等於矩陣A的第n行和矩陣B的第m列的點乘。
從Task Parallelism的角度出發,我們可能把計算C的每一個元素當做一個獨立的Task。接下來,為了提高CPU的緩存利用率,我們可能把鄰近幾個單元格的計算合並成一個大一點的Task。從Data Parallelism的角度出發,我們可能一開始把C按行分成不同的塊。為了探索到底怎樣的划分更加有效率,我們可能調整划分的方式和大小,最后,可能發現,最有效率的做法是把A,B,C都分成幾個不同的小塊,進行分塊矩陣的乘法。可以看到,這個結果實際上和從Task Parallelism出發考慮的方案是殊途同歸的。
2.5 Recursive Splitting
Recursive Splitting指的是這樣一種算法模式:為了解決一個大問題,把它分解為可以獨立求解的小問題。分解出來的小問題,可能又可以進一步分解為更小的問題。把問題分解到足夠小的規模后,就可以直接求解了。最后,把各個小問題的解合並為原始的大問題的解。這實際上是我們傳統的串行算法領域里面也有的“divide and conquer”的思想。
舉兩個例子。第一個是傳統的歸並排序。例如,要排序下面的8個元素的數組,我們不管三七二十一先把它一分為二。排序4個元素的數組還是顯得太復雜了,於是又一分為二。現在,排序2個元素的數組很簡單,按照大小交換順序就行。最后,把排好序的數組按序依次組合起來,就得到我們最終的輸出結果。
第二個例子稍微有趣一點,是一個如何用程序解“數獨”游戲的例子。“數獨”就是在一個9*9的大九宮格內有9個3*3的小九宮格。里面有些格子已經填入了數字,玩家必須在剩下的空格里也填入1到9的數字,使每個數字在每行、每列以及每個小九宮格內只出現一次。
這里作為舉例說明,我們考慮一個簡單一點的情況:在一個4*4的格子里填入1~4的數字,使其在每行、每列以及每個2*2的格子里只出現一次。
解“數獨”游戲的算法可以有很多種。如果是人來解,大概會按照上圖的次序依次填入1,2,3到相應的格子當中。每填入一個新數字,都會重新按規則評估周圍的空格,看能否按現有情況再填入一些數字。這個方法當然沒錯,不過看上去不太容易並行化。下面介紹一個按照“recursive splitting”的方法可以很容易做到並行化的解法。
1) 首先,將二維的數獨格子展開成一個一維的數組。已經有數字的地方是原來的數字,空格子的地方填上“0”。
2) 接下來,從前到后對數組進行掃描。第一格是“3”,已經有數字了,跳過。移動搜索指針到下一格。
3) 第二格是“0”,意味着我們需要填入一個新的數字。這個新的數字有4種可能性:1, 2, 3, 4。所以創建4個新的搜索分支:
4) 接下來根據現有的數字信息檢查各個搜索分支。明顯,第三和第四個搜索分支是非法的。因為我們在同一行中已經有了數字“3”和“4”。所以忽略這兩個分支。第一和第二條分支用現有的數字檢查不出沖突,所以繼續從這兩個分支各派生出4條新的分支進行搜索… …
這個思路像極了我們之前的歸並排序的例子,都是在算法運行的過程中不斷產生出新的任務。所以實際上這也是一個“Task Parallelism”的例子。
2.6 Pipeline
“Pipeline”也是一種比較常見的算法模式。通常,我們都會用汽車裝配中的流水線、CPU中指令執行的流水線來類比的說明這一模式。它說的是我們會對一批數據進行有序、分階段的處理,前一階段處理的輸出作為下一階段處理的輸入。每一個階段永遠只重復自己這一階段的任務,不停的接受新的數據進行處理。用一個軟件上的例子打比方,我們要打開一批文本文件,將里面每一個單詞的字母全部改成大寫,然后寫到一批新的文件里面去,就可以創建一條有3個stage的流水線:讀文件,改大寫,寫文件。
“Pipeline”模式的概念看上去很容易理解,可是也不是每一個人都能一下子理解的那么透徹的。例如有這樣一個問題:我們有一個for循環,循環體是一條有3個stage的pipeline,每個stage的運行時間分別是10, 40, 和10個CPU的時鍾間隔。請問這個for循環執行N次大概需要多長時間(N是一個很大的數)?
A. 60*N
B. 10*N
C. 60
D. 40*N
請仔細思考並選擇一個答案。:-)
答案是40*N。流水線總的執行時間是由它最慢的一個stage決定的。原因請見下圖。
2.7 Geometric decomposition
接下來這兩個算法模式看上去都顯得比較特殊化,只針對某些特定的應用類型。“Geometric decomposition”說的是對於一些線性的數據結構(例如數組),我們可以把數據切分成幾個連續的子集。因為這種切分模式看上去和把一塊幾何區域切分成連續的幾塊很類似,我們就把它叫做”Geometric decomposition”。
最常用的例子是分塊矩陣的乘法。例如,為了計算兩個矩陣A,B的乘法。我們可以把他們切分成各自可以相乘的小塊。
結果矩陣當然也是分塊的:
結果矩陣每一分塊的計算按照如下公式進行:
例如:
最終的結果就是:
下面這幅圖顯示了兩個4*4的分塊矩陣A,B進行乘法時,計算結果矩陣C的某一分塊時,需要依次訪問的A,B矩陣的分塊。黑色矩陣分塊代表要計算的C的分塊,行方向上的灰色矩陣代表要訪問的矩陣A的分塊,列方向上的灰色分塊代表要訪問的矩陣B的分塊。
2.8 Non-work-efficient Parallelism
這個模式的名字取得很怪異,也有其他人把它叫做“Recursive Data”。不過相比而言,還是這個名字更為貼切。它指的是這一類模式:有些問題的處理使用傳統的方法,必須依賴於對數據進行有序的訪問,例如深度優先搜索,這樣就很難並行化。但是假如我們願意花費一些額外的計算量,我們就能夠采用並行的方法來解決這個問題。
常用的一個例子是如下的“尋找根節點”的問題。假設我們有一個森林,里面每一個節點都只記錄了自己的前向節點,根節點的前向節點就是它自己。我們要給每一個節點找到它的根節點。用傳統的方法,我們只能從當前節點出發,依次查找它的前向節點,直到前向節點是它自身。這種算法對每一個節點的時間復雜度是O(N)。總的時間復雜度是N*O(N)。
如果我們能換一種思路來解這個問題就可以將其並行化了。我們可以給每一個節點定義一個successor(后繼結點),successor的初始值都是其前向節點。然后我們可以同步的更新每一個節點的successor,令其等於“successor的successor”,直到successor的值不再變化為止。這樣對於上圖的例子,最快兩次更新,我們就可以找到每個節點的根節點了。這種方法能同時找到所有節點的根節點,總的時間復雜度是N*log (N)。
3. 后記
如前所述,“並行編程中的設計模式”是一個仍在不斷變化、發展的領域。這篇博文簡要介紹了這一領域當前的發展現狀,並選取八個常用的模式進行了介紹。我涉足並行編程領域不久,很多理解也並不深入。所以文章的缺點錯誤在所難免,真誠希望能得到大家的批評指正。
4. 參考文獻
本文的寫作參考了如下資源,可以作為進一步閱讀的材料:
· UC Berkeley, A Pattern Language for Parallel Programming
· Eun-Gyu Kim, Parallel Programming Patterns
· Jim Browne, Design Patterns for Parallel Computation
· Tim Mattson, Patterns for Parallel Programming
· Michael Nielsen, Using Map Reduce to compute PageRank
· Aater Suleman, Parallel Programming: Do you know Pipeline Parallelism?