Question différences inattendues d'empreinte mémoire lors de la génération du pool multiprocesseur python


Tenter de contribuer à l'optimisation de la parallélisation dans le module pystruct et dans les discussions pour expliquer pourquoi je voulais instancier des pools aussi tôt que possible dans l'exécution et les conserver le plus longtemps possible, les réutiliser, j'ai réalisé que je savais que cela fonctionne mieux, mais je ne sais pas pourquoi.

Je sais que l'affirmation, sur les systèmes * nix, est qu'un sous-processus de travail de pool copie à l'écriture de tous les globals du processus parent. C’est certainement le cas dans l’ensemble, mais je pense qu’il faut ajouter que lorsque l’un de ces globals est une structure de données particulièrement dense comme une matrice numpy ou scipy, il semble que toutes les références qui sont copiées dans l’ouvrier sont en fait assez belles. Même si l’objet entier n’est pas copié, générer de nouveaux pools en fin d’exécution peut entraîner des problèmes de mémoire. J'ai trouvé que la meilleure pratique était de créer un pool le plus tôt possible, de sorte que toutes les structures de données soient petites.

Je le sais depuis un moment et je l'ai conçu pour en faire des applications au travail, mais la meilleure explication que j'ai obtenue est ce que j'ai posté dans le fil de discussion ici:

https://github.com/pystruct/pystruct/pull/129#issuecomment-68898032

