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

Python Enhancement Proposals

PEP 574 – Pickle-Protokoll 5 mit Out-of-Band-Daten

Autor:
Antoine Pitrou <solipsis at pitrou.net>
BDFL-Delegate:
Alyssa Coghlan
Status:
Final
Typ:
Standards Track
Erstellt:
23. März 2018
Python-Version:
3.8
Post-History:
28. März 2018, 30. April 2019
Resolution:
Python-Dev Nachricht

Inhaltsverzeichnis

Zusammenfassung

Dieses PEP schlägt die Standardisierung einer neuen Version des Pickle-Protokolls und begleitender APIs vor, um diese vollständig zu nutzen.

  1. Eine neue Pickle-Protokollversion (5), um die zusätzlichen Metadaten für Out-of-Band-Datenpuffer zu erfassen.
  2. Ein neuer PickleBuffer-Typ für __reduce_ex__-Implementierungen, um Out-of-Band-Datenpuffer zurückzugeben.
  3. Ein neuer buffer_callback-Parameter beim Pickling zur Handhabung von Out-of-Band-Datenpuffern.
  4. Ein neuer buffers-Parameter beim Unpickling zur Bereitstellung von Out-of-Band-Datenpuffern.

Das PEP garantiert unverändertes Verhalten für alle, die die neuen APIs nicht verwenden.

Begründung

Das Pickle-Protokoll wurde ursprünglich 1995 für die Persistenz beliebiger Python-Objekte auf der Festplatte entwickelt. Die Leistung eines Speichermediums aus den 1990er Jahren machte es wahrscheinlich irrelevant, sich auf Leistungsmetriken wie die Nutzung von RAM-Bandbreite beim Kopieren temporärer Daten vor dem Schreiben auf die Festplatte zu konzentrieren.

Heutzutage findet das Pickle-Protokoll eine wachsende Anwendung in Bereichen, in denen die meisten Daten niemals auf der Festplatte gespeichert werden (oder, wenn doch, ein portables Format anstelle eines Python-spezifischen verwendet wird). Stattdessen wird Pickle verwendet, um Daten und Befehle von einem Prozess zu einem anderen zu übertragen, entweder auf demselben oder auf mehreren Rechnern. Diese Anwendungen befassen sich manchmal mit sehr großen Daten (wie Numpy-Arrays oder Pandas-DataFrames), die übertragen werden müssen. Für diese Anwendungen ist Pickle derzeit verschwenderisch, da es unnötige Speicherkopien der serialisierten Daten verursacht.

Tatsächlich verwendet das Standardmodul multiprocessing Pickle für die Serialisierung und leidet daher ebenfalls unter diesem Problem, wenn große Daten an einen anderen Prozess gesendet werden.

Drittanbieter-Python-Bibliotheken wie Dask [1], PyArrow [4] und IPyParallel [3] haben begonnen, alternative Serialisierungsschemata zu implementieren, mit dem ausdrücklichen Ziel, Kopien bei großen Daten zu vermeiden. Die Implementierung eines neuen Serialisierungsschemas ist schwierig und führt oft zu einer geringeren Allgemeingültigkeit (da viele Python-Objekte Pickle unterstützen, aber nicht das neue Serialisierungsschema). Ein Rückgriff auf Pickle für nicht unterstützte Typen ist eine Option, aber dann erhalten Sie die unnötigen Speicherkopien zurück, die Sie ursprünglich vermeiden wollten. Zum Beispiel kann dask Speicherkopien für Numpy-Arrays und integrierte Container davon (wie Listen oder Wörterbücher, die Numpy-Arrays enthalten) vermeiden, aber wenn ein großes Numpy-Array ein Attribut eines benutzerdefinierten Objekts ist, serialisiert dask das benutzerdefinierte Objekt als Pickle-Stream, was zu Speicherkopien führt.

