Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 266 – Optimierung des Zugriffs auf globale Variablen/Attribute

Autor:
Skip Montanaro <skip at pobox.com>
Status:
Zurückgezogen
Typ:
Standards Track
Erstellt:
13-Aug-2001
Python-Version:
2.3
Post-History:


Inhaltsverzeichnis

Zusammenfassung

Die Bindungen für die meisten globalen Variablen und Attribute anderer Module ändern sich normalerweise nie während der Ausführung eines Python-Programms. Aufgrund der dynamischen Natur von Python muss Code, der auf solche globalen Objekte zugreift, jedes Mal eine vollständige Suche durchlaufen, wenn das Objekt benötigt wird. Dieses PEP schlägt einen Mechanismus vor, der es Code, der auf die meisten globalen Objekte zugreift, ermöglicht, diese wie lokale Objekte zu behandeln und die Verantwortung für die Aktualisierung von Referenzen auf den Code zu legen, der die Namensbindungen solcher Objekte ändert.

Einleitung

Betrachten Sie die Arbeits-Funktion sre_compile._compile. Sie ist die interne Kompilierungsfunktion für das Modul sre. Sie besteht fast ausschließlich aus einer Schleife über die Elemente des zu kompilierenden Musters, vergleicht Opcodes mit bekannten konstanten Werten und hängt Tokens an eine Ausgabeliste an. Die meisten Vergleiche erfolgen mit Konstanten, die aus dem Modul sre_constants importiert werden. Das bedeutet, dass es viele LOAD_GLOBAL Bytecodes in der kompilierten Ausgabe dieses Moduls gibt. Allein durch das Lesen des Codes ist ersichtlich, dass der Autor beabsichtigt hat, dass LITERAL, NOT_LITERAL, OPCODES und viele andere Symbole Konstanten sind. Dennoch müssen sie jedes Mal neu gesucht werden, wenn sie in einem Ausdruck vorkommen.

Die meisten globalen Zugriffe erfolgen tatsächlich auf Objekte, die "fast konstant" sind. Dazu gehören globale Variablen im aktuellen Modul sowie Attribute anderer importierter Module. Da sie sich selten ändern, erscheint es vernünftig, die Verantwortung für die Aktualisierung von Referenzen auf solche Objekte dem Code aufzubürden, der die Namensbindungen ändert. Wenn sre_constants.LITERAL so geändert wird, dass es auf ein anderes Objekt verweist, wäre es vielleicht lohnenswert, dass der Code, der das Modul-Dictionary von sre_constants modifiziert, alle aktiven Referenzen auf dieses Objekt korrigiert. Dadurch könnten in vielen Fällen globale Variablen und die Attribute vieler Objekte als lokale Variablen gecached werden. Wenn sich die Bindungen zwischen den Namen, die Objekten zugewiesen werden, und den Objekten selbst selten ändern, sollten die Kosten für die Verfolgung solcher Objekte gering sein und der potenzielle Gewinn beträchtlich.

Um die Auswirkungen dieses Vorschlags abzuschätzen, habe ich das Pystone-Benchmark-Programm, das in der Python-Distribution enthalten ist, modifiziert, um globale Funktionen zu cachen. Seine Hauptfunktion, Proc0, ruft zehn verschiedene Funktionen innerhalb ihrer for-Schleife auf. Darüber hinaus ruft Func2 wiederholt Func1 innerhalb einer Schleife auf. Wenn lokale Kopien dieser 11 globalen Bezeichner vor dem Betreten der Schleifen der Funktionen erstellt werden, verbessert sich die Leistung bei diesem speziellen Benchmark um etwa zwei Prozent (von 5561 Pystones auf 5685 auf meinem Laptop). Dies gibt einen Hinweis darauf, dass die Leistung durch das Caching der meisten globalen Variablenzugriffe verbessert würde. Beachten Sie auch, dass der Pystone-Benchmark im Wesentlichen keine Zugriffe auf globale Modulattribute macht, ein erwarteter Verbesserungsbereich für dieses PEP.

Vorgeschlagene Änderung

Ich schlage vor, die Python-Virtual-Machine zu modifizieren, um die Opcodes TRACK_OBJECT und UNTRACK_OBJECT aufzunehmen. TRACK_OBJECT würde einem globalen Namen oder einem Attribut eines globalen Namens einen Slot im lokalen Variablenarray zuordnen und eine initiale Suche des zugehörigen Objekts durchführen, um den Slot mit einem gültigen Wert zu füllen. Die erstellte Zuordnung würde von dem Code, der für die Änderung der Namens-zu-Objekt-Bindung zuständig ist, bemerkt werden, um die zugehörige lokale Variable zu aktualisieren. Der Opcode UNTRACK_OBJECT würde jede Zuordnung zwischen dem Namen und dem lokalen Variablen-Slot löschen.

