web-dev-qa-db-ja.com

Joblib並列の複数のCPUは単一よりも遅い

Joblibモジュールの使用を開始したばかりで、Parallel関数の動作を理解しようとしています。以下は、並列化によって実行時間が長くなる例ですが、理由はわかりません。 1 cpuでのランタイムは51秒でしたが、2 cpuでは217秒でした。

私の想定では、ループを並列に実行すると、リストaとbが各プロセッサにコピーされます。次に、1つのCPUにitem_nをディスパッチし、もう1つのCPUにitem_n + 1をディスパッチし、関数を実行して、結果をリストに(順番に)書き戻します。次に、次の2つのアイテムを取得します。私は明らかに何かが欠けています。

これは、劣悪な例またはjoblibの使用ですか?コードを間違って構造化しただけですか?

以下に例を示します。

import numpy as np
from matplotlib.path import Path
from joblib import Parallel, delayed

## Create pairs of points for line segments
a = Zip(np.random.Rand(5000,2),np.random.Rand(5000,2))

b = Zip(np.random.Rand(300,2),np.random.Rand(300,2))

## Check if one line segment contains another. 
def check_paths(path, paths):
    for other_path in paths:
        res='no cross'
        chck = Path(other_path)
        if chck.contains_path(path)==1:
            res= 'cross'
            break
    return res

res = Parallel(n_jobs=2) (delayed(check_paths) (Path(points), a) for points in b)
24
mhabiger

要するに:私はあなたの問題を再現することはできません。 Windowsを使用している場合は、メインループにプロテクターを使用する必要があります: documentation of _joblib.Parallel_ 。私が目にする唯一の問題は、大量のデータコピーのオーバーヘッドですが、あなたの数字はそれによって引き起こされると非現実的に思えます。

長い間、ここにあなたのコードの私のタイミングがあります:

I7 3770k(4コア、8スレッド)では、さまざまな_n_jobs_に対して次の結果が得られます。

_For-loop: Finished in 33.8521318436 sec
n_jobs=1: Finished in 33.5527760983 sec
n_jobs=2: Finished in 18.9543449879 sec
n_jobs=3: Finished in 13.4856410027 sec
n_jobs=4: Finished in 15.0832719803 sec
n_jobs=5: Finished in 14.7227740288 sec
n_jobs=6: Finished in 15.6106669903 sec
_

したがって、複数のプロセスを使用することで利点があります。ただし、4つのコアがありますが、3つのプロセスで既にゲインが飽和しています。したがって、実行時間は実際にはプロセッサ時間ではなくメモリアクセスによって制限されると思います。

各単一ループエントリの引数は、それを実行するプロセスにコピーされることに注意してください。つまり、aの各要素にbをコピーします。それは効果がありません。そのため、代わりにグローバルaにアクセスします。 (Parallelはプロセスをフォークし、すべてのグローバル変数を新しく生成されたプロセスにコピーするため、aにアクセスできます)。これにより、次のコードが得られます(joblibのドキュメントが推奨するタイミングとメインループガードを使用)。

_import numpy as np
from matplotlib.path import Path
from joblib import Parallel, delayed
import time
import sys

## Check if one line segment contains another. 

def check_paths(path):
    for other_path in a:
        res='no cross'
        chck = Path(other_path)
        if chck.contains_path(path)==1:
            res= 'cross'
            break
    return res

if __name__ == '__main__':
    ## Create pairs of points for line segments
    a = Zip(np.random.Rand(5000,2),np.random.Rand(5000,2))
    b = Zip(np.random.Rand(300,2),np.random.Rand(300,2))

    now = time.time()
    if len(sys.argv) >= 2:
        res = Parallel(n_jobs=int(sys.argv[1])) (delayed(check_paths) (Path(points)) for points in b)
    else:
        res = [check_paths(Path(points)) for points in b]
    print "Finished in", time.time()-now , "sec"
_

タイミング結果:

