そしお再びPythonのGILに぀いお

たえがき



私が仕事をする幞運な分野は、心臓の蚈算電気生理孊ず呌ばれおいたす 。 心臓掻動の生理孊は、個々の心筋现胞のレベルで発生する電気的プロセスによっお決定されたす。 これらの電気プロセスは、枬定が容易な電界を䜜り出したす。 さらに、静電気の数孊的モデルの枠組みで非垞によく説明されおいたす。 ここで、心臓の働きを厳密に数孊的に蚘述するナニヌクな機䌚が生じたす。これは、倚くの心臓病の治療方法を改善するこずを意味したす。



この分野での仕事䞭に、さたざたなコンピュヌティングテクノロゞヌを䜿甚した経隓を積んできたした。 この出版物の䞀郚ずしおだけでなく、私にずっお興味深いかもしれないいく぀かの質問に答えようずしたす。



Scientific Pythonに぀いお簡単に



倧孊の最初のコヌスから始めお、数倀アルゎリズムの迅速な開発のための理想的なツヌルを芋぀けようずしたした。 いく぀かの率盎に蚀っお限界的な技術を捚おた堎合、C ++ずMATLABの間を走りたした。 これは、Scientific Python [1]を発芋するたで続きたした。



Scientific Pythonは、科孊蚈算ず科孊芖芚化のためのPythonラむブラリのコレクションです。 私の仕事では、私のニヌズの玄90をカバヌする以䞋のパッケヌゞを䜿甚したす。

圹職 説明
ナンピヌ 基本的なラむブラリの1぀を䜿甚するず、MATLABスタむルの単䞀オブゞェクトずしお倚次元配列を操䜜できたす。

線圢代数、フヌリ゚倉換、乱数の凊理などの基本手順の実装が含たれたす。
シピヌ NumPy拡匵には、最適化メ゜ッドの実装、攟電されたマトリックスの凊理、統蚈などが含たれたす。
パンダ 倚次元デヌタず統蚈の分析甚の個別のパッケヌゞ。
シンピヌ シンボリック数孊のパッケヌゞ。
マトプロプリブ 二次元グラフィックス。
マダビ2 VTKに基づく3次元グラフィックス。
スパむダヌ 数孊アルゎリズムのむンタラクティブな開発に䟿利なIDE。
Scientific Pythonでは、数倀アルゎリズムの迅速な開発のための䟿利な高レベルの抜象化ず、最新の開発蚀語ずの間に倧きなバランスがあるこずがわかりたした。 しかし、ご存じのずおり、完璧なツヌルはありたせん。 そしお、Pythonのかなり重芁な問題の1぀は、䞊列蚈算の問題です。



Pythonでの䞊列蚈算の問題。



この蚘事の䞊列コンピュヌティングによっお、SMP-共有メモリを䜿甚した察称型マルチプロセッシングに぀いお理解できたす。 CUDAず共有メモリを備えたシステムの䜿甚は扱いたせんMPI暙準が最もよく䜿甚されたす。



問題はGILです。 GILGlobal Interpreter Lockは、耇数のスレッドが同じバむトコヌドを実行するのを防ぐミュヌテックスです。 残念ながら、CPythonのメモリ管理システムはスレッドセヌフではないため、このロックが必芁です。 はい、GILはPythonの問題ではなく、CPythonむンタヌプリタヌの実装の問題です。 しかし、残念ながら、Pythonの残りの実装は、高速な数倀アルゎリズムの䜜成にはあたり適しおいたせん。



幞いなこずに、珟圚GILの問題を解決する方法はいく぀かありたす。 それらを考慮しおください。



テストタスク



Nベクトルの2぀のセットが䞎えられたす3次元ナヌクリッド空間のP = {p 1 、p 2 、...、p N }およびQ = {q 1 、q 2 、...、q N } 。 次元N x Nの行列Rを䜜成する必芁がありたす。各芁玠r i、jは次の匏で蚈算されたす。









倧たかに蚀えば、すべおのベクトル間のペアワむズ距離を䜿甚しお行列を蚈算する必芁がありたす。 この行列は、実際の蚈算でよく䜿甚されたす。たずえば、RBF補間や積分方皋匏の方法による緊急状態での差分の解決などです。



テスト実隓では、ベクトルの数はN = 5000です。蚈算には、4コアのプロセッサが䜿甚されたした。 結果は、10の開始の平均時間によっお取埗されたす。



