Comprendi quanta memoria usi gli oggetti Python

Python è un fantastico linguaggio di programmazione. È anche noto per essere piuttosto lento, dovuto principalmente alla sua enorme flessibilità e alle sue caratteristiche dinamiche. Per molte applicazioni e domini non è un problema a causa delle loro esigenze e varie tecniche di ottimizzazione. È meno noto che i grafici degli oggetti Python (dizionari nidificati di liste e tuple e tipi primitivi) occupano una quantità significativa di memoria. Questo può essere un fattore limitante molto più grave a causa dei suoi effetti sul caching, memoria virtuale, multi-tenancy con altri programmi e in generale esaurendo più velocemente la memoria disponibile, che è una risorsa scarsa e costosa.

Si scopre che non è banale capire quanta memoria è effettivamente consumata. In questo articolo ti guiderò attraverso le complessità della gestione della memoria dell'oggetto Python e mostrerò come misurare con precisione la memoria consumata.

In questo articolo mi concentro esclusivamente su CPython, l'implementazione primaria del linguaggio di programmazione Python. Gli esperimenti e le conclusioni qui non si applicano ad altre implementazioni Python come IronPython, Jython e PyPy.

Inoltre, ho eseguito i numeri su Python 2.7 a 64 bit. In Python 3 i numeri sono a volte un po 'diversi (specialmente per le stringhe che sono sempre Unicode), ma i concetti sono gli stessi.

Esplorazione pratica dell'uso della memoria Python

Per prima cosa, esploriamo un po 'e acquisiamo un concreto senso dell'utilizzo effettivo della memoria degli oggetti Python.

La funzione built-in sys.getsizeof ()

Il modulo sys della libreria standard fornisce la funzione getsizeof (). Quella funzione accetta un oggetto (e facoltativo di default), chiama l'oggetto taglia di() e restituisce il risultato, in modo da poter rendere anche gli oggetti ispezionabili.

Misurare la memoria degli oggetti Python

Iniziamo con alcuni tipi numerici:

"python import sys

sys.getsizeof (5) 24 "

Interessante. Un numero intero accetta 24 byte.

python sys.getsizeof (5.3) 24

Hmm ... un float accetta anche 24 byte.

python dall'importazione decimale Decimal sys.getsizeof (Decimal (5.3)) 80

Wow. 80 byte! Questo ti fa davvero riflettere se vuoi rappresentare un gran numero di numeri reali come float o decimali.

Passiamo alle stringhe e alle raccolte:

"python sys.getsizeof (") 37 sys.getsizeof ('1') 38 sys.getsizeof ('1234') 41

sys.getsizeof (u ") 50 sys.getsizeof (u'1 ') 52 sys.getsizeof (u'1234') 58"

OK. Una stringa vuota richiede 37 byte e ogni carattere aggiuntivo aggiunge un altro byte. Questo dice molto sui compromessi nel mantenere più brevi stringhe in cui pagherai l'overhead di 37 byte per ciascuna contro una singola stringa lunga in cui pagherai l'overhead una sola volta.

Le stringhe Unicode si comportano allo stesso modo, ad eccezione del sovraccarico di 50 byte e ogni carattere aggiuntivo aggiunge 2 byte. Questo è qualcosa da considerare se si utilizzano librerie che restituiscono stringhe Unicode, ma il testo può essere rappresentato come stringhe semplici.

A proposito, in Python 3, le stringhe sono sempre Unicode e l'overhead è di 49 byte (hanno salvato un byte da qualche parte). L'oggetto bytes ha un sovraccarico di soli 33 byte. Se hai un programma che elabora molte stringhe brevi in ​​memoria e ti preoccupi delle prestazioni, considera Python 3.

python sys.getsizeof ([]) 72 sys.getsizeof ([1]) 88 sys.getsizeof ([1, 2, 3, 4]) 104 sys.getsizeof (['long long long string']])

Cosa sta succedendo? Una lista vuota richiede 72 byte, ma ogni int aggiuntivo aggiunge solo 8 byte, dove la dimensione di un int è di 24 byte. Un elenco che contiene una stringa lunga richiede solo 80 byte.

La risposta è semplice L'elenco non contiene gli oggetti int stessi. Contiene solo un puntatore a 8 byte (su versioni a 64 bit di CPython) sull'oggetto int reale. Ciò significa che la funzione getsizeof () non restituisce la memoria effettiva della lista e tutti gli oggetti in essa contenuti, ma solo la memoria della lista e i puntatori ai suoi oggetti. Nella prossima sezione introdurrò la funzione deep_getsizeof () che risolve questo problema.

python sys.getsizeof (()) 56 sys.getsizeof ((1)) 64 sys.getsizeof ((1, 2, 3, 4)) 88 sys.getsizeof (('una lunga stringa longlong',)) 64

La storia è simile per le tuple. L'overhead di una tupla vuota è di 56 byte rispetto al 72 di una lista. Ancora una volta, questa differenza di 16 byte per sequenza è un frutto a basso impatto se si dispone di una struttura dati con molte sequenze piccole e immutabili.

