web-dev-qa-db-fra.com

Existe-t-il un moyen de contourner Python list.append () de plus en plus lent dans une boucle à mesure que la liste s'allonge?

Je lis un gros fichier et convertis toutes les quelques lignes en une instance d'un objet.

Comme je suis en train de parcourir le fichier, je coche l'instance dans une liste à l'aide de list.append (instance), puis je continue la lecture en boucle.

Il s’agit d’un fichier d’une taille d’environ 100 Mo, il n’est donc pas trop volumineux, mais à mesure que la liste s’allonge, la lecture en boucle ralentit progressivement. (J'imprime le temps pour chaque tour dans la boucle).

Ce n’est pas intrinsèque à la boucle ~ lorsque j’imprime chaque nouvelle instance en parcourant le fichier, le programme progresse à une vitesse constante ~ c’est seulement lorsque je les ajoute à une liste qu’il devient lent.

Mon ami a suggéré de désactiver la récupération de place avant la boucle while et de l'activer par la suite et d'effectuer un appel de récupération de place.

Quelqu'un d'autre a-t-il observé un problème similaire avec list.append de plus en plus lent? Y a-t-il un autre moyen de contourner cela?


Je vais essayer les deux choses suivantes suggérées ci-dessous.

(1) "pré-allouer" la mémoire ~ quel est le meilleur moyen de le faire? (2) essayez d'utiliser deque

Plusieurs messages (voir le commentaire d'Alex Martelli) suggéraient une fragmentation de la mémoire (il dispose d'une grande quantité de mémoire disponible, comme je le fais) ~ mais pas de solution évidente pour améliorer les performances.

Pour reproduire le phénomène, veuillez exécuter le code de test fourni ci-dessous dans les réponses et supposez que les listes contiennent des données utiles.


gc.disable () et gc.enable () aident au timing. Je ferai également une analyse minutieuse de l'endroit où tout le temps est passé.

50
Deniz

La mauvaise performance que vous observez est due à un bogue du ramasse-miettes Python de la version que vous utilisez. Effectuez une mise à niveau vers Python 2.7, ou 3.1 ou une version ultérieure pour retrouver le comportement amoritisé 0(1) attendu de la liste qui s'ajoute en Python.

_ {Si vous ne pouvez pas effectuer la mise à niveau, désactivez le garbage collection lors de la création de la liste, puis activez-la une fois que vous avez terminé. 

(Vous pouvez également modifier les déclencheurs du récupérateur de mémoire ou appeler de manière sélective la collecte au fur et à mesure de votre progression, mais je n’explore pas ces options dans cette réponse car elles sont plus complexes et je suppose que votre cas d’utilisation répond à la solution ci-dessus.)

Contexte:

Voir: https://bugs.python.org/issue4074 et aussi https://docs.python.org/release/2.5.2/lib/module-gc.html

Le journaliste observe que l'ajout d'objets complexes (objets qui ne sont ni des nombres ni des chaînes) à une liste ralentit de manière linéaire à mesure que la liste s'allonge.

La raison de ce problème est que le garbage collector vérifie et revérifie chaque objet de la liste pour voir s'il est éligible pour le garbage collection. Ce comportement provoque l'augmentation linéaire dans le temps d'ajouter des objets à une liste. Un correctif devrait atterrir dans py3k, il ne devrait donc pas s'appliquer à l'interpréteur que vous utilisez.

Tester:

J'ai fait un test pour le démontrer. Pour 1k itérations, j'ajoute 10k objets à une liste et enregistre le temps d'exécution pour chaque itération. La différence d’exécution globale est immédiatement évidente. Avec la récupération de place désactivée pendant la boucle interne du test, le temps d’exécution sur mon système est de 18,6 s. Avec la récupération de place activée pour l’ensemble du test, le temps d’exécution est de 899.4s.

C'est le test:

import time
import gc

class A:
    def __init__(self):
        self.x = 1
        self.y = 2
        self.why = 'no reason'

def time_to_append(size, append_list, item_gen):
    t0 = time.time()
    for i in xrange(0, size):
        append_list.append(item_gen())
    return time.time() - t0

def test():
    x = []
    count = 10000
    for i in xrange(0,1000):
        print len(x), time_to_append(count, x, lambda: A())

def test_nogc():
    x = []
    count = 10000
    for i in xrange(0,1000):
        gc.disable()
        print len(x), time_to_append(count, x, lambda: A())
        gc.enable()

Source complète: https://hypervolu.me/~erik/programming/python_lists/listtest.py.txt

Résultat graphique: le rouge est avec gc activé, le bleu est avec gc désactivé. L'axe des y est exprimé en secondes par logarithme.

http://hypervolu.me/~erik/programming/python_lists/gc.png

Comme les deux courbes diffèrent de plusieurs ordres de grandeur dans la composante y, elles sont ici indépendamment avec l’axe des y mis à l’échelle de façon linéaire.

http://hypervolu.me/~erik/programming/python_lists/gc_on.png

http://hypervolu.me/~erik/programming/python_lists/gc_off.png

Fait intéressant, avec le ramassage des ordures désactivé, nous ne voyons que de petites augmentations de la durée d'exécution par ajout de 10 000, ce qui suggère que les coûts de réallocation de la liste de Python sont relativement faibles. Dans tous les cas, ils sont de plusieurs ordres de grandeur inférieurs aux coûts de collecte des ordures.

La densité des tracés ci-dessus fait qu'il est difficile de voir qu'avec le ramasse-miettes activé, la plupart des intervalles ont de bonnes performances. Ce n'est que lorsque le ramasse-miettes effectue un cycle que nous rencontrons le comportement pathologique. Vous pouvez l'observer dans cet histogramme de 10 000 secondes. La plupart des points de données se situent autour de 0,02 s par tranche de 10 000 $.

http://hypervolu.me/~erik/programming/python_lists/gc_on.hist.png

Les données brutes utilisées pour produire ces graphiques sont disponibles à l’adresse http://hypervolu.me/~erik/programming/python_lists/

91
Erik Garrison

Il n'y a rien à contourner: l'ajout à une liste est O(1) amorti.  

Une liste (en CPython) est un tableau au moins aussi long que la liste et jusqu'à deux fois plus long. Si le tableau n'est pas complet, l'ajout à une liste est aussi simple que l'attribution d'un des membres du tableau (O (1)). Chaque fois que le tableau est plein, sa taille double automatiquement. Cela signifie qu’une opération O(n) est parfois requise, mais que n’est requise que toutes les n opérations, et elle est de moins en moins nécessaire à mesure que la liste s’allonge. O(n)/n ==> O (1). (Dans d'autres implémentations, les noms et les détails peuvent potentiellement changer, mais les propriétés de la même heure doivent être conservées.)

L'ajout à une liste est déjà mis à l'échelle.

Est-il possible que, lorsque le fichier devient volumineux, vous ne puissiez pas tout conserver en mémoire et que vous rencontriez des problèmes avec la pagination du système d'exploitation sur le disque? Est-il possible que ce soit une partie différente de votre algorithme qui ne s'adapte pas bien?

13
Mike Graham

Beaucoup de ces réponses ne sont que des suppositions sauvages. J'aime le meilleur de Mike Graham parce qu'il a raison sur la manière dont les listes sont mises en œuvre. Mais j'ai écrit du code pour reproduire votre demande et l'examiner plus en profondeur. Voici quelques résultats.

Voici ce que j'ai commencé avec.

import time
x = []
for i in range(100):
    start = time.clock()
    for j in range(100000):
        x.append([])
    end = time.clock()
    print end - start

J'ajoute simplement des listes vides à la liste x. J'imprime une durée pour chaque tranche de 100 000, 100 fois. Cela ralentit comme vous l'avez dit. (0,03 seconde pour la première itération et 0,84 seconde pour la dernière ... une différence.)

Évidemment, si vous instanciez une liste sans l'ajouter à x, elle fonctionnera beaucoup plus rapidement et ne sera pas mise à l'échelle dans le temps.

Mais si vous changez x.append([]) en x.append('hello world'), il n'y aura aucune augmentation de vitesse. Le même objet est ajouté à la liste 100 * 100 000 fois.

Ce que je fais de ça:

  • La diminution de la vitesse n'a rien à voir avec la taille de la liste. Cela a à voir avec le nombre d'objets Python vivants.
  • Si vous n'ajoutez pas les éléments à la liste, ils sont simplement récupérés immédiatement et ne sont plus gérés par Python.
  • Si vous ajoutez le même élément à plusieurs reprises, le nombre d'objets Python en direct n'augmente pas. Mais la liste doit se redimensionner de temps en temps. Mais ce n'est pas la source du problème de performance.
  • Comme vous créez et ajoutez de nombreux objets nouvellement créés à une liste, ils restent actifs et ne sont pas récupérés. Le ralentissement a probablement quelque chose à voir avec cela.

En ce qui concerne les éléments internes de Python qui pourraient expliquer cela, je ne suis pas sûr. Mais je suis à peu près sûr que la structure de données de la liste n'est pas le coupable.

6
FogleBird

J'ai rencontré ce problème lors de l'utilisation des tableaux Numpy, créés comme suit:

import numpy
theArray = array([],dtype='int32')

L'ajout à ce tableau dans une boucle a pris de plus en plus de temps à mesure que le tableau se développait, ce qui a été un facteur décisif étant donné que j'avais 14 millions d'ajouts à ajouter.

La solution de ramasse-miettes décrite ci-dessus semblait prometteuse mais ne fonctionnait pas.

Ce qui a fonctionné a été la création du tableau avec une taille prédéfinie comme suit:

theArray = array(arange(limit),dtype='int32')

Assurez-vous simplement que limit est plus grand que le tableau dont vous avez besoin.

Vous pouvez ensuite définir chaque élément du tableau directement:

theArray[i] = val_i

Et à la fin, si nécessaire, vous pouvez supprimer la partie inutilisée du tableau

theArray = theArray[:i]

Cela a fait une énorme différence dans mon cas.

1
Nathan Labenz

Peux-tu essayer http://docs.python.org/release/2.5.2/lib/deque-objects.html allouer le nombre attendu d'éléments requis dans votre liste? ? Je parierais que cette liste est un stockage contigu qui doit être réaffecté et copié toutes les quelques itérations . (Semblable à certaines implémentations populaires de std :: vector en c ++)

EDIT: sauvegardé par http://www.python.org/doc/faq/general/#how-are-lists-implemented

1
Tomasz Zielinski

Utilisez un ensemble à la place puis convertissez-le en une liste à la fin

my_set=set()
with open(in_file) as f:
    # do your thing
    my_set.add(instance)


my_list=list(my_set)
my_list.sort() # if you want it sorted

J'ai eu le même problème et cela a résolu le problème de temps par plusieurs commandes. 

0
Kahiga