テストタスクの完党な実装は、GitHubで芋るこずができたす[2]。
「@chersaya」からのコメントの正しい発蚀。 ここでは、このテストケヌスを䟋ずしお䜿甚したす。 ペアワむズ距離を本圓に蚈算する必芁がある堎合は、scipy.spatial.distance.cdist関数を䜿甚するこずをお勧めしたす。


C ++䞊列実装



Pythonでの䞊列蚈算の効率を比范するために、このタスクをC ++で実装したした。 䞻な機胜コヌドは次のずおりです。



ナニプロセッサの実装



//! Single thread matrix R calculation void spGetR(vector<Vector3D> & p, vector<Vector3D> & q, MatrixMN & R) { for (int i = 0; i < p.size(); i++) { Vector3D & a = p[i]; for (int j = 0; j < q.size(); j++) { Vector3D & b = q[j]; Vector3D r = b - a; R(i, j) = 1 / (1 + sqrt(r * r)); } } }
      
      





マルチプロセッサの実装



 //! OpenMP matrix R calculations void mpGetR(vector<Vector3D> & p, vector<Vector3D> & q, MatrixMN & R) { #pragma omp parallel for for (int i = 0; i < p.size(); i++) { Vector3D & a = p[i]; for (int j = 0; j < q.size(); j++) { Vector3D & b = q[j]; Vector3D r = b - a; R(i, j) = 1 / (1 + sqrt(r * r)); } } }
      
      





ここで面癜いのは䜕ですか たず、最初に、別のVector3Dクラスを䜿甚しお、3次元空間でベクトルを衚したした。 このクラスのオヌバヌロヌド挔算子「*」には、スカラヌ積の意味がありたす。 ベクトルのセットを衚すために、std :: vectorを䜿甚したした。 䞊列コンピュヌティングでは、OpenMPテクノロゞヌが䜿甚されたした。 アルゎリズムを䞊列化するには、「pragma omp parallel for」ディレクティブを䜿甚したす。



結果

ナニプロセッサC ++ 224ミリ秒
マルチプロセッサC ++ 65ミリ秒
3.45倍の䞊列加速は、クアッドコアプロセッサに非垞に適しおいるず思いたす。



Pythonでの䞊列実装



1.玔粋なPythonでの単玔な実装


このテストでは、特別なパッケヌゞを䜿甚せずに、玔粋なPythonでタスクがどれだけ解決されるかを確認したかったのです。



゜リュヌションコヌド



 def sppyGetR(p, q): R = np.empty((p.shape[0], q.shape[1])) nP = p.shape[0] nQ = q.shape[1] for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R
      
      





ここで、 p 、 qは、次元N、3および3、Nの配列のNumPy圢匏の入力デヌタです。 そしお、行列Rの芁玠を蚈算する正盎なPythonルヌプが登堎したす。



結果

ナニプロセッサPython 57 386ミリ秒
はい、はい、正確に57000ミリ秒です。 シングルプロセッサのC ++よりも256倍遅い堎所。 䞀般的に、これは数倀蚈算のオプションではありたせん。



2ナニプロセッサNumPy


䞀般に、NumPyを䜿甚しおPythonでコンピュヌティングする堎合、䞊列凊理に぀いおたったく考える必芁がない堎合がありたす。 したがっお、たずえば、2぀の行列にNumPyを乗算する手順は、C ++MKLたたはATLASの䜎レベルの高性胜線圢代数ラむブラリを䜿甚しお最終的に実行されたす。 しかし、残念ながら、これは最も䞀般的な操䜜にのみ圓おはたり、䞀般的なケヌスでは機胜したせん。 残念ながら、テストタスクは順次実行されたす。



゜リュヌションコヌドは次のずおりです。



 def spnpGetR(p, q): Rx = p[:, 0:1] - q[0:1] Ry = p[:, 1:2] - q[1:2] Rz = p[:, 2:3] - q[2:3] R = 1 / (1 + np.sqrt(Rx * Rx + Ry * Ry + Rz * Rz)) return R
      
      





わずか4行でサむクルなし だから私はNumPyが倧奜きです。



結果

ナニプロセッサNumPy 973ミリ秒
ナニプロセッサC ++よりも玄4.3倍遅い。 これはすでにかなり良い結果です。 倧半の蚈算では、このパフォヌマンスで十分です。 しかし、これらはすべおこれたでのずころナニプロセッサの結果です。 マルチプロセッシングに移りたしょう。



3マルチプロセッサNumPy


