web-dev-qa-db-fra.com

Python multitraitement: comprendre la logique derrière `chunksize`

Quels facteurs déterminent un argument optimal chunksize vers des méthodes telles que multiprocessing.Pool.map()? La méthode .map() semble utiliser une heuristique arbitraire pour sa taille de bloc par défaut (expliquée ci-dessous); Qu'est-ce qui motive ce choix et existe-t-il une approche plus réfléchie basée sur une situation/configuration particulière?

Exemple - dites que je suis:

  • Passer une iterable à .map() qui a ~ 15 millions d'éléments;
  • Travailler sur une machine à 24 cœurs et utiliser la valeur par défaut processes = os.cpu_count() dans multiprocessing.Pool().

Ma pensée naïve est de donner à chacun des 24 travailleurs et travailleuses une part égale, c’est-à-dire 15_000_000 / 24 ou 625 000. Les gros morceaux devraient réduire le roulement/les frais généraux tout en utilisant pleinement tous les travailleurs. Mais il semble que cela manque certains inconvénients potentiels de donner de gros lots à chaque travailleur. Est-ce une image incomplète et que me manque-t-il?


Une partie de ma question découle de la logique par défaut pour si chunksize=None: .map() et .starmap() appellent .map_async() , ce qui ressemble à ceci:

def _map_async(self, func, iterable, mapper, chunksize=None, callback=None,
               error_callback=None):
    # ... (materialize `iterable` to list if it's an iterator)
    if chunksize is None:
        chunksize, extra = divmod(len(iterable), len(self._pool) * 4)  # ????
        if extra:
            chunksize += 1
    if len(iterable) == 0:
        chunksize = 0

Quelle est la logique derrière divmod(len(iterable), len(self._pool) * 4)? Cela implique que la taille de morceau sera plus proche de 15_000_000 / (24 * 4) == 156_250. Quelle est l'intention de multiplier len(self._pool) par 4?

Cela fait que la taille de morceau résultante est un facteur de 4 plus petit que ma "logique naïve" vue d'en haut, qui consiste à diviser simplement la longueur de l'itéré par le nombre de travailleurs dans pool._pool.

Enfin, il y a aussi ceci extrait du Python docs sur .imap() qui pousse encore plus ma curiosité:

L'argument chunksize est identique à celui utilisé par la méthode map(). Pour les très longs iterables, utiliser une valeur élevée pour chunksize peut rendre le travail complet beaucoup plus rapide que d'utiliser la valeur par défaut de 1.


Réponse associée qui est utile mais un peu trop sophistiquée: Multitraitement Python: pourquoi les gros morceaux sont-ils plus lents? .

44
Brad Solomon

À propos de cette réponse

Cette réponse est la partie II de la réponse acceptée ci-dessus .


7. Naive vs Chunksize-Algorithm de Pool

Avant d’entrer dans les détails, considérons les deux gifs ci-dessous. Pour une gamme de longueurs iterable différentes, ils montrent comment les deux algorithmes comparés segmentent le iterable transmis (il s'agira d'une séquence à ce moment-là) et comment les tâches résultantes pourraient être réparties. L'ordre des travailleurs est aléatoire et le nombre de tâches distribuées par travailleur peut en réalité différer de ces images pour des tâches légères ou des tâches dans un scénario étendu. Comme mentionné précédemment, les frais généraux ne sont pas inclus ici. Pour des tâches suffisamment lourdes dans un scénario dense avec des tailles de données transmises négligeables, les calculs réels dessinent une image très similaire, cependant.

cs_4_50

cs_200_250

Comme indiqué dans le chapitre " 5. Algorithme de Chunksize de Pool ", avec l'algorithme de Chunksize de Pool, le nombre de morceaux se stabilisera à n_chunks == n_workers * 4 pour des valeurs suffisamment grandes, tout en restant basculement entre n_chunks == n_workers et n_chunks == n_workers + 1 avec l'approche naïve. Pour que l'algorithme naïf s'applique: Puisque n_chunks % n_workers == 1 est True pour n_chunks == n_workers + 1, une nouvelle section sera créée dans laquelle un seul travailleur sera employé.

Naive Chunksize-Algorithm:

Vous pourriez penser que vous avez créé des tâches dans le même nombre de travailleurs, mais cela ne sera vrai que dans les cas où il n'y a pas de reste pour len_iterable / n_workers. Si il y a un reste, il y aura une nouvelle section avec une seule tâche pour un seul ouvrier. À ce stade, vos calculs ne seront plus parallèles.