Das gemeinsame Merkmal dieser Bemühungen zur Serialisierung durch Drittanbieter ist die Erzeugung eines Streams von Objektmetadaten (der pickle-ähnliche Informationen über die serialisierten Objekte enthält) und eines separaten Streams von Null-Kopie-Pufferobjekten für die Payloads großer Objekte. Beachten Sie, dass in diesem Schema kleine Objekte wie Integers usw. zusammen mit dem Metadaten-Stream übertragen werden können. Verfeinerungen können die opportunistische Komprimierung großer Daten je nach Typ und Layout umfassen, wie dask es tut.

Dieses PEP zielt darauf ab, pickle so nutzbar zu machen, dass große Daten als separater Stream von Null-Kopie-Puffern behandelt werden, was es der Anwendung ermöglicht, diese Puffer optimal zu handhaben.

Beispiel

Um das Beispiel einfach zu halten und das Wissen über Drittanbieterbibliotheken nicht vorauszusetzen, konzentrieren wir uns hier auf ein bytearray-Objekt (aber das Problem ist konzeptionell dasselbe bei ausgefeilteren Objekten wie Numpy-Arrays). Wie die meisten Objekte wird das bytearray-Objekt vom Pickle-Modul nicht sofort verstanden und muss daher sein Zerlegungsschema angeben.

Hier ist, wie sich ein bytearray-Objekt derzeit für das Pickling zerlegt:

>>> b.__reduce_ex__(4)
(<class 'bytearray'>, (b'abc',), None)

Dies liegt daran, dass die bytearray.__reduce_ex__-Implementierung moralisch wie folgt gelesen wird:

class bytearray:

   def __reduce_ex__(self, protocol):
      if protocol == 4:
         return type(self), bytes(self), None
      # Legacy code for earlier protocols omitted

Im Gegenzug erzeugt dies den folgenden Pickle-Code:

>>> pickletools.dis(pickletools.optimize(pickle.dumps(b, protocol=4)))
    0: \x80 PROTO      4
    2: \x95 FRAME      30
   11: \x8c SHORT_BINUNICODE 'builtins'
   21: \x8c SHORT_BINUNICODE 'bytearray'
   32: \x93 STACK_GLOBAL
   33: C    SHORT_BINBYTES b'abc'
   38: \x85 TUPLE1
   39: R    REDUCE
   40: .    STOP

(Der Aufruf von pickletools.optimize oben dient nur dazu, den Pickle-Stream lesbarer zu machen, indem die MEMOIZE-Opcodes entfernt werden.)

Wir können mehrere Dinge über die Payload des bytearray (die Bytesequenz b'abc') bemerken:

  • bytearray.__reduce_ex__ erzeugt eine erste Kopie, indem es ein neues Bytes-Objekt aus den Daten des bytearrays instanziiert.
  • pickle.dumps erzeugt eine zweite Kopie, wenn der Inhalt dieses Bytes-Objekts in den Pickle-Stream eingefügt wird, nach dem SHORT_BINBYTES-Opcode.
  • Darüber hinaus wird beim Deserialisieren des Pickle-Streams ein temporäres Bytes-Objekt erstellt, wenn der SHORT_BINBYTES-Opcode angetroffen wird (was eine Datenkopie induziert).

Was wir wirklich wollen, ist so etwas wie das Folgende:

  • bytearray.__reduce_ex__ erzeugt eine *Ansicht* der Daten des bytearrays.
  • pickle.dumps versucht nicht, diese Daten in den Pickle-Stream zu kopieren, sondern übergibt die Pufferansicht an seinen Aufrufer (der über die effizienteste Handhabung dieses Puffers entscheiden kann).
  • Beim Deserialisieren nimmt pickle.loads den Pickle-Stream und die Pufferansicht separat entgegen und übergibt die Pufferansicht direkt an den bytearray-Konstruktor.

