Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 574 – 带外数据的 Pickle 协议 5

作者:
Antoine Pitrou <solipsis at pitrou.net>
BDFL 代表:
Alyssa Coghlan
状态:
最终
类型:
标准跟踪
创建:
2018 年 3 月 23 日
Python 版本:
3.8
历史记录:
2018 年 3 月 28 日,2019 年 4 月 30 日
决议:
Python-Dev 消息

目录

摘要

本 PEP 提出标准化一个新的 pickle 协议版本,以及配套的 API 以充分利用它。

  1. 一个新的 pickle 协议版本 (5) 用于涵盖带外数据缓冲区所需的额外元数据。
  2. 一个新的 PickleBuffer 类型,供 __reduce_ex__ 实现返回带外数据缓冲区。
  3. 一个新的 buffer_callback 参数用于序列化时处理带外数据缓冲区。
  4. 一个新的 buffers 参数用于反序列化时提供带外数据缓冲区。

PEP 保证对于不使用新 API 的任何人来说行为保持不变。

基本原理

pickle 协议最初设计于 1995 年,用于任意 Python 对象的磁盘持久化。1995 年存储介质的性能可能使得关注诸如在写入磁盘之前复制临时数据时使用 RAM 带宽等性能指标变得无关紧要。

如今,pickle 协议在大多数数据从未持久化到磁盘(或者,当持久化时,使用可移植格式而不是 Python 特定格式)的应用程序中得到了越来越广泛的应用。相反,pickle 被用于在同一台机器或多台机器上的进程之间传输数据和命令。这些应用程序有时会处理非常大的数据(例如 NumPy 数组或 Pandas 数据框),这些数据需要在进程之间传递。对于这些应用程序,pickle 目前效率低下,因为它会对正在序列化的数据进行无谓的内存复制。

事实上,标准的 multiprocessing 模块使用 pickle 进行序列化,因此在将大型数据发送到另一个进程时也会遇到此问题。

第三方 Python 库,例如 Dask [1]、PyArrow [4] 和 IPyParallel [3],已开始实现替代序列化方案,其明确目标是避免大型数据的复制。实现新的序列化方案很困难,并且通常会导致通用性降低(因为许多 Python 对象支持 pickle,但不支持新的序列化方案)。回退到 pickle 处理不支持的类型是一种选择,但这样一来,您又回到了您原本想要避免的无谓内存复制。例如,dask 能够避免 NumPy 数组及其内置容器(例如包含 NumPy 数组的列表或字典)的内存复制,但是如果大型 NumPy 数组是用户定义对象的属性,dask 将把用户定义的对象序列化为 pickle 流,从而导致内存复制。

这些第三方序列化工作的共同主题是生成对象元数据流(其中包含有关正在序列化的对象的类似 pickle 的信息)和单独的零复制缓冲区对象流,用于大型对象的有效载荷。请注意,在此方案中,诸如整数等小型对象可以与元数据流一起转储。改进可以包括根据数据类型和布局对大型数据进行机会性压缩,例如 dask 所做的那样。

本 PEP 的目标是使 pickle 能够以一种将大型数据作为单独的零复制缓冲区流处理的方式使用,让应用程序能够以最佳方式处理这些缓冲区。

示例

为了使示例保持简单并避免需要了解第三方库,我们这里将重点放在 bytearray 对象上(但问题在概念上与更复杂的物体(如 NumPy 数组)相同)。与大多数对象一样,bytearray 对象不会被 pickle 模块立即理解,因此必须指定其分解方案。

以下是 bytearray 对象当前如何分解以进行 pickle 操作:

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

这是因为 bytearray.__reduce_ex__ 实现从道德上讲如下所示:

class bytearray:

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

反过来,它会生成以下 pickle 代码:

>>> 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

(上面对 pickletools.optimize 的调用仅旨在通过删除 MEMOIZE 操作码使 pickle 流更易读)

我们可以注意到有关 bytearray 的有效载荷(字节序列 b'abc')的一些事情:

  • bytearray.__reduce_ex__ 通过从 bytearray 的数据实例化一个新的 bytes 对象来生成第一个副本。
  • pickle.dumps 在将该 bytes 对象的内容插入 pickle 流(在 SHORT_BINBYTES 操作码之后)时生成第二个副本。
  • 此外,在反序列化 pickle 流时,当遇到 SHORT_BINBYTES 操作码时会创建一个临时的 bytes 对象(导致数据复制)。

我们真正想要的是类似以下内容:

  • bytearray.__reduce_ex__ 生成 bytearray 数据的视图
  • pickle.dumps 不会尝试将该数据复制到 pickle 流中,而是将缓冲区视图传递给其调用方(该调用方可以决定该缓冲区的最高效处理方式)。
  • 在反序列化时,pickle.loads 分别获取 pickle 流和缓冲区视图,并将缓冲区视图直接传递给 bytearray 构造函数。

