Der Peephole-Optimierer von CPython durchsucht den Bytecode nach unerreichbaren Blöcken—Instruktionssequenzen, die auf einen unbedingten Sprung (JUMP_ABSOLUTE, JUMP_FORWARD, RETURN_VALUE, RAISE_VARARGS) folgen und von anderen Ästen aus keine Einstiegspunkte haben. Wenn sie identifiziert werden, entfernt er diese toten Anweisungen, um den Cache-Druck zu verringern und die Instruktionsdichte zu verbessern.
Da die Ausnahmetabellen, Schleifenbaukonstrukte und bedingten Sprünge von Python Zielorte als absolute Byte-Offsets in die co_code-Sequenz des Code-Objekts speichern, muss der Optimierer eine Umzugslandkarte erstellen, die erfasst, wie viele Bytes vor jeder überlebenden Instruktion gelöscht wurden. Er durchläuft dann alle Sprunganweisungen und Bereiche von Ausnahmbehandlern und passt ihre Ziel-Offsets an, indem er die kumulative Löschanzahl an der Zielposition subtrahiert. Dies gewährleistet, dass SETUP_FINALLY-Blöcke, FOR_ITER-Schleifen und benutzerdefinierte Sprünge an den richtigen Opcode gelangen, selbst nachdem der vorhergehende Bytecode komprimiert wurde.
Ein Team von Datenpipelines bemerkte, dass das Startup-Skript ihres ETL-Tools umfangreiche Debug-Protokollierungsblöcke enthielt, die durch if DEBUG:-Flags geschützt waren, wobei DEBUG eine modulübergreifende Konstante war, die auf False gesetzt war. Trotz der Bedingung, die statisch falsch war, enthielt der kompilierte Bytecode nach der Kompilierung immer noch die Protokollierungslogik, was die Größe der .pyc-Datei um 40% erhöhte und die lokale Instruktionscache-Wirkung auf den Produktionsservern leicht verschlechterte.
Sie bewerteten drei verschiedene Ansätze.
Erstens erwogen sie den Einsatz eines C-Präprozessors oder Jinja2-Templates, um Debug-Code vor der Bereitstellung zu entfernen. Dieser Ansatz würde garantieren, dass im Produktionsbetrieb kein Debug-Bytecode vorhanden ist, führte jedoch zu einer komplexen Abhängigkeit von Build-Schritten und riskierte subtile Abweichungen zwischen Entwicklungs- und Produktionscodebasen, was das Debuggen von Produktionsproblemen komplizierte, bei denen der Quellcode nicht mehr mit dem ausgeführten Bytecode übereinstimmte.
Zweitens bewerteten sie die Umstrukturierung aller Debug-Blöcke in separate Funktionen in einem Untermodul in der Hoffnung, dass nicht aufgerufene Funktionen nicht geladen würden. Allerdings kompiliert Python gesamte Module auf einmal, und nicht aufgerufene Funktionen bleiben als Code-Objekte im Wörterbuch des Moduls; der Peephole-Optimierer führt keine interproceduralen toten Code-Eliminierungen durch, sodass die Bytecode-Größe unverändert blieb.
Drittens untersuchten sie die Kompilierungspipeline von CPython und entdeckten, dass der Peephole-Optimierer automatisch Code nach if False:-Konstrukten entfernt, da der Compiler einen unbedingten Sprung um den Block ausgibt, und der Peephole-Pass den unerreichbaren Tail löscht. Durch die Überprüfung mit dem dis-Modul, dass RETURN_VALUE oder JUMP_FORWARD keinem toten Code folgten, bestätigten sie, dass die Optimierung aktiv war. Sie entschieden sich, sich auf diesen eingebauten Mechanismus zu verlassen, und stellten sicher, dass DEBUG ein literales False war, anstatt einer zur Laufzeit berechneten Variablen, was die Größe des kompilierte Bytecodes um 35% reduzierte, ohne zusätzliches Werkzeug.
Warum kann der Peephole-Optimierer unerreichbaren Code nicht entfernen, wenn das vorhergehende Sprungziel von einer berechneten Sprunganweisung adressiert wird?
Berechnete Sprünge bestimmen ihr Ziel zur Laufzeit basierend auf einem Wert im Stack, wie in MATCH-Anweisungen oder dynamischen Dispatch-Mustern. Da der Optimierer statisch nicht wissen kann, welche Offsets angesprochen werden könnten, muss er konservativ annehmen, dass jede Anweisung ein Einstiegspunkt sein könnte. Daher entfernt er nur Code, der nachweislich über die statische Analyse von unbedingten Sprüngen und Kontrollflussgraphen unerreichbar ist, und bewahrt jeden Block, der das Ziel eines dynamischen Dispatchs sein könnte, um undefiniertes Verhalten zu verhindern.
Wie geht der Optimierer mit Ausnahmhandler-Tabellen (co_exceptiontable) um, wenn er NOP-Anweisungen entfernt, die als Sprungplatzhalter verwendet werden?
Wenn der Compiler Sprünge zu noch unbekannten Forward-Standorten generiert, gibt er häufig NOP (no-operation)-Anweisungen als Platzhalter oder Polster aus und patcht die Sprungziele später. Bei der Peephole-Optimierung werden diese NOP entfernt, um Platz zu sparen. Der Optimierer pflegt eine bidirektionale Zuordnung zwischen den ursprünglichen und endgültigen Offsets. Bei der Verarbeitung der Ausnahmetabelle—die start, end und handler-Offsets für try/except-Blöcke speichert—wenden sie die kumulative Delta der entfernten Bytes auf jeden Eintrag an. Wenn ein NOP innerhalb eines Ausnahmebereichs fällt, verschiebt dessen Entfernung den end-Offset nach links und stellt sicher, dass der geschützte Bytecode-Bereich genau bleibt und Ausnahmen an den richtigen Grenzen gefangen werden.
Was hindert den Peephole-Optimierer daran, unabhängige Anweisungen umzusortieren, um die Pipeline-Effizienz zu verbessern, wie es bei C-Compilern zu sehen ist?
Der Bytecode von Python ist eng gekoppelt an die Semantik des Auswertungs-Stacks und die für die Generierung von Traceback benötigten Zeilennummern-Tabellen. Das Umordnen von Anweisungen—zum Beispiel das Vorverschieben eines LOAD_CONST vor einem LOAD_NAME—könnte den Zustand des Stacks ändern, wenn eine Ausnahme auftritt, was die gemeldete Zeilennummer in Tracebacks ändern oder die erforderlichen Stapeltiefen-Invarianten, die von der Interpreter-Schleife gefordert werden, verletzen könnte. Darüber hinaus, da Python die Introspektion von Rahmenobjekten und f_lasti (dem Anweisungszeiger) erlaubt, könnte beliebiges Umordnen Debugger und Profiler, die auf deterministische Zuordnung von Offsets zu Quelltext basieren, stören. Daher ist der Optimierer auf das Löschen von unerreichbarem Code und das Umleiten von Sprüngen beschränkt, ohne die relative Reihenfolge ausführbarer Anweisungen zu verändern.