Wir sehen, dass mehrere Bedingungen für das Gelingen des Obigen erforderlich sind:

  • __reduce__ oder __reduce_ex__ muss *etwas* zurückgeben können, das eine serialisierbare Null-Kopie-Pufferansicht anzeigt.
  • Das Pickle-Protokoll muss in der Lage sein, Referenzen auf solche Pufferansichten darzustellen und den Unpickler anzuweisen, dass er den tatsächlichen Puffer möglicherweise out-of-band abrufen muss.
  • Die pickle.Pickler-API muss ihrem Aufrufer eine Möglichkeit bieten, solche Pufferansichten während der Serialisierung zu empfangen.
  • Die pickle.Unpickler-API muss es ihrem Aufrufer ebenfalls ermöglichen, die für die Deserialisierung erforderlichen Pufferansichten bereitzustellen.
  • Aus Kompatibilitätsgründen muss das Pickle-Protokoll auch direkte Serialisierungen solcher Pufferansichten enthalten können, so dass aktuelle Verwendungen der pickle-API nicht geändert werden müssen, wenn sie sich nicht um Speicherkopien kümmern.

Producer-API

Wir führen einen neuen Typ pickle.PickleBuffer ein, der aus jedem Puffer unterstützenden Objekt instanziiert werden kann und speziell dafür vorgesehen ist, von __reduce__-Implementierungen zurückgegeben zu werden.

class bytearray:

   def __reduce_ex__(self, protocol):
      if protocol >= 5:
         return type(self), (PickleBuffer(self),), None
      # Legacy code for earlier protocols omitted

PickleBuffer ist ein einfacher Wrapper, der nicht über alle Memoryview-Semantiken und -Funktionalitäten verfügt, aber vom pickle-Modul erkannt wird, wenn Protokoll 5 oder höher aktiviert ist. Es ist ein Fehler, zu versuchen, einen PickleBuffer mit Pickle-Protokollversion 4 oder früher zu serialisieren.

Nur die rohen *Daten* des PickleBuffer werden vom pickle-Modul berücksichtigt. Alle typspezifischen *Metadaten* (wie Formen oder Datentypen) müssen separat von der __reduce__-Implementierung des Typs zurückgegeben werden, wie es bereits der Fall ist.

PickleBuffer-Objekte

Die Klasse PickleBuffer unterstützt eine sehr einfache Python-API. Ihr Konstruktor nimmt ein einziges PEP 3118-kompatibles Objekt entgegen. PickleBuffer-Objekte selbst unterstützen das Pufferprotokoll, sodass Verbraucher memoryview(...) darauf aufrufen können, um zusätzliche Informationen über den zugrunde liegenden Puffer zu erhalten (wie z. B. den ursprünglichen Typ, die Form usw.). Darüber hinaus verfügen PickleBuffer-Objekte über die folgenden Methoden:

raw()

Gibt eine Memoryview der rohen Speicherbyte zurück, die dem PickleBuffer zugrunde liegen, und löscht alle Informationen über Form, Schrittweiten und Format. Dies ist erforderlich, um Fortran-kontinuierliche Puffer in der reinen Python-Pickle-Implementierung korrekt zu handhaben.

release()

Gibt den zugrunde liegenden Puffer des PickleBuffer frei und macht ihn unbrauchbar.

Auf der C-Seite wird eine einfache API bereitgestellt, um PickleBuffer-Objekte zu erstellen und zu inspizieren.

PyObject *PyPickleBuffer_FromObject(PyObject *obj)

Erstellt ein PickleBuffer-Objekt, das eine Ansicht über das PEP 3118-kompatible *obj* enthält.

PyPickleBuffer_Check(PyObject *obj)

Gibt zurück, ob *obj* eine Instanz von PickleBuffer ist.

const Py_buffer *PyPickleBuffer_GetBuffer(PyObject *picklebuf)

Gibt einen Zeiger auf den internen Py_buffer zurück, der von der PickleBuffer-Instanz gehalten wird. Eine Ausnahme wird ausgelöst, wenn der Puffer freigegeben ist.

int PyPickleBuffer_Release(PyObject *picklebuf)

Gibt den zugrunde liegenden Puffer der PickleBuffer-Instanz frei.

Buffer-Anforderungen

