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 以充分利用它
- 一个新的 pickle 协议版本 (5) 以涵盖带外数据缓冲区所需的额外元数据。
- 一种新的
PickleBuffer类型,用于__reduce_ex__实现以返回带外数据缓冲区。 - 在 pickle 时添加一个新的
buffer_callback参数,用于处理带外数据缓冲区。 - 在 unpickle 时添加一个新的
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在 SHORT_BINBYTES 操作码之后将该 bytes 对象的内容插入 pickle 流时产生第二次复制。- 此外,在反序列化 pickle 流时,当遇到 SHORT_BINBYTES 操作码时会创建一个临时 bytes 对象(导致数据复制)。
我们真正想要的是以下内容:
bytearray.__reduce_ex__产生一个 bytearray 数据的 视图。pickle.dumps不会尝试将该数据复制到 pickle 流中,而是将缓冲区视图传递给其调用者(调用者可以决定如何最有效地处理该缓冲区)。- 在反序列化时,
pickle.loads分别获取 pickle 流和缓冲区视图,并将缓冲区视图直接传递给 bytearray 构造函数。
我们看到上述功能需要几个条件
__reduce__或__reduce_ex__必须能够返回 某种东西 来指示可序列化的无复制缓冲区视图。- pickle 协议必须能够表示对此类缓冲区视图的引用,指示 unpickler 可能需要从带外获取实际缓冲区。
pickle.PicklerAPI 必须为其调用者提供一种在序列化时接收此类缓冲区视图的方法。pickle.UnpicklerAPI 必须类似地允许其调用者提供反序列化所需的缓冲区视图。- 为了兼容性,pickle 协议还必须能够包含此类缓冲区视图的直接序列化,以便如果当前的
pickleAPI 使用不涉及内存复制,则无需修改。
生产者 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 底层原始内存字节的 memoryview,清除任何形状、步幅和格式信息。这是在纯 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 流中紧随其后的数据创建一个 bytearray 并将其推送到堆栈上(就像BINBYTES8对 bytes 对象所做的那样);NEXT_BUFFER从buffers可迭代对象中获取一个缓冲区并将其推送到堆栈。READONLY_BUFFER对堆栈顶部创建一个只读视图。
当 pickling 遇到 PickleBuffer 时,该缓冲区可以被视为带内或带外,具体取决于以下条件:
- 如果没有给出
buffer_callback,则缓冲区是带内的; - 如果给定了
buffer_callback,则使用缓冲区调用它。如果回调返回一个真值,则缓冲区是带内的;如果回调返回一个假值,则缓冲区是带外的。
带内缓冲区序列化如下
- 如果缓冲区是可写的,则将其序列化到 pickle 流中,就像它是一个
bytearray对象一样。 - 如果缓冲区是只读的,则将其序列化到 pickle 流中,就像它是一个
bytes对象一样。
带外缓冲区序列化如下
- 如果缓冲区是可写的,则将
NEXT_BUFFER操作码附加到 pickle 流中。 - 如果缓冲区是只读的,则将
NEXT_BUFFER操作码附加到 pickle 流中,后跟一个READONLY_BUFFER操作码。
可读写缓冲区和只读缓冲区之间的区别将在下面解释(参见“可变性”)。
副作用
改进的带内性能
即使是带内 pickling 也可以通过从 __reduce_ex__ 返回 PickleBuffer 实例来改进,因为在序列化路径上可以避免一次复制 [10] [12]。
注意事项
可变性
PEP 3118 缓冲区可以是只读或可写的。某些对象,如 Numpy 数组,需要可变缓冲区作为其完整操作的后端。使用 buffer_callback 和 buffers 参数的 Pickle 消费者必须小心地重新创建可变缓冲区。在进行 I/O 时,这意味着使用缓冲区传递 API 变体,例如 readinto(通常也更受性能偏好)。
数据共享
如果您在同一进程中对一个对象进行 pickle 和 unpickle,并传递带外缓冲区视图,那么 unpickle 后的对象可能与原始 pickle 对象共享相同的底层缓冲区。
例如,Numpy 数组的缩减可能合理地实现如下(为简单起见,省略了形状等关键元数据):
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(即不传递 buffers 和 buffer_callback 参数)不会发生这种情况,因为那时缓冲区视图会在 pickle 流中序列化并进行复制。
被拒绝的替代方案
使用现有的持久加载接口
pickle 持久化接口是一种在 pickle 流中存储对指定对象的引用,同时在带外处理其实际序列化的方法。例如,对于 bytearray 的零拷贝序列化,可以考虑以下内容:
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消费者都必须重新实现Pickler和Unpickler子类,并为每种感兴趣的类型编写自定义代码。本质上,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]。
对 pickle 协议 5 和带外缓冲区的支持已添加到 Numpy [11] 中。
对 pickle 协议 5 和带外缓冲区的支持已添加到 Apache Arrow Python 绑定 [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