Diccionario vs Objeto: ¿cuál es más eficiente y por qué?

126

¿Qué es más eficiente en Python en términos de uso de memoria y consumo de CPU: diccionario u objeto?

Antecedentes: tengo que cargar una gran cantidad de datos en Python. Creé un objeto que es solo un contenedor de campo. Crear instancias de 4M y ponerlas en un diccionario tomó aproximadamente 10 minutos y ~ 6GB de memoria. Una vez que el diccionario está listo, acceder a él es un abrir y cerrar de ojos.

Ejemplo: para verificar el rendimiento, escribí dos programas simples que hacen lo mismo: uno está usando objetos, otro diccionario:

Objeto (tiempo de ejecución ~ 18 segundos):

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

Diccionario (tiempo de ejecución ~ 12 segundos):

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

Pregunta: ¿Estoy haciendo algo mal o el diccionario es más rápido que el objeto? Si el diccionario funciona mejor, ¿alguien puede explicar por qué?

tkokoszka
fuente
10
Realmente deberías usar xrange en lugar de rango al generar secuencias grandes como esa. Por supuesto, dado que se trata de segundos de tiempo de ejecución, no habrá mucha diferencia, pero aun así, es un buen hábito.
Xiong Chiamiov el
2
a menos que sea python3
Barney

Respuestas:

157

¿Has intentado usar __slots__?

De la documentación :

Por defecto, las instancias de las clases de estilo antiguo y nuevo tienen un diccionario para el almacenamiento de atributos. Esto desperdicia espacio para objetos que tienen muy pocas variables de instancia. El consumo de espacio puede agudizarse cuando se crean grandes cantidades de instancias.

El valor predeterminado se puede anular definiendo __slots__en una definición de clase de estilo nuevo. La __slots__declaración toma una secuencia de variables de instancia y reserva suficiente espacio en cada instancia para mantener un valor para cada variable. Se ahorra espacio porque __dict__no se crea para cada instancia.

Entonces, ¿esto ahorra tiempo y memoria?

Comparando los tres enfoques en mi computadora:

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 (compatible con 2.6):

import collections

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

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

Ejecutar benchmark (usando 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__)

Usando CPython 2.6.2, incluida la prueba de tupla nombrada:

$ 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)

Entonces sí (no es realmente una sorpresa), el uso __slots__es una optimización del rendimiento. El uso de una tupla con nombre tiene un rendimiento similar a __slots__.

codeape
fuente
2
Eso es genial, ¡gracias! He intentado lo mismo en mi máquina: el objeto con ranuras es el enfoque más eficiente (obtuve ~ 7 segundos).
tkokoszka
66
También hay tuplas con nombre, docs.python.org/library/collections.html#collections.namedtuple , una fábrica de clases para objetos con ranuras. Definitivamente es más ordenado y tal vez aún más optimizado.
Jochen Ritzel
También probé tuplas con nombre y actualicé la respuesta con los resultados.
codeape
1
Ejecuté su código varias veces y me sorprendió que mis resultados fueran diferentes: ranuras = 3 segundos obj = 11 segundos dict = 12 segundos namedtuple = 16 segundos. Estoy usando CPython 2.6.6 en Win7 64bit
Jonathan
Para enfatizar la frase clave : namedtuple obtuvo los peores resultados en lugar de los mejores
Jonathan
15

El acceso a atributos en un objeto usa el acceso al diccionario detrás de escena, por lo que al usar el acceso a atributos está agregando una sobrecarga adicional. Además, en el caso del objeto, está incurriendo en una sobrecarga adicional debido, por ejemplo, a asignaciones de memoria adicionales y ejecución de código (por ejemplo, del __init__método).

En su código, si oes una Objinstancia, o.attres equivalente a o.__dict__['attr']una pequeña cantidad de sobrecarga adicional.

Vinay Sajip
fuente
¿Probaste esto? o.__dict__["attr"]es el que tiene sobrecarga adicional, tomando un bytecode adicional op; obj.attr es más rápido. (Por supuesto, el acceso a los atributos no va a ser más lento que el acceso a la suscripción; es una ruta de código crítica y muy optimizada)
Glenn Maynard
2
Obviamente, si realmente hace o .__ dict __ ["attr"] será más lento, solo quería decir que era equivalente a eso, no que se implementó exactamente de esa manera. Supongo que no está claro por mi redacción. También mencioné otros factores como asignaciones de memoria, tiempo de llamada del constructor, etc.
Vinay Sajip
¿Sigue siendo así con las versiones recientes de python3, 11 años después?
Matanster
9