Ci-dessous, vous voyez une figure similaire à celle du chapitre 5, mais affichant le nombre de sections au lieu du nombre de morceaux. Pour l'algorithme de taille de morceau complet de Pool (n_pool2), n_sections se stabilisera au facteur infâme et codé en dur 4. Pour l'algorithme naïf, n_sections alternera entre un et deux.

figure10

Pour l'algorithme chunksize de Pool, la stabilisation à n_chunks = n_workers * 4 par le précédent extra-treatment , empêche la création d'une nouvelle section ici et conserve le Partage de ralenti limité à un seul ouvrier suffisamment longtemps. Non seulement cela, mais l'algorithme continuera à réduire la taille relative de la Idling Share , ce qui conduit à une valeur RDE convergeant vers 100%.

"Assez longtemps" pour n_workers=4 est len_iterable=210 par exemple. Pour des itérables égaux ou supérieurs à cela, le Idling Share sera limité à un seul ouvrier, trait perdu à l'origine à cause de la multiplication de 4- dans l'algorithme chunksize de la première place.

figure11

L'algorithme naïf chunksize converge également vers 100%, mais il le fait plus lentement. L'effet convergent dépend uniquement du fait que la partie relative de la queue se contracte dans les cas où il y aura deux sections. Cette queue avec un seul employé est limitée à la longueur de l'axe x n_workers - 1, le maximum possible pour len_iterable / n_workers.

Comment les valeurs RDE réelles diffèrent-elles pour l'algorithme naïf et le chunksize de Pool?

Vous trouverez ci-dessous deux cartes thermiques indiquant les valeurs RDE pour toutes les longueurs itérables allant jusqu'à 5 000, pour tous les nombres de travailleurs de 2 à 100. L'échelle de couleur va de 0,5 à 1 (50% à 100%). Vous remarquerez beaucoup plus de zones sombres (valeurs RDE plus faibles) pour l'algorithme naïf dans la carte thermique de gauche. En revanche, l’algorithme de Chunksize de Pool sur la droite dessine une image beaucoup plus sombre.

figure12

Le gradient diagonal des coins sombres en bas à gauche par rapport aux coins clairs en haut à droite montre à nouveau la dépendance du nombre de travailleurs pour ce que l’on appelle une "longue itérable".

Comment peut-il obtenir avec chaque algorithme?

Avec l'algorithme chunksize de Pool, a RDE = 81.25% est la valeur la plus basse pour la plage de travailleurs et les longueurs itératives spécifiées ci-dessus:

figure13

Avec l'algorithme naïf chunksize, les choses peuvent devenir bien pires. Le plus bas RDE est ici 50,72%. Dans ce cas, près de la moitié du temps de calcul est occupé par un seul ouvrier! Alors, faites attention, fiers propriétaires de Knights Landing . ;)

figure14


8. Vérification de la réalité

Dans les chapitres précédents, nous avons envisagé un modèle simplifié pour le problème de la distribution purement mathématique, dépouillé des détails qui rendent le multitraitement un sujet aussi épineux. Pour mieux comprendre dans quelle mesure le modèle de distribution (DM) seul peut contribuer à expliquer l'utilisation observée des travailleurs, nous allons maintenant examiner les calendriers parallèles dessinés par réels calculs.

Installer

Les graphes suivants traitent tous d’exécutions parallèles d’une fonction factice simple, liée à un processeur, appelée avec différents arguments afin que nous puissions observer comment le calendrier parallèle dessiné varie en fonction des valeurs d’entrée. Le "travail" dans cette fonction consiste uniquement en une itération sur un objet de plage. Ceci est déjà suffisant pour occuper un cœur car nous transmettons des nombres énormes. La fonction accepte éventuellement une tâche supplémentaire unique data propre à chaque tâche, qui est simplement restituée sans modification. Étant donné que chaque tâche comprend exactement la même quantité de travail, nous avons toujours affaire à un scénario dense.

La fonction est décorée avec un wrapper prenant des horodatages avec une résolution ns (Python 3.7+). Les horodatages sont utilisés pour calculer la durée d'une tâche et permettent donc d'établir un calendrier parallèle empirique.

@stamp_taskel
def busy_foo(i, it, data=None):
    """Dummy function for CPU-bound work."""
    for _ in range(int(it)):
        pass
    return i, data


