De memory footprint van je Python-applicatie
Hoewel het makkelijk is om met Python een idee om te zetten in een programma, loop je al snel tegen bottlenecks aan die je code minder performant maken dan je zou willen. Een van die bottlenecks is geheugen, waarvan Python veel verbruikt in vergelijking met statisch getypeerde talen. Wie online om advies vraagt hoe je een Python-applicatie kunt optimaliseren, krijgt dan ook waarschijnlijk het volgende advies: “Rewrite it in Rust”. Om voor de hand liggende redenen is dat meestal niet erg praktisch advies. Dus moeten we het doen met wat we hebben: Python, en libraries die voor Python geschreven zijn.
Wat volgt is een uiteenzetting van het geheugenmodel achter je Python-applicatie: hoe objecten worden aangemaakt, waar ze worden opgeslagen, en hoe ze uiteindelijk worden opgeruimd. Hoewel het zwaartepunt van het verhaal op de theorie ligt, komen er toch verschillende praktische overwegingen aan bod die je kunt gebruiken om het geheugengebruik van je code te optimaliseren.
Het geheugenmodel van Python
Het geheugenmodel kan worden uitgelegd aan de hand van drie concepten: de stack en de heap, die de belangrijkste geheugengebieden in een Python-applicatie vormen, en ‘pointers’, variabelen die geheugenadressen bevatten die naar een object verwijzen. Elk van deze concepten wordt hieronder toegelicht.
Stack
De stack is het geheugengebied dat de uitvoeringsflow van je applicatie bevat. Telkens wanneer een functie een andere functie aanroept, wordt er een nieuwe stack frame bovenop de stack geplaatst. Wanneer een functie terugkeert, wordt die stack frame vernietigd en wordt de controle doorgegeven aan de frame die één niveau lager ligt. De uitvoering van een programma eindigt wanneer de onderste frame op de stack terugkeert.
De “stack trace”, de foutmelding die bij een exception wordt getoond, is naar de stack vernoemd en geeft de uitvoeringsstaat weer op het moment dat de fout optrad.
De stack is ook waar lokale variabelen leven. Zo worden lokale variabelen aan het einde van de uitvoering van een functie opgeruimd, en daarom kunnen functies buiten een functiescope geen lokale variabelen binnen die scope benaderen.
Heap
De heap is de grootste geheugenruimte van de applicatie en het is waar Python alle objecten neerzet die het aanmaakt. In tegenstelling tot de stack is de heap op geen enkele bepaalde manier gestructureerd. Ruimte voor het aanmaken van objecten wordt naar behoefte beschikbaar gemaakt. Daardoor staat de levensduur van objecten op de heap niet van tevoren vast. Objecten bestaan zolang ze nodig worden geacht.
Vanuit de stack moeten objecten op de heap uiteraard bereikbaar zijn. Net zo zal het ook nodig zijn om objecten vanuit andere objecten te bereiken. Die bereikbaarheid wordt gefaciliteerd via pointers.
Pointers
Iedereen die met C en C++ heeft gewerkt, weet wat pointers zijn. Zij begrijpen ook waarom de meeste andere programmeertalen zo hun best doen om het concept van pointers volledig weg te abstraheren van de ontwikkelaar. Pointers zijn geheugenadressen. Ze heten zoals ze heten omdat ze ‘wijzen’ naar een adres in het geheugen waarvan wordt aangenomen dat het data bevat die naar een specifiek type object verwijst.
In Python gebeurt er bij het aanmaken van een object eigenlijk het volgende: er wordt een object op de heap aangemaakt, waarbij een pointer beschikbaar wordt gesteld binnen de lokale scope die de initialisatie van het object aanvroeg. Dit betekent dat de eerdere uitspraak, “lokale variabelen leven op hun respectievelijke stack frame”, niet helemaal klopt: het daadwerkelijke object leeft ergens op de heap, terwijl de (aanvankelijk) enige beschikbare pointer naar het object binnen de stack frame leeft.
Het gevolg van dit onderscheid is dat het vernietigen van een stack frame alleen de pointer opruimt, maar niet het daadwerkelijke object. Dat komt doordat het mogelijk is dat er sinds het aanmaken een andere verwijzing naar hetzelfde object is gemaakt, en die verwijzing nog steeds actief is. We zullen in de sectie over garbage collection zien hoe Python het opruimen van het bijbehorende object afhandelt.
De grootte van een pointer is gelijk aan de word size van de architectuur van je systeem, en dus 8 bytes op 64-bits systemen, wat de meeste moderne computers zijn.
Hoe Python objecten in het geheugen afhandelt
C-style arrays vs Python-lists
In C is een array-type eigenlijk een pointer naar het eerste element van die array. Er wordt geen aanvullende informatie gegeven, zelfs niet de vermeende grootte van de array. Om dit type datastructuur mogelijk te maken, moeten de elementen van een array aan elkaar grenzen in het geheugen. Bovendien moet het type van de data van tevoren bekend zijn, om te kunnen anticiperen op de hoeveelheid geheugenruimte die elk object inneemt. Het resultaat is een optimale layout van array-data in het geheugen, met lager geheugengebruik en hogere performance.
Python’s dynamische aard, specifiek de dynamic typing, verhindert dit type efficiënte dataopslag. Op enkele uitzonderingen na verwijzen alle objecten indirect naar elkaar, via de aanwezigheid van een pointer. Deze praktijk geldt ook voor Python’s collections, list, set en tuple: alle standaard datastructuren worden in het geheugen gerepresenteerd door een onderliggende array van pointers.
Het feit dat bijna alle objecten in Python door pointers worden gerepresenteerd, draagt in grote mate bij aan de flexibiliteit van Python. Het is bijvoorbeeld de reden waarom Python’s list- en tuple-datastructuren objecten van verschillende types kunnen bevatten. Maar het is ook waarom Python-collections veel minder ruimte-efficiënt zijn dan statisch getypeerde talen. Bepaalde klassen uit de Python-standaardbibliotheek, met name de array-library, omzeilen deze beperking en laten de gebruiker sequences van een datatype met vaste grootte definiëren voor geoptimaliseerde opslag. Dit kan een goede oplossing zijn als je grote sequences van numerieke types in het geheugen moet bewaren. Maar uiteraard gebruiken de meeste real-world applicaties liever een third-party library die specifiek op hun behoeften is afgestemd, met numpy als bekendste voorbeeld.
Reference counting en de garbage collector
Houd er rekening mee dat deze concepten gelden voor de CPython-implementatie. Alternatieve implementaties zoals PyPy gebruiken geen garbage collector die op reference counting steunt.
Python gebruikt een combinatie van reference counting en reachability. Reference counting houdt bij hoe vaak een object aan een variabele is toegewezen, minus het aantal keren dat het werd losgekoppeld. Een object waarnaar geen enkel ander object verwijst, heeft een reference count van nul en kan dus worden opgeruimd. Het gebruikt ook reachability-checks om te zien of er een keten van verwijzingen bestaat die het object bereikbaar maakt vanuit de stack. Daarmee kan het cyclische maar losgekoppelde grafen van objecten opruimen, die met reference counting alleen niet ontdekt kunnen worden. Zoals wanneer twee objecten naar elkaar verwijzen, maar nergens anders worden gerefereerd.
Python’s CPython garbage collector is generational. Objecten worden eerst aangemaakt en toegewezen aan generatie 0, en de garbage collector controleert elke generatie afzonderlijk. Een object dat een collection-ronde van zijn generatie overleeft, wordt opgewaardeerd naar een hogere generatie. Hogere generaties worden minder vaak op verlopen objecten gecontroleerd, volgens de universele observatie dat hoe langer iets er al is, hoe langer het er waarschijnlijk nog zal zijn. In totaal zijn er drie generaties.
Beperkte handmatige controle over garbage collection kan worden uitgeoefend via de gc-library, die deel uitmaakt van de stdlib. Garbage collection kan bijvoorbeeld handmatig worden geactiveerd via de methode gc.collect(). Garbage collection kan ook helemaal worden opgeschort door gebruik van gc.disable() en gc.enable(). Dit kan handig zijn wanneer het onvoorspelbaar stilvallen van een programma (tijdelijk) ongewenst is tijdens bepaalde stukken uitvoering.
Omdat alle Python-types objecten zijn en omdat alle objecten door de garbage collector worden beheerd, hebben types in CPython noodzakelijkerwijs allemaal twee extra velden: ob_refcnt, gebruikt voor reference counting, en ob_type, dat naar de klasse van een object verwijst. Samen zorgen die voor een onvermijdelijke 16 bytes aan extra geheugenoverhead per object. Dat is behoorlijk significant, gezien een vergelijkbaar int-type in statisch getypeerde talen slechts 4 bytes is.
Memory footprint van veelvoorkomende Python-types
We kunnen de sys-module gebruiken om het geheugengebruik van types in het geheugen te inspecteren. In CPython is de waarde van een float 24 bytes, zoals we mogen verwachten: 8 bytes voor een double-precision floating point-getal, en 16 extra bytes overhead
>>> sys.getsizeof(1.0)
24
integers in python zijn wat vreemder, omdat ze oneindig doorlopen. Waar een 4-byte integer maar ongeveer 4 miljard waarden kan bevatten, gaan python ints eindeloos door. Dit leidt tot een kleine hoeveelheid extra overhead. 28 bytes voor ints met een kleine waarde. Het geheugengebruik neemt echter toe naarmate de waarde van de integer stijgt.
>>> sys.getsizeof(0)
28
>>> sys.getsizeof(2**256)
60
Python gebruikt unicode-encoding voor strings. Ascii-tekens worden bij voorkeur door één byte gerepresenteerd, plus een onvermijdelijke overhead van minstens 49 bytes. Als een string niet-ascii-tekens bevat, gebruikt Python een alternatieve string-implementatie met een onvermijdelijke overhead van minstens 73 bytes, en een grootte per teken die afhangt van de unicode-waarde van het grootste gebruikte teken (tot 4 bytes per teken).
>>> sys.getsizeof('abc')
52
>>> sys.getsizeof('🐍')
80
Van de standaard collection-types is tuple nét iets geheugenefficiënter dan lists. Beide zijn echter veel minder geheugenhongerig dan sets, die veel meer overhead hebben vanwege de hashing die nodig is om snelle uniciteitscontroles te garanderen. Deze overhead schaalt mee met de lengte van de collection. Daarom kan het waardevol zijn om voor grote collections de voorkeur te geven aan lists en uniciteit op een andere manier te waarborgen. (Als snel controleren op aanwezigheid vereist is, moet je mogelijk je eigen boomachtige collection-object schrijven, of een third-party library importeren. Dat zou checks in O(log n) mogelijk maken terwijl er minder geheugen wordt verbruikt.) Diezelfde geheugeninefficiëntie van sets geldt verder ook voor dictionary-keys.
my_list = [random.randint(0, 1000) for _ in range(100)]
>>> sys.getsizeof(my_list)
920
>>> sys.getsizeof(tuple(my_list))
840
>>> sys.getsizeof(set(my_list))
8408
Memory footprint van Python-objecten
Hoewel het bovenstaande de meest voorkomende Python-built-ins behandelt, raakt het nog niet aan de interne representatie van objecten in het geheugen. Het belangrijkste om te weten over python-klassen is dat de waarden van hun velden standaard binnen een dictionary worden opgeslagen. Specifiek bevinden ze zich in het veld dat __dict__ heet.
Dit is wat Python-objecten ongelooflijk flexibel maakt, omdat velden naar believen aan een object kunnen worden toegevoegd, zelfs na initialisatie.
Code als deze werkt bijvoorbeeld prima in Python:
class Foo:
def __init__(self):
self.bar = 2
foo = Foo()
foo.xyz = 3 # valid
Maar afgezien van het feit dat het mogelijk ongewenst is (en frustrerend, omdat een typefout in veldnamen niet tot een fout leidt), zorgt deze eigenschap van Python ook voor een flinke toename van de memory footprint van je objecten:
>>> sys.getsizeof(foo.__dict__)
296
Wat de meeste mensen zich echter zullen hebben gerealiseerd, is dat deze flexibiliteit niet geldt voor Python’s ingebouwde types. Specifiek leiden beide onderstaande regels code tot een AttributeError
int(0).xyz = 3
object().xyz = 3
Als we hier verder naar kijken, realiseren we ons al snel dat het __dict__-attribuut niet bestaat voor deze types:
int(0).__dict__ # Also an AttributeError
Python heeft een feature genaamd slotted classes, een alternatieve manier voor een object om zijn velden op te slaan die minder flexibel is, maar geheugenefficiënter. De syntax hiervoor is vrij eenvoudig, zij het wat verbose:
class Foo:
__slots__ = ('bar', 'baz') # specify exactly which fields the class has
def __init__(self, bar, baz):
self.bar = bar
self.baz = baz
foo = Foo('bar', 'baz')
foo.xyz = 3 # This is now an AttributeError
Deze syntax specificeert van tevoren welke velden het object naar verwachting heeft, hoewel het niet vereist dat deze velden door __init__ (of op enig moment) geïnitialiseerd moeten worden. Als extra bonus is het benaderen van een slotted attribuut sneller dan bij de aanpak met __dict__
Wel moet worden opgemerkt dat de meeste Python-programmeurs waar mogelijk liever dataclasses gebruiken. En zoals het toevallig uitkomt, zijn slots gemakkelijker te specificeren voor een dataclass, zonder de extra boilerplate die we hierboven zien:
@dataclass(slots=True)
class Foo:
bar: str
baz: str
foo = Foo('bar', 'baz')
Dit zal dus waarschijnlijk je voorkeursmanier zijn om slotted classes te instantiëren.
Conclusie
Het begrijpen van het onderliggende geheugenmodel van je Python-applicatie is een nuttige leidraad om de performance-valkuilen te doorgronden die Python’s flexibele aard met zich meebrengt. Hoewel algemeen bekend is dat Python “minder efficiënt” is dan statisch getypeerde talen, zijn de factoren die aan die inefficiëntie bijdragen niet zo breed bekend. Maar met slechts een beetje uitleg hoop ik je te hebben geholpen te begrijpen en grip te krijgen op hoe Python het geheugen van je computer gebruikt, en je te hebben laten zien hoe enkele relatief eenvoudige aanpassingen substantiële verbeteringen in het geheugengebruik van je applicatie kunnen opleveren.
Tot slot is het goed om op te merken dat vrijwel geen enkele commerciële toepassing van Python “puur” Python gebruikt om zijn taken uit te voeren. Er is bijna altijd een afhankelijkheid van third-party libraries of frameworks die op zijn minst gedeeltelijk in een gecompileerde taal zijn geschreven. Bij het schrijven van competitieve code, wanneer geheugenefficiëntie echt een aandachtspunt wordt, brengt bovenstaande kennis je maar tot op zekere hoogte, en wordt het noodzakelijk je te verdiepen in de geheugenefficiënte opties die als Python-libraries binnen jouw domein worden aangeboden.
bijgewerkt op 19 maart: de sectie over string-encoding aangepast op basis van een correctie die ik ontving.