PickleBuffer kann jede Art von Puffer wrappen, einschließlich nicht zusammenhängender Puffer. Es ist jedoch erforderlich, dass __reduce__ nur einen zusammenhängenden PickleBuffer zurückgibt (*zusammenhängend* ist hier im Sinne von PEP 3118 zu verstehen: entweder C-Ordnung oder Fortran-Ordnung). Nicht zusammenhängende Puffer lösen beim Pickling einen Fehler aus.

Diese Einschränkung ist hauptsächlich ein Problem der Implementierungsvereinfachung für das pickle-Modul, aber auch für andere Verbraucher von Out-of-Band-Puffern. Die einfachste Lösung auf Anbieterseite ist, eine zusammenhängende Kopie eines nicht zusammenhängenden Puffers zurückzugeben; ein ausgefeilter Anbieter kann sich jedoch stattdessen entscheiden, eine Sequenz zusammenhängender Teilpuffer zurückzugeben.

Consumer-API

pickle.Pickler.__init__ und pickle.dumps werden um einen zusätzlichen buffer_callback-Parameter erweitert.

class Pickler:
   def __init__(self, file, protocol=None, ..., buffer_callback=None):
      """
      If *buffer_callback* is None (the default), buffer views are
      serialized into *file* as part of the pickle stream.

      If *buffer_callback* is not None, then it can be called any number
      of times with a buffer view.  If the callback returns a false value
      (such as None), the given buffer is out-of-band; otherwise the
      buffer is serialized in-band, i.e. inside the pickle stream.

      The callback should arrange to store or transmit out-of-band buffers
      without changing their order.

      It is an error if *buffer_callback* is not None and *protocol* is
      None or smaller than 5.
      """

def pickle.dumps(obj, protocol=None, *, ..., buffer_callback=None):
   """
   See above for *buffer_callback*.
   """

pickle.Unpickler.__init__ und pickle.loads werden um einen zusätzlichen buffers-Parameter erweitert.

class Unpickler:
   def __init__(file, *, ..., buffers=None):
      """
      If *buffers* is not None, it should be an iterable of buffer-enabled
      objects that is consumed each time the pickle stream references
      an out-of-band buffer view.  Such buffers have been given in order
      to the *buffer_callback* of a Pickler object.

      If *buffers* is None (the default), then the buffers are taken
      from the pickle stream, assuming they are serialized there.
      It is an error for *buffers* to be None if the pickle stream
      was produced with a non-None *buffer_callback*.
      """

def pickle.loads(data, *, ..., buffers=None):
   """
   See above for *buffers*.
   """

Protokolländerungen

Drei neue Opcodes werden eingeführt:

  • BYTEARRAY8 erstellt ein bytearray aus den folgenden Daten im Pickle-Stream und legt es auf den Stack (genau wie BINBYTES8 für Bytes-Objekte);
  • NEXT_BUFFER ruft einen Puffer aus dem buffers-Iterable ab und legt ihn auf den Stack.
  • READONLY_BUFFER erstellt eine schreibgeschützte Ansicht vom oberen Element des Stacks.

Wenn beim Pickling ein PickleBuffer angetroffen wird, kann dieser Puffer je nach folgenden Bedingungen als In-Band oder Out-of-Band betrachtet werden:

  • Wenn kein buffer_callback angegeben ist, ist der Puffer In-Band;
  • Wenn ein buffer_callback angegeben ist, wird dieser mit dem Puffer aufgerufen. Wenn der Callback einen wahren Wert zurückgibt, ist der Puffer In-Band; wenn der Callback einen falschen Wert zurückgibt, ist der Puffer Out-of-Band.

Ein In-Band-Puffer wird wie folgt serialisiert:

  • Wenn der Puffer beschreibbar ist, wird er in den Pickle-Stream serialisiert, als wäre er ein bytearray-Objekt.
  • Wenn der Puffer schreibgeschützt ist, wird er in den Pickle-Stream serialisiert, als wäre er ein bytes-Objekt.

