使用Cython中prange函數(shù)實現(xiàn)for循環(huán)的并行
楔子
上一篇文章我們探討了 GIL 的原理,以及如何釋放 GIL 實現(xiàn)并行,做法是將函數(shù)聲明為 nogil,然后使用 with nogil 上下文管理器即可。在使用上非常簡單,但如果我們想讓循環(huán)也能夠并行執(zhí)行,那么該方式就不太方便了,為此 Cython 提供了一個 prange 函數(shù),專門用于循環(huán)的并行執(zhí)行。
這個 prange 的特殊功能是 Cython 獨一無二的,并且 prange 只能與 for 循環(huán)搭配使用,不能獨立存在。
Cython 使用 OpenMP API 實現(xiàn) prange,用于多平臺共享內(nèi)存的處理。但 OpenMP 需要 C 或者 C++ 編譯器支持,并且編譯時需要指定特定的編譯參數(shù)來啟動。例如:當(dāng)我們使用 gcc 時,必須在編譯和鏈接二進制文件的時候指定一個 -fopenmp,以確保啟用 OpenMP。
許多編譯器均支持 OpenMP ,包括免費的和商業(yè)的。但 Clang/LLVM 則是一個最顯著的例外,它只在一個單獨的分支中得到了初步的支持,而為它完全實現(xiàn)的 OpenMP 還在開發(fā)當(dāng)中。
而使用 prange,需要從 cython.parallel 中進行導(dǎo)入。但是在這之前,我們先來看一個例子:
import?numpy?as?np from?cython?cimport?boundscheck,?wraparound cdef?inline?double?norm2(double?complex?z)?nogil: ????""" ????接收一個復(fù)數(shù)?z,?計算它的模的平方 ????由于 norm2 要被下面的?escape?函數(shù)多次調(diào)用 ????這里通過?inline?聲明成內(nèi)聯(lián)函數(shù) ????:param?z:? ????:return:? ????""" ????return?z.real?*?z.real?+?z.imag?*?z.imag cdef?int?escape(double?complex?z, ????????????????double?complex?c, ????????????????double?z_max, ????????????????int?n_max)?nogil: ????""" ????這個函數(shù)具體做什么,?不是我們的重點 ????我們不需要關(guān)心 ????""" ????cdef: ????????int?i?=?0 ????????double?z_max2?=?z_max?*?z_max ????while?norm2(z)?<?z_max2?and?i?<?n_max: ????????z?=?z?*?z?+?c ????????i?+=?1 ????return?i @boundscheck(False) @wraparound(False) def?calc_julia(int?resolution, ???????????????double?complex?c, ???????????????double?bound=1.5, ???????????????double?z_max=4.0, ???????????????int?n_max=1000): ????""" ????我們將要在?Python?中調(diào)用的函數(shù) ????""" ????cdef: ????????double?step?=?2.0?*?bound?/?resolution ????????int?i,?j ????????double?complex?z ????????double?real,?imag ????????int[:,?::?1]?counts ????counts?=?np.zeros((resolution?+?1,?resolution?+?1),?dtype="int32") ????for?i?in?range(resolution?+?1): ????????real?=?-bound?+?i?*?step ????????for?j?in?range(resolution?+?1): ????????????imag?=?-bound?+?j?*?step ????????????z?=?real?+?imag?*?1j ????????????counts[i,?j]?=?escape(z,?c,?z_max,?n_max) ????return?np.array(counts,?copy=False)
我們手動編譯一下,然后調(diào)用 calc_julia 函數(shù),這個函數(shù)做什么不需要關(guān)心,我們只需要將注意力放在那兩層 for 循環(huán)(準(zhǔn)確的說是外層循環(huán))上即可,這里我們采用手動編譯的形式。
import?cython_test import?numpy?as?np import?matplotlib.pyplot?as?plt arr?=?cython_test.calc_julia(1000,?0.322?+?0.05j) plt.imshow(np.log(arr)) plt.show()
那么 calc_julia 這個函數(shù)耗時多少呢?我們來測試一下:
使用 prange
對于上面的代碼來說,外層循環(huán)里面的邏輯是彼此獨立的,即當(dāng)前循環(huán)不依賴上一層循環(huán)的結(jié)果,因此這非常適合并行執(zhí)行。所以 prange 便閃亮登場了,我們只需要做簡單的修改即可:
import?numpy?as np from?cython?cimport boundscheck,?wraparound from?cython.parallel?cimport prange cdef?inline?double?norm2(double?complex?z)?nogil: ????return?z.real?*?z.real?+?z.imag?*?z.imag cdef?int?escape(double?complex?z, ????????????????double?complex?c, ????????????????double?z_max, ????????????????int?n_max)?nogil: ????cdef: ????????int?i?=?0 ????????double?z_max2?=?z_max?*?z_max ????while?norm2(z)?<?z_max2?and?i?<?n_max: ????????z?=?z?*?z?+?c ????????i?+=?1 ????return?i @boundscheck(False) @wraparound(False) def?calc_julia(int?resolution, ???????????????double?complex?c, ???????????????double?bound=1.5, ???????????????double?z_max=4.0, ???????????????int?n_max=1000): ????cdef: ????????double?step?=?2.0?*?bound?/?resolution ????????int?i,?j ????????double?complex?z ????????double?real,?imag ????????int[:,?::?1]?counts ????counts?=?np.zeros((resolution?+?1,?resolution?+?1),?dtype="int32") #?只需要將外層的 range 換成 prange ????for?i?in?prange(resolution?+?1, nogil=True): ????????real?=?-bound?+?i?*?step ????????for?j?in?range(resolution?+?1): ????????????imag?=?-bound?+?j?*?step ????????????z?=?real?+?imag?*?1j ????????????counts[i,?j]?=?escape(z,?c,?z_max,?n_max) ????return?np.array(counts,?copy=False)
我們只需要將外層循環(huán)的 range 換成 prange 即可,里面指定 nogil=True,便可實現(xiàn)并行的效果,至于這個函數(shù)的其它參數(shù)以及用法后面會說。而且一旦使用了 prange,那么在編譯的時候,必須啟用 OpenMP,下面看一下編譯腳本。
from?distutils.core?import?setup,?Extension from?Cython.Build?import?cythonize ext?=?[Extension("cython_test", ?????????????????sources=["cython_test.pyx"], ?????????????????extra_compile_args=["-fopenmp"], ?????????????????extra_link_args=["-fopenmp"])] setup(ext_modules=cythonize(ext,?language_level=3))
編譯測試一下:
我們看到效率大概是提升了兩倍,因為我 Windows 上使用的不是 gcc,所以這里是在 CentOS 上演示的。而我的 CentOS 服務(wù)器只有兩個核,因此效率提升大概兩倍左右。
所以只是做了一些非常簡單的修改,便可帶來如此巨大的性能提升,簡直妙啊。prange 是要搭配 for 循環(huán)來使用的,如果 for 循環(huán)內(nèi)部的邏輯彼此獨立,即第二層循環(huán)不依賴第一層循環(huán)的某些結(jié)果,那么不妨使用 prange 吧。
注意還沒完,我們還能做得更好,下面就來看看 prange 里面的其它的參數(shù),這樣我們能更好利用 prange 的并行特性。
prange 的其它參數(shù)
prange 函數(shù)的原型如下:
#?第一個參數(shù)?self?我們不需要管 #?prange?實際上是類?CythonDotParallel?的成員函數(shù) #?因為?Cython?內(nèi)部執(zhí)行了下面這行邏輯 #?sys.modules['cython.parallel']?=?CythonDotParallel() #?所以它將一個實例對象變成了一個模塊 def?prange(self,?start=0,?stop=None,?step=1,? ???????????nogil=False,?schedule=None,? ???????????chunksize=None,?num_threads=None):
我們先來看前三個參數(shù),start、stop、step。
- prange(3): 相當(dāng)于 start=0、stop=3;
- prange(1, 3): 相當(dāng)于 start=1、stop=3;
- prange(1, 3, 2): 相當(dāng)于 start=1、stop=3、step=2;
類似于 range,同樣不包含結(jié)尾 stop。
然后是第四個參數(shù) nogil,它默認是 False,但事實上我們必須將其設(shè)置為 True,否則會報出編譯錯誤。
然后剩下的三個參數(shù),如果我們不指定的話,那么 Cython 編譯器采取的策略是將整個循環(huán)分成多個大小相同的連續(xù)塊,然后給每一個可用線程一個塊。然而這個策略實際上并不是最好的,因為每一層循環(huán)用的時間不一定一樣,如果一個線程很快就完成了,那么不就造成資源上的浪費了嗎?
我們修改一下,將 schedule 指定為 "static",chunksize 指定為 1:
for?i?in?prange(resolution?+?1,?nogil=True,? ????????????????schedule="static",?chunksize=1):
其它地方不變,只是加兩個參數(shù),然后重新測試一下。
我們看到效率上是差不多的,原因是我的機器只有兩個核,如果核數(shù)再多一些的話,那么速度就會明顯地提升。
下面來解釋一下剩余的三個參數(shù)的含義,首先是 schedule,它有以下幾個選項:
"static"
整個循環(huán)在編譯時會以一種固定的方式分配給多個線程,如果 chunksize 沒有指定,那么會分成 num_threads 個連續(xù)塊,一個線程一個塊。如果指定了 chunksize,那么每一塊會以輪詢調(diào)度算法(Round Robin)交給線程進行處理,適用于任務(wù)均勻分布的情況。
"dynamic"
線程在運行時動態(tài)地向調(diào)度器申請下一個塊,chunksize 默認為 1,當(dāng)任務(wù)負載不均時,動態(tài)調(diào)度是最佳的選擇。
"guided"
塊是動態(tài)分布的,就像 dynamic 一樣,但這與 dynamic 還不同,chunksize 的比例不是固定的,而是和 剩余迭代次數(shù) / 線程數(shù) 成比例關(guān)系。
"runtime"
不常用。
控制 schedule 和 chunksize 可以方便地探索不同的并行執(zhí)行策略、以及工作負載分配,通常指定 schedule 為 "static",加上設(shè)置一個合適的 chunksize 是最好的選擇。而 dynamic 和 guided 適用于動態(tài)變化的執(zhí)行上下文,但會導(dǎo)致運行時開銷。
當(dāng)然還有最后一個參數(shù) num_threads,很明顯不需要解釋,就是使用的線程數(shù)量。如果不指定,那么 prange 會使用盡可能多的線程。所以我們只是做了一點修改,便可以帶來巨大的性能提升,這種性能提升與 Cython 在純 Python 上帶來的性能提升成倍增關(guān)系。
在reductions操作上使用prange
我們經(jīng)常會循環(huán)遍歷數(shù)組計算它們的累和、累積等等,這種數(shù)據(jù)量減少的操作我們稱之為 reduction 操作。而 prange 對這樣的操作也是支持并行執(zhí)行的,我們舉個例子:
from?cython?cimport boundscheck,?wraparound @boundscheck(False) @wraparound(False) def calc_julia(int?[:,?::?1]?counts, ???????????????int?val): ????cdef: ????????int?total?=?0 ????????int?i,?j,?M,?N ????N,?M?=?counts.shape[:?2] ????for?i?in?range(M): ????????for?j?in?range(N): ????????????if?counts[i,?j]?==?val: ????????????????total?+=?1 ????return?total?/?float(counts.size)
顯然我們是希望計算一個數(shù)組中值 val 的元素的個數(shù),下面測試一下:
如果改成 prange 的話,會有什么效果呢?代碼的其它部分不變,只需要導(dǎo)入 prange,然后將 range(M) 改成 prange(M, nogil=True) 即可。
速度比原來快了兩倍多,還是很可觀的,如果你的 CPU 是多核的,那么效率提升會更明顯。
這里我們沒有使用 schedule 和 chunksize 參數(shù),你也可以加上去。當(dāng)然啦,如果占用內(nèi)存過大的話,它可能無法像預(yù)期的一樣顯著地提升性能,因為 prange 的優(yōu)化重點是在 CPU 上面。
但是可能有人會有疑問,多個線程同時對 total 變量進行自增操作,這么做不會造成沖突嗎?答案是不會的,因為加法是可交換的,即無論是 a + b 還是 b + a,結(jié)果都是相同的。Cython(通過 OpenMP)生成線程代碼,每個線程計算循環(huán)子集的和,然后所有線程再將各自的和匯總在一起。
如果是交給 Numpy 來做的話,那么等價于如下:
np.sum(counts?==?val)?/?float(counts.size)
但是效率如何呢?我們來對比一下:
我們采用并行計算用的是 6.13 毫秒,Numpy 用的是 20 毫秒,看樣子是我們贏了,并且 CPU 核心數(shù)越多,差距越明顯,這便是并行計算的威力。當(dāng)然對于這種算法來說,還是直接交給 Numpy 吧,畢竟人家都幫你封裝好了,一個函數(shù)調(diào)用就可以解決了。
因此有效利用計算機硬件資源確實是最直接的辦法。
并行編程的局限性
雖然 Cython 的 prange 容易使用,但其實還是有局限性的,當(dāng)然這個局限性和 Cython無關(guān),因為理想化的并行擴展本身就是一個難以實現(xiàn)的事情。我們舉個例子:
def filter(nrows, ncols): ????for?i?in?range(nrows): ????????for?j?in?range(ncols): ????????????b[i,?j]?=?(a[i,?j]?+?a[i?-?1,?j]?+?a[i?+?1,?j]?+ ???????????????????????a[i,?j?-?1]?+?a[i,?j?+?1])?/?5.0)
假設(shè)我們要做一個過濾器,計算每一個點加上它周圍的四個點的平均值。但如果這里將外層的 range 換成 prange,那么它的整體性能不會明顯提升。因為內(nèi)層循環(huán)訪問的是不連續(xù)的數(shù)組元素,由于缺乏數(shù)據(jù)本地性,CPU 的緩存無法生效,反而導(dǎo)致 prange 變慢。
那么我們什么時候使用 prange 呢?遵循以下法則即可:
- 1. prange 能夠很好的利用 CPU 并行操作, 這一點我們已經(jīng)說過了;
- 2. 非本地讀寫的那些和內(nèi)存綁定的操作很難提高速度;
- 3. 用較少的線程更容易實現(xiàn)加速, 因為對于 CPU 密集而言, 即便指定了超越核心數(shù)的線程也是沒有意義的;
- 4. 使用優(yōu)化的線程并行庫是將 CPU 所有核心都用于常規(guī)計算的最佳方式;
當(dāng)然,其實我們在開發(fā)的時候是可以隨時使用 prange 的,只要循環(huán)體不和 Python 對象進行交互即可。
小結(jié)
Cython 允許我們繞過全局解釋器鎖,只要我們把和 Python 無關(guān)的代碼分離出來即可。對于那些不需要和 Python 交互的 C 代碼,可以輕松地使用 prange 實現(xiàn)基于線程的并行。
在其它語言中,基于線程的并行很容易出錯,并且難以正確處理。而 Cython 的 prange 則不需要我們在這方面費心,能夠輕松地處理很多性能瓶頸。
到此這篇關(guān)于使用Cython中prange函數(shù)實現(xiàn)for循環(huán)的并行的文章就介紹到這了,更多相關(guān)Cython prange for循環(huán)并行內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Python可視化Matplotlib折線圖plot用法詳解
這篇文章主要為大家介紹了Python可視化中Matplotlib折線圖plot用法的詳解,有需要的朋友可以借鑒參考下,希望可以有所幫助,祝大家多多進步2021-09-09Python實現(xiàn)圖片和視頻的相互轉(zhuǎn)換
有時候我們需要把很多的圖片合成視頻,或者說自己寫一個腳本去加快或者放慢視頻;也有時候需要把視頻裁剪成圖片,進行后續(xù)操作。這篇文章就將為大家介紹如何通過Python實現(xiàn)圖片和視頻的相互轉(zhuǎn)換,需要的可以參考一下2021-12-12