Threads

Die Ausführung dieses Codes in multithreadeten Programmen wird sich nicht von der in unthreaded Programmen unterscheiden. Wenn Sie ein Objekt sperren müssen, um darauf zuzugreifen, hätten Sie dies bereits tun müssen, bevor TRACK_OBJECT ausgeführt wurde, und diese Sperre beibehalten, bis Sie es nicht mehr verwenden.

FIXME: Ich vermute, ich brauche hier mehr.

Begründung

Globale Variablen und Attribute ändern sich selten. Wenn eine Funktion beispielsweise das math-Modul importiert, ist die Bindung zwischen dem Namen *math* und dem Modul, auf das es verweist, wahrscheinlich nicht mehr änderbar. Ebenso ist es unwahrscheinlich, dass sich die *sin*-Attributreferenz ändert, wenn die Funktion, die das math-Modul verwendet. Dennoch muss jedes Mal, wenn das Modul die Funktion math.sin aufrufen möchte, zuerst ein Paar von Anweisungen ausgeführt werden.

LOAD_GLOBAL     math
LOAD_ATTR       sin

Wenn das Client-Modul immer davon ausginge, dass math.sin eine lokale Konstante ist und die Verantwortung dafür, die Referenz korrekt zu halten, bei "externen Kräften" außerhalb der Funktion liegt, hätten wir Code wie diesen:

TRACK_OBJECT       math.sin
...
LOAD_FAST          math.sin
...
UNTRACK_OBJECT     math.sin

Wenn LOAD_FAST in einer Schleife wäre, könnte die Einsparung bei reduzierten globalen Ladevorgängen und Attributsuchen erheblich sein.

Diese Technik könnte theoretisch auf jeden globalen Variablenzugriff oder jede Attributsuche angewendet werden. Betrachten Sie diesen Code:

l = []
for i in range(10):
    l.append(math.sin(i))
return l

Obwohl *l* eine lokale Variable ist, zahlen Sie immer noch zehnmal die Kosten für das Laden von l.append in der Schleife. Der Compiler (oder ein Optimierer) könnte erkennen, dass sowohl math.sin als auch l.append in der Schleife aufgerufen werden, und entscheiden, den getrackten lokalen Code zu generieren, und ihn für die eingebaute Funktion range() vermeiden, da sie nur einmal während der Schleifeninitialisierung aufgerufen wird. Leistungsprobleme im Zusammenhang mit dem Zugriff auf lokale Variablen machen das Tracking von l.append weniger attraktiv als das Tracking von globalen Variablen wie math.sin.

Laut einem Beitrag an python-dev von Marc-Andre Lemburg [1] machen LOAD_GLOBAL Opcodes über 7 % aller von der Python-Virtual-Machine ausgeführten Anweisungen aus. Dies kann eine sehr teure Anweisung sein, zumindest im Vergleich zu einer LOAD_FAST Anweisung, die ein einfacher Array-Index ist und keine zusätzlichen Funktionsaufrufe durch die Virtual Machine erfordert. Ich glaube, dass viele LOAD_GLOBAL Anweisungen und LOAD_GLOBAL/LOAD_ATTR Paare in LOAD_FAST Anweisungen umgewandelt werden könnten.

