Steigert die Verwendung von Slots tatsächlich die Leistung von Pydantic und ORMs? Eine Benchmark-Studie
Grace Collins
Solutions Engineer · Leapcell

Einleitung
In der Welt von Python ist die Optimierung des Speicherverbrauchs und der Ausführungsgeschwindigkeit ein ständiges Bestreben, insbesondere bei datenintensiven Anwendungen. Zwei kritische Bibliotheken in diesem Bereich sind Pydantic, das häufig für Datenvalidierung und -parsing verwendet wird, und Object-Relational Mapper (ORMs), die Datenbankinteraktionen abstrahieren. Entwickler suchen oft nach Möglichkeiten, jede Menge Leistung herauszuholen, und __slots__ ist eine häufig zitierte Optimierungstechnik. Aber liefert die Anwendung von __slots__ auf Pydantic-Modelle und ORM-Objekte wirklich die versprochenen Speicher- und Leistungsverbesserungen? Diese Frage, die scheinbar einfach ist, beinhaltet das Verständnis der internen Mechanismen von Python und erfordert empirische Validierung anstelle von bloßen Annahmen. In diesem Artikel werden wir uns mit den Details befassen, eine Benchmark durchführen und klare Antworten liefern.
Einblicke in Slots, Pydantic und ORMs
Bevor wir uns mit den Benchmarks befassen, lassen Sie uns die wichtigsten Konzepte klären.
Was sind __slots__?
In Python speichern Instanzen einer Klasse normalerweise ihre Attribute in einem Wörterbuch namens __dict__. Dieses Wörterbuch bietet immense Flexibilität, die es ermöglicht, Attribute zur Laufzeit dynamisch hinzuzufügen oder zu entfernen. Diese Flexibilität hat jedoch ihren Preis: Jede Instanz trägt die Mehrkosten dieses Wörterbuchs und verbraucht mehr Speicher.
Das Attribut __slots__, wenn es in einer Klasse definiert ist, weist Python an, für Objekte dieser Klasse kein instanzbezogenes __dict__ zu erstellen. Stattdessen wird eine vordefinierte Menge an Speicher für eine Reihe von vordefinierten Attributen vorab zugewiesen. Dieser Kompromiss – die Aufgabe der dynamischen Attributzuweisung zugunsten eines reduzierten Speicherbedarfs und potenziell schnellerem Attributzugriff – ist das Kernversprechen von __slots__.
# Beispiel für eine Klasse ohne __slots__ class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(1, 2) # print(p.__dict__) # {'x': 1, 'y': 2} # print(f"Memory size without slots: {sys.getsizeof(p)}") # Beispiel für eine Klasse mit __slots__ class SlottedPoint: __slots__ = ('x', 'y') def __init__(self, x, y): self.x = x self.y = y sp = SlottedPoint(1, 2) # print(hasattr(sp, '__dict__')) # False # print(f"Memory size with slots: {sys.getsizeof(sp)}")
Pydantic-Modelle
Pydantic ist eine Bibliothek zur Datenvalidierung und -serialisierung, die Python-Typ-Hints verwendet, um Datenmodelle zu definieren. Es ist bekannt für seine robusten Validierungs-, Serialisierungs- und Deserialisierungsfunktionen. Pydantic-Modelle sind standardmäßig reguläre Python-Klassen, die ihre validierten Daten in einem internen __dict__ speichern. Dies ermöglicht ihnen die nahtlose Integration mit anderen Python-Funktionen und bietet eine flexible Datenstruktur.
from pydantic import BaseModel class User(BaseModel): id: int name: str email: str user = User(id=1, name="Alice", email="alice@example.com") # print(user.__dict__)
ORM-Objekte
Object-Relational Mapper (ORMs) bieten eine objektorientierte Möglichkeit zur Interaktion mit Datenbanken. Bibliotheken wie SQLAlchemy, Django ORM und Peewee mappen Datenbanktabellen auf Python-Klassen und Zeilen auf Python-Objekte. Diese ORM-Objekte tragen oft eine erhebliche Menge an Metadaten, einschließlich ihrer Attribute, Beziehungen und Datenbank-Sitzungsinformationen, die typischerweise in ihrem Instanz-__dict__ oder einer ähnlichen internen Struktur gespeichert sind.
Die Hypothese: Wie __slots__ helfen könnten
Die Theorie besagt, dass wir durch die Anwendung von __slots__ auf Pydantic-Modelle oder ORM-Objekte Folgendes erreichen könnten:
- Reduzierung des Speicherverbrauchs: Jede Instanz würde weniger Speicher benötigen, da der Overhead des
__dict__eliminiert wird. Dies ist besonders relevant, wenn Millionen von Objekten verarbeitet werden. - Verbesserung der Attributzugriffsgeschwindigkeit: Der Zugriff auf Attribute direkt über feste Slots könnte schneller sein als Dictionary-Lookups.
Das Benchmarking-Setup
Um unsere Hypothese zu testen, werden wir Folgendes benchmarken:
- Speicherverbrauch: Wie viel Speicher wird von einer großen Anzahl von Instanzen verbraucht.
- Objektinstanziierungszeit: Wie lange dauert die Erstellung einer großen Anzahl von Objekten.
- Attributzugriffszeit: Wie lange dauert das Lesen von Attributen aus Objekten.
Wir werden Folgendes vergleichen:
- Standard-Pydantic-Modelle vs. Pydantic-Modelle mit
__slots__ - Standard-ORM-ähnliche Objekte vs. ORM-ähnliche Objekte mit
__slots__(Simulation von ORM-Objekten, da eine vollständige ORM-Einrichtung zu viele Variablen hinzufügt, die nicht direkt mit__slots__selbst zusammenhängen.)
Wir verwenden die Funktion sys.getsizeof für eine grobe Schätzung der Objektgröße und das Modul timeit für Leistungsmessungen.
import sys import timeit from pydantic import BaseModel, ConfigDict from memory_profiler import profile # 1. Pydantic-Modelle ohne Slots class UserNoSlots(BaseModel): id: int name: str email: str # 2. Pydantic-Modelle mit Slots class UserWithSlots(BaseModel): model_config = ConfigDict(slots=True) # Pydantic v2 Methode id: int name: str email: str # Für Pydantic v1 würden Sie __slots__ direkt definieren: # __slots__ = ('id', 'name', 'email', '__pydantic_fields_set__', '__pydantic_extra__', ..., etc.) # Hinweis: Pydantic v1 erforderte eine sorgfältigere Verwaltung interner Attribute in Slots. # Pydantic v2 behandelt __slots__ intern besser über model_config. # 3. Einfaches ORM-ähnliches Objekt ohne Slots class ProductNoSlots: def __init__(self, item_id: int, name: str, price: float): self.item_id = item_id self.name = name self.price = price # 4. Einfaches ORM-ähnliches Objekt mit Slots class ProductWithSlots: __slots__ = ('item_id', 'name', 'price') def __init__(self, item_id: int, name: str, price: float): self.item_id = item_id self.name = name self.price = price # Helfer für Speichervergleich (ungefähre Schätzung) def get_total_memory_usage(objects): return sum(sys.getsizeof(obj) for obj in objects) NUM_OBJECTS = 100000 print("--- Pydantic-Benchmarks ---") # Instanziierung und Speicher - Pydantic print("\n[Pydantic Instanziierungszeit und Speicher]") setup_pydantic_noslots = f""" from __main__ import UserNoSlots objects = [UserNoSlots(id=i, name=f"User {{i}}", email=f"user{{i}}@example.com") for i in range({NUM_OBJECTS})] """ time_noslots = timeit.timeit(setup_pydantic_noslots, number=1) print(f"UserNoSlots Instanziierungszeit ({NUM_OBJECTS} Objekte): {time_noslots:.4f} Sekunden") setup_pydantic_withslots = f""" from __main__ import UserWithSlots objects = [UserWithSlots(id=i, name=f"User {{i}}", email=f"user{{i}}@example.com") for i in range({NUM_OBJECTS})] """ time_withslots = timeit.timeit(setup_pydantic_withslots, number=1) print(f"UserWithSlots Instanziierungszeit ({NUM_OBJECTS} Objekte): {time_withslots:.4f} Sekunden") # Speicher (erfordert Erstellung von Objekten außerhalb von timeit zur Messung) user_noslots_list = [UserNoSlots(id=i, name=f"User {i}", email=f"user{i}@example.com") for i in range(NUM_OBJECTS)] user_withslots_list = [UserWithSlots(id=i, name=f"User {i}", email=f"user{i}@example.com") for i in range(NUM_OBJECTS)] print(f"UserNoSlots GesamtSpeicher ({NUM_OBJECTS} Objekte): {get_total_memory_usage(user_noslots_list) / (1024*1024):.2f} MB") print(f"UserWithSlots GesamtSpeicher ({NUM_OBJECTS} Objekte): {get_total_memory_usage(user_withslots_list) / (1024*1024):.2f} MB") # Attributzugriff - Pydantic print("\n[Pydantic Attributzugriff]") access_pydantic_noslots = f""" for user in user_noslots_list: _ = user.id _ = user.name _ = user.email """ time_access_noslots = timeit.timeit(access_pydantic_noslots, globals=globals(), number=10) print(f"UserNoSlots Attributzugriff ({NUM_OBJECTS*3*10} Zugriffe): {time_access_noslots:.4f} Sekunden") access_pydantic_withslots = f""" for user in user_withslots_list: _ = user.id _ = user.name _ = user.email """ time_access_withslots = timeit.timeit(access_pydantic_withslots, globals=globals(), number=10) print(f"UserWithSlots Attributzugriff ({NUM_OBJECTS*3*10} Zugriffe): {time_access_withslots:.4f} Sekunden") print("\n--- ORM-ähnliche Objekt-Benchmarks ---") # Instanziierung und Speicher - ORM-ähnlich print("\n[ORM-ähnliche Instanziierungszeit und Speicher]") setup_orm_noslots = f""" from __main__ import ProductNoSlots objects = [ProductNoSlots(item_id=i, name=f"Product {{i}}", price=float(i)/100) for i in range({NUM_OBJECTS})] """ time_orm_noslots = timeit.timeit(setup_orm_noslots, number=1) print(f"ProductNoSlots Instanziierungszeit ({NUM_OBJECTS} Objekte): {time_orm_noslots:.4f} Sekunden") setup_orm_withslots = f""" from __main__ import ProductWithSlots objects = [ProductWithSlots(item_id=i, name=f"Product {{i}}", price=float(i)/100) for i in range({NUM_OBJECTS})] """ time_orm_withslots = timeit.timeit(setup_orm_withslots, number=1) print(f"ProductWithSlots Instanziierungszeit ({NUM_OBJECTS} Objekte): {time_orm_withslots:.4f} Sekunden") # Speicher (erfordert Erstellung von Objekten außerhalb von timeit zur Messung) product_noslots_list = [ProductNoSlots(item_id=i, name=f"Product {i}", price=float(i)/100) for i in range(NUM_OBJECTS)] product_withslots_list = [ProductWithSlots(item_id=i, name=f"Product {i}", price=float(i)/100) for i in range(NUM_OBJECTS)] print(f"ProductNoSlots GesamtSpeicher ({NUM_OBJECTS} Objekte): {get_total_memory_usage(product_noslots_list) / (1024*1024):.2f} MB") print(f"ProductWithSlots GesamtSpeicher ({NUM_OBJECTS} Objekte): {get_total_memory_usage(product_withslots_list) / (1024*1024):.2f} MB") # Attributzugriff - ORM-ähnlich print("\n[ORM-ähnlicher Attributzugriff]") access_orm_noslots = f""" for product in product_noslots_list: _ = product.item_id _ = product.name _ = product.price """ time_access_orm_noslots = timeit.timeit(access_orm_noslots, globals=globals(), number=10) print(f"ProductNoSlots Attributzugriff ({NUM_OBJECTS*3*10} Zugriffe): {time_access_orm_noslots:.4f} Sekunden") access_orm_withslots = f""" for product in product_withslots_list: _ = product.item_id _ = product.name _ = product.price """ time_access_orm_withslots = timeit.timeit(access_orm_withslots, globals=globals(), number=10) print(f"ProductWithSlots Attributzugriff ({NUM_OBJECTS*3*10} Zugriffe): {time_access_orm_withslots:.4f} Sekunden")
Analyse der Benchmark-Ergebnisse
(Hinweis: Die genauen Zahlen variieren je nach Hardware und Python-Version, aber die Trends sollten konsistent sein.)
Pydantic-Modelle:
- Speicherverbrauch: Wenn Pydantic-Modelle mit
slots=Truekonfiguriert sind (insbesondere ab Version 2, die dies elegant handhabt), kann ein spürbarer Rückgang des Speicherbedarfs verzeichnet werden. Dies liegt daran, dass Pydantic intern die__slots__-Definition verwaltet, um seine eigenen notwendigen internen Attribute zusammen mit Ihren deklarierten Feldern aufzunehmen. Zum Beispiel__pydantic_fields_set__,__pydantic_extra__, etc. Für ein einfaches Pydantic-Modell wird die Aktivierung vonslotswahrscheinlich den Overhead pro Objekt reduzieren. - Instanziierungszeit: Die Instanziierung mit
__slots__kann manchmal etwas langsamer oder vernachlässigbar schneller sein. Der Overhead der Einrichtung von__slots__während der Klassenerstellung und der Prozess der Zuweisung von Werten zu festen Slots anstelle eines dynamischen Wörterbuchs können geringfügige Unterschiede einführen. Die interne Validierungs- und Parsing-Logik von Pydantic dominiert ebenfalls die Instanziierungszeit. - Attributzugriffszeit: Der Attributzugriff auf geslotete Pydantic-Modelle ist oft vernachlässigbar schneller oder etwa gleichwertig. Auch hier können die internen Mechanismen von Pydantic einige der direkten
__slots__-Vorteile verdecken.
Das wichtigste Ergebnis für Pydantic ist, dass __slots__ dann wirksam ist, wenn es sorgfältig zusammen mit den internen Attributen von Pydantic implementiert wird. Glücklicherweise vereinfacht Pydantic v2 mit model_config = ConfigDict(slots=True) dies, indem es die komplexen Teile für Sie übernimmt und oft gute Speicherersparnisse erzielt.
ORM-ähnliche Objekte:
- Speicherverbrauch: Für einfache, reine Python-Objekte (die unsere ORM-ähnlichen Objekte simulieren) bietet
__slots__erhebliche Speicherersparnisse. Der Overhead des__dict__für jede Instanz wird vollständig entfernt, was sich direkt in einem geringeren Speicherbedarf niederschlägt, insbesondere wennNUM_OBJECTSgroß ist. - Instanziierungszeit: Die Erstellung von Objekten mit
__slots__ist oft etwas schneller, da der Interpreter kein instanzbezogenes__dict__für jedes Objekt Erstellen und initialisieren muss. - Attributzugriffszeit: Der Zugriff auf Attribute von gesloteten Objekten ist typischerweise schneller. Anstatt eines Dictionary-Lookups führt Python einen direkten Lookup in einer Array-ähnlichen Struktur fester Größe durch.
Wichtige Überlegungen:
- Unveränderbarkeit: Bei der Verwendung von
__slots__können Sie nach der Erstellung normalerweise keine neuen Attribute dynamisch zu Instanzen hinzufügen. Dies ist ein Kernkompromiss, der möglicherweise nicht für alle Anwendungsfälle geeignet ist, insbesondere bei ORMs, die manchmal Proxy-Attribute oder Lazy-Loading-Beziehungen hinzufügen. - Vererbung:
__slots__kann zu Komplexitäten bei der Vererbung führen. Eine Unterklasse einer gesloteten Klasse kann ihr eigenes__dict__haben, es sei denn, sie definiert ebenfalls__slots__und enthält einen__dict__-Eintrag in ihren eigenen__slots__oder ihre übergeordnete Klasse definiert__slots__mit einem__dict__-Eintrag. - Interne Arbeitsweise von Pydantic: Pydantic-Modelle sind komplexer als einfache Python-Objekte. Sie haben internen Zustand (z. B.
__pydantic_fields_set__, Validatoren, berechnete Eigenschaften). Damit__slots__effektiv funktioniert, muss Pydantic auch diese internen Attributeslotten. Wie bereits erwähnt, hat Pydantic v2 dies mit der OptionConfigDict(slots=True)übernommen, was es weitaus praktikabler und vorteilhafter macht, als__slots__manuell in Pydantic v1 zu definieren. - ORM-Komplexität: Echte ORM-Objekte (wie SQLAlchemy-Modelle) sind hochdynamisch und verwalten ihren Zustand auf komplexe Weise, oft unter Verwendung von Deskriptor-Protokollen, Proxy-Objekten und Lazy Loading. Die direkte Anwendung von
__slots__auf eine ORM-Klasse kann deren interne Mechanismen stören oder zu unerwartetem Verhalten führen. ORM-Entwickler machen__slots__aus diesem Grund selten als konfigurierbare Option verfügbar. Der Nutzen von__slots__könnte durch die Notwendigkeit des ORMs für dynamisches Attributmanagement vollständig aufgehoben oder sogar nachteilig sein.
Fazit
Für reine Python-Objekte oder benutzerdefinierte Datenstrukturen kann die Verwendung von __slots__ erhebliche Speicherersparnisse und moderate Leistungsverbesserungen beim Attributzugriff und der Instanziierung erzielen, was es zu einer wertvollen Optimierungstechnik für große Sammlungen einfacher, unveränderlicher Objekte macht. Für Pydantic-Modelle, insbesondere mit Pydantic v2s ConfigDict(slots=True), bietet es echte Speicheroptimierungen ohne signifikante Leistungseinbußen, was es zu einer praktikablen Option für speicherbeschränkte Anwendungen macht. Die Anwendung von __slots__ auf ORM-Objekte wird jedoch im Allgemeinen aufgrund des komplexen internen Zustandsmanagements und der dynamischen Natur von ORMs nicht empfohlen, bei denen die Vorteile wahrscheinlich nicht die potenziellen Störungen aufwiegen.