¿Has considerado usar una tupla con nombre ? ( enlace para python 2.4 / 2.5 )

Es la nueva forma estándar de representar datos estructurados que le brinda el rendimiento de una tupla y la conveniencia de una clase.

Su único inconveniente en comparación con los diccionarios es que (como las tuplas) no le da la capacidad de cambiar los atributos después de la creación.

John Fouhy
fuente
5

Aquí hay una copia de la respuesta @hughdbrown para python 3.6.1, hice el conteo 5 veces más grande y agregué un código para probar la huella de memoria del proceso de python al final de cada ejecución.

Antes de que los votantes voten, tenga en cuenta que este método de contar el tamaño de los objetos no es exacto.

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()

Y estos son mis resultados.

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

Mi conclusión es:

  1. Las ranuras tienen la mejor huella de memoria y son razonables en cuanto a velocidad.
  2. Los dictos son los más rápidos, pero usan la mayor cantidad de memoria.
Jarrod Chesney
fuente
Hombre, deberías convertir esto en una pregunta. También lo ejecuté en mi propia computadora, solo para asegurarme (no tenía psutil instalado, así que saqué esa parte). De todos modos, esto es desconcertante para mí y significa que la pregunta original no está completamente respondida. Todas las otras respuestas son como "namedtuple is great" y "use slots ", y aparentemente un nuevo objeto dict cada vez es más rápido que ellos. ¿Supongo que los dictos están realmente bien optimizados?
Multihunter
1
Parece ser el resultado de la función makeL que devuelve una cadena. Si devuelve una lista vacía, los resultados coinciden aproximadamente con los de hughdbrown de python2. Excepto que las tuplas nombradas son siempre más lentas que SlotObj :(
Multihunter
Podría haber un pequeño problema: makeL podría ejecutarse con diferentes velocidades en cada ronda '@timeit' ya que las cadenas se almacenan en caché en python, pero tal vez me equivoque.
Barney el
@BarnabasSzabolcs debería crear una nueva cadena cada vez porque tiene que sustituir el valor "Esta es una cadena de muestra% s"% i
Jarrod Chesney
Sí, eso es cierto dentro del ciclo, pero en la segunda prueba, vuelvo a comenzar desde 0.
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()

Resultados:

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
fuente
3

No hay pregunta.
Tiene datos, sin otros atributos (sin métodos, nada). Por lo tanto, tiene un contenedor de datos (en este caso, un diccionario).

Por lo general, prefiero pensar en términos de modelado de datos . Si hay un gran problema de rendimiento, entonces puedo renunciar a algo en la abstracción, pero solo con muy buenas razones.
La programación tiene que ver con la gestión de la complejidad, y el mantenimiento de la abstracción correcta es a menudo una de las formas más útiles para lograr dicho resultado.

Sobre las razones por las que un objeto es más lento, creo que su medición no es correcta.
Está realizando asignaciones muy pequeñas dentro del ciclo for, y por lo tanto, lo que ve allí es el tiempo diferente necesario para instanciar un dict (objeto intrínseco) y un objeto "personalizado". Aunque desde la perspectiva del lenguaje son iguales, tienen una implementación bastante diferente.
Después de eso, el tiempo de asignación debería ser casi el mismo para ambos, ya que al final los miembros se mantienen dentro de un diccionario.

robar
fuente
0

Existe otra forma de reducir el uso de memoria si se supone que la estructura de datos no contiene ciclos de referencia.

Comparemos dos clases:

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

y

$ 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

Se hizo posible ya que las structclassclases basadas no admiten la recolección de basura cíclica, lo que no es necesario en tales casos.

También hay una ventaja sobre la __slots__clase basada en la base: puede agregar atributos adicionales:

>>> 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
fuente
0

Aquí están mis ejecuciones de prueba del muy buen script de @ Jarrod-Chesney. A modo de comparación, también lo ejecuto contra python2 con "rango" reemplazado por "xrange".

Por curiosidad, también agregué pruebas similares con OrderedDict (ordict) para comparar.

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

Entonces, en ambas versiones principales, las conclusiones de @ Jarrod-Chesney todavía se ven bien.

Florent V
fuente