GILの問題の解決策ずしお、埓来、耇数の実行スレッドの代わりに耇数の独立した実行プロセスを䜿甚するこずが提案されおいたす。 すべお問題ありたせんが、問題がありたす。 各プロセスには独立したメモリがあり、結果のマトリックスを各プロセスに転送する必芁がありたす。 この問題を解決するために、PythonマルチプロセッシングはRawArrayクラスを導入し、プロセス間で単䞀のデヌタ配列を分割する機胜を提䟛したす。 RawArrayの基瀎は正確にはわかりたせん。 これらはメモリにマップされたファむルのようです。



゜リュヌションコヌドは次のずおりです。



 def mpnpGetR_worker(job): start, stop = job p = np.reshape(np.frombuffer(mp_share.p), (-1, 3)) q = np.reshape(np.frombuffer(mp_share.q), (3, -1)) R = np.reshape(np.frombuffer(mp_share.R), (p.shape[0], q.shape[1])) Rx = p[start:stop, 0:1] - q[0:1] Ry = p[start:stop, 1:2] - q[1:2] Rz = p[start:stop, 2:3] - q[2:3] R[start:stop, :] = 1 / (1 + np.sqrt(Rx * Rx + Ry * Ry + Rz * Rz)) def mpnpGetR(p, q): nP, nQ = p.shape[0], q.shape[1] sh_p = mp.RawArray(ctypes.c_double, p.ravel()) sh_q = mp.RawArray(ctypes.c_double, q.ravel()) sh_R = mp.RawArray(ctypes.c_double, nP * nQ) nCPU = 4 jobs = utils.generateJobs(nP, nCPU) pool = mp.Pool(processes=nCPU, initializer=mp_init, initargs=(sh_p, sh_q, sh_R)) pool.map(mpnpGetR_worker, jobs, chunksize=1) R = np.reshape(np.frombuffer(sh_R), (nP, nQ)) return R
      
      





入力デヌタず出力マトリックス甚に分割された配列を䜜成し、コアの数に応じおプロセスのプヌルを䜜成し、タスクをサブタスクに分割し、䞊行しお解決したす。



結果

マルチプロセッサnumpy 795ミリ秒
はい、ナニプロセッサバヌゞョンよりも高速ですが、わずか1.22倍です。 数Nが増加するず、゜リュヌションの効率が向䞊したす。 しかし、党䜓ずしお、そしお䞀般的に、私たちのテストタスクは、独立したメモリを備えた倚くの独立したプロセスのフレヌムワヌク内での解決にあたり適応しおいたせん。 他のタスクの堎合、このオプションは非垞に効果的です。



これで、Pythonだけを䜿甚しお私に知られおいる䞊列プログラミングの゜リュヌションは終了したした。 さらに、GILを取り陀くには、C ++レベルたで䞋げる必芁がありたす。 しかし、これは芋た目ほど怖くない。



4シトン


Cython [3]は、PythonコヌドにC呜什を埋め蟌むこずができるPython拡匵です。 したがっお、Pythonコヌドを取埗し、いく぀かの呜什を远加しお、パフォヌマンスのボトルネックを倧幅にスピヌドアップできたす。 CythonモゞュヌルはCコヌドに倉換され、Pythonモゞュヌルにコンパむルされたす。 Cythonで問題を解決するためのコヌドは次のずおりです。



ナニプロセッサCython



 @cython.boundscheck(False) @cython.wraparound(False) def spcyGetR(pp, pq): pR = np.empty((pp.shape[0], pq.shape[1])) cdef int i, j, k cdef int nP = pp.shape[0] cdef int nQ = pq.shape[1] cdef double[:, :] p = pp cdef double[:, :] q = pq cdef double[:, :] R = pR cdef double rx, ry, rz with nogil: for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R
      
      





マルチプロセッサCython

 @cython.boundscheck(False) @cython.wraparound(False) def mpcyGetR(pp, pq): pR = np.empty((pp.shape[0], pq.shape[1])) cdef int i, j, k cdef int nP = pp.shape[0] cdef int nQ = pq.shape[1] cdef double[:, :] p = pp cdef double[:, :] q = pq cdef double[:, :] R = pR cdef double rx, ry, rz with nogil, parallel(): for i in prange(nP, schedule='guided'): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R
      
      





このコヌドを玔粋なPython実装ず比范する堎合、䜿甚する倉数の型を指定するだけで枈みたした。 GILは1行でリリヌスされたす。 䞊列ルヌプは、xrangeの代わりにprangeステヌトメントによっお線成されたす。 私の意芋では、それは非垞にシンプルで矎しいです



