Dictionnaire vs objet - qui est le plus efficace et pourquoi?

126

Qu'est-ce qui est plus efficace en Python en termes d'utilisation de la mémoire et de consommation CPU - Dictionnaire ou Objet?

Contexte: je dois charger une énorme quantité de données dans Python. J'ai créé un objet qui n'est qu'un conteneur de champ. Créer des instances 4M et les mettre dans un dictionnaire a pris environ 10 minutes et ~ 6 Go de mémoire. Une fois que le dictionnaire est prêt, y accéder est un clin d'œil.

Exemple: pour vérifier les performances, j'ai écrit deux programmes simples qui font la même chose - l'un utilise des objets, l'autre un dictionnaire:

Objet (temps d'exécution ~ 18sec):

class Obj(object):
  def __init__(self, i):
    self.i = i
    self.l = []
all = {}
for i in range(1000000):
  all[i] = Obj(i)

Dictionnaire (temps d'exécution ~ 12sec):

all = {}
for i in range(1000000):
  o = {}
  o['i'] = i
  o['l'] = []
  all[i] = o

Question: Est-ce que je fais quelque chose de mal ou le dictionnaire est-il juste plus rapide qu'un objet? Si effectivement le dictionnaire fonctionne mieux, quelqu'un peut-il expliquer pourquoi?

tkokoszka
la source
10
Vous devriez vraiment utiliser xrange au lieu de range lorsque vous générez de grandes séquences comme ça. Bien sûr, puisque vous avez affaire à des secondes de temps d'exécution, cela ne fera pas beaucoup de différence, mais c'est quand même une bonne habitude.
Xiong Chiamiov
2
sauf si c'est python3
Barney

Réponses:

157

Avez-vous essayé d'utiliser __slots__?

De la documentation :

Par défaut, les instances des classes de style ancien et nouveau ont un dictionnaire pour le stockage des attributs. Cela gaspille de l'espace pour les objets ayant très peu de variables d'instance. La consommation d'espace peut devenir aiguë lors de la création d'un grand nombre d'instances.

La valeur par défaut peut être remplacée en définissant __slots__dans une définition de classe de nouveau style. La __slots__déclaration prend une séquence de variables d'instance et réserve juste assez d'espace dans chaque instance pour contenir une valeur pour chaque variable. L'espace est économisé car il __dict__n'est pas créé pour chaque instance.

Est-ce que cela économise du temps et de la mémoire?

Comparaison des trois approches sur mon ordinateur:

test_slots.py:

class Obj(object):
  __slots__ = ('i', 'l')
  def __init__(self, i):
    self.i = i
    self.l = []
all = {}
for i in range(1000000):
  all[i] = Obj(i)

test_obj.py:

class Obj(object):
  def __init__(self, i):
    self.i = i
    self.l = []
all = {}
for i in range(1000000):
  all[i] = Obj(i)

test_dict.py:

all = {}
for i in range(1000000):
  o = {}
  o['i'] = i
  o['l'] = []
  all[i] = o

test_namedtuple.py (pris en charge dans 2.6):

import collections

Obj = collections.namedtuple('Obj', 'i l')

all = {}
for i in range(1000000):
  all[i] = Obj(i, [])

Exécutez le benchmark (en utilisant CPython 2.5):

$ lshw | grep product | head -n 1
          product: Intel(R) Pentium(R) M processor 1.60GHz
$ python --version
Python 2.5
$ time python test_obj.py && time python test_dict.py && time python test_slots.py 

real    0m27.398s (using 'normal' object)
real    0m16.747s (using __dict__)
real    0m11.777s (using __slots__)

Utilisation de CPython 2.6.2, y compris le test de tuple nommé:

$ python --version
Python 2.6.2
$ time python test_obj.py && time python test_dict.py && time python test_slots.py && time python test_namedtuple.py 

real    0m27.197s (using 'normal' object)
real    0m17.657s (using __dict__)
real    0m12.249s (using __slots__)
real    0m12.262s (using namedtuple)

Alors oui (pas vraiment une surprise), l'utilisation __slots__est une optimisation des performances. L'utilisation d'un tuple nommé a des performances similaires à celles de __slots__.

codeape
la source
2
C'est génial - merci! J'ai essayé la même chose sur ma machine - un objet avec des slots est l'approche la plus efficace (j'ai ~ 7sec).
tkokoszka
6
Il existe également des tuples nommés docs.python.org/library/collections.html#collections.namedtuple , une fabrique de classes pour les objets avec des emplacements. C'est définitivement plus soigné et peut-être encore plus optimisé.
Jochen Ritzel
J'ai également testé des tuples nommés et mis à jour la réponse avec les résultats.
codeape
1
J'ai exécuté votre code plusieurs fois et j'ai été surpris que mes résultats diffèrent - slots = 3sec obj = 11sec dict = 12sec namedtuple = 16sec. J'utilise CPython 2.6.6 sur Win7 64bit
Jonathan
Pour souligner la punchline - namedtuple a obtenu les pires résultats au lieu des meilleurs
Jonathan
15

L'accès aux attributs dans un objet utilise l'accès au dictionnaire en arrière-plan. Ainsi, en utilisant l'accès aux attributs, vous ajoutez une surcharge supplémentaire. De plus, dans le cas de l'objet, vous subissez une surcharge supplémentaire en raison, par exemple, d'allocations de mémoire supplémentaires et de l'exécution de code (par exemple de la __init__méthode).

Dans votre code, si oest une Objinstance, cela o.attréquivaut à o.__dict__['attr']une petite quantité de surcharge supplémentaire.

Vinay Sajip
la source
Avez-vous testé cela? o.__dict__["attr"]est celui avec des frais généraux supplémentaires, prenant une opération de bytecode supplémentaire; obj.attr est plus rapide. (Bien sûr, l'accès aux attributs ne sera pas plus lent que l'accès aux abonnements - c'est un chemin de code critique et fortement optimisé.)
Glenn Maynard
2
Évidemment, si vous faites réellement o .__ dict __ ["attr"], ce sera plus lent - je voulais seulement dire que c'était équivalent à cela, pas que cela a été implémenté exactement de cette façon. Je suppose que ce n'est pas clair d'après mon libellé. J'ai également mentionné d'autres facteurs tels que les allocations de mémoire, le temps d'appel du constructeur, etc.
Vinay Sajip
Est-ce toujours le cas avec les versions récentes de python3, 11 ans plus tard?
matanster
9

Avez-vous envisagé d'utiliser un namedtuple ? ( lien pour python 2.4 / 2.5 )

C'est la nouvelle façon standard de représenter des données structurées qui vous donne les performances d'un tuple et la commodité d'une classe.

Le seul inconvénient par rapport aux dictionnaires est que (comme les tuples) cela ne vous donne pas la possibilité de changer les attributs après la création.

John Fouhy
la source
6

Voici une copie de la réponse @hughdbrown pour python 3.6.1, j'ai agrandi le décompte 5x et ajouté du code pour tester l'empreinte mémoire du processus python à la fin de chaque exécution.

Avant que les downvoters ne s'y attardent, sachez que cette méthode de comptage de la taille des objets n'est pas précise.

from datetime import datetime
import os
import psutil

process = psutil.Process(os.getpid())


ITER_COUNT = 1000 * 1000 * 5

RESULT=None

def makeL(i):
    # Use this line to negate the effect of the strings on the test 
    # return "Python is smart and will only create one string with this line"

    # Use this if you want to see the difference with 5 million unique strings
    return "This is a sample string %s" % i

def timeit(method):
    def timed(*args, **kw):
        global RESULT
        s = datetime.now()
        RESULT = method(*args, **kw)
        e = datetime.now()

        sizeMb = process.memory_info().rss / 1024 / 1024
        sizeMbStr = "{0:,}".format(round(sizeMb, 2))

        print('Time Taken = %s, \t%s, \tSize = %s' % (e - s, method.__name__, sizeMbStr))

    return timed

class Obj(object):
    def __init__(self, i):
       self.i = i
       self.l = makeL(i)

class SlotObj(object):
    __slots__ = ('i', 'l')
    def __init__(self, i):
       self.i = i
       self.l = makeL(i)

from collections import namedtuple
NT = namedtuple("NT", ["i", 'l'])

@timeit
def profile_dict_of_nt():
    return [NT(i=i, l=makeL(i)) for i in range(ITER_COUNT)]

@timeit
def profile_list_of_nt():
    return dict((i, NT(i=i, l=makeL(i))) for i in range(ITER_COUNT))

@timeit
def profile_dict_of_dict():
    return dict((i, {'i': i, 'l': makeL(i)}) for i in range(ITER_COUNT))

@timeit
def profile_list_of_dict():
    return [{'i': i, 'l': makeL(i)} for i in range(ITER_COUNT)]

@timeit
def profile_dict_of_obj():
    return dict((i, Obj(i)) for i in range(ITER_COUNT))

@timeit
def profile_list_of_obj():
    return [Obj(i) for i in range(ITER_COUNT)]

@timeit
def profile_dict_of_slot():
    return dict((i, SlotObj(i)) for i in range(ITER_COUNT))

@timeit
def profile_list_of_slot():
    return [SlotObj(i) for i in range(ITER_COUNT)]

profile_dict_of_nt()
profile_list_of_nt()
profile_dict_of_dict()
profile_list_of_dict()
profile_dict_of_obj()
profile_list_of_obj()
profile_dict_of_slot()
profile_list_of_slot()

Et ce sont mes résultats

Time Taken = 0:00:07.018720,    provile_dict_of_nt,     Size = 951.83
Time Taken = 0:00:07.716197,    provile_list_of_nt,     Size = 1,084.75
Time Taken = 0:00:03.237139,    profile_dict_of_dict,   Size = 1,926.29
Time Taken = 0:00:02.770469,    profile_list_of_dict,   Size = 1,778.58
Time Taken = 0:00:07.961045,    profile_dict_of_obj,    Size = 1,537.64
Time Taken = 0:00:05.899573,    profile_list_of_obj,    Size = 1,458.05
Time Taken = 0:00:06.567684,    profile_dict_of_slot,   Size = 1,035.65
Time Taken = 0:00:04.925101,    profile_list_of_slot,   Size = 887.49

Ma conclusion est:

  1. Les slots ont la meilleure empreinte mémoire et sont raisonnables en termes de vitesse.
  2. les dictionnaires sont les plus rapides, mais utilisent le plus de mémoire.
Jarrod Chesney
la source
Mec, tu devrais en faire une question. Je l'ai également exécuté sur mon propre ordinateur, juste pour être sûr (je n'avais pas installé psutil, j'ai donc retiré cette partie). Quoi qu'il en soit, cela me déroute et signifie que la question initiale n'a pas été entièrement répondue. Toutes les autres réponses sont comme "namedtuple is great" et "use slots ", et apparemment un tout nouvel objet dict est à chaque fois plus rapide qu'eux? Je suppose que les dictionnaires sont vraiment bien optimisés?
Multihunter
1
Cela semble être le résultat de la fonction makeL qui renvoie une chaîne. Si vous retournez une liste vide, à la place, les résultats correspondent à peu près à ceux de hughdbrown de python2. Sauf que les namedtuples sont toujours plus lents que SlotObj :(
Multihunter
Il pourrait y avoir un petit problème: makeL pourrait fonctionner avec des vitesses différentes à chaque tour '@timeit' puisque les chaînes sont mises en cache en python - mais peut-être que je me trompe.
Barney
@BarnabasSzabolcs devrait créer une nouvelle chaîne à chaque fois car elle doit la remplacer par la valeur "Ceci est un exemple de chaîne% s"% i
Jarrod Chesney
Oui, c'est vrai dans la boucle, mais dans le deuxième test, je recommence à zéro.
Barney
4
from datetime import datetime

ITER_COUNT = 1000 * 1000

def timeit(method):
    def timed(*args, **kw):
        s = datetime.now()
        result = method(*args, **kw)
        e = datetime.now()

        print method.__name__, '(%r, %r)' % (args, kw), e - s
        return result
    return timed

class Obj(object):
    def __init__(self, i):
       self.i = i
       self.l = []

class SlotObj(object):
    __slots__ = ('i', 'l')
    def __init__(self, i):
       self.i = i
       self.l = []

@timeit
def profile_dict_of_dict():
    return dict((i, {'i': i, 'l': []}) for i in xrange(ITER_COUNT))

@timeit
def profile_list_of_dict():
    return [{'i': i, 'l': []} for i in xrange(ITER_COUNT)]

@timeit
def profile_dict_of_obj():
    return dict((i, Obj(i)) for i in xrange(ITER_COUNT))

@timeit
def profile_list_of_obj():
    return [Obj(i) for i in xrange(ITER_COUNT)]

@timeit
def profile_dict_of_slotobj():
    return dict((i, SlotObj(i)) for i in xrange(ITER_COUNT))

@timeit
def profile_list_of_slotobj():
    return [SlotObj(i) for i in xrange(ITER_COUNT)]

if __name__ == '__main__':
    profile_dict_of_dict()
    profile_list_of_dict()
    profile_dict_of_obj()
    profile_list_of_obj()
    profile_dict_of_slotobj()
    profile_list_of_slotobj()

Résultats:

hbrown@hbrown-lpt:~$ python ~/Dropbox/src/StackOverflow/1336791.py 
profile_dict_of_dict ((), {}) 0:00:08.228094
profile_list_of_dict ((), {}) 0:00:06.040870
profile_dict_of_obj ((), {}) 0:00:11.481681
profile_list_of_obj ((), {}) 0:00:10.893125
profile_dict_of_slotobj ((), {}) 0:00:06.381897
profile_list_of_slotobj ((), {}) 0:00:05.860749
Hughdbrown
la source
3

Il n'y a aucune question.
Vous avez des données, sans autres attributs (pas de méthodes, rien). Vous avez donc un conteneur de données (dans ce cas, un dictionnaire).

Je préfère généralement penser en termes de modélisation de données . S'il y a un énorme problème de performance, je peux abandonner quelque chose dans l'abstraction, mais seulement pour de très bonnes raisons.
La programmation consiste à gérer la complexité, et le maintien de l' abstraction correcte est très souvent l'un des moyens les plus utiles pour obtenir un tel résultat.

À propos des raisons pour lesquelles un objet est plus lent, je pense que votre mesure n'est pas correcte.
Vous effectuez trop peu d'affectations à l'intérieur de la boucle for, et donc ce que vous voyez là est le temps différent nécessaire pour instancier un dict (objet intrinsèque) et un objet "personnalisé". Bien que du point de vue linguistique, ils soient identiques, ils ont une implémentation assez différente.
Après cela, le temps d'affectation devrait être presque le même pour les deux, car à la fin, les membres sont conservés dans un dictionnaire.

Rob
la source
0

Il existe encore un autre moyen de réduire l'utilisation de la mémoire si la structure de données n'est pas censée contenir des cycles de référence.

Comparons deux classes:

class DataItem:
    __slots__ = ('name', 'age', 'address')
    def __init__(self, name, age, address):
        self.name = name
        self.age = age
        self.address = address

et

$ pip install recordclass

>>> from recordclass import structclass
>>> DataItem2 = structclass('DataItem', 'name age address')
>>> inst = DataItem('Mike', 10, 'Cherry Street 15')
>>> inst2 = DataItem2('Mike', 10, 'Cherry Street 15')
>>> print(inst2)
>>> print(sys.getsizeof(inst), sys.getsizeof(inst2))
DataItem(name='Mike', age=10, address='Cherry Street 15')
64 40

Cela est devenu possible car les structclassclasses basées sur la base de données ne prennent pas en charge le garbage collection cyclique, ce qui n'est pas nécessaire dans de tels cas.

Il y a aussi un avantage sur la __slots__classe surbasée: vous pouvez ajouter des attributs supplémentaires:

>>> DataItem3 = structclass('DataItem', 'name age address', usedict=True)
>>> inst3 = DataItem3('Mike', 10, 'Cherry Street 15')
>>> inst3.hobby = ['drawing', 'singing']
>>> print(inst3)
>>> print(sizeof(inst3), 'has dict:',  bool(inst3.__dict__))
DataItem(name='Mike', age=10, address='Cherry Street 15', **{'hobby': ['drawing', 'singing']})
48 has dict: True
Intellimath
la source
0

Voici mes tests du très joli script de @ Jarrod-Chesney. A titre de comparaison, je l'exécute également sur python2 avec "range" remplacé par "xrange".

Par curiosité, j'ai également ajouté des tests similaires avec OrderedDict (ordict) pour comparaison.

Python 3.6.9:

Time Taken = 0:00:04.971369,    profile_dict_of_nt,     Size = 944.27
Time Taken = 0:00:05.743104,    profile_list_of_nt,     Size = 1,066.93
Time Taken = 0:00:02.524507,    profile_dict_of_dict,   Size = 1,920.35
Time Taken = 0:00:02.123801,    profile_list_of_dict,   Size = 1,760.9
Time Taken = 0:00:05.374294,    profile_dict_of_obj,    Size = 1,532.12
Time Taken = 0:00:04.517245,    profile_list_of_obj,    Size = 1,441.04
Time Taken = 0:00:04.590298,    profile_dict_of_slot,   Size = 1,030.09
Time Taken = 0:00:04.197425,    profile_list_of_slot,   Size = 870.67

Time Taken = 0:00:08.833653,    profile_ordict_of_ordict, Size = 3,045.52
Time Taken = 0:00:11.539006,    profile_list_of_ordict, Size = 2,722.34
Time Taken = 0:00:06.428105,    profile_ordict_of_obj,  Size = 1,799.29
Time Taken = 0:00:05.559248,    profile_ordict_of_slot, Size = 1,257.75

Python 2.7.15+:

Time Taken = 0:00:05.193900,    profile_dict_of_nt,     Size = 906.0
Time Taken = 0:00:05.860978,    profile_list_of_nt,     Size = 1,177.0
Time Taken = 0:00:02.370905,    profile_dict_of_dict,   Size = 2,228.0
Time Taken = 0:00:02.100117,    profile_list_of_dict,   Size = 2,036.0
Time Taken = 0:00:08.353666,    profile_dict_of_obj,    Size = 2,493.0
Time Taken = 0:00:07.441747,    profile_list_of_obj,    Size = 2,337.0
Time Taken = 0:00:06.118018,    profile_dict_of_slot,   Size = 1,117.0
Time Taken = 0:00:04.654888,    profile_list_of_slot,   Size = 964.0

Time Taken = 0:00:59.576874,    profile_ordict_of_ordict, Size = 7,427.0
Time Taken = 0:10:25.679784,    profile_list_of_ordict, Size = 11,305.0
Time Taken = 0:05:47.289230,    profile_ordict_of_obj,  Size = 11,477.0
Time Taken = 0:00:51.485756,    profile_ordict_of_slot, Size = 11,193.0

Ainsi, sur les deux versions majeures, les conclusions de @ Jarrod-Chesney semblent toujours bonnes.

Florent V
la source