Ein Out-of-Band-Puffer wird wie folgt serialisiert:

  • Wenn der Puffer beschreibbar ist, wird ein NEXT_BUFFER-Opcode an den Pickle-Stream angehängt.
  • Wenn der Puffer schreibgeschützt ist, wird ein NEXT_BUFFER-Opcode an den Pickle-Stream angehängt, gefolgt von einem READONLY_BUFFER-Opcode.

Die Unterscheidung zwischen schreibgeschützten und beschreibbaren Puffern wird unten erläutert (siehe "Änderbarkeit").

Nebeneffekte

Verbesserte In-Band-Leistung

Selbst In-Band-Pickling kann durch die Rückgabe einer PickleBuffer-Instanz von __reduce_ex__ verbessert werden, da auf dem Serialisierungspfad eine Kopie vermieden wird [10] [12].

Hinweise

Änderbarkeit

PEP 3118-Puffer können schreibgeschützt oder beschreibbar sein. Einige Objekte, wie Numpy-Arrays, müssen für eine vollständige Funktionalität durch einen veränderbaren Puffer unterstützt werden. Pickle-Verbraucher, die die Argumente buffer_callback und buffers verwenden, müssen vorsichtig sein, um veränderbare Puffer neu zu erstellen. Bei I/O bedeutet dies die Verwendung von Puffer-Passing-API-Varianten wie readinto (die oft auch aus Leistungsgründen vorzuziehen sind).

Datenaustausch

Wenn Sie ein Objekt im selben Prozess picklen und dann unpicklen, wobei Out-of-Band-Pufferansichten übergeben werden, kann das unpickelte Objekt durch denselben Puffer wie das ursprüngliche gepickelte Objekt gesichert sein.

Zum Beispiel könnte es sinnvoll sein, die Reduzierung eines Numpy-Arrays wie folgt zu implementieren (wichtige Metadaten wie Formen sind der Einfachheit halber weggelassen):

class ndarray:

   def __reduce_ex__(self, protocol):
      if protocol == 5:
         return numpy.frombuffer, (PickleBuffer(self), self.dtype)
      # Legacy code for earlier protocols omitted

Das einfache Übergeben des PickleBuffer von dumps an loads erzeugt dann ein neues Numpy-Array, das denselben zugrunde liegenden Speicher wie das ursprüngliche Numpy-Objekt teilt (und es übrigens am Leben erhält).

>>> import numpy as np
>>> a = np.zeros(10)
>>> a[0]
0.0
>>> buffers = []
>>> data = pickle.dumps(a, protocol=5, buffer_callback=buffers.append)
>>> b = pickle.loads(data, buffers=buffers)
>>> b[0] = 42
>>> a[0]
42.0

Dies geschieht nicht mit der traditionellen pickle-API (d. h. ohne Übergabe der Parameter buffers und buffer_callback), da die Pufferansicht dann mit einer Kopie innerhalb des Pickle-Streams serialisiert wird.

Abgelehnte Alternativen

Verwendung der bestehenden persistent load-Schnittstelle

Die pickle-Persistenzschnittstelle ist eine Möglichkeit, Referenzen auf designierte Objekte im Pickle-Stream zu speichern und deren eigentliche Serialisierung out-of-band zu handhaben. Zum Beispiel könnte man für die Null-Kopie-Serialisierung von bytearrays Folgendes in Betracht ziehen:

class MyPickle(pickle.Pickler):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.buffers = []

    def persistent_id(self, obj):
        if type(obj) is not bytearray:
            return None
        else:
            index = len(self.buffers)
            self.buffers.append(obj)
            return ('bytearray', index)


class MyUnpickle(pickle.Unpickler):

    def __init__(self, *args, buffers, **kwargs):
        super().__init__(*args, **kwargs)
        self.buffers = buffers

    def persistent_load(self, pid):
        type_tag, index = pid
        if type_tag == 'bytearray':
            return self.buffers[index]
        else:
            assert 0  # unexpected type

