PLINQ并行開發中常見性能問題及應對方案
在上月舉行的PDC 09大會上,微軟并行庫團隊的開發工程師Igor Ostrovsky介紹了PLINQ的工作原理,以及多核編程中,尤其是在PLINQ使用過程中幾種常見性能問題及應對方法。Igor表示,這些性能問題很少在順序編程中遇到,因此在并行環境中容易被人忽視。
#T#
第一個性能問題是內存分配
由于利用了多核CPU進行運算,對象分配的速度也加快了。此外,程序中可以還會出現更高頻率的字符串連接或裝箱操作,這都會使GC壓力增大。.NET應用程序所使用的默認GC方式為Concurrent GC,它的性能很高,并且為降低應用程序的延遲作了很多優化。它的最佳使用場景是用戶交互式應用,這樣可以盡可能避免用戶界面的停頓,但是它在長期運行的多核程序中表現并不好。而最終的結果是大量計算時間耗費在GC上,此時應用程序算法即便是利用了多個核,也會發現它的伸縮能力受到了GC限制。解決這個問題的方法之一是減小內存分配,例如可以使用值類型來代替引用類型。值類型的對象會分配在線程棧而不是堆上,以此避免對GC產生壓力。第二個方法是在config文件中啟用Server GC。使用Server GC會改變.NET分配對象的方式,此時.NET會為每個核準備不同的堆,并且獨立進行垃圾回收。這樣在一臺4核的機器上便可以有4個線程同時進行垃圾回收,性能自然也就隨著多核而提升了。
第二個性能問題是CPU在局部化(Locality)和緩存方面的問題
在流行的多核架構中,每個核都有獨立的二級緩存。CPU并不會緩存單個地址中的數據,而是緩存以64字節或128字節相鄰內存的緩存條目(cache line),因此當某個核改變了內存中的數據時,則其他核中地址相鄰的緩存數據也會失效,這樣CPU每次進行計算時都要從速度較慢的內存中加載數據。這個性能問題的隱蔽之處在于代碼中的不同數據——例如同一個數組的不同下標——可能在內存中處在同一個緩存條目中,因此這個問題又被稱為錯誤共享(False Sharing)。Igor演示了一段性能低下的代碼,在這個實現中多個線程會不斷讀寫同一個數組的相鄰下標,因此造成了錯誤共享。Igor的修改方法是將數據存放在數組中相距較遠的下標,甚至是不同的數組中。由于CPU的緩存條目大小有限,這種方法可以避免出現錯誤共享。博客園老趙在《計算機體系結構與程序性能》一文中也提出了一種優化方式,他的做法是盡可能使用局部變量來保存計算過程中的中間值,以此減少對數組的修改操作。由于局部變量分處不同線程的棧空間內,因此地址相距很遠,不會造成錯誤共享問題。當有人問起到這種優化方式是否安全時,Igor答到,這其實和CPU架構的實現方式有很大關系。如果某一天緩存實現變化了,可能這種優化方式會適得其反。不過在目前主流架構中,這樣的做法是比較安全的。Igor補充道,他認為這也是為什么“全自動”并行化那么困難的原因之一,因為在并行環境下影響程序性能的方面實在太多了。
第三個問題在于開發人員傾向于在PLINQ中使用大量小粒度的委托來完成工作
此時每個委托的計算任務很小,而委托的執行次數會很多。在計算較長的序列時,小粒度的委托對象也能獲得性能提高,但是它會產生額外的負載。例如,MoveNext和Current的調用,以及每個委托的執行性能都和虛方法比較接近。此外,一個較長的輸入序列也會受限于內存的吞吐量。因此,Igor建議開發人員在使用PLINQ時盡可能使用計算量較大的委托,以此減少計算主體外的性能開銷。
第四和第五問題則與PLINQ的實現有關
Igor表示,PLINQ可以并行執行所有的LINQ查詢,但是相對于復雜的LINQ查詢,PLINQ能夠對簡單的LINQ操作有更好的優化。因此,Igor建議開發人員在使用PLINQ時可以手動將復雜的LINQ表達式拆分為簡單的LINQ查詢,并且只在真正需要大量計算的地方才開始并行化。這種結合順序執行和并行執行的方式,可以讓應用程序的性能達到最優。此外,為不同的輸入方式選擇不同的分塊(partition)策略對性能的影響很大,因此PLINQ會對數組和IList<>進行靜態的分割,而對IEnumerable<>集合按實際需求進行劃分,而開發人員也可以通過自定義Partitioner的方式來指定特別的分割策略。
最后,Igor強調,使用并行計算進行程序性能優化之前,一定要通過合適的評測方式來找到代碼的瓶頸。如果這個瓶頸正符合數據并行(data parallel)模式,那么可以使用PLINQ進行性能優化。而優化完成后還需要評測其效果,并使用之前提出的幾種方案進行合適的調整。
延伸閱讀
PLINQ(Parallel LINQ)。微軟對PLINQ在Parallel FX中的定位是:PLINQ是TPL(Task Parallel Library)的一個高層應用。目前PLINQ已經被集成到.NET 4.0當中了。