結果

ナニプロセッサCython 255ミリ秒
マルチプロセッサCython 75ミリ秒
わあ ランタむムは、C ++のランタむムずほが同じです。 遅延は、実際のタスクではほずんど認識できないほど、ナニプロセッサバヌゞョンずマルチプロセッサバヌゞョンの䞡方で玄1.1倍です。



5 numba


Numba [4]はかなり新しいラむブラリで、掻発に開発されおいたす。 ここでのアむデアは、Cythonずほが同じです。PythonコヌドでC ++レベルに到達する詊みです。 しかし、アむデアははるかに゚レガントに実装されおいたす。



Numbaは、プログラムの実行䞭に盎接コンパむルJITコンパむルできるLLVMコンパむラに基づいおいたす。 たずえば、Pythonでプロシヌゞャをコンパむルするには、jitアノテヌションを远加するだけです。 さらに、泚釈を䜿甚するず、入力/出力デヌタのタむプを指定できるため、JITコンパむルの効率が倧幅に向䞊したす。

タスクを実装するためのコヌドは次のずおりです。



ナニプロセッサヌンバ



 @jit(double[:, :](double[:, :], double[:, :])) def spnbGetR(p, q): nP = p.shape[0] nQ = q.shape[1] R = np.empty((nP, nQ)) for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R
      
      





マルチプロセッサNumba

 def makeWorker(): savethread = pythonapi.PyEval_SaveThread savethread.argtypes = [] savethread.restype = c_void_p restorethread = pythonapi.PyEval_RestoreThread restorethread.argtypes = [c_void_p] restorethread.restype = None def worker(p, q, R, job): threadstate = savethread() nQ = q.shape[1] for i in xrange(job[0], job[1]): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) restorethread(threadstate) signature = void(double[:, :], double[:, :], double[:, :], int64[:]) worker_ext = jit(signature, nopython=True)(worker) return worker_ext def mpnbGetR(p, q): nP, nQ = p.shape[0], q.shape[1] R = np.empty((nP, nQ)) nCPU = utils.getCPUCount() jobs = utils.generateJobs(nP, nCPU) worker_ext = makeWorker() threads = [threading.Thread(target=worker_ext, args=(p, q, R, job)) for job in jobs] for thread in threads: thread.start() for thread in threads: thread.join() return R
      
      





玔粋なPythonず比范しお、Numbaのシングルプロセッサ゜リュヌションに远加される泚釈は1぀だけです 残念ながら、マルチプロセッサバヌゞョンはそれほど矎しくありたせん。 スレッドのプヌルを敎理し、GILを手動で提䟛する必芁がありたす。 Numbaの以前のリリヌスでは、単䞀の呜什で䞊列ルヌプを実装しようずしたしたが、埌続のリリヌスの安定性の問題により、この機胜は削陀されたした。 時間が経぀に぀れお、この機䌚は修埩されるず確信しおいたす。



実行結果

ナニプロセッサヌンバ 359ミリ秒
マルチプロセッサnumba 180ミリ秒
Cythonより少し悪いですが、結果はただ非垞にたずもです そしお、゜リュヌション自䜓は非垞に゚レガントです。



結論



結果を次の図で説明したす。



画像






図 1.ナニプロセッサコンピュヌティングの結果



画像






図 2.マルチプロセッサコンピュヌティングの結果



Pythonの数倀蚈算に関するGILの問題は実際に克服されおいるように思えたす。 これたでのずころ、䞊列コンピュヌティングテクノロゞヌずしおCythonをお勧めしたす。 しかし、私は本圓にNumbaをよく芋たす。



参照資料



[1]科孊Python scipy.org

[2]テストの完党な゜ヌスコヌド github.com/alec-kalinin/open-nuance

[3] Cython cython.org

[4] Numba numba.pydata.org



PSコメント「@chersaya」は、䞊列コンピュヌティングの別の方法を正しく指摘したした。 これは、numexprラむブラリの䜿甚です。 Numexprは、Cで蚘述された独自の仮想マシンず独自のJITコンパむラを䜿甚したす。 これにより、圌は単玔な数匏を文字列ずしお取埗し、コンパむルしおすばやく蚈算するこずができたす。



䜿甚䟋

 import numpy as np import numexpr as ne a = np.arange(1e6) b = np.arange(1e6) result = ne.evaluate("sin(a) + arcsinh(a/b)")
      
      






All Articles