"python sys.getsizeof (set ()) 232 sys.getsizeof (set ([1)) 232 sys.getsizeof (set ([1, 2, 3, 4])) 232

sys.getsizeof () 280 sys.getsizeof (dict (a = 1)) 280 sys.getsizeof (dict (a = 1, b = 2, c = 3)) 280 "

Insiemi e dizionari apparentemente non crescono affatto quando si aggiungono oggetti, ma si noti l'enorme sovraccarico.

La linea di fondo è che gli oggetti Python hanno un enorme sovraccarico fisso. Se la struttura dei dati è composta da un numero elevato di oggetti di raccolta come stringhe, elenchi e dizionari che contengono un numero limitato di elementi, si paga un pedaggio pesante.

La funzione deep_getsizeof ()

Ora che ti ho spaventato a morte e ho anche dimostrato che sys.getsizeof () può solo dirti quanta memoria ha un oggetto primitivo, diamo un'occhiata a una soluzione più adeguata. La funzione deep_getsizeof () esegue il drill in modo ricorsivo e calcola l'effettivo utilizzo della memoria di un grafico di oggetto Python.

"python from collections import Mapping, Container from sys import getsizeof

def deep_getsizeof (o, ids): "" "Trova l'impronta di memoria di un oggetto Python

Questa è una funzione ricorsiva che analizza un grafo di oggetti Python come un dizionario che contiene dizionari nidificati con elenchi di liste e tuple e insiemi. La funzione sys.getsizeof ha solo una dimensione ridotta. Conteggia ogni oggetto all'interno di un contenitore come puntatore solo a prescindere dalla sua grandezza. : param o: l'oggetto: param id:: return: "" "d = deep_getsizeof se id (o) in ids: return 0 r = getsizeof (o) ids.add (id (o)) if isinstance (o, str ) o isinstance (0, unicode): return r if isinstance (o, Mapping): return r + sum (d (k, ids) + d (v, ids) per k, v in o.iteritems ()) se isinstance (o, Container): return r + sum (d (x, ids) per x in o) return r "

Ci sono molti aspetti interessanti di questa funzione. Prende in considerazione gli oggetti a cui si fa riferimento più volte e li conteggia solo una volta tenendo traccia degli ID oggetto. L'altra caratteristica interessante dell'implementazione è che sfrutta appieno le classi base astratte del modulo collezioni. Ciò consente alla funzione di gestire in modo molto conciso qualsiasi raccolta che implementa le classi di base Mapping o Container anziché gestire direttamente una miriade di tipi di raccolta come: stringa, Unicode, byte, elenco, tupla, dict, frozendict, OrderedDict, set, frozenset, ecc..

Vediamolo in azione:

python x = '1234567' deep_getsizeof (x, set ()) 44

Una stringa di lunghezza 7 richiede 44 byte (37 overhead + 7 byte per ogni carattere).

python deep_getsizeof ([], set ()) 72

Una lista vuota richiede 72 byte (solo in alto).

python deep_getsizeof ([x], set ()) 124

Un elenco che contiene la stringa x richiede 124 byte (72 + 8 + 44).

python deep_getsizeof ([x, x, x, x, x], set ()) 156

Un elenco che contiene la stringa x 5 volte richiede 156 byte (72 + 5 * 8 + 44).

L'ultimo esempio mostra che deep_getsizeof () conta i riferimenti allo stesso oggetto (la stringa x) solo una volta, ma viene conteggiato ogni puntatore del riferimento.

Treats or Tricks

Si scopre che CPython ha diversi asso nella manica, quindi i numeri che ottieni da deep_getsizeof () non rappresentano pienamente l'utilizzo della memoria di un programma Python.

Conteggio di riferimento

Python gestisce la memoria usando la semantica del conteggio dei riferimenti. Una volta che un oggetto non viene più referenziato, la sua memoria viene deallocata. Ma finché c'è un riferimento, l'oggetto non sarà deallocato. Cose come i riferimenti ciclici possono morderti abbastanza.

Piccoli oggetti

CPython gestisce piccoli oggetti (meno di 256 byte) in pool speciali su limiti di 8 byte. Esistono pool per 1-8 byte, 9-16 byte e fino a 249-256 byte. Quando viene allocato un oggetto di dimensione 10, viene allocato dal pool a 16 byte per gli oggetti 9-16 byte di dimensione. Quindi, anche se contiene solo 10 byte di dati, costerà 16 byte di memoria. Se si assegnano 1.000.000 di oggetti di dimensione 10, si utilizzano effettivamente 16.000.000 di byte e non 10.000.000 di byte come si può presumere. Questo overhead del 60% non è ovviamente banale.

Interi

CPython mantiene un elenco globale di tutti gli interi nell'intervallo [-5, 256]. Questa strategia di ottimizzazione ha senso perché i piccoli numeri interi appaiono dappertutto, e dato che ogni intero prende 24 byte, salva molta memoria per un programma tipico.