Dieser Mechanismus hat zwei Nachteile:

  • Jeder pickle-Konsument muss benutzerdefinierte Pickler- und Unpickler-Unterklassen mit speziellem Code für jeden interessierenden Typ neu implementieren. Im Wesentlichen implementieren N Pickle-Konsumenten jeweils benutzerdefinierten Code für M Produzenten. Dies ist schwierig (insbesondere für ausgefeilte Typen wie Numpy-Arrays) und schlecht skalierbar.
  • Jedes vom Pickle-Modul angetroffene Objekt (auch einfache integrierte Objekte wie Integers und Strings) löst einen Aufruf der persistent_id()-Methode des Benutzers aus, was zu einem möglichen Leistungsabfall im Vergleich zum Nominal führt.

    (Das cPickle-Modul in Python 2 unterstützte einen undokumentierten inst_persistent_id()-Hook, der nur auf Nicht-Built-in-Typen aufgerufen wurde; er wurde 1997 hinzugefügt, um das Leistungsproblem des Aufrufs von persistent_id zu mildern, vermutlich auf Anfrage von ZODB).

Übergabe einer Sequenz von Puffern in buffer_callback

Durch die Übergabe einer Sequenz von Puffern anstelle eines einzelnen Puffers würden wir potenziell Funktionsaufruf-Overhead einsparen, falls während der Serialisierung eine große Anzahl von Puffern erzeugt wird. Dies würde zusätzliche Unterstützung im Pickler erfordern, um Puffer vor dem Aufruf des Callbacks zu speichern. Es würde jedoch auch verhindern, dass der Puffer-Callback einen booleschen Wert zurückgibt, um anzuzeigen, ob ein Puffer In-Band oder Out-of-Band serialisiert werden soll.

Wir gehen davon aus, dass die Serialisierung einer großen Anzahl von Puffern ein unwahrscheinlicher Fall ist, und haben beschlossen, dem Puffer-Callback einen einzelnen Puffer zu übergeben.

Erlaubt das Serialisieren eines PickleBuffer in Protokoll 4 und früher

Wenn wir erlauben würden, einen PickleBuffer in Protokollen 4 und früher zu serialisieren, würde dies tatsächlich eine zusätzliche Speicher kopieren, wenn der Puffer veränderbar ist. Tatsächlich würde ein veränderbarer PickleBuffer in diesen Protokollen als bytearray-Objekt serialisiert werden (das ist eine erste Kopie), und die Serialisierung des bytearray-Objekts würde bytearray.__reduce_ex__ aufrufen, was ein bytes-Objekt zurückgibt (das ist eine zweite Kopie).

Um Implementierer von __reduce__ daran zu hindern, unfreiwillige Leistungsregressionen einzuführen, haben wir beschlossen, PickleBuffer abzulehnen, wenn das Protokoll kleiner als 5 ist. Dies zwingt die Implementierer, auf __reduce_ex__ umzusteigen und protokollabhängige Serialisierung zu implementieren, wobei der beste Pfad für jedes Protokoll genutzt wird (oder zumindest Protokoll 5 und höher von Protokollen 4 und darunter getrennt behandelt wird).

Implementierung

Das PEP wurde ursprünglich in der GitHub-Fork des Autors implementiert [6]. Es wurde später in Python 3.8 integriert [7].

Ein Backport für Python 3.6 und 3.7 kann von PyPI heruntergeladen werden [8].

Die Unterstützung für das Pickle-Protokoll 5 und Out-of-Band-Puffer wurde in Numpy hinzugefügt [11].

Die Unterstützung für das Pickle-Protokoll 5 und Out-of-Band-Puffer wurde zu den Apache Arrow Python-Bindings hinzugefügt [9].

Danksagungen

Dank der folgenden Personen für frühes Feedback: Alyssa Coghlan, Olivier Grisel, Stefan Krah, MinRK, Matt Rocklin, Eric Snow.

Dank an Pierre Glaser und Olivier Grisel für Experimente mit der Implementierung.

Referenzen


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

Zuletzt geändert: 2025-02-01 08:59:27 GMT