Code, der stark globale Variablen verwendet, greift oft auf verschiedene Tricks zurück, um globale Variablen- und Attributsuchen zu vermeiden. Die oben erwähnte Funktion sre_compile._compile cacht die append-Methode der wachsenden Ausgabeliste. Viele Leute missbrauchen häufig die Standardargumentfunktion von Funktionen, um globale Variablen-Suchen zu cachen. Beide dieser Schemata sind hacky und adressieren selten alle verfügbaren Optimierungsmöglichkeiten. (Zum Beispiel cacht sre_compile._compile nicht die beiden Globallen, die es am häufigsten verwendet: die eingebaute Funktion len und das globale Array OPCODES, das es aus sre_constants.py importiert.

Fragen

Was ist mit Threads? Was passiert, wenn math.sin sich ändert, während es im Cache ist?

Ich glaube, dass der globale Interpreter-Lock Werte vor Beschädigung schützt. In jedem Fall wäre die Situation nicht schlimmer als heute. Wenn ein Thread math.sin modifizierte, nachdem ein anderer Thread bereits LOAD_GLOBAL math ausgeführt hatte, aber bevor er LOAD_ATTR sin ausführte, würde der Client-Thread den alten Wert von math.sin sehen.

Die Idee ist folgende. Ich verwende unten eine Multi-Attribut-Ladeoperation als Beispiel, nicht weil sie sehr oft vorkommen würde, sondern weil durch die Demonstration der rekursiven Natur mit einem zusätzlichen Aufruf hoffentlich klarer wird, was ich meine. Angenommen, eine Funktion, die im Modul foo definiert ist, möchte auf spam.eggs.ham zugreifen und spam ist ein Modul, das auf Modulebene in foo importiert wurde.

import spam
...
def somefunc():
...
x = spam.eggs.ham

Beim Eintritt in somefunc wird eine TRACK_GLOBAL Anweisung ausgeführt.

TRACK_GLOBAL spam.eggs.ham n

spam.eggs.ham ist ein String-Literal, das im Konstanten-Array der Funktion gespeichert ist. *n* ist ein Index für die schnellen lokalen Variablen. &fastlocals[n] ist eine Referenz auf den Slot *n* im fastlocals-Array des ausführenden Frames, der Stelle, an der die Referenz auf *spam.eggs.ham* gespeichert wird. Hier ist, was ich mir vorstelle:

  1. Die TRACK_GLOBAL Anweisung lokalisiert das Objekt, auf das sich der Name *spam* bezieht, und findet es in seinem Modul-Scope. Sie führt dann eine C-Funktion wie diese aus:
    _PyObject_TrackName(m, "spam.eggs.ham", &fastlocals[n])
    

    wobei m das Modulobjekt mit einem Attribut spam ist.

  2. Das Modulobjekt entfernt das führende *spam.* und speichert die notwendigen Informationen (*eggs.ham* und &fastlocals[n]), falls sich seine Bindung für den Namen *eggs* ändert. Es lokalisiert dann das Objekt, auf das sich der Schlüssel *eggs* in seinem Dictionary bezieht, und ruft rekursiv auf:
    _PyObject_TrackName(eggs, "eggs.ham", &fastlocals[n])
    
  3. Das *eggs*-Objekt entfernt das führende *eggs.*, speichert die Informationen (*ham*, &fastlocals[n]) und lokalisiert das Objekt in seinem Namensraum namens ham und ruft erneut _PyObject_TrackName auf:
    _PyObject_TrackName(ham, "ham", &fastlocals[n])
    
  4. Das *ham*-Objekt entfernt den führenden String (diesmal kein "."), sieht, dass das Ergebnis leer ist, und verwendet dann seinen eigenen Wert (self, wahrscheinlich), um die übergebene Stelle zu aktualisieren.
    Py_XDECREF(&fastlocals[n]);
    &fastlocals[n] = self;
    Py_INCREF(&fastlocals[n]);
    

    Zu diesem Zeitpunkt weiß jedes Objekt, das an der Auflösung von spam.eggs.ham beteiligt ist, welcher Eintrag in seinem Namensraum verfolgt werden muss und welche Stelle aktualisiert werden soll, falls sich dieser Name ändert. Darüber hinaus kann das unterste Objekt, wenn sich der eine Name, den es in seinem lokalen Speicher verfolgt, ändert, _PyObject_TrackName mit dem neuen Objekt aufrufen, sobald die Änderung vorgenommen wurde. Am Ende der Nahrungskette wird das letzte Objekt immer einen Namen entfernen, die leere Zeichenkette sehen und wissen, dass sein Wert an die übergebene Stelle eingefügt werden soll.

    Wenn das Objekt, auf das durch den Punkt-Ausdruck spam.eggs.ham verwiesen wird, aus dem Gültigkeitsbereich fällt, wird eine UNTRACK_GLOBAL spam.eggs.ham n Anweisung ausgeführt. Dies hat zur Folge, dass alle Tracking-Informationen gelöscht werden, die TRACK_GLOBAL etabliert hat.

    Die Tracking-Operation mag teuer erscheinen, aber denken Sie daran, dass die verfolgten Objekte als "fast konstant" angenommen werden, so dass die Setup-Kosten gegen hoffentlich mehrere lokale statt globale Ladevorgänge abgewogen werden. Für globale Variablen mit Attributen wächst die Tracking-Setup-Kosten, wird aber durch die Vermeidung der zusätzlichen LOAD_ATTR Kosten ausgeglichen. Die TRACK_GLOBAL Anweisung muss einen PyDict_GetItemString für den ersten Namen in der Kette ausführen, um festzustellen, wo sich das Top-Level-Objekt befindet. Jedes Objekt in der Kette muss einen String und eine Adresse speichern, wahrscheinlich in einem Dictionary, das Speicherorte als Schlüssel (z.B. das &fastlocals[n]) und Strings als Werte verwendet. (Dieses Dictionary könnte möglicherweise ein zentrales Dictionary von Dictionaries sein, dessen Schlüssel Objektadressen sind, anstatt ein pro-Objekt-Dictionary.) Es sollte nicht andersherum sein, da mehrere aktive Frames spam.eggs.ham verfolgen wollen könnten, aber nur ein Frame diesen Namen einem seiner Fast-Local-Slots zuordnen möchte.

Ungelöste Probleme

Threading

Was ist mit diesem (dummen) Code?

l = []
lock = threading.Lock()
...
def fill_l()::
   for i in range(1000)::
      lock.acquire()
      l.append(math.sin(i))
      lock.release()
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

Es ist aus einer statischen Analyse des Codes nicht klar, was die Sperre schützt. (Kann man zur Kompilierzeit nicht erkennen, dass Threads überhaupt beteiligt sind?) Würde oder sollte dies Versuche beeinflussen, l.append oder math.sin in der Funktion fill_l zu verfolgen?

Wenn wir den Code mit mythischen track_object und untrack_object Builtins annotieren (ich schlage das nicht vor, nur um zu illustrieren, wo die Dinge hingehen würden!), erhalten wir:

l = []
lock = threading.Lock()
...
def fill_l()::
   track_object("l.append", append)
   track_object("math.sin", sin)
   for i in range(1000)::
      lock.acquire()
      append(sin(i))
      lock.release()
   untrack_object("math.sin", sin)
   untrack_object("l.append", append)
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

Ist das sowohl mit als auch ohne Threads korrekt (oder zumindest gleichermaßen falsch mit und ohne Threads)?

Verschachtelte Scopes

Die Anwesenheit von verschachtelten Gültigkeitsbereichen wird beeinflussen, wo TRACK_GLOBAL eine globale Variable findet, sollte aber nichts danach beeinflussen. (Ich glaube.)

Fehlende Attribute

Angenommen, ich verfolge das Objekt, auf das mit spam.eggs.ham verwiesen wird, und spam.eggs wird an ein Objekt gebunden, das kein ham-Attribut hat. Es ist klar, dass dies ein AttributeError wäre, wenn der Programmierer versucht, spam.eggs.ham in der aktuellen Python-Virtual-Machine aufzulösen, aber nehmen wir an, der Programmierer hat diesen Fall antizipiert:

if hasattr(spam.eggs, "ham"):
    print spam.eggs.ham
elif hasattr(spam.eggs, "bacon"):
    print spam.eggs.bacon
else:
    print "what? no meat?"

Sie können keinen AttributeError auslösen, wenn die Tracking-Informationen neu berechnet werden. Wenn kein AttributeError ausgelöst wird und das Tracking stattdessen beibehalten wird, kann dies den Programmierer auf einen sehr subtilen Fehler vorbereiten.

Eine Lösung für dieses Problem wäre es, die kürzestmögliche Wurzel jedes Punkt-Ausdrucks zu verfolgen, auf den die Funktion direkt verweist. Im obigen Beispiel würde spam.eggs verfolgt werden, aber spam.eggs.ham und spam.eggs.bacon nicht.

Wer macht die schmutzige Arbeit?

In den Fragen habe ich die Existenz einer _PyObject_TrackName Funktion postuliert. Während die API relativ einfach zu spezifizieren ist, ist die Implementierung im Hintergrund nicht so offensichtlich. Ein zentrales Dictionary könnte verwendet werden, um die Namens-/Speicherort-Zuordnungen zu verfolgen, aber es scheint, dass alle setattr-Funktionen modifiziert werden müssten, um dieser neuen Funktionalität Rechnung zu tragen.

Wenn alle Typen die Funktion PyObject_GenericSetAttr zur Attributfestlegung verwenden würden, würde dies den Aktualisierungscode etwas lokalisieren. Das tun sie aber nicht (was nicht überraschend ist), daher scheinen alle getattrfunc und getattrofunc Funktionen aktualisiert werden zu müssen. Darüber hinaus würde dies eine absolute Anforderung für Autoren von C-Erweiterungsmodulen bedeuten, eine Funktion aufzurufen, wenn ein Attribut seinen Wert ändert (PyObject_TrackUpdate?).

Schließlich ist es durchaus möglich, dass einige Attribute durch Seiteneffekte und nicht durch einen direkten Aufruf einer setattr-Methode gesetzt werden. Betrachten Sie ein Geräteschnittstellenmodul, das eine Interrupt-Routine hat, die den Inhalt eines Geräte-Registers in einen Slot der struct des Objekts kopiert, wann immer er sich ändert. In diesen Situationen müssten umfangreichere Modifikationen vom Modulautor vorgenommen werden. Solche Situationen zur Kompilierzeit zu identifizieren wäre unmöglich. Ich denke, ein zusätzlicher Slot könnte zu PyTypeObjects hinzugefügt werden, um anzuzeigen, ob der Code eines Objekts für globales Tracking sicher ist. Er hätte einen Standardwert von 0 (Py_TRACKING_NOT_SAFE). Wenn ein Autor eines Erweiterungsmoduls die notwendige Tracking-Unterstützung implementiert hat, könnte dieses Feld auf 1 (Py_TRACKING_SAFE) initialisiert werden. _PyObject_TrackName könnte dieses Feld überprüfen und eine Warnung ausgeben, wenn es aufgefordert wird, ein Objekt zu verfolgen, das der Autor nicht explizit als sicher für das Tracking bezeichnet hat.

Diskussion

Jeremy Hylton hat einen alternativen Vorschlag auf dem Tisch [2]. Sein Vorschlag zielt darauf ab, ein Hybridobjekt aus Dictionary und Liste für globale Namenssuchen zu erstellen, wodurch globale Variablenzugriffe eher wie lokale Variablenzugriffe aussehen. Obwohl kein C-Code zur Untersuchung verfügbar ist, scheint die in seinem Vorschlag gegebene Python-Implementierung immer noch eine Dictionary-Schlüssel-Suche zu erfordern. Es scheint nicht, dass sein Vorschlag die Suche nach Attributen lokaler Variablen beschleunigen könnte, was in einigen Situationen lohnenswert wäre, wenn potenzielle Leistungsbelastungen angegangen werden könnten.

Abwärtskompatibilität

Ich glaube nicht, dass es ernsthafte Probleme mit der Rückwärtskompatibilität geben wird. Offensichtlich könnte Python-Bytecode, der TRACK_OBJECT Opcodes enthält, nicht von früheren Versionen des Interpreters ausgeführt werden, aber Brüche auf Bytecode-Ebene werden zwischen Versionen oft angenommen.

Implementierung

TBD. Hier brauche ich Hilfe. Ich glaube, es sollte entweder eine zentrale Namens-/Speicherort-Registry geben oder der Code, der Objektattribute modifiziert, sollte modifiziert werden, aber ich bin mir nicht sicher, wie am besten vorzugehen ist. Wenn man sich den Code ansieht, der die STORE_GLOBAL und STORE_ATTR Opcodes implementiert, scheint es wahrscheinlich, dass einige Änderungen an PyDict_SetItem und PyObject_SetAttr oder deren String-Varianten erforderlich sein werden. Idealerweise gäbe es einen ziemlich zentralen Ort, um diese Änderungen zu lokalisieren. Wenn man beginnt, Attribute lokaler Variablen zu verfolgen, gerät man auch in Probleme bei der Modifikation von STORE_FAST, was ein Problem sein könnte, da die Namensbindungen für lokale Variablen viel häufiger geändert werden. (Ich denke, ein Optimierer könnte vermeiden, den Tracking-Code für die Attribute für lokale Variablen einzufügen, bei denen sich die Namensbindung der Variable ändert.)

Performance

Ich glaube (obwohl ich noch keinen Code habe, um es zu beweisen), dass die Implementierung von TRACK_OBJECT im Allgemeinen nicht viel teurer sein wird als eine einzelne LOAD_GLOBAL Anweisung oder ein LOAD_GLOBAL/LOAD_ATTR Paar. Ein Optimierer sollte vermeiden können, LOAD_GLOBAL und LOAD_GLOBAL/LOAD_ATTR in das neue Schema umzuwandeln, es sei denn, der Objektzugriff erfolgte innerhalb einer Schleife. Weiter unten könnte eine Register-orientierte Ersetzung für die aktuelle Python-Virtual-Machine [3] konsequenterweise die meisten LOAD_FAST Anweisungen eliminieren.

Die Anzahl der verfolgten Objekte sollte relativ gering sein. Alle aktiven Frames aller aktiven Threads könnten potenziell Objekte verfolgen, aber das scheint gering im Vergleich zur Anzahl der Funktionen, die in einer gegebenen Anwendung definiert sind.

Referenzen


Quelle: https://github.com/python/peps/blob/main/peps/pep-0266.rst

Zuletzt geändert: 2025-02-01 08:55:40 GMT