web-dev-qa-db-fra.com

Joblib Parallel multiple cpu's slower than single

Je viens de commencer à utiliser le module Joblib et j'essaie de comprendre comment fonctionne la fonction parallèle. Vous trouverez ci-dessous un exemple de cas où la parallélisation conduit à des durées d'exécution plus longues, mais je ne comprends pas pourquoi. Mon temps d'exécution sur 1 unité centrale était de 51 secondes contre 217 secondes sur 2 unités centrales.

Mon hypothèse était que l'exécution de la boucle en parallèle copierait les listes a et b sur chaque processeur. Envoyez ensuite item_n à un cpu et item_n + 1 à l'autre cpu, exécutez la fonction puis réécrivez les résultats dans une liste (dans l'ordre). Saisissez ensuite les 2 éléments suivants et ainsi de suite. Il me manque visiblement quelque chose.

Est-ce un mauvais exemple ou une mauvaise utilisation de joblib? Ai-je simplement mal structuré le code?

Voici l'exemple:

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

En bref: je ne peux pas reproduire votre problème. Si vous êtes sous Windows, vous devez utiliser un protecteur pour votre boucle principale: documentation de joblib.Parallel . Le seul problème que je vois est beaucoup de frais généraux de copie de données, mais vos chiffres semblent irréalistes à cause de cela.

En bref, voici mes horaires avec votre code:

Sur mon i7 3770k (4 cœurs, 8 threads), j'obtiens les résultats suivants pour différents 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

Il y a donc un avantage à utiliser plusieurs processus. Cependant, même si j'ai quatre cœurs, le gain sature déjà à trois processus. Je suppose donc que le temps d'exécution est en fait limité par l'accès à la mémoire plutôt que par le temps processeur.

Vous devez remarquer que les arguments de chaque entrée de boucle unique sont copiés dans le processus qui l'exécute. Cela signifie que vous copiez a pour chaque élément dans b. C'est inefficace. Accédez donc à la place au global a. (Parallel va bifurquer le processus, copiant toutes les variables globales dans les nouveaux processus générés, donc a est accessible). Cela me donne le code suivant (avec synchronisation et garde de boucle principale comme le recommande la documentation de 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"

Résultats de chronométrage:

 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

La saturation s'est maintenant légèrement déplacée vers n_jobs=4 Qui est la valeur à attendre.

check_paths Effectue plusieurs calculs redondants qui peuvent facilement être éliminés. Tout d'abord pour tous les éléments de other_paths=a La ligne Path(...) est exécutée à chaque appel. Précalculez cela. Deuxièmement, la chaîne res='no cross' Est écrite à chaque tour de boucle, bien qu'elle ne puisse changer qu'une seule fois (suivie d'une pause et d'un retour). Déplacez la ligne devant la boucle. Ensuite, le code ressemble à ceci:

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"

avec des horaires:

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

Un nœud latéral sur votre code, bien que je n'aie pas vraiment suivi son objectif car cela n'était pas lié à votre question, contains_path Ne renverra que Trueif this path completely contains the given path. (Voir documentation ). Par conséquent, votre fonction retournera toujours no cross Étant donné l'entrée aléatoire.

36
Nabla

En plus de la réponse ci-dessus, et pour référence future, il y a deux aspects à cette question, et les récentes évolutions de joblib aident avec les deux.

Frais généraux de création de pool parallèle : Le problème ici est que la création d'un pool parallèle est coûteuse. C'était particulièrement coûteux ici, car le code non protégé par le " main " était exécuté dans chaque travail lors de la création de l'objet Parallel. Dans le dernier joblib (toujours en version bêta), Parallel peut être utilisé comme gestionnaire de contexte pour limiter le nombre de fois qu'un pool est créé, et donc l'impact de cette surcharge.

Dispatching overhead : il est important de garder à l'esprit que l'envoi d'un élément de la boucle for a un overhead (beaucoup plus grand que l'itération d'une boucle for sans parallèle) . Ainsi, si ces éléments de calcul individuels sont très rapides, cette surcharge dominera le calcul. Dans le dernier joblib, joblib tracera le temps d'exécution de chaque travail et commencera à les regrouper s'ils sont très rapides. Cela limite fortement l'impact de la surcharge de répartition dans la plupart des cas (voir PR pour le banc et la discussion).


Disclaimer: Je suis l'auteur original de joblib (je dis juste de mettre en garde contre les conflits d'intérêts potentiels dans ma réponse, bien qu'ici je pense que ce n'est pas pertinent).

20
Gael Varoquaux