PPL 和AMP並行編程

根據我的理解,PPL是指Parallel Patterns Library,這是微軟爲了提出並行計算(就是現在的C++ AMP)而在Visual Studio2010中引入的提供了類似於標準模板庫 STL 的編程模型:並行模式庫。具體MSDN上關於PPL的介紹參見:Parallel Patterns Library (PPL)

C++ AMP也是同樣類似於標準模板庫(STL)的編程模型庫,他將在Visual Studio2012中引入。MSDN參見: C++ AMP Overview

PPLAMP都是相應的模板庫,他們的目的只有一個就是爲並行編程服務(Parallel Programming)。

這裏有個最近的C++ AMP的英文鏈接,希望對你有幫助:http://blogs.msdn.com/b/vcblog/archive/2012/08/31/10345173.aspx

(都是microsoft搞的並行計算庫)

遇見PPL:C++ 的並行和異步
 

作者:李永倫,發佈於2012-9-14,來源:InfoQ

 

並行計算正弦值

假設我們有一個數組,裏面包含一組隨機生成的浮點數,現在要計算每個浮點數對應的正弦值,如果你看過我的 《遇見C++ Lambda》,你可能會想到用for_each函數,如代碼1所示。爲了可以把數組裏的浮點數替換成對應的正弦值,我們需要把Lambda的參數聲明爲引用,如果你想保留那些浮點數,可以創建一個新的數組存放計算結果。

代碼 1

值得提醒的是,這裏使用begin和end兩個函數分別獲取數組的起止位置,這是C++ 11的推薦寫法。此前,我們使用STL容器的begin和end兩個成員函數分別獲取起止位置,但這種做法無法覆蓋C風格數組;如今,C++ 11通過begin和end兩個函數把獲取C風格數組和STL容器的起止位置的寫法統一起來,不難想象,遵循新的寫法可以提高代碼的一致性。

STL提供的for_each函數是串行執行的,如果你想充分利用多核的優勢,可以考慮換用PPL(Parallel Patterns Library)提供的parallel_for_each函數,整個改造過程只需三步:

1.#include <ppl.h>

2.using namespace concurrency;

3.把for_each改爲parallel_for_each,如代碼2所示

代碼 2

需要說明的是,如果你在Visual C++ 2010上使用PPL,你需要引用Concurrency命名空間(首字母大寫),這裏引用的concurrency命名空間(全小寫)是Visual C++ 2012的PPL爲了和其他常見的全小寫命名空間(如stl)保持一致而創建的命名空間別名。

如果你不想影響那些浮點數,可以創建一個新的數組,然後通過parallel_for函數把計算結果對應地存到新的數組裏,如代碼3所示。這裏選擇parallel_for函數主要是爲了藉助索引管理兩個數組的元素的對應關係,如果你要在多個數組之間周旋,比如說,你要爲A、B、C和D四個集合實現對應元素的 (A + B) / (C - D) 操作,那麼使用parallel_for函數就會非常直觀。

代碼 3

對於我們這裏的簡單需求,如果你不想自己管理元素的對應關係,可以考慮parallel_transform函數,如代碼4所示。

parallel_transform函數的前兩個參數指定輸入容器的起止位置,第三個參數指定輸出容器的開始位置,前兩個參數指向的位置之間的元素個數必須小於或等於第三個參數指向的位置和輸出容器的結束位置之間的元素個數,否則將會出錯。

代碼 4

並行數奇數個數

《遇見C++ Lambda》裏,我們通過for_each函數數一下隨機生成的整數裏有多少個奇數,這個過程可以並行化嗎?可以的,一般的做法是聲明一個變量存放個數,在迭代的過程中一旦發現奇數就遞增一下這個變量,由於涉及到多線程,可以通過系統提供的InterlockedIncrement函數確保遞增操作的安全,如代碼5所示。

代碼 5

上面的代碼可以得到正確結果,但存在一個問題,每次發現奇數都要調用InterlockedIncrement函數,如果nums數組裏的奇數佔大多數,那麼調用InterlockedIncrement函數帶來的開銷可能會抵消並行帶來的好處,最終導致執行效率甚至比不上串行版本。爲了避免這種影響,我們可以把volatile變量和InterlockedIncrement函數的組合寫法替換成PPL提供的combinable對象,如代碼6所示。

