Słownik kontra obiekt - co jest bardziej wydajne i dlaczego?

126

Co jest bardziej wydajne w Pythonie pod względem wykorzystania pamięci i zużycia procesora - słownik czy obiekt?

Tło: muszę załadować ogromną ilość danych do Pythona. Stworzyłem obiekt, który jest po prostu kontenerem pola. Utworzenie 4M instancji i umieszczenie ich w słowniku zajęło około 10 minut i ~ 6 GB pamięci. Gdy słownik jest gotowy, dostęp do niego jest w mgnieniu oka.

Przykład: Aby sprawdzić działanie, napisałem dwa proste programy, które robią to samo - jeden wykorzystuje obiekty, drugi słownik:

Obiekt (czas wykonania ~ 18 sekund):

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

Słownik (czas wykonania ~ 12 sekund):

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

Pytanie: Czy robię coś źle, czy słownik jest po prostu szybszy niż obiekt? Jeśli rzeczywiście słownik działa lepiej, czy ktoś może wyjaśnić, dlaczego?

tkokoszka
źródło
10
Naprawdę powinieneś używać xrange zamiast range podczas generowania dużych sekwencji w ten sposób. Oczywiście, ponieważ masz do czynienia z sekundami czasu wykonania, nie zrobi to dużej różnicy, ale nadal jest to dobry nawyk.
Xiong Chiamiov
2
chyba że jest to python3
Barney

Odpowiedzi:

157

Czy próbowałeś użyć __slots__?

Z dokumentacji :

Domyślnie wystąpienia klas starego i nowego stylu mają słownik do przechowywania atrybutów. To marnuje miejsce na obiekty mające bardzo niewiele zmiennych instancji. Zużycie miejsca może stać się dotkliwe przy tworzeniu dużej liczby instancji.

Wartość domyślną można zastąpić, definiując __slots__definicję klasy w nowym stylu. __slots__Deklaracja wykonuje sekwencję zmiennych instancji i rezerwy tylko wystarczająco dużo miejsca w każdym przypadku do przechowywania wartości dla każdej zmiennej. Miejsce jest oszczędzane, ponieważ __dict__nie jest tworzone dla każdej instancji.

Czy to oszczędza czas i pamięć?

Porównanie trzech podejść na moim komputerze:

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 (obsługiwane w 2.6):

import collections

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

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

Uruchom test porównawczy (używając 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__)

Korzystanie z CPython 2.6.2, w tym nazwany test krotek:

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

Więc tak (nie jest to niespodzianka), używanie __slots__to optymalizacja wydajności. Używanie nazwanej krotki ma podobną wydajność do __slots__.

codeape
źródło
2
To świetnie - dzięki! Próbowałem tego samego na moim komputerze - obiekt ze slotami jest najbardziej wydajnym podejściem (mam ~ 7 sekund).
tkokoszka
6
Istnieją również nazwane krotki, docs.python.org/library/collections.html#collections.namedtuple , fabryka klas dla obiektów ze szczelinami. Jest zdecydowanie schludniejszy i może nawet bardziej zoptymalizowany.
Jochen Ritzel
Przetestowałem również krotki nazwane i zaktualizowałem odpowiedź o wyniki.
codeape
1
Uruchomiłem Twój kod kilka razy i byłem zaskoczony, że moje wyniki się różnią - slots = 3sec obj = 11sec dict = 12s namedtuple = 16sec. Używam CPythona 2.6.6 na Win7 64bit
Jonathan,
Aby podkreślić puentę - namedtuple uzyskało najgorsze wyniki zamiast najlepszych
Jonathan.
15

Dostęp do atrybutu w obiekcie wykorzystuje dostęp do słownika w tle - więc korzystając z dostępu do atrybutów, dodajesz dodatkowe obciążenie. Dodatkowo w przypadku obiektu ponosisz dodatkowy narzut z powodu np. Dodatkowej alokacji pamięci i wykonania kodu (np. __init__Metody).

W twoim kodzie, jeśli ojest Objinstancją, o.attrjest równoważne o.__dict__['attr']z niewielkim dodatkowym narzutem.

Vinay Sajip
źródło
Czy to przetestowałeś? o.__dict__["attr"]jest tym z dodatkowym narzutem, pobierającym dodatkowy operację kodu bajtowego; obj.attr jest szybszy. (Oczywiście dostęp do atrybutów nie będzie wolniejszy niż dostęp do subskrypcji - jest to krytyczna, mocno zoptymalizowana ścieżka kodu).
Glenn Maynard
2
Oczywiście, jeśli faktycznie zrobisz o .__ dict __ ["attr"], będzie wolniej - chciałem tylko powiedzieć, że było to równoważne, a nie że zostało zaimplementowane dokładnie w ten sposób. Myślę, że nie wynika to jasno z mojego sformułowania. Wspomniałem również o innych czynnikach, takich jak alokacje pamięci, czas wywołania konstruktora itp.
Vinay Sajip.
Czy tak jest nadal w przypadku ostatnich wersji pythona3, 11 lat później?
matanster
9