Significa anche che CPython pre-alloca 266 * 24 = 6384 byte per tutti questi numeri interi, anche se non ne usi la maggior parte. Puoi verificarlo usando la funzione id () che dà il puntatore all'oggetto reale. Se chiami id (x) multiplo per qualsiasi x nell'intervallo [-5, 256], otterrai sempre lo stesso risultato (per lo stesso intero). Ma se provi per numeri interi al di fuori di questo intervallo, ognuno sarà diverso (un nuovo oggetto viene creato al volo ogni volta).

Ecco alcuni esempi all'interno della gamma:

"python id (-3) 140251817361752

id (-3) 140251817361752

id (-3) 140251817361752

id (201) 140251817366736

id (201) 140251817366736

id (201) 140251817366736 "

Ecco alcuni esempi al di fuori dell'intervallo:

"python id (301) 140251846945800

id (301) 140251846945776

id (-6) 140251846946960

id (-6) 140251846946936 "

Memoria Python e memoria di sistema

CPython è un tipo possessivo. In molti casi, quando gli oggetti di memoria nel tuo programma non sono più referenziati, lo sono non restituito al sistema (ad esempio gli oggetti piccoli). Questo è positivo per il tuo programma se assegni e dealloculi molti oggetti (che appartengono allo stesso pool di 8 byte) perché Python non deve disturbare il sistema, che è relativamente costoso. Ma non è così grande se il tuo programma normalmente usa X byte e in alcune condizioni temporanee ne usa 100 volte (ad esempio analizzando ed elaborando un grande file di configurazione solo all'avvio).

Ora, quella memoria 100X potrebbe essere intrappolata inutilmente nel tuo programma, non essere mai più usata e negare al sistema di allocarla ad altri programmi. L'ironia è che se utilizzi il modulo di elaborazione per eseguire più istanze del tuo programma, limiti severamente il numero di istanze che puoi eseguire su una determinata macchina.

Memory Profiler

Per misurare e misurare l'effettivo utilizzo della memoria del tuo programma, puoi usare il modulo memory_profiler. Ho giocato un po 'con questo e non sono sicuro di fidarmi dei risultati. Usarlo è molto semplice. Decorate una funzione (potrebbe essere la principale (funzione 0) con @profiler decorator e quando il programma termina, il profiler della memoria stampa sullo standard output un comodo rapporto che mostra il totale e le modifiche in memoria per ogni linea. Ecco un esempio programma che ho eseguito sotto il profiler:

"python from memory_profiler import profile

@profile def main (): a = [] b = [] c = [] per i in range (100000): a.append (5) per i in range (100000): b.append (300) per i in intervallo (100000): c.append ('123456789012345678901234567890') del a del b del c

stampa 'Fatto!' se __name__ == '__main__': main () "

Ecco l'output:

Riga # Mem utilizzo Incremento Riga Contenuto =========================================== ===== 3 22.9 MiB 0.0 MiB @profile 4 def main (): 5 22.9 MiB 0.0 MiB a = [] 6 22.9 MiB 0.0 MiB b = [] 7 22.9 MiB 0.0 MiB c = [] 8 27.1 MiB 4.2 MiB per i in gamma (100000): 9 27,1 MiB 0.0 MiB a.append (5) 10 27,5 MiB 0.4 MiB per i in range (100000): 11 27,5 MiB 0.0 MiB b.append (300) 12 28.3 MiB 0.8 MiB per i nel range (100.000): 13 28.3 MiB 0.0 MiB c.append ('123456789012345678901234567890') 14 27.7 MiB -0.6 MiB del a 15 27.9 MiB 0.2 MiB del b 16 27.3 MiB -0.6 MiB del c 17 18 27.3 MiB 0.0 MiB print ' Fatto!' 

Come puoi vedere, ci sono 22.9 MB di memoria in testa. Il motivo per cui la memoria non aumenta quando si aggiungono interi sia all'interno che all'esterno dell'intervallo [-5, 256] e anche quando si aggiunge la stringa è che un singolo oggetto viene utilizzato in tutti i casi. Non è chiaro il motivo per cui il primo ciclo di intervallo (100000) sulla linea 8 aggiunge 4,2 MB mentre il secondo sulla linea 10 aggiunge solo 0,4 MB e il terzo ciclo sulla riga 12 aggiunge 0,8 MB. Infine, quando si eliminano gli elenchi a, b e c, viene rilasciato -0,6 MB per a e c, ma per b 0,2 MB viene aggiunto. Non posso dare molto senso a questi risultati.

Conclusione

CPython usa molta memoria per i suoi oggetti. Usa vari trucchi e ottimizzazioni per la gestione della memoria. Tenendo traccia dell'utilizzo della memoria dell'oggetto e della consapevolezza del modello di gestione della memoria, è possibile ridurre in modo significativo l'ingombro di memoria del programma.

Impara Python

Impara Python con la nostra guida completa al tutorial su Python, sia che tu stia appena iniziando o che sei un programmatore esperto che cerca di imparare nuove abilità.