代碼 6

combinable對象是如何協助parallel_for_each函數提高執行效率的呢?這個需要稍微瞭解一下parallel_for_each函數的工作方式,簡單的說,它會把我們傳給它的數據分成N塊,分別交給N個線程並行處理,但同一塊數據會在對應的線程裏串行處理,這意味着處理同一塊數據的代碼可以直接實現同步,combinable對象正是利用這點減少不必要的同步,從而提高parallel_for_each函數的執行效率。

combinable對象會爲每個線程提供一個線程局部存儲(Thread-Local Storage),每個線程局部存儲都會使用創建對象時提供的Lambda進行初始化。我們可以通過local成員函數訪問當前線程的線程局部存儲,因爲combinable對象保證local成員函數返回的對象一定是當前線程的,所以我們可以放心的直接操作。當每個線程的操作都完成之後,我們就可以調用combine成員函數把每個線程局部存儲的結果彙總起來,這個時候會產生線程之間的同步,但同步工作由combinable對象負責,無需我們費心,我們只需告訴它彙總的方法就行了,在我們的示例裏,這個邏輯是STL提供的plus函數對象。

parallel_for_each函數和combinable對象的組合寫法本質上就是一個Reduce過程,PPL提供了一個parallel_reduce函數專門處理這類需求,如代碼7所示,它非常直接地展示了parallel_for_each函數和combinable對象隱藏起來的二段處理過程。

代碼 7

第一個階段,parallel_reduce函數會把我們傳給它的數據分成N塊,分別交給N個線程並行處理,每個線程執行的代碼由第四個參數指定。在我們的示例裏,這個參數是一個Lambda,parallel_reduce函數會通過Lambda的參數告訴我們每塊數據的起止位置,以及計算的初始值,這個初始值其實來自parallel_reduce函數的第三個參數,而Lambda的函數體則是不折不扣的串行代碼。所有線程執行完畢之後就會進入第二個階段,彙總每個線程的執行結果,彙總的方法由第五個參數指定。

parallel_reduce函數和前面提到的parallel_transform函數可以組合起來實現並行MapReduce操作,而STL提供的transform和accumulate兩個函數則可以組合起來實現串行MapReduce操作。

同時執行不同任務

假設我們現在的任務是計算一組隨機整數裏的所有奇數之和與第一個素數的商,一般的做法是按順序執行以下步驟:

1.生成一組隨機整數

2.計算所有奇數的和

3.找出第一個素數

4.計算最終結果

由於第二、三步是相互獨立的,它們只依賴於第一步的結果,我們可以同時執行這兩步提高程序的整體執行效率。那麼,如何同時執行兩個不同的代碼呢?可以使用parallel_invoke函數,如代碼8所示。

代碼 8

parallel_invoke函數最多可以接受十個參數,換句話說,它可以同時執行最多十個不同的代碼,如果我們需要同時執行超過十個代碼呢?這個時候我們可以考慮創建一個Lambda數組,然後交給parallel_for_each/parallel_for函數去執行,如代碼9所示。

代碼 9

這些代碼都能得到正確的結果,但它們都有一個缺點——阻塞當前線程。想想看,一般需要動用並行編程的地方都是計算量比較大的,如果要等它們算好才能繼續,恐怕會把用戶惹毛,但是,如果不等它們算好,後面的步驟可能沒法正常運作,怎麼辦呢?

async + continuation

我們可以通過task對象異步執行第一步,然後通過continuation把後續步驟按照既定的順序連結起來,這樣既可避免阻塞當前線程,又能確保正確的執行順序。

首先,把各個步驟需要共享的變量挪到前面,如代碼10所示,這些變量將被對應的步驟捕獲並使用。

代碼 10

然後,通過create_task函數創建一個task對象,異步執行第一步,如代碼11所示。create_task函數負責用我們傳給它的Lambda創建task對象,這個Lambda可以有返回值,但不能接受任何參數,否則將會編譯出錯。當我們需要從外部獲取輸入時,可以藉助閉包或者調用其他函數。

