PEP 509 – Füge eine private Version zu dict hinzu
- Autor:
- Victor Stinner <vstinner at python.org>
- Status:
- Abgelöst
- Typ:
- Standards Track
- Erstellt:
- 04-Jan-2016
- Python-Version:
- 3.6
- Post-History:
- 08-Jan-2016, 11-Jan-2016, 14-Apr-2016, 19-Apr-2016
- Ersetzt-Durch:
- 699
- Resolution:
- Python-Dev Nachricht
Zusammenfassung
Füge eine neue private Version zum eingebauten Typ dict hinzu, die bei jeder Dictionary-Erstellung und jeder Dictionary-Änderung inkrementiert wird, um schnelle Guards für Namespaces zu implementieren.
Begründung
In Python wird der eingebaute Typ dict von vielen Instruktionen verwendet. Zum Beispiel sucht die Instruktion LOAD_GLOBAL nach einer Variablen im globalen Namespace oder im Builtins-Namespace (zwei dict-Lookups). Python verwendet dict für den Builtins-Namespace, den globalen Namespace, Typ-Namespaces, Instanz-Namespaces usw. Der lokale Namespace (Funktions-Namespace) wird normalerweise zu einem Array optimiert, kann aber auch ein dict sein.
Python ist schwer zu optimieren, da fast alles veränderlich ist: eingebaute Funktionen, Funktionscode, globale Variablen, lokale Variablen, ... können zur Laufzeit geändert werden. Die Implementierung von Optimierungen, die die Python-Semantik berücksichtigen, erfordert die Erkennung von Änderungen: wir nennen diese Prüfungen "Guards".
Die Beschleunigung von Optimierungen hängt von der Geschwindigkeit der Guard-Prüfungen ab. Dieses PEP schlägt vor, Dictionaries eine private Version hinzuzufügen, um schnelle Guards für Namespaces zu implementieren.
Dictionary-Lookups können übersprungen werden, wenn sich die Version nicht ändert, was für die meisten Namespaces der übliche Fall ist. Die Version ist global eindeutig, daher ist die Überprüfung der Version auch ausreichend, um zu verifizieren, dass das Namespace-Dictionary nicht durch ein neues Dictionary ersetzt wurde.
Wenn sich die Dictionary-Version nicht ändert, hängt die Performance eines Guards nicht von der Anzahl der beobachteten Dictionary-Einträge ab: die Komplexität beträgt O(1).
Beispiel für eine Optimierung: Kopieren des Werts einer globalen Variablen in Funktionskonstanten. Diese Optimierung erfordert einen Guard auf der globalen Variable, um zu prüfen, ob sie nach dem Kopieren geändert wurde. Wenn die globale Variable nicht geändert wird, verwendet die Funktion die gecachte Kopie. Wenn die globale Variable geändert wird, verwendet die Funktion einen regulären Lookup und deoptimiert möglicherweise auch die Funktion (um den Overhead der Guard-Prüfung für zukünftige Funktionsaufrufe zu entfernen).
Siehe das PEP 510 – Spezialisiere Funktionen mit Guards für konkrete Nutzung von Guards zur Spezialisierung von Funktionen und für eine allgemeinere Begründung von statischen Python-Optimierern.
Guard Beispiel
Pseudocode eines schnellen Guards zur Überprüfung, ob ein Dictionary-Eintrag geändert (erstellt, aktualisiert oder gelöscht) wurde, unter Verwendung einer hypothetischen Funktion dict_get_version(dict)
UNSET = object()
class GuardDictKey:
def __init__(self, dict, key):
self.dict = dict
self.key = key
self.value = dict.get(key, UNSET)
self.version = dict_get_version(dict)
def check(self):
"""Return True if the dictionary entry did not change
and the dictionary was not replaced."""
# read the version of the dictionary
version = dict_get_version(self.dict)
if version == self.version:
# Fast-path: dictionary lookup avoided
return True
# lookup in the dictionary
value = self.dict.get(self.key, UNSET)
if value is self.value:
# another key was modified:
# cache the new dictionary version
self.version = version
return True
# the key was modified
return False
Verwendung der dict-Version
Beschleunigung von Methodenaufrufen
Yury Selivanov schrieb einen Patch zur Optimierung von Methodenaufrufen. Der Patch hängt von dem Patch „Implementierung eines pro-Opcode-Caches in ceval“ ab, der Dictionary-Versionen zur Invalidierung des Caches benötigt, falls das Globals-Dictionary oder das Builtins-Dictionary geändert wurde.
Der Cache erfordert auch, dass die Dictionary-Version global eindeutig ist. Es ist möglich, eine Funktion in einem Namespace zu definieren und sie in einem anderen Namespace aufzurufen, z.B. mit exec() mit dem globals-Parameter. In diesem Fall wurde das Globals-Dictionary ersetzt und der Cache muss ebenfalls invalidiert werden.
Spezialisierte Funktionen mit Guards
PEP 510 schlägt eine API zur Unterstützung spezialisierter Funktionen mit Guards vor. Sie ermöglicht die Implementierung statischer Optimierer für Python, ohne die Python-Semantik zu verletzen.
Der fatoptimizer des FAT Python-Projekts ist ein Beispiel für einen statischen Python-Optimierer. Er implementiert viele Optimierungen, die Guards für Namespaces erfordern.
- Aufruf reiner Builtins: Um
len("abc")durch3zu ersetzen, sind Guards fürbuiltins.__dict__['len']undglobals()['len']erforderlich. - Schleifenentrollung: Um die Schleife
for i in range(...): ...zu entrollen, sind Guards fürbuiltins.__dict__['range']undglobals()['range']erforderlich. - usw.
Pyjion
Laut Brett Cannon, einem der beiden Hauptentwickler von Pyjion, kann Pyjion von der Dictionary-Version profitieren, um Optimierungen zu implementieren.
Pyjion ist ein JIT-Compiler für Python, der auf CoreCLR (Microsoft .NET Core Runtime) basiert.
Cython
Cython kann von der Dictionary-Version profitieren, um Optimierungen zu implementieren.
Cython ist ein optimierender statischer Compiler sowohl für die Programmiersprache Python als auch für die erweiterte Cython-Programmiersprache.
Unladen Swallow
Auch wenn die Dictionary-Version nicht explizit erwähnt wurde, war die Optimierung von Globals- und Builtins-Lookups Teil des Unladen Swallow-Plans: "Implement one of the several proposed schemes for speeding lookups of globals and builtins." (Quelle: Unladen Swallow ProjectPlan).
Unladen Swallow ist ein Fork von CPython 2.6.1, der einen JIT-Compiler hinzufügt, der mit LLVM implementiert ist. Das Projekt wurde 2011 eingestellt: Unladen Swallow Retrospective.
Änderungen
Füge ein Feld ma_version_tag zur Struktur PyDictObject mit dem C-Typ PY_UINT64_T, 64-Bit-Ganzzahl ohne Vorzeichen, hinzu. Füge außerdem eine globale Dictionary-Version hinzu.
Jedes Mal, wenn ein Dictionary erstellt wird, wird die globale Version inkrementiert und die Dictionary-Version wird mit der globalen Version initialisiert.
Jedes Mal, wenn der Inhalt des Dictionary geändert wird, muss die globale Version inkrementiert und auf die Dictionary-Version kopiert werden. Dictionary-Methoden, die ihren Inhalt ändern können:
clear()pop(key)popitem()setdefault(key, value)__delitem__(key)__setitem__(key, value)update(...)
Die Entscheidung, ob die Version erhöht wird oder nicht, wenn eine Dictionary-Methode ihren Inhalt nicht ändert, bleibt der Python-Implementierung überlassen. Eine Python-Implementierung kann entscheiden, die Version nicht zu erhöhen, um Dictionary-Lookups in Guards zu vermeiden. Beispiele für Fälle, in denen Dictionary-Methoden ihren Inhalt nicht ändern:
clear(), wenn das dict bereits leer istpop(key), wenn der Schlüssel nicht existiertpopitem(), wenn das dict leer istsetdefault(key, value), wenn der Schlüssel bereits existiert__delitem__(key), wenn der Schlüssel nicht existiert__setitem__(key, value), wenn der neue Wert identisch mit dem aktuellen Wert istupdate(), wenn ohne Argument aufgerufen oder wenn neue Werte identisch mit aktuellen Werten sind
Das Setzen eines Schlüssels auf einen neuen Wert, der dem alten Wert entspricht, wird ebenfalls als eine Operation betrachtet, die den Inhalt des Dictionary ändert.
Zwei verschiedene leere Dictionaries müssen eine unterschiedliche Version haben, um ein Dictionary nur anhand seiner Version identifizieren zu können. Dies ermöglicht es einem Guard zu überprüfen, ob ein Namespace nicht ersetzt wurde, ohne eine starke Referenz auf das Dictionary zu speichern. Die Verwendung einer geliehenen Referenz funktioniert nicht: wenn das alte Dictionary zerstört wird, ist es möglich, dass ein neues Dictionary an derselben Speicheradresse allokiert wird. Übrigens unterstützen Dictionaries keine Weak References.
Die Versionserhöhung muss atomar sein. In CPython schützt der Global Interpreter Lock (GIL) bereits dict-Methoden, um Änderungen atomar zu machen.
Beispiel unter Verwendung einer hypothetischen Funktion dict_get_version(dict)
>>> d = {}
>>> dict_get_version(d)
100
>>> d['key'] = 'value'
>>> dict_get_version(d)
101
>>> d['key'] = 'new value'
>>> dict_get_version(d)
102
>>> del d['key']
>>> dict_get_version(d)
103
Das Feld heißt ma_version_tag und nicht ma_version, um darauf hinzuweisen, dass es mit version_tag == old_version_tag verglichen werden soll, und nicht mit version <= old_version, was nach einem Integer-Überlauf falsch wird.
Abwärtskompatibilität
Da die Struktur PyDictObject kein Teil der stabilen ABI ist und die neue Dictionary-Version nicht auf Python-Ebene exponiert wird, sind die Änderungen abwärtskompatibel.
Implementierung und Performance
Das Issue #26058: PEP 509: Add ma_version_tag to PyDictObject enthält einen Patch, der dieses PEP implementiert.
Bei pybench und timeit Microbenchmarks scheint der Patch keine zusätzlichen Kosten für Dictionary-Operationen zu verursachen. Zum Beispiel dauern die folgenden timeit Microbenchmarks 318 Nanosekunden vor und nach der Änderung.
python3.6 -m timeit 'd={1: 0}; d[2]=0; d[3]=0; d[4]=0; del d[1]; del d[2]; d.clear()'
Wenn sich die Version nicht ändert, benötigt PyDict_GetItem() 14,8 ns für einen Dictionary-Lookup, während eine Guard-Prüfung nur 3,8 ns benötigt. Darüber hinaus kann ein Guard mehrere Schlüssel beobachten. Zum Beispiel kosten bei einer Optimierung, die 10 globale Variablen in einer Funktion verwendet, 10 Dictionary-Lookups 148 ns, während der Guard bei unveränderter Version immer noch nur 3,8 ns kostet (39x schneller).
Das fat-Modul implementiert solche Guards: fat.GuardDict basiert auf der Dictionary-Version.
Integer-Überlauf
Die Implementierung verwendet den C-Typ PY_UINT64_T zur Speicherung der Version: eine 64-Bit-Ganzzahl ohne Vorzeichen. Der C-Code verwendet version++. Bei einem Integer-Überlauf wird die Version gemäß dem C-Standard auf 0 zurückgesetzt (und wird dann weiter inkrementiert).
Nach einem Integer-Überlauf kann ein Guard erfolgreich sein, obwohl der beobachtete Dictionary-Schlüssel geändert wurde. Der Fehler tritt bei einer Guard-Prüfung nur auf, wenn seit der letzten Guard-Prüfung genau 2 ** 64 Dictionary-Erstellungen oder -Änderungen stattgefunden haben.
Wenn ein Dictionary jede Nanosekunde geändert wird, dauern 2 ** 64 Änderungen länger als 584 Jahre. Mit einer 32-Bit-Version dauert dies nur 4 Sekunden. Deshalb wird auch auf 32-Bit-Systemen ein 64-Bit-Signatur-Typ verwendet. Ein Dictionary-Lookup auf C-Ebene dauert 14,8 ns.
Ein Fehlerrisiko von einmal in 584 Jahren ist akzeptabel.
Alternativen
Exponiere die Version auf Python-Ebene als schreibgeschütztes __version__ Property
Die erste Version des PEP schlug vor, die Dictionary-Version als schreibgeschütztes __version__-Property auf Python-Ebene zu exponieren und das Property auch zu collections.UserDict hinzuzufügen (da dieser Typ die dict-API nachahmen muss).
Es gibt mehrere Probleme:
- Um konsistent zu sein und böse Überraschungen zu vermeiden, muss die Version zu allen Mapping-Typen hinzugefügt werden. Die Implementierung eines neuen Mapping-Typs würde zusätzlichen Aufwand ohne Nutzen bedeuten, da die Version praktisch nur für den
dict-Typ benötigt wird. - Alle Python-Implementierungen müssten dieses neue Property implementieren, was anderen Implementierungen mehr Arbeit macht, obwohl sie die Dictionary-Version möglicherweise gar nicht nutzen.
- Die Exponierung der Dictionary-Version auf Python-Ebene kann zu falschen Annahmen über die Performance führen. Das Prüfen von
dict.__version__auf Python-Ebene ist nicht schneller als ein Dictionary-Lookup. Ein Dictionary-Lookup in Python kostet 48,7 ns und die Überprüfung der Version kostet 47,5 ns, der Unterschied beträgt nur 1,2 ns (3%).$ python3.6 -m timeit -s 'd = {str(i):i for i in range(100)}' 'd["33"] == 33' 10000000 loops, best of 3: 0.0487 usec per loop $ python3.6 -m timeit -s 'd = {str(i):i for i in range(100)}' 'd.__version__ == 100' 10000000 loops, best of 3: 0.0475 usec per loop - Das
__version__kann bei Integer-Überlauf „eingewickelt“ werden. Dies ist fehleranfällig: die Verwendung vondict.__version__ <= guard_versionist falsch, stattdessen mussdict.__version__ == guard_versionverwendet werden, um das Fehlerrisiko bei Integer-Überlauf zu verringern (auch wenn der Integer-Überlauf in der Praxis unwahrscheinlich ist).
Obligatorisches „Bikeshedding“ auf dem Property-Namen
__cache_token__: Name vorgeschlagen von Alyssa Coghlan, Name abgeleitet von abc.get_cache_token().__version____version_tag____timestamp__
Füge eine Version zu jedem dict-Eintrag hinzu
Eine einzige Version pro Dictionary erfordert das Beibehalten einer starken Referenz auf den Wert, was den Wert länger als erwartet lebendig halten kann. Wenn wir auch eine Version pro Dictionary-Eintrag hinzufügen, kann der Guard nur die Eintrag-Version (eine einfache Ganzzahl) speichern, um die starke Referenz auf den Wert zu vermeiden: nur starke Referenzen auf das Dictionary und den Schlüssel werden benötigt.
Änderungen: Füge ein Feld me_version_tag zur Struktur PyDictKeyEntry hinzu. Das Feld hat den C-Typ PY_UINT64_T. Wenn ein Schlüssel erstellt oder geändert wird, wird die Eintrag-Version auf die Dictionary-Version gesetzt, die bei jeder Änderung (Erstellung, Änderung, Löschung) inkrementiert wird.
Pseudocode eines schnellen Guards zur Überprüfung, ob ein Dictionary-Schlüssel geändert wurde, unter Verwendung hypothetischer Funktionen dict_get_version(dict) und dict_get_entry_version(dict)
UNSET = object()
class GuardDictKey:
def __init__(self, dict, key):
self.dict = dict
self.key = key
self.dict_version = dict_get_version(dict)
self.entry_version = dict_get_entry_version(dict, key)
def check(self):
"""Return True if the dictionary entry did not change
and the dictionary was not replaced."""
# read the version of the dictionary
dict_version = dict_get_version(self.dict)
if dict_version == self.version:
# Fast-path: dictionary lookup avoided
return True
# lookup in the dictionary to read the entry version
entry_version = get_dict_key_version(dict, key)
if entry_version == self.entry_version:
# another key was modified:
# cache the new dictionary version
self.dict_version = dict_version
self.entry_version = entry_version
return True
# the key was modified
return False
Der Hauptnachteil dieser Option ist die Auswirkung auf den Speicherbedarf. Sie erhöht die Größe jedes Dictionary-Eintrags, sodass die Überbelastung von der Anzahl der Buckets (Dictionary-Einträge, verwendet oder nicht verwendet) abhängt. Zum Beispiel erhöht sie die Größe jedes Dictionary-Eintrags um 8 Bytes auf 64-Bit-Systemen.
In Python ist der Speicherbedarf wichtig, und der Trend geht dahin, ihn zu reduzieren. Beispiele:
Füge einen neuen dict-Subtyp hinzu
Füge einen neuen Typ verdict hinzu, einen Subtyp von dict. Wenn Guards benötigt werden, verwende verdict für Namespaces (Modul-Namespace, Typ-Namespace, Instanz-Namespace usw.) anstelle von dict.
Lasse den Typ dict unverändert, um keinen Overhead (CPU, Speicherbedarf) hinzuzufügen, wenn Guards nicht verwendet werden.
Technisches Problem: Viel C-Code im Umlauf, einschließlich des CPython-Kerns, der den exakten Typ dict erwartet. Probleme:
exec()benötigt eindictfür Globals und Locals. Viel Code verwendetglobals={}. Es ist nicht möglich, dasdictin einendict-Subtyp umzuwandeln, da der Aufrufer erwartet, dass der Parameterglobalsgeändert wird (dictist veränderlich).- C-Funktionen rufen direkt
PyDict_xxx()-Funktionen auf, anstattPyObject_xxx()aufzurufen, wenn das Objekt eindict-Subtyp ist. - Die Prüfung
PyDict_CheckExact()schlägt beidict-Subtypen fehl, während einige Funktionen den exakten Typdictbenötigen. Python/ceval.cunterstützt Dict-Subtypen für Namespaces nicht vollständig.
Das exec()-Problem ist ein Blockierungsproblem.
Andere Probleme
- Der Garbage Collector hat speziellen Code, um
dict-Instanzen zu „entfolgen“. Wenn eindict-Subtyp für Namespaces verwendet wird, kann der Garbage Collector einige Referenzzyklen nicht brechen. - Einige Funktionen haben einen Fast-Path für
dict, der fürdict-Subtypen nicht genommen würde, und dies würde Python ein wenig langsamer machen.
Vorhandene Lösungen
Methoden-Cache und Typ-Versions-Tag
Im Jahr 2007 schrieb Armin Rigo einen Patch zur Implementierung eines Methoden-Caches. Er wurde in Python 2.6 integriert. Der Patch fügt Typen (die Struktur PyTypeObject) einen „type attribute cache version tag“ (tp_version_tag) und ein Flag „valid version tag“ hinzu.
Der Typ-Versions-Tag wird nicht auf Python-Ebene exponiert.
Der Versionstag hat den C-Typ unsigned int. Der Cache ist eine globale Hash-Tabelle mit 4096 Einträgen, die von allen Typen gemeinsam genutzt wird. Der Cache ist global, um ihn „schnell, mit geringem Speicherbedarf und deterministisch zu machen und ihn einfach zu invalidieren“. Jeder Cache-Eintrag hat einen Versionstag. Ein globaler Versionstag wird verwendet, um den nächsten Versionstag zu erstellen; er hat ebenfalls den C-Typ unsigned int.
Standardmäßig ist das Flag „valid version tag“ eines Typs gelöscht, um anzuzeigen, dass der Versionstag ungültig ist. Wenn die erste Methode des Typs gecached wird, werden der Versionstag und das Flag „valid version tag“ gesetzt. Wenn ein Typ geändert wird, werden das Flag „valid version tag“ des Typs und seiner Unterklassen gelöscht. Später, wenn ein Cache-Eintrag dieser Typen verwendet wird, wird der Eintrag entfernt, da sein Versionstag veraltet ist.
Bei Integer-Überlauf wird der gesamte Cache gelöscht und der globale Versionstag wird auf 0 zurückgesetzt.
Siehe Method cache (issue #1685986) und Armin’s method cache optimization updated for Python 2.6 (issue #1700288).
Globale / Builtin-Cache
Im Jahr 2010 schlug Antoine Pitrou einen Globals / builtins cache (issue #10401) vor, der ein privates Feld ma_version zur Struktur PyDictObject (Typ dict) hinzufügt. Das Feld hat den C-Typ Py_ssize_t.
Der Patch fügt Funktionen und Frames einen „globalen und builtin-Cache hinzu und ändert die Instruktionen LOAD_GLOBAL und STORE_GLOBAL, um den Cache zu verwenden.
Die Änderung an der Struktur PyDictObject ist der dieses PEP sehr ähnlich.
Gecachter Globals+Builtins Lookup
Im Jahr 2006 schlug Andrea Griffini einen Patch zur Implementierung einer Cached globals+builtins lookup optimization vor. Der Patch fügt der Struktur PyDictObject (Typ dict) ein privates Feld timestamp hinzu. Das Feld hat den C-Typ size_t.
Thread auf python-dev: About dictionary lookup caching (Dezember 2006).
Guard gegen Änderung des dict während der Iteration
Im Jahr 2013 schlug Serhiy Storchaka Guard against changing dict during iteration (issue #19332) vor, der ein Feld ma_count zur Struktur PyDictObject (Typ dict) hinzufügt. Das Feld hat den C-Typ size_t. Dieses Feld wird inkrementiert, wenn das Dictionary geändert wird.
PySizer
PySizer: ein Speicherprofiler für Python, ein Google Summer of Code 2005 Projekt von Nick Smallbone.
Dieses Projekt hat einen Patch für CPython 2.4, der die Felder key_time und value_time zu Dictionary-Einträgen hinzufügt. Er verwendet einen globalen, prozessweiten Zähler für Dictionaries, der bei jeder Änderung eines Dictionaries inkrementiert wird. Die Zeiten werden verwendet, um zu entscheiden, wann Kindobjekte zum ersten Mal in ihren Elternobjekten erschienen sind.
Diskussion
Threads in den Mailinglisten
- python-dev: Updated PEP 509
- python-dev: RFC: PEP 509: Add a private version to dict
- python-dev: PEP 509: Add a private version to dict (Januar 2016)
- python-ideas: RFC: PEP: Add dict.__version__ (Januar 2016)
Akzeptanz
Das PEP wurde am 2016-09-07 von Guido van Rossum akzeptiert. Die Implementierung des PEP wurde seitdem in das Repository übernommen.
Urheberrecht
Dieses Dokument wurde gemeinfrei erklärt.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0509.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT