PEP 709 – Inlined comprehensions
- Autor:
- Carl Meyer <carl at oddbird.net>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Typ:
- Standards Track
- Erstellt:
- 24. Feb. 2023
- Python-Version:
- 3.12
- Post-History:
- 25. Feb. 2023
- Resolution:
- Discourse-Nachricht
Zusammenfassung
Comprehensions werden derzeit als verschachtelte Funktionen kompiliert, was eine Isolierung der Iterationsvariable der Comprehension ermöglicht, aber zur Laufzeit ineffizient ist. Diese PEP schlägt vor, Listen-, Dictionary- und Set-Comprehensions in den Code einzufügen, in dem sie definiert sind, und die erwartete Isolierung durch das Pushen/Poppen von kollidierenden Locals auf dem Stack bereitzustellen. Diese Änderung macht Comprehensions deutlich schneller: bis zu 2x schneller für ein Mikrobenchmark einer alleinstehenden Comprehension, was einer Geschwindigkeitssteigerung von 11 % für ein Beispiel-Benchmark entspricht, das aus realem Code abgeleitet ist und Comprehensions im Kontext realer Aufgaben intensiv nutzt.
Motivation
Comprehensions sind ein beliebtes und weit verbreitetes Feature der Python-Sprache. Die verschachtelte Funktionskompilierung von Comprehensions optimiert die Komplexität des Compilers auf Kosten der Leistung des Benutzercodes. Es ist möglich, nahezu identische Semantiken (siehe Rückwärtskompatibilität) mit deutlich besserer Laufzeitperformance für alle Benutzer von Comprehensions zu bieten, mit nur einem geringen Anstieg der Compilerkomplexität.
Begründung
Inlining ist eine gängige Compiler-Optimierung in vielen Sprachen. Generalisiertes Inlining von Funktionsaufrufen zur Compile-Zeit in Python ist fast unmöglich, da Aufrufziele zur Laufzeit gepatcht werden können. Comprehensions sind ein Sonderfall, bei dem wir ein statisch im Compiler bekanntes Aufrufziel haben, das weder gepatcht (abgesehen von undokumentiertem und nicht unterstütztem direkten Eingriff in Bytecode) noch entkommen kann.
Inlining ermöglicht auch, dass andere Compiler-Optimierungen von Bytecode effektiver sind, da sie nun den Comprehension-Bytecode „durchschauen“ können, anstatt dass es sich um einen undurchsichtigen Aufruf handelt.
Normalerweise wäre eine Leistungsverbesserung kein Grund für eine PEP. In diesem Fall führt die einfachste und effizienteste Implementierung zu einigen benutzerseitig sichtbaren Effekten, daher handelt es sich nicht nur um eine Leistungsverbesserung, sondern um eine (kleine) Änderung der Sprache.
Spezifikation
Gegeben eine einfache Comprehension
def f(lst):
return [x for x in lst]
Der Compiler gibt derzeit den folgenden Bytecode für die Funktion f aus
1 0 RESUME 0
2 2 LOAD_CONST 1 (<code object <listcomp> at 0x...)
4 MAKE_FUNCTION 0
6 LOAD_FAST 0 (lst)
8 GET_ITER
10 CALL 0
20 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x...>:
2 0 RESUME 0
2 BUILD_LIST 0
4 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 4 (to 18)
10 STORE_FAST 1 (x)
12 LOAD_FAST 1 (x)
14 LIST_APPEND 2
16 JUMP_BACKWARD 6 (to 6)
>> 18 END_FOR
20 RETURN_VALUE
Der Bytecode für die Comprehension befindet sich in einem separaten Codeobjekt. Jedes Mal, wenn f() aufgerufen wird, wird ein neues, einmalig verwendbares Funktionsobjekt alloziert (durch MAKE_FUNCTION), aufgerufen (wodurch ein neuer Frame auf dem Python-Stack alloziert und dann zerstört wird) und dann sofort verworfen.
Unter dieser PEP gibt der Compiler stattdessen den folgenden Bytecode für f() aus
1 0 RESUME 0
2 2 LOAD_FAST 0 (lst)
4 GET_ITER
6 LOAD_FAST_AND_CLEAR 1 (x)
8 SWAP 2
10 BUILD_LIST 0
12 SWAP 2
>> 14 FOR_ITER 4 (to 26)
18 STORE_FAST 1 (x)
20 LOAD_FAST 1 (x)
22 LIST_APPEND 2
24 JUMP_BACKWARD 6 (to 14)
>> 26 END_FOR
28 SWAP 2
30 STORE_FAST 1 (x)
32 RETURN_VALUE
Es gibt kein separates Codeobjekt mehr, keine Erzeugung eines einmalig verwendbaren Funktionsobjekts und keine Notwendigkeit, einen Python-Frame zu erstellen und zu zerstören.
Die Isolierung der Iterationsvariable x wird durch die Kombination des neuen Opcodes LOAD_FAST_AND_CLEAR an Offset 6 erreicht, der jeden äußeren Wert von x vor der Ausführung der Comprehension auf dem Stack speichert, und 30 STORE_FAST, der den äußeren Wert von x (falls vorhanden) nach der Ausführung der Comprehension wiederherstellt.
Wenn die Comprehension Variablen aus dem äußeren Geltungsbereich zugreift, vermeidet das Inlining die Notwendigkeit, diese Variablen in einer Zelle abzulegen, und ermöglicht stattdessen, dass die Comprehension (und alle anderen Codes in der äußeren Funktion) auf sie als normale lokale Variablen zugreift. Dies bietet weitere Leistungssteigerungen.
In einigen Fällen kann die Iterationsvariable der Comprehension eine globale Variable, eine Cellvar oder eine Freevar sein, anstatt einer einfachen lokalen Variable im äußeren Geltungsbereich. In diesen Fällen pusht und poppt der Compiler intern auch die Geltungsbereichsinformationen für die Variable beim Ein- und Austritt aus der Comprehension, sodass die Semantik erhalten bleibt. Wenn die Variable beispielsweise außerhalb der Comprehension global ist, wird LOAD_GLOBAL immer noch dort verwendet, wo sie außerhalb der Comprehension referenziert wird, aber LOAD_FAST / STORE_FAST werden innerhalb der Comprehension verwendet. Wenn es sich um eine Cellvar/Freevar außerhalb der Comprehension handelt, ändern sich die für das Speichern/Wiederherstellen verwendeten LOAD_FAST_AND_CLEAR / STORE_FAST nicht (es gibt kein LOAD_DEREF_AND_CLEAR), was bedeutet, dass die gesamte Zelle (nicht nur der darin enthaltene Wert) gespeichert/wiederhergestellt wird, sodass die Comprehension nicht in die äußere Zelle schreibt.
Comprehensions im Modul- oder Klassengeltungsbereich werden ebenfalls inline ausgeführt. In diesem Fall führt die Comprehension innerhalb der Comprehension nur die Verwendung von lokalen Variablen (LOAD_FAST / STORE_FAST) für die Iterationsvariable der Comprehension ein, in einem Geltungsbereich, in dem ansonsten nur LOAD_NAME / STORE_NAME verwendet würden, wodurch die Isolierung erhalten bleibt.
Effektiv führen Comprehensions einen Unter-Geltungsbereich ein, in dem lokale Variablen vollständig isoliert sind, jedoch ohne die Leistungskosten oder den Stack-Frame-Eintritt eines Aufrufs.
Generatorausdrücke werden in der Referenzimplementierung dieser PEP derzeit nicht inline ausgeführt. Zukünftig können einige Generatorausdrücke inline ausgeführt werden, sofern das zurückgegebene Generatorobjekt nicht leakt.
Asynchrone Comprehensions werden genauso inline ausgeführt wie synchrone; keine besondere Behandlung erforderlich.
Abwärtskompatibilität
Comprehension-Inlining wird die folgenden sichtbaren Verhaltensänderungen verursachen. Es waren keine Änderungen in der Standardbibliothek oder der Testsuite erforderlich, um sich an diese Änderungen in der Implementierung anzupassen, was darauf hindeutet, dass die Auswirkungen auf den Benutzer-Code wahrscheinlich minimal sind.
Spezialisierte Werkzeuge, die auf undokumentierten Details der Compiler-Bytecode-Ausgabe basieren, können natürlich in anderer Weise als unten aufgeführt betroffen sein, aber diese Werkzeuge müssen sich bereits an Bytecode-Änderungen in jeder Python-Version anpassen.
locals() schließt äußere Variablen ein
Der Aufruf von locals() innerhalb einer Comprehension schließt alle lokalen Variablen der Funktion ein, die die Comprehension enthält. Z.B. gegeben die folgende Funktion
def f(lst):
return [locals() for x in lst]
Der Aufruf von f([1]) im aktuellen Python gibt zurück
[{'.0': <list_iterator object at 0x7f8d37170460>, 'x': 1}]
wobei .0 ein internes Implementierungsdetail ist: das synthetische einzige Argument der Comprehension „Funktion“.
Unter dieser PEP wird stattdessen zurückgegeben
[{'lst': [1], 'x': 1}]
Dies schließt nun die äußere Variable lst als lokale Variable ein und eliminiert die synthetische .0.
Kein Comprehension-Frame in Tracebacks
Unter dieser PEP hat eine Comprehension keinen eigenen dedizierten Frame mehr in einem Stack-Trace. Z.B. gegeben diese Funktion
def g():
raise RuntimeError("boom")
def f():
return [g() for x in [1]]
Der Aufruf von f() führt derzeit zu folgendem Traceback
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in f
File "<stdin>", line 5, in <listcomp>
File "<stdin>", line 2, in g
RuntimeError: boom
Beachten Sie den dedizierten Frame für <listcomp>.
Unter dieser PEP sieht der Traceback stattdessen so aus
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in f
File "<stdin>", line 2, in g
RuntimeError: boom
Es gibt keinen zusätzlichen Frame mehr für die Listen-Comprehension. Der Frame für die Funktion f hat jedoch die korrekte Zeilennummer für die Comprehension, sodass dies den Traceback einfach kompakter macht, ohne nützliche Informationen zu verlieren.
Es ist theoretisch möglich, dass Code, der Warnungen mit dem Argument stacklevel verwendet, eine Verhaltensänderung aufgrund der Änderung des Frame-Stacks beobachten könnte. In der Praxis scheint dies jedoch unwahrscheinlich. Es würde eine Warnung erfordern, die in Bibliotheks-Code ausgelöst wird, der immer über eine Comprehension in derselben Bibliothek aufgerufen wird, wobei die Warnung ein stacklevel von 3+ verwendet, um die Comprehension und ihre enthaltende Funktion zu umgehen und auf einen aufrufenden Frame außerhalb der Bibliothek zu verweisen. In einem solchen Szenario wäre es normalerweise einfacher und zuverlässiger, die Warnung näher am aufrufenden Code auszulösen und weniger Frames zu umgehen.
Tracing/Profiling zeigt keinen Aufruf/Rücksprung mehr für die Comprehension
Natürlich, da Listen-/Dict-/Set-Comprehensions nicht mehr als Aufruf einer verschachtelten Funktion implementiert werden, werden auch das Tracing/Profiling mit sys.settrace oder sys.setprofile nicht mehr widerspiegeln, dass ein Aufruf und eine Rückkehr stattgefunden haben.
Auswirkungen auf andere Python-Implementierungen
Nach Kommentaren von Vertretern von GraalPython und PyPy würden sie wahrscheinlich das Bedürfnis verspüren, sich an die hier beobachtbaren Verhaltensänderungen anzupassen, angesichts der Wahrscheinlichkeit, dass jemand zu irgendeinem Zeitpunkt von ihnen abhängen wird. Somit wären bei gleichen Bedingungen weniger beobachtbare Änderungen weniger Arbeit. Aber diese Änderungen (zumindest im Fall von GraalPython) sollten „ohne viel Kopfzerbrechen“ zu bewältigen sein.
Wie man das lehrt
Es ist nicht intuitiv offensichtlich, dass die Comprehension-Syntax die Erzeugung und den Aufruf einer verschachtelten Funktion bewirken wird oder sollte. Für neue Benutzer, die noch nicht an das frühere Verhalten gewöhnt sind, vermute ich, dass das neue Verhalten dieser PEP intuitiver sein und weniger Erklärung erfordern wird. („Warum gibt es eine Zeile <listcomp> in meinem Traceback, wenn ich keine solche Funktion definiert habe? Was ist diese Variable .0, die ich in locals() sehe?)
Sicherheitsimplikationen
Keine bekannt.
Referenzimplementierung
Diese PEP hat eine Referenzimplementierung in Form von einem PR gegen den CPython-Main-Branch, der alle Tests besteht.
Die Referenzimplementierung führt das Mikrobenchmark ./python -m pyperf timeit -s 'l = [1]' '[x for x in l]' 1,96x schneller aus als der main-Branch (in einem Build, der mit --enable-optimizations kompiliert wurde).
Die Referenzimplementierung führt das Benchmark comprehensions in der pyperformance-Benchmark-Suite durch (welches kein Mikrobenchmark von Comprehensions allein ist, sondern Code testet, der aus realen Fällen abgeleitet ist und realistische Aufgaben mit Comprehensions ausführt) 11 % schneller als der main-Branch (wiederum in optimierten Builds). Andere Benchmarks in pyperformance (keiner davon verwendet Comprehensions stark) zeigen keine Auswirkungen über das Rauschen hinaus.
Die Implementierung hat keine Auswirkungen auf Code, der keine Comprehensions verwendet.
Abgelehnte Ideen
Effizientere Comprehension-Aufrufe ohne Inlining
Ein alternativer Ansatz führt einen neuen Opcode für das „Aufrufen“ einer Comprehension auf gestraffte Weise ein, ohne die Notwendigkeit, ein Wegwerf-Funktionsobjekt zu erstellen, aber dennoch einen neuen Python-Frame zu erstellen. Dies vermeidet alle unter Rückwärtskompatibilität aufgeführten sichtbaren Effekte und bietet etwa die Hälfte des Leistungsvorteils (1,5-fache Verbesserung beim Mikrobenchmark, 4 % Verbesserung beim comprehensions-Benchmark in pyperformance). Es erfordert auch das Hinzufügen eines neuen Zeigers zur _PyInterpreterFrame-Struktur und einen neuen Py_INCREF bei jeder Frame-Konstruktion, was bedeutet (im Gegensatz zu dieser PEP), dass es eine (sehr geringe) Leistungseinbuße für allen Code hat. Außerdem bietet es weniger Spielraum für zukünftige Optimierungen.
Diese PEP vertritt die Position, dass vollständiges Inlining ausreichende zusätzliche Leistung bietet, um die Verhaltensänderungen mehr als zu rechtfertigen.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0709.rst
Zuletzt geändert: 2023-12-15 15:06:12 GMT