Dicionário x objeto - qual é mais eficiente e por quê?

126

O que é mais eficiente no Python em termos de uso de memória e consumo de CPU - Dicionário ou Objeto?

Antecedentes: tenho que carregar uma quantidade enorme de dados no Python. Eu criei um objeto que é apenas um contêiner de campo. Criar instâncias da 4M e colocá-las em um dicionário levou cerca de 10 minutos e ~ 6 GB de memória. Depois que o dicionário estiver pronto, acessá-lo é um piscar de olhos.

Exemplo: Para verificar o desempenho, escrevi dois programas simples que fazem o mesmo - um está usando objetos, outro dicionário:

Objeto (tempo de execução ~ 18s):

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

Dicionário (tempo de execução ~ 12seg):

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

Pergunta: Estou fazendo algo errado ou o dicionário é apenas mais rápido que o objeto? Se de fato o dicionário tem um desempenho melhor, alguém pode explicar o porquê?

tkokoszka
fonte
10
Você realmente deve usar xrange em vez de range ao gerar sequências grandes como essa. Claro, como você está lidando com segundos de tempo de execução, não fará muita diferença, mas ainda assim, é um bom hábito.
Xiong Chiamiov 26/08/09
2
a menos que se é python3
Barney

Respostas:

157

Você já tentou usar __slots__?

A partir da documentação :

Por padrão, as instâncias das classes antiga e de novo estilo têm um dicionário para armazenamento de atributos. Isso desperdiça espaço para objetos com muito poucas variáveis ​​de instância. O consumo de espaço pode se tornar agudo ao criar um grande número de instâncias.

O padrão pode ser substituído, definindo __slots__uma definição de classe de novo estilo. A __slots__declaração utiliza uma sequência de variáveis ​​de instância e reserva espaço suficiente em cada instância para armazenar um valor para cada variável. O espaço é salvo porque __dict__não é criado para cada instância.

Isso economiza tempo e memória?

Comparando as três abordagens no meu computador:

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 (suportado em 2.6):

import collections

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

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