我们可以看到,上述内容需要满足几个条件:

  • __reduce____reduce_ex__ 必须能够返回某些内容,以指示可序列化的无复制缓冲区视图。
  • pickle 协议必须能够表示对这种缓冲区视图的引用,指示反序列化程序可能必须从带外获取实际的缓冲区。
  • pickle.Pickler API 必须为其调用方提供一种方法,以便在序列化期间接收此类缓冲区视图。
  • pickle.Unpickler API 也必须允许其调用方提供反序列化所需的缓冲区视图。
  • 为了兼容性,pickle 协议还必须能够包含此类缓冲区视图的直接序列化,这样如果当前使用 pickle API 不关心内存复制,则无需修改。

生产者 API

我们引入了一种新的类型 pickle.PickleBuffer,它可以从任何支持缓冲区的对象实例化,并且专门用于从 __reduce__ 实现中返回。

class bytearray:

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

PickleBuffer 是一个简单的包装器,它没有所有 memoryview 的语义和功能,但如果启用了协议 5 或更高版本,则会被 pickle 模块专门识别。尝试使用 pickle 协议版本 4 或更早版本序列化 PickleBuffer 是错误的。

pickle 模块将只考虑 PickleBuffer 的原始数据。任何类型特定的元数据(例如形状或数据类型)都必须由类型的 __reduce__ 实现单独返回,这与现在的情况相同。

PickleBuffer 对象

PickleBuffer 类支持非常简单的 Python API。其构造函数接受一个 PEP 3118 兼容的对象。 PickleBuffer 对象本身支持缓冲区协议,因此消费者可以在它们上调用 memoryview(...) 以获取有关底层缓冲区的更多信息(例如原始类型、形状等)。此外, PickleBuffer 对象具有以下方法:

raw()

返回 PickleBuffer 底层原始内存字节的内存视图,清除任何形状、步幅和格式信息。这需要在纯 Python pickle 实现中正确处理 Fortran 连续缓冲区。

release()

释放 PickleBuffer 的底层缓冲区,使其无法使用。

在 C 端,将提供一个简单的 API 来创建和检查 PickleBuffer 对象:

PyObject *PyPickleBuffer_FromObject(PyObject *obj)

创建一个 PickleBuffer 对象,该对象保存对 PEP 3118 兼容的 obj 的视图。

PyPickleBuffer_Check(PyObject *obj)

返回 obj 是否为 PickleBuffer 实例。

const Py_buffer *PyPickleBuffer_GetBuffer(PyObject *picklebuf)

返回指向 PickleBuffer 实例拥有的内部 Py_buffer 的指针。如果缓冲区已释放,则会引发异常。

int PyPickleBuffer_Release(PyObject *picklebuf)

释放 PickleBuffer 实例的底层缓冲区。

缓冲区要求

PickleBuffer 可以包装任何类型的缓冲区,包括非连续缓冲区。但是,要求 __reduce__ 仅返回连续的 PickleBuffer(这里的连续性指的是 PEP 3118 的含义:C 顺序或 Fortran 顺序)。非连续缓冲区在 pickle 时会引发错误。

此限制主要是 pickle 模块和其他带外缓冲区使用者在实现方面的简化问题。提供方最简单的解决方案是返回非连续缓冲区的连续副本;但是,复杂的提供方可以决定改为返回连续子缓冲区的序列。

消费者 API