Czy rozważałeś użycie namedtuple ? ( link do pythona 2.4 / 2.5 )

Jest to nowy, standardowy sposób przedstawiania danych strukturalnych, który zapewnia wydajność krotki i wygodę klasy.

Jedyną wadą w porównaniu ze słownikami jest to, że (podobnie jak krotki) nie daje możliwości zmiany atrybutów po utworzeniu.

John Fouhy
źródło
5

Oto kopia odpowiedzi @hughdbrown dla Pythona 3.6.1, zwiększyłem liczbę 5x i dodałem kod, aby przetestować ślad pamięci procesu Pythona na końcu każdego uruchomienia.

Zanim to zrobią spadkobiercy, pamiętaj, że ta metoda obliczania wielkości obiektów nie jest dokładna.

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

A to są moje wyniki

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

Mój wniosek jest taki:

  1. Gniazda mają najlepszy ślad pamięci i są rozsądne pod względem szybkości.
  2. dykty są najszybsze, ale zajmują najwięcej pamięci.
Jarrod Chesney
źródło
Człowieku, powinieneś zamienić to w pytanie. Uruchomiłem go również na swoim komputerze, dla pewności (nie miałem zainstalowanego psutil, więc usunąłem tę część). W każdym razie jest to dla mnie zaskakujące i oznacza, że ​​nie ma pełnej odpowiedzi na pierwotne pytanie. Wszystkie inne odpowiedzi brzmią jak „namedtuple is great” i „use slots ”, i najwyraźniej nowy obiekt dyktu za każdym razem jest szybszy od nich? Myślę, że dykty są naprawdę dobrze zoptymalizowane?
Multihunter
1
Wygląda na to, że funkcja makeL zwraca napis. Jeśli zamiast tego zwrócisz pustą listę, wyniki z grubsza pasują do hughdbrown z python2. Z wyjątkiem nazwanych krotek są zawsze wolniejsze niż SlotObj :(
Multihunter,
Może być mały problem: makeL może działać z różnymi prędkościami w każdej rundzie „@timeit”, ponieważ ciągi znaków są buforowane w Pythonie - ale może się mylę.
Barney
@BarnabasSzabolcs powinien utworzyć nowy ciąg za każdym razem, ponieważ musi on zastąpić wartość „To jest przykładowy ciąg% s”% i
Jarrod Chesney
Tak, to prawda w pętli, ale w drugim teście znowu zaczynam od 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()

Wyniki:

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
źródło
3

Nie ma wątpliwości.
Masz dane bez innych atrybutów (żadnych metod, nic). Stąd masz kontener danych (w tym przypadku słownik).

Zwykle wolę myśleć w kategoriach modelowania danych . Jeśli jest jakiś poważny problem z wydajnością, mogę zrezygnować z czegoś w abstrakcji, ale tylko z bardzo dobrych powodów.
Programowanie polega na zarządzaniu złożonością, a utrzymanie prawidłowej abstrakcji jest bardzo często jednym z najbardziej użytecznych sposobów osiągnięcia takiego wyniku.

Jeśli chodzi o powody, dla których obiekt jest wolniejszy, myślę, że twój pomiar jest nieprawidłowy.
Wykonujesz zbyt mało przypisań wewnątrz pętli for, dlatego widzisz inny czas potrzebny do utworzenia instancji dyktu (obiektu wewnętrznego) i obiektu „niestandardowego”. Chociaż z perspektywy językowej są takie same, mają zgoła inną implementację.
Następnie czas przypisania powinien być prawie taki sam dla obu, ponieważ ostatecznie członkowie są utrzymywani wewnątrz słownika.

obrabować
źródło
0

Istnieje jeszcze jeden sposób na zmniejszenie zużycia pamięci, jeśli struktura danych nie zawiera cykli referencyjnych.

Porównajmy dwie klasy:

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

i

$ 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

Stało się to możliwe, ponieważ structclassklasy oparte na opcjach nie obsługują cyklicznego czyszczenia pamięci, co nie jest potrzebne w takich przypadkach.

Jest też jedna zaleta w porównaniu z __slots__klasą podstawową: możesz dodać dodatkowe atrybuty:

>>> 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
źródło
0

Oto moje testy bardzo ładnego skryptu @ Jarrod-Chesney. Dla porównania uruchomiłem go również na pythonie2 z „zakresem” zastąpionym przez „xrange”.

Z ciekawości dodałem również podobne testy z OrderedDict (ordict) dla porównania.

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

Tak więc w obu głównych wersjach wnioski @ Jarrod-Chesney wciąż wyglądają dobrze.

Florent V
źródło