_ n_jobs=1: Finished in 34.2845709324 sec
 n_jobs=2: Finished in 16.6254048347 sec
 n_jobs=3: Finished in 11.219119072 sec
 n_jobs=4: Finished in 8.61683392525 sec
 n_jobs=5: Finished in 8.51907801628 sec
 n_jobs=6: Finished in 8.21842098236 sec
 n_jobs=7: Finished in 8.21816396713 sec
 n_jobs=8: Finished in 7.81841087341 sec
_

飽和は、予想される値である_n_jobs=4_にわずかに移動しました。

_check_paths_は、簡単に除去できる冗長な計算をいくつか行います。まず、_other_paths=a_のすべての要素に対して、Path(...)行がすべての呼び出しで実行されます。それを事前計算します。次に、文字列_res='no cross'_はループターンごとに書き込まれますが、1回しか変更できません(その後にブレークとリターンが続きます)。ループの前で線を移動します。次に、コードは次のようになります。

_import numpy as np
from matplotlib.path import Path
from joblib import Parallel, delayed
import time
import sys

## Check if one line segment contains another. 

def check_paths(path):
    #global a
    #print(path, a[:10])
    res='no cross'
    for other_path in a:
        if other_path.contains_path(path)==1:
            res= 'cross'
            break
    return res

if __name__ == '__main__':
    ## Create pairs of points for line segments
    a = Zip(np.random.Rand(5000,2),np.random.Rand(5000,2))
    a = [Path(x) for x in a]

    b = Zip(np.random.Rand(300,2),np.random.Rand(300,2))

    now = time.time()
    if len(sys.argv) >= 2:
        res = Parallel(n_jobs=int(sys.argv[1])) (delayed(check_paths) (Path(points)) for points in b)
    else:
        res = [check_paths(Path(points)) for points in b]
    print "Finished in", time.time()-now , "sec"
_

タイミングで:

_n_jobs=1: Finished in 5.33742594719 sec
n_jobs=2: Finished in 2.70858597755 sec
n_jobs=3: Finished in 1.80810618401 sec
n_jobs=4: Finished in 1.40814709663 sec
n_jobs=5: Finished in 1.50854086876 sec
n_jobs=6: Finished in 1.50901818275 sec
n_jobs=7: Finished in 1.51030707359 sec
n_jobs=8: Finished in 1.51062297821 sec
_

あなたのコードのサイドノード、これはあなたの質問とは無関係であるため、実際にはその目的には従いませんが、_contains_path_はTrue _if this path completely contains the given path._のみを返します( ドキュメントを参照) )。したがって、ランダム入力が与えられると、基本的に関数は常に_no cross_を返します。

36
Nabla

上記の答えに加えて、今後の参考のために、この質問には2つの側面があり、joblibの最近の進化は両方に役立ちます。

並列プール作成のオーバーヘッド:ここでの問題は、並列プールの作成にコストがかかることです。 「main」によって保護されていないコードは、Parallelオブジェクトの作成時に各ジョブで実行されたため、ここでは特にコストがかかりました。最新のjoblib(まだベータ版)では、Parallelを context manager として使用して、プールの作成回数を制限し、このオーバーヘッドの影響を制限できます。

ディスパッチのオーバーヘッド:forループの項目のディスパッチにはオーバーヘッドがあることに注意することが重要です(forループを並列処理せずに繰り返すよりもはるかに大きい) 。したがって、これらの個々の計算項目が非常に速い場合、このオーバーヘッドが計算を支配します。最新のjoblibでは、joblibは各ジョブの実行時間をトレースし、それらが非常に高速である場合、それらをまとめて開始します。これにより、ほとんどの場合、ディスパッチオーバーヘッドの影響が大幅に制限されます(ベンチとディスカッションについては [〜#〜] pr [〜#〜] を参照してください)。


免責事項:私はjoblibの元の著​​者です(ここでは無関係であると思いますが、私の答えの潜在的な利益相反に対して警告するように言っています)。

18
Gael Varoquaux