En regardant le script python ci-dessous, vous vous attendez essentiellement à ce que la mémoire libre créée dans le pool créé lors de la première exécution et la matrice créée dans la seconde soit pratiquement identique, comme dans les deux appels finaux du pool final. Mais ils ne le sont jamais, il y a toujours (sauf si quelque chose d'autre se passe sur la machine bien sûr) plus de mémoire libre lorsque vous créez d'abord le pool. Cet effet augmente avec la complexité (et la taille) des structures de données dans l'espace de noms global au moment de la création du pool (je pense). Est-ce que quelqu'un a une bonne explication à cela?

J'ai fait cette petite image avec la boucle bash et le script R ci-dessous pour illustrer, montrant la mémoire libre globale après la création du pool et de la matrice, en fonction de l'ordre:

free memory trend plot, both ways

pool_memory_test.py:

import numpy as np
import multiprocessing as mp
import logging

def memory():
    """
    Get node total memory and memory usage
    """
    with open('/proc/meminfo', 'r') as mem:
        ret = {}
        tmp = 0
        for i in mem:
            sline = i.split()
            if str(sline[0]) == 'MemTotal:':
                ret['total'] = int(sline[1])
            elif str(sline[0]) in ('MemFree:', 'Buffers:', 'Cached:'):
                tmp += int(sline[1])
        ret['free'] = tmp
        ret['used'] = int(ret['total']) - int(ret['free'])
    return ret

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--pool_first', action='store_true')
    parser.add_argument('--call_map', action='store_true')
    args = parser.parse_args()

    if args.pool_first:
        logging.debug('start:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        p = mp.Pool()
        logging.debug('pool created:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        biggish_matrix = np.ones((50000,5000))
        logging.debug('matrix created:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        print memory()['free']
    else:
        logging.debug('start:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        biggish_matrix = np.ones((50000,5000))
        logging.debug('matrix created:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        p = mp.Pool()
        logging.debug('pool created:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        print memory()['free']
    if args.call_map:
        row_sums = p.map(sum, biggish_matrix)
        logging.debug('sum mapped:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))
        p.terminate()
        p.join()
        logging.debug('pool terminated:\n\t {}\n'.format(' '.join(['{}: {}'.format(k,v)
            for k,v in memory().items()])))

pool_memory_test.sh

#! /bin/bash
rm pool_first_obs.txt > /dev/null 2>&1;
rm matrix_first_obs.txt > /dev/null 2>&1;
for ((n=0;n<100;n++)); do
    python pool_memory_test.py --pool_first >> pool_first_obs.txt;
    python pool_memory_test.py >> matrix_first_obs.txt;
done

pool_memory_test_plot.R:

library(ggplot2)
library(reshape2)
pool_first = as.numeric(readLines('pool_first_obs.txt'))
matrix_first = as.numeric(readLines('matrix_first_obs.txt'))
df = data.frame(i=seq(1,100), pool_first, matrix_first)
ggplot(data=melt(df, id.vars='i'), aes(x=i, y=value, color=variable)) +
    geom_point() + geom_smooth() + xlab('iteration') + 
    ylab('free memory') + ggsave('multiprocessing_pool_memory.png')

EDIT: correction d'un petit bogue dans un script causé par une recherche / remplacement et une réexécution trop zélés

EDIT2: "-0" tranchage? Vous pouvez le faire? :)

EDIT3: meilleur script python, bouclage bash et visualisation, ok pour le moment avec ce lapin :)


12
2018-01-07 00:26


origine


Réponses:


Votre question touche plusieurs mécaniciens faiblement couplés. Et c'est aussi une cible facile pour des points de karma supplémentaires, mais vous pouvez sentir que quelque chose ne va pas et 3 heures plus tard, c'est une question complètement différente. Donc, en échange de tout le plaisir que j'ai eu, vous pouvez trouver utiles certaines des informations ci-dessous.

TL; DR: Mesurer la mémoire utilisée, pas libre. Cela donne des résultats cohérents de (presque) le même résultat pour l'ordre des pools / matrices et la taille des gros objets pour moi.

def memory():
    import resource
    # RUSAGE_BOTH is not always available
    self = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    children = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
    return self + children

Avant de répondre aux questions que vous n'avez pas posées, mais celles qui sont étroitement liées, voici quelques informations de base.

Contexte

L'implémentation la plus répandue, CPython (versions 2 et 3), utilise la gestion de la mémoire de comptage de référence [1]. Chaque fois que vous utilisez un objet Python en tant que valeur, son compteur de référence est augmenté de 1 et diminué lorsque la référence est perdue. Le compteur est un entier défini dans la structure C contenant les données de chaque objet Python [2]. A emporter: le compteur de référence change tout le temps, il est stocké avec le reste des données d'objet.

La plupart des "systèmes d'exploitation inspirés d'Unix" (famille BSD, Linux, OSX, etc.) sont dotés d'une sémantique d'accès à la mémoire de copie sur écriture [3]. Après fork(), deux processus ont des tables de pages mémoire distinctes pointant vers les mêmes pages physiques. Mais le système d'exploitation a marqué les pages comme protégées en écriture, de sorte que lorsque vous écrivez de la mémoire, le processeur déclenche une exception d'accès à la mémoire, gérée par le système d'exploitation pour copier la page d'origine. Il marche et les charlatans comme le processus ont une mémoire isolée, mais bon, économisons du temps (lors de la copie) et de la RAM alors que des parties de la mémoire sont équivalentes. À emporter: fork (ou mp.Pool) créer de nouveaux processus, mais ils (presque) n’utilisent pas encore de mémoire supplémentaire.

CPython stocke les "petits" objets dans les grands bassins (arènes) [4]. Dans les cas courants où vous créez et détruisez un grand nombre de petits objets, par exemple, des variables temporaires au sein d'une fonction, vous ne souhaitez pas appeler trop souvent la gestion de la mémoire du système d'exploitation. Les autres langages de programmation (au moins les plus compilés) utilisent la pile à cette fin.

Questions connexes

  • Utilisation différente de la mémoire juste après mp.Pool() sans aucun travail effectué par pool: multiprocessing.Pool.__init__ crée N (pour le nombre de processeurs détectés). La sémantique de la copie sur écriture commence à ce stade.
  • "la revendication, sur les systèmes * nix, est qu'un sous-processus de travail de pool copie à partir de tous les globals du processus parent": le multitraitement copie les globales de son "contexte", et non les globales de votre module. OS. [5]
  • Utilisation différente de la mémoire de numpy.ones et Python list: matrix = [[1,1,...],[1,2,...],...] est une liste Python de listes Python d'entiers Python. Beaucoup d'objets Python = beaucoup de PyObject_HEAD = beaucoup de compteurs ref. L'accès à chacun d'eux dans un environnement fourchu toucherait tous les compteurs ref, copiant par conséquent leurs pages de mémoire. matrix = numpy.ones((50000, 5000)) est un objet Python de type numpy.array. C'est ça, un objet Python, un ref-counter. Le reste est constitué de chiffres purs de bas niveau stockés en mémoire côte à côte, sans aucun compteur de ref. Par souci de simplicité, vous pouvez utiliser data = '.'*size [5] - crée également un objet unique en mémoire.

Sources

  1. https://docs.python.org/2/c-api/refcounting.html
  2. https://docs.python.org/2/c-api/structures.html#c.PyObject_HEAD
  3. http://minnie.tuhs.org/CompArch/Lectures/week09.html#tth_sEc2.8
  4. http://www.evanjones.ca/memoryallocator/
  5. https://github.com/python/cpython/search?utf8=%E2%9C%93&q=globals+path%3ALib%2Fmultiprocessing%2F&type=Code
  6. Tout dire ensemble https://gist.github.com/temoto/af663106a3da414359fa

2
2018-04-24 04:35