代碼 11

接着,在create_task函數返回的task對象上調用then函數創建一個continuation,如代碼12所示。這個continuation會在前一個task結束之後纔開始,從而確保執行第二、三步所需的數據在執行之前準備好。

代碼 12

最後,在then函數返回的task對象上調用then函數創建一個continuation,執行第四步,如代碼13所示。理論上,你可以通過then函數創建任意數目的continuation。值得提醒的是,在Metro風格的應用程序裏,continuation默認是在UI線程裏執行的,因此可以在continuation裏直接更新UI控件而不必使用Dispatcher對象,但是,如果你想在後臺執行continuation,你需要把task_continuation_context::use_arbitrary傳給then函數的_ContinuationContext參數。

代碼 13

如果你把這些代碼組合起來放在main函數裏執行,並且在最後放置一個cin.get()等待結果,那麼一切都會運作正常。但是,如果你把它們放在一個work函數裏,然後在main函數裏調用這個work函數,你可能會碰到異常,大概是說我們讀了不該讀的地方。這是因爲我們的task是異步執行的,執行的過程中work函數可能已經返回了,連帶那些分配在棧上的變量也一併銷燬了,如果此時訪問那些變量就會出錯。怎麼解決這個問題?

前面曾經說過,我們傳給create_task函數的Lambda可以有返回值,這個返回值將會通過參數傳給後續的continuation,我們可以通過這個機制把那些變量內化到Lambda裏,如代碼14所示。

代碼 14

值得提醒的是,我們通過tuple對象把第二、三步的計算結果傳給第四步,然後通過tie函數把tuple對象裏的數據提取到兩個變量裏,這種寫法類似於F#的“let sum_of_odds, first_prime = operands”。

另外,如果你擔心在task之間傳遞vector會帶來性能問題,可以通過智能指針單獨處理,如代碼15所示。智能指針本身是一個對象,會隨着work函數的返回而銷燬,因此需要通過按值傳遞的方式捕獲它。

代碼 15

到目前爲止,我們還沒有任何異常處理的代碼,如果其中一個task拋出異常怎麼處理?我們可以在任務鏈的末端加上一個特殊的continuation,如代碼16所示,它的參數是一個task對象,任務鏈上的任何一個task拋出來的異常都會傳到這裏,這個異常可以通過調用get函數重新拋出,因此我們用一個try…catch語句把get成員函數的調用包圍起來,然後處理它拋出來的異常。

代碼 16

你可能會問的問題

1 使用PPL需要什麼條件?

parellel_for、parellel_for_each和parallel_invoke等函數可以在Visual Studio 2010上使用,使用時需要包含ppl.h頭文件並引用Concurrency命名空間,而parellel_transform和parallel_reduce函數,以及和task相關的部分則需要Visual Studio 2012,使用時需要分別包含ppl.h和ppltask.h頭文件。

2 能否推薦一些PPL的參考資料?

關於本文提到的PPL函數和類型,可以參考MSDN的concurrency類庫。另外,MSDN的Parallel Patterns Library (PPL)和Parallel Programming with Microsoft Visual C++: Design Patterns for Decomposition and Coordination on Multicore Architectures也是很好的學習資料。

3 STL是否提供task的替代品?

C++ 11的STL提供了std::future類,結合std::async函數可以實現task的異步效果,如代碼17所示,但std::future類目前不支持contiuation,只能通過get成員函數獲取結果,調用get成員函數的時候,如果相關代碼還在執行,則會阻塞當前線程。

代碼 17

4 PPL能否在Windows以外的平臺上使用?

PPL目前只能在Windows上使用,如果你想在其他平臺上進行類似的並行編程,可以考慮Intel Threading Building Blocks,它同時支持Windows、Mac OS X和Linux,提供的API和PPL的類似。TBB是開源的,Intel爲它提供商業和GPLv2兩種許可協議。

5 能否推薦一些TBB的參考資料?

Intel Threading Building Blocks: Outfitting C++ for Multi-Core Processor Parallelism是一本不錯的學習資料,另外,Intel也提供了豐富的示例代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章