def stamp_taskel(func):
    """Decorator for taking timestamps on start and end of decorated
    function execution.
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time_ns()
        result = func(*args, **kwargs)
        end_time = time_ns()
        return (current_process().name, (start_time, end_time)), result
    return wrapper

La méthode starmap de Pool est également décorée de telle sorte que seul l'appel starmap lui-même est chronométré. "Début" et "fin" de cet appel déterminent le minimum et le maximum sur l'axe des x de la planification parallèle produite.

Nous allons observer le calcul de 40 tâches sur quatre processus de travail sur une machine avec ces spécifications: Python 3.7.1, Ubuntu 18.04.2, Processeur Intel® Core ™ i7-2600K @ 3.40GHz × 8

Les valeurs d'entrée à modifier sont le nombre d'itérations dans la boucle for (30k, 30M, 600M) et la taille de données d'envoi supplémentaire (par tâche, nombre de chiffres, 0 MiB, 50 MiB).

...
N_WORKERS = 4
LEN_ITERABLE = 40
ITERATIONS = 30e3  # 30e6, 600e6
DATA_MiB = 0  # 50

iterable = [
    # extra created data per taskel
    (i, ITERATIONS, np.arange(int(DATA_MiB * 2**20 / 8)))  # taskel args
    for i in range(LEN_ITERABLE)
]


with Pool(N_WORKERS) as pool:
    results = pool.starmap(busy_foo, iterable)

Les cycles présentés ci-dessous ont été triés sur le volet pour avoir le même ordre de morceaux afin que vous puissiez mieux distinguer les différences par rapport au calendrier parallèle du modèle de distribution, mais n'oubliez pas que l'ordre dans lequel les travailleurs obtiennent leur tâche n'est pas déterministe.

Prévision DM

Pour rappel, le modèle de distribution "prédit" une planification parallèle comme nous l'avons déjà vu au chapitre 6.2:

figure15

1er RUN: 30 000 itérations et 0 Mio de données par tâche

figure16

Notre première course ici est très courte, les tâches sont très "légères". L’appel pool.starmap()- n’a pris que 14,5 ms au total. Vous remarquerez que contrairement à avec DM, le ralenti n'est pas limité à la section de fin, mais se produit également entre tâches et même entre tâches. En effet, notre emploi du temps réel inclut naturellement toutes sortes de frais généraux. Ralenti signifie ici tout en dehors d’une tâche. Possible réel inactif pendant une tâche n'est pas capturée comme déjà mentionné auparavant.

De plus, vous pouvez constater que tous les travailleurs ne s’acquittent pas de leurs tâches en même temps. Cela est dû au fait que tous les travailleurs sont nourris sur une inqueue partagée et qu'un seul travailleur peut la lire à la fois. Il en va de même pour la outqueue. Cela peut provoquer de plus grandes perturbations dès que vous transmettez des tailles de données non marginales comme nous le verrons plus tard.

En outre, vous pouvez constater que, même si chaque tâche comprend la même quantité de travail, la durée réelle mesurée pour une tâche varie considérablement. Les tâches distribuées aux ouvriers 3 et 4 ont besoin de plus de temps que celles traitées par les deux premiers ouvriers. Je suppose que pour cette exécution, cela est dû à turbo boost n'étant plus disponible sur les noyaux pour worker-3/4 à ce moment-là, ils ont donc traité leurs tâches avec une fréquence d'horloge plus basse.

Le calcul est si léger que les facteurs de chaos introduits par le matériel ou le système d’exploitation peuvent biaiser radicalement PS. Le calcul est une "feuille de vent" et la prédiction DM - a peu de signification, même dans le cas d'un scénario approprié.

2ème cycle: 30 millions d'itérations et 0 Mio de données par tâche

figure17

En augmentant le nombre d'itérations dans la boucle for de 30 000 à 30 millions, il en résulte un véritable programme parallèle, qui correspond presque parfaitement à celui prédit par les données fournies par DM, bravo! Le calcul par tâche est maintenant assez lourd pour marginaliser les parties inactives au début et entre les deux, ne laissant apparaître que le gros partage de la marche au ralenti que le DM prédit.

3rd RUN: 30 millions d'itérations et 50 Mio de données par tâche

figure18

Garder les 30 millions d’itérations, mais en plus, envoyer 50 Mio par tâche et retour fait basculer l’image de nouveau. Ici, l'effet de file d'attente est bien visible. Worker-4 doit attendre plus longtemps que Worker-1 pour sa deuxième tâche. Maintenant, imaginez cet horaire avec 70 travailleurs!

Dans le cas où les tâches sont très légères en calcul, mais fournissent une quantité considérable de données en charge utile, le goulot d'étranglement d'une seule file d'attente partagée peut empêcher tout avantage supplémentaire d'ajouter davantage de travailleurs au pool, même s'ils sont sauvegardés par des cœurs physiques. Dans un tel cas, Worker-1 pourrait être effectué avec sa première tâche et en attendre une nouvelle avant même que Worker-40 ait obtenu sa première tâche.

Il devrait maintenant devenir évident pourquoi les temps de calcul dans un Pool ne diminuent pas toujours linéairement avec le nombre de travailleurs. L'envoi de relativement grandes quantités de données peut conduire à des scénarios dans lesquels la plupart du temps est consacré à l'attente de la copie des données dans l'espace d'adressage d'un opérateur et qu'un seul opérateur peut être nourri à la fois.

4ème cycle: 600 millions d'itérations et 50 Mio de données par tâche

figure19

Ici, nous envoyons à nouveau 50 Mio, mais augmentons le nombre d'itérations de 30 à 600 millions, ce qui porte le temps de calcul total de 10 à 152 s. Le calendrier parallèle dessiné à nouveau est presque identique à celui prédit, le temps système nécessaire à la copie des données est marginalisé.


9. Conclusion

La multiplication décrite par 4 augmente la flexibilité de la planification, mais exploite également l’inégalité des distributions taskel. Sans cette multiplication, la part de ralenti serait limitée à un seul travailleur, même pour de courts itérables (pour DM avec scénario dense). L'algorithme chunksize de Pool a besoin que les entrées-itérables soient d'une certaine taille pour retrouver ce trait.

Comme cette réponse l'a montré, espérons-le, l'algorithme chunksize de Pool conduit à une meilleure utilisation du noyau en moyenne par rapport à l'approche naïve, du moins dans le cas moyen et sans perte de temps. L'algorithme naïf peut avoir une efficacité de distribution (DE) aussi faible que ~ 51%, tandis que l'algorithme de la taille de bloc de Pool est faible, à ~ 81%. DE mais ne comprend pas de surcharge de parallélisation (PO) comme IPC. Le chapitre 8 a montré que DE peut toujours avoir un grand pouvoir prédictif pour le scénario dense avec une surcharge marginalisée.

Malgré le fait que l'algorithme chunksize de Pool réalise une valeur DE plus élevée que celle de l'approche naïve, , il ne fournit pas des distributions de tâches optimales pour chaque constellation d'entrée. Alors qu'un algorithme de segmentation statique simple ne peut pas optimiser (PE) le rendement de parallélisation (PE), il n'y a aucune raison inhérente pour laquelle il ne pourrait pas toujours une efficacité de distribution relative (RDE) de 100%, c'est-à-dire la même chose DE comme avec chunksize=1. Un algorithme simple de gros morceaux consiste uniquement en mathématiques de base et est libre de "trancher le gâteau" de quelque manière que ce soit.

Contrairement à la mise en œuvre par Pool d'un algorithme "de taille égale", un algorithme "de taille uniforme" fournirait un RDE de 100% pour chaque len_iterable/n_workers combinaison. Un algorithme de segmentation uniforme serait légèrement plus compliqué à implémenter dans le source de Pool, mais peut être modulé par-dessus l'algorithme existant simplement en empaquetant les tâches à l'extérieur (je créerai un lien à partir de là au cas où je laisserais passer un Q/A sur comment faire ça).

21
Darkonaut

Je pense qu’une partie de ce qui vous manque, c’est que votre estimation naïve présume que chaque unité de travail prend le même temps, auquel cas votre stratégie serait la meilleure. Toutefois, si certains travaux se terminent plus tôt que d’autres, certains cœurs risquent de devenir inactifs en attendant la fin des travaux lents.

Ainsi, en brisant les morceaux en 4 fois plus de morceaux, alors si un morceau a fini tôt, ce noyau peut démarrer le morceau suivant (tandis que les autres noyaux continuent à travailler sur leur morceau plus lent).

Je ne sais pas pourquoi ils ont choisi le facteur 4 exactement, mais ce serait un compromis entre minimiser les frais généraux du code de la carte (qui veut les plus gros morceaux possibles) et équilibrer des morceaux prenant un nombre de fois différent (qui veut le plus petit morceau possible ).

5
Rob