Execute o benchmark (usando o 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 o CPython 2.6.2, incluindo o teste de tupla nomeado:

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

Então, sim (não é realmente uma surpresa), usar __slots__é uma otimização de desempenho. O uso de uma tupla nomeada tem desempenho semelhante ao __slots__.

codeape
fonte
2
Isso é ótimo - obrigado! Eu tentei o mesmo na minha máquina - objeto com slots é a abordagem mais eficiente (eu tenho ~ 7seg).
tkokoszka
6
Também existem tuplas nomeadas, docs.python.org/library/collections.html#collections.namedtuple , uma fábrica de classes para objetos com slots. Definitivamente mais limpo e talvez ainda mais otimizado.
Jochen Ritzel 26/08/09
Também testei tuplas nomeadas e atualizei a resposta com os resultados.
codeape
1
Eu corri seu código algumas vezes e fiquei surpreso com meus resultados diferentes - slots = 3seg obj = 11seg dict = 12ss nomeduplo = 16seg. Estou usando CPython 2.6.6 em Win7 64bit
Jonathan
Para enfatizar a punchline - namedtuple obteve os piores resultados em vez do melhor
Jonathan
15

O acesso ao atributo em um objeto usa o acesso ao dicionário nos bastidores - portanto, usando o acesso ao atributo, você está adicionando uma sobrecarga extra. Além disso, no caso do objeto, você está enfrentando uma sobrecarga adicional devido a, por exemplo, alocações adicionais de memória e execução de código (por exemplo, do __init__método).

No seu código, se ofor uma Objinstância, o.attré equivalente a o.__dict__['attr']uma pequena quantidade de sobrecarga extra.

Vinay Sajip
fonte
Você testou isso? o.__dict__["attr"]é aquele com sobrecarga extra, tendo um op extra de bytecode; obj.attr é mais rápido. (Claro acesso atributo não vai ser mais lento do que o acesso de assinatura - é, um caminho de código altamente otimizado crítica.)
Glenn Maynard
2
Obviamente, se você realmente fizer o .__ dict __ ["attr"], será mais lento - só quis dizer que era equivalente a isso, não que foi implementado exatamente dessa maneira. Eu acho que não está claro pelo meu texto. Eu também mencionou outros fatores, como as alocações de memória, tempo de chamada do construtor etc.
Vinay Sajip
Esse ainda é o caso das versões recentes do python3, 11 anos depois?
matanster
9

Você já pensou em usar um nomeado duplo ? ( link para python 2.4 / 2.5 )

É a nova maneira padrão de representar dados estruturados que fornece o desempenho de uma tupla e a conveniência de uma classe.

A única desvantagem em comparação com os dicionários é que (como tuplas), não permite alterar atributos após a criação.

John Fouhy
fonte
5

Aqui está uma cópia da resposta @hughdbrown para python 3.6.1. Aumentei a contagem 5x e adicionei algum código para testar a pegada de memória do processo python no final de cada execução.

Antes que os downvotores o façam, lembre-se de que esse método de contagem do tamanho dos objetos não é preciso.

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

E esses são meus 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

Minha conclusão é:

  1. Os slots têm a melhor pegada de memória e são razoáveis ​​em velocidade.
  2. Os ditados são os mais rápidos, mas usam mais memória.
Jarrod Chesney
fonte
Cara, você deveria transformar isso em questão. Também executei no meu próprio computador, apenas para ter certeza (eu não tinha o psutil instalado, então tirei essa parte). De qualquer forma, isso é desconcertante para mim e significa que a pergunta original não foi totalmente respondida. Todas as outras respostas são como "o nome nomeado é ótimo" e "use slots ", e aparentemente um novo objeto de ditado sempre é mais rápido que eles? Eu acho que os ditados estão realmente bem otimizados?
Multihunter
1
Parece ser o resultado da função makeL retornando uma string. Se você retornar uma lista vazia, os resultados corresponderão aproximadamente aos de hughdbrown do python2. Exceto que os nomeados são sempre mais lentos que o SlotObj :(
Multihunter
Pode haver um pequeno problema: o makeL pode ser executado com velocidades diferentes em cada rodada do '@timeit', já que as strings são armazenadas em cache em python - mas talvez eu esteja errado.
Barney
@BarnabasSzabolcs deve criar uma nova seqüência de cada vez, porque ele tem que substituir no valor "Esta é uma string de amostra% s" % i
Jarrod Chesney
Sim, isso é verdade dentro do loop, mas no segundo teste, eu começo de 0 novamente.
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
fonte
3

Não há dúvida.
Você tem dados, sem outros atributos (sem métodos, nada). Portanto, você tem um contêiner de dados (neste caso, um dicionário).

Normalmente, prefiro pensar em termos de modelagem de dados . Se houver algum problema de desempenho enorme, posso desistir de algo na abstração, mas apenas por boas razões.
Programar é gerenciar a complexidade e manter a abstração correta é muitas vezes uma das maneiras mais úteis de obter esse resultado.

Sobre as razões pelas quais um objeto é mais lento, acho que sua medida não está correta.
Você está executando muito poucas atribuições dentro do loop for e, portanto, o que vê é o tempo diferente necessário para instanciar um ditado (objeto intrínseco) e um objeto "personalizado". Embora da perspectiva da linguagem sejam iguais, eles têm uma implementação bem diferente.
Depois disso, o tempo de atribuição deve ser quase o mesmo para ambos, pois no final os membros são mantidos dentro de um dicionário.

roubar
fonte
0

Existe ainda outra maneira de reduzir o uso de memória se a estrutura de dados não deve conter ciclos de referência.

Vamos comparar duas classes:

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

e

$ 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

Tornou-se possível, pois as structclassclasses baseadas em não suportam a coleta de lixo cíclica, o que não é necessário nesses casos.

Há também uma vantagem sobre a __slots__classe baseada em: você pode adicionar atributos extras:

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

Aqui estão minhas execuções de teste do script muito bom de @ Jarrod-Chesney. Para comparação, também o executo no python2 com "range" substituído por "xrange".

Por curiosidade, também adicionei testes semelhantes com OrderedDict (ordenict) para comparação.

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

Portanto, nas duas versões principais, as conclusões de @ Jarrod-Chesney ainda estão boas.

Florent V
fonte