pickle.Pickler.__init__pickle.dumps 添加了一个额外的 buffer_callback 参数

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__pickle.loads 添加了一个额外的 buffers 参数

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*.
   """

协议更改

引入了三个新的操作码

  • BYTEARRAY8 从 pickle 流中接下来的数据创建一个字节数组,并将其压入堆栈(就像 BINBYTES8 对字节对象所做的那样);
  • NEXT_BUFFERbuffers 可迭代对象中获取一个缓冲区,并将其压入堆栈。
  • READONLY_BUFFER 创建堆栈顶部的只读视图。

当 pickle 遇到 PickleBuffer 时,该缓冲区可以根据以下条件被认为是带内或带外的

  • 如果没有给出 buffer_callback,则缓冲区为带内;
  • 如果给出了 buffer_callback,则会使用该缓冲区调用它。如果回调返回真值,则缓冲区为带内;如果回调返回假值,则缓冲区为带外。

带内缓冲区的序列化方式如下

  • 如果缓冲区是可写的,则将其序列化到 pickle 流中,就像它是 bytearray 对象一样。
  • 如果缓冲区是只读的,则将其序列化到 pickle 流中,就像它是 bytes 对象一样。

带外缓冲区的序列化方式如下

  • 如果缓冲区是可写的,则将 NEXT_BUFFER 操作码追加到 pickle 流中。
  • 如果缓冲区是只读的,则将 NEXT_BUFFER 操作码追加到 pickle 流中,然后追加 READONLY_BUFFER 操作码。

下面解释了只读和可写缓冲区之间的区别(参见“可变性”)。

副作用

改进的带内性能

即使是带内 pickle 也可以通过从 __reduce_ex__ 返回 PickleBuffer 实例来改进,因为在序列化路径上避免了一次复制 [10] [12]

注意事项

可变性

PEP 3118 缓冲区可以是只读的或可写的。某些对象(例如 NumPy 数组)需要由可变缓冲区支持才能完全运行。使用 buffer_callbackbuffers 参数的 pickle 使用者必须小心地重新创建可变缓冲区。在执行 I/O 时,这意味着使用缓冲区传递 API 变体,例如 readinto(这些变体通常也更利于性能)。

数据共享

如果在同一个进程中 pickle 和 unpickle 一个对象,并传递带外缓冲区视图,则 unpickled 对象可能与原始 pickled 对象共享相同的缓冲区。

例如,可以考虑如下实现 NumPy 数组的 reduction(为简单起见,省略了形状等关键元数据)

class ndarray:

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

然后,只需将 PickleBuffer 从 dumps 传递到 loads,就会产生一个新的 NumPy 数组,它与原始 NumPy 对象共享相同的底层内存(并且顺便说一句,保持它存活)

>>> 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

使用传统的 pickle API(即不传递 buffersbuffer_callback 参数)不会发生这种情况,因为在这种情况下,缓冲区视图会在 pickle 流中复制序列化。

被拒绝的替代方案

使用现有的持久加载接口

pickle 持久性接口是一种在 pickle 流中存储对指定对象的引用的方法,同时处理它们的实际带外序列化。例如,可以考虑以下用于字节数组的零拷贝序列化的方案

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

此机制有两个缺点

  • 每个 pickle 使用者都必须重新实现 PicklerUnpickler 子类,并为每个感兴趣的类型编写自定义代码。本质上,N 个 pickle 使用者最终都会为 M 个生产者实现自定义代码。这很困难(特别是对于复杂的类型,例如 NumPy 数组),并且扩展性差。
  • pickle 模块遇到的每个对象(即使是简单的内置对象,如整数和字符串)都会触发对用户 persistent_id() 方法的调用,这可能会导致与标称值相比性能下降。

    (Python 2 cPickle 模块支持一个未公开的 inst_persistent_id() 钩子,它仅在非内置类型上调用;它是在 1997 年添加的,目的是为了缓解调用 persistent_id 的性能问题,大概是应 ZODB 的要求)

buffer_callback 中传递缓冲区序列

通过传递一系列缓冲区而不是单个缓冲区,如果在序列化期间产生了大量缓冲区,我们可能会节省函数调用开销。这需要 Pickler 中的额外支持,以便在调用回调之前保存缓冲区。但是,它也会阻止缓冲区回调返回布尔值以指示缓冲区是否要带内序列化。

我们认为需要序列化大量缓冲区的情况不太可能发生,因此决定将单个缓冲区传递给缓冲区回调。

允许在协议 4 及更早版本中序列化 PickleBuffer

如果我们允许在协议 4 及更早版本中序列化 PickleBuffer,实际上当缓冲区可变时会进行额外的内存复制。实际上,可变 PickleBuffer 会在这些协议中序列化为 bytearray 对象(这是第一次复制),并且序列化 bytearray 对象会调用 bytearray.__reduce_ex__,它返回一个 bytes 对象(这是第二次复制)。

为了防止 __reduce__ 实现者引入无意的性能下降,我们决定在协议小于 5 时拒绝 PickleBuffer。这迫使实现者切换到 __reduce_ex__ 并实现协议相关的序列化,利用每个协议的最佳路径(或者至少将协议 5 及更高版本与协议 4 及更低版本分开处理)。

实现

该 PEP 最初在作者的 GitHub fork 中实现 [6]。后来合并到 Python 3.8 中 [7]

Python 3.6 和 3.7 的反向移植可以从 PyPI 下载 [8]

NumPy 添加了对 pickle 协议 5 和带外缓冲区的支持 [11]

Apache Arrow Python 绑定添加了对 pickle 协议 5 和带外缓冲区的支持 [9]

致谢

感谢以下人员提供早期反馈:Alyssa Coghlan、Olivier Grisel、Stefan Krah、MinRK、Matt Rocklin、Eric Snow。

感谢 Pierre Glaser 和 Olivier Grisel 尝试实现。

参考文献


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

最后修改时间:2023-10-11 12:05:51 GMT