PEP 509 – 为 dict 添加一个私有版本
- 作者:
- Victor Stinner <vstinner at python.org>
- 状态:
- 已取代
- 类型:
- 标准跟踪
- 创建日期:
- 2016年1月4日
- Python 版本:
- 3.6
- 发布历史:
- 2016年1月8日, 2016年1月11日, 2016年4月14日, 2016年4月19日
- 取代者:
- 699
- 决议:
- Python-Dev 消息
摘要
为内置的 dict 类型添加一个新的私有版本,在每次字典创建和每次字典更改时递增,以实现在命名空间上的快速守卫。
基本原理
在Python中,内置的 dict 类型被许多指令使用。例如,LOAD_GLOBAL 指令在全局命名空间或内置命名空间中查找变量(两次字典查找)。Python将 dict 用于内置命名空间、全局命名空间、类型命名空间、实例命名空间等。局部命名空间(函数命名空间)通常被优化为数组,但它也可以是字典。
Python难以优化,因为几乎所有东西都是可变的:内置函数、函数代码、全局变量、局部变量……都可以在运行时修改。实现尊重Python语义的优化需要检测何时“发生变化”:我们将这些检查称为“守卫”。
优化的加速取决于守卫检查的速度。本PEP提议为字典添加一个私有版本,以实现对命名空间的快速守卫。
如果版本不变,可以跳过字典查找,这对于大多数命名空间来说是常见情况。版本是全局唯一的,因此检查版本也足以验证命名空间字典是否未被新字典替换。
当字典版本不变时,守卫的性能不取决于被监视字典条目的数量:复杂度为O(1)。
优化示例:将全局变量的值复制到函数常量。此优化需要对全局变量进行守卫,以检查其在复制后是否被修改。如果全局变量未修改,则函数使用缓存的副本。如果全局变量被修改,则函数使用常规查找,并且可能还会使函数去优化(以消除下次函数调用时守卫检查的开销)。
有关守卫的具体用法来专门化函数以及关于Python静态优化器的更一般性原理,请参阅 510 – 使用守卫的专用函数。
守卫示例
使用假设的 dict_get_version(dict) 函数检查字典条目是否被修改(创建、更新或删除)的快速守卫伪代码
UNSET = object()
class GuardDictKey:
def __init__(self, dict, key):
self.dict = dict
self.key = key
self.value = dict.get(key, UNSET)
self.version = dict_get_version(dict)
def check(self):
"""Return True if the dictionary entry did not change
and the dictionary was not replaced."""
# read the version of the dictionary
version = dict_get_version(self.dict)
if version == self.version:
# Fast-path: dictionary lookup avoided
return True
# lookup in the dictionary
value = self.dict.get(self.key, UNSET)
if value is self.value:
# another key was modified:
# cache the new dictionary version
self.version = version
return True
# the key was modified
return False
dict版本的使用
加速方法调用
Yury Selivanov 写了一个 补丁来优化方法调用。该补丁依赖于 “在ceval中实现每个操作码缓存” 补丁,该补丁需要字典版本在全局字典或内置字典被修改时使缓存失效。
缓存还要求字典版本是全局唯一的。可以在一个命名空间中定义一个函数,并在另一个命名空间中调用它,例如使用带有 globals 参数的 exec()。在这种情况下,全局字典被替换,缓存也必须失效。
使用守卫的专用函数
PEP 510 提出了一种API,以支持带有守卫的专用函数。它允许在不破坏Python语义的情况下实现Python的静态优化器。
fatoptimizer 是 FAT Python 项目的一个静态Python优化器示例。它实现了许多需要在命名空间上进行守卫的优化。
- 调用纯内置函数:要将
len("abc")替换为3,需要对builtins.__dict__['len']和globals()['len']进行守卫。 - 循环展开:要展开循环
for i in range(...): ...,需要对builtins.__dict__['range']和globals()['range']进行守卫。 - 等等。
Pyjion
根据Pyjion的两位主要开发者之一 Brett Cannon 的说法,Pyjion 可以从字典版本中受益以实现优化。
Pyjion 是一个基于 CoreCLR (Microsoft .NET Core runtime) 的 Python JIT 编译器。
Cython
Cython 可以从字典版本中受益以实现优化。
Cython 是一个针对Python编程语言和扩展的Cython编程语言的优化静态编译器。
Unladen Swallow
即使没有明确提及字典版本,优化全局变量和内置函数的查找也是 Unladen Swallow 计划的一部分:“实现几种提议的方案之一,以加快全局变量和内置函数的查找。”(来源:Unladen Swallow ProjectPlan)。
Unladen Swallow 是 CPython 2.6.1 的一个分支,通过 LLVM 实现了 JIT 编译器。该项目于2011年停止:Unladen Swallow 回顾。
更改
在 PyDictObject 结构中添加一个 ma_version_tag 字段,C 类型为 PY_UINT64_T,一个64位无符号整数。还添加一个全局字典版本。
每次创建字典时,全局版本递增,字典版本初始化为全局版本。
每次修改字典内容时,全局版本必须递增并复制到字典版本。可以修改其内容的字典方法:
clear()pop(key)popitem()setdefault(key, value)__delitem__(key)__setitem__(key, value)update(...)
当字典方法不改变其内容时,是否增加版本的选择留给Python实现。Python实现可以决定不增加版本以避免守卫中的字典查找。字典方法不改变其内容的示例:
- 如果字典已经为空,则
clear() - 如果键不存在,则
pop(key) - 如果字典为空,则
popitem() - 如果键已经存在,则
setdefault(key, value) - 如果键不存在,则
__delitem__(key) - 如果新值与当前值相同,则
__setitem__(key, value) - 如果调用时没有参数,或者新值与当前值相同,则
update()
将键设置为等于旧值的新值也被视为修改字典内容的操作。
两个不同的空字典必须具有不同的版本,才能仅通过其版本来识别字典。它允许在守卫中验证命名空间是否未被替换,而无需存储对字典的强引用。使用借用引用不起作用:如果旧字典被销毁,则可能在新内存地址处分配新字典。顺便说一句,字典不支持弱引用。
版本增加必须是原子性的。在CPython中,全局解释器锁(GIL)已经保护 dict 方法以使更改原子化。
使用假设的 dict_get_version(dict) 函数的示例
>>> d = {}
>>> dict_get_version(d)
100
>>> d['key'] = 'value'
>>> dict_get_version(d)
101
>>> d['key'] = 'new value'
>>> dict_get_version(d)
102
>>> del d['key']
>>> dict_get_version(d)
103
该字段名为 ma_version_tag,而不是 ma_version,以建议使用 version_tag == old_version_tag 进行比较,而不是 version <= old_version,后者在整数溢出后会变得错误。
向后兼容性
由于 PyDictObject 结构不是稳定ABI的一部分,并且新的字典版本未在Python范围内公开,因此更改是向后兼容的。
实现和性能
问题 #26058: PEP 509: 为 PyDictObject 添加 ma_version_tag 包含一个实现此PEP的补丁。
在pybench和timeit微基准测试中,该补丁似乎没有给字典操作增加任何开销。例如,以下timeit微基准测试在更改前后均耗时318纳秒。
python3.6 -m timeit 'd={1: 0}; d[2]=0; d[3]=0; d[4]=0; del d[1]; del d[2]; d.clear()'
当版本不变时,PyDict_GetItem() 进行字典查找需要14.8纳秒,而守卫检查仅需3.8纳秒。此外,一个守卫可以监视多个键。例如,对于在一个函数中使用10个全局变量的优化,10次字典查找需要148纳秒,而当版本不变时,守卫仍然只花费3.8纳秒(快39倍)。
fat模块 实现了这样的守卫:fat.GuardDict 基于字典版本。
整数溢出
该实现使用C类型 PY_UINT64_T 来存储版本:一个64位无符号整数。C代码使用 version++。根据C标准,在整数溢出时,版本被包装为 0(然后继续递增)。
整数溢出后,守卫可能会成功,而受监视的字典键已被修改。该错误仅在守卫检查时发生,如果自上次守卫检查以来,恰好发生了 2 ** 64 次字典创建或修改。
如果字典每纳秒修改一次,则 2 ** 64 次修改需要超过584年。使用32位版本,仅需4秒。这就是为什么在32位系统上也使用64位无符号类型的原因。C级别的字典查找需要14.8纳秒。
每584年出现一次错误的风险是可以接受的。
备选方案
在Python级别将版本公开为只读的 __version__ 属性
PEP的第一个版本建议在Python级别将字典版本公开为只读的 __version__ 属性,并将其添加到 collections.UserDict(因为此类型必须模仿 dict API)。
存在多个问题
- 为了保持一致并避免意外,版本必须添加到所有映射类型。实现一个新的映射类型将需要额外的工作,没有任何好处,因为实际上只需要在
dict类型上使用版本。 - 所有Python实现都必须实现这个新属性,这给其他实现带来了更多工作,而它们可能根本不使用字典版本。
- 在Python级别公开字典版本可能导致对性能的错误假设。在Python级别检查
dict.__version__并不比字典查找快。Python中的字典查找成本为48.7纳秒,检查版本的成本为47.5纳秒,差异仅为1.2纳秒(3%)。$ python3.6 -m timeit -s 'd = {str(i):i for i in range(100)}' 'd["33"] == 33' 10000000 loops, best of 3: 0.0487 usec per loop $ python3.6 -m timeit -s 'd = {str(i):i for i in range(100)}' 'd.__version__ == 100' 10000000 loops, best of 3: 0.0475 usec per loop __version__可能会在整数溢出时回绕。这容易出错:使用dict.__version__ <= guard_version是错误的,必须使用dict.__version__ == guard_version来降低整数溢出时出现错误的风险(即使实际上整数溢出的可能性很小)。
属性名称的强制性辩论
__cache_token__:Alyssa Coghlan 提出的名称,来自 abc.get_cache_token()。__version____version_tag____timestamp__
为每个字典条目添加一个版本
每个字典只有一个版本需要保持对值的强引用,这可能会使值存活时间比预期更长。如果每个字典条目也添加一个版本,守卫可以只存储条目版本(一个简单的整数),以避免对值的强引用:只需要对字典和键的强引用。
更改:在 PyDictKeyEntry 结构中添加一个 me_version_tag 字段,该字段的C类型为 PY_UINT64_T。当创建或修改键时,条目版本设置为字典版本,该版本在任何更改(创建、修改、删除)时递增。
使用假设的 dict_get_version(dict) 和 dict_get_entry_version(dict) 函数检查字典键是否被修改的快速守卫伪代码
UNSET = object()
class GuardDictKey:
def __init__(self, dict, key):
self.dict = dict
self.key = key
self.dict_version = dict_get_version(dict)
self.entry_version = dict_get_entry_version(dict, key)
def check(self):
"""Return True if the dictionary entry did not change
and the dictionary was not replaced."""
# read the version of the dictionary
dict_version = dict_get_version(self.dict)
if dict_version == self.version:
# Fast-path: dictionary lookup avoided
return True
# lookup in the dictionary to read the entry version
entry_version = get_dict_key_version(dict, key)
if entry_version == self.entry_version:
# another key was modified:
# cache the new dictionary version
self.dict_version = dict_version
self.entry_version = entry_version
return True
# the key was modified
return False
此选项的主要缺点是对内存占用量的影响。它增加了每个字典条目的大小,因此开销取决于桶的数量(字典条目,无论是否使用)。例如,在64位系统上,它将每个字典条目的大小增加8字节。
在Python中,内存占用很重要,而且趋势是减少它。示例:
添加一个新的dict子类型
添加一个新的 verdict 类型,作为 dict 的子类型。当需要守卫时,对命名空间(模块命名空间、类型命名空间、实例命名空间等)使用 verdict 而不是 dict。
保持 dict 类型不变,以便在不使用守卫时不会增加任何开销(CPU、内存占用)。
技术问题:野外有很多C代码,包括CPython核心,都期望确切的 dict 类型。问题:
exec()要求全局变量和局部变量是dict。许多代码使用globals={}。无法将dict强制转换为dict子类型,因为调用者期望globals参数被修改(dict是可变的)。- C函数直接调用
PyDict_xxx()函数,而不是在对象是dict子类型时调用PyObject_xxx() PyDict_CheckExact()检查在dict子类型上失败,而某些函数要求确切的dict类型。Python/ceval.c不完全支持命名空间的dict子类型。
exec() 问题是一个阻碍性问题。
其他问题
- 垃圾回收器有一个特殊的代码来“取消跟踪”
dict实例。如果dict子类型用于命名空间,垃圾回收器可能无法打破某些引用循环。 - 一些函数对
dict有快速路径,这些路径将不会用于dict子类型,因此会使Python稍微变慢。
先行实践
方法缓存和类型版本标签
2007年,Armin Rigo 写了一个补丁来实现方法缓存。它被合并到Python 2.6中。该补丁添加了一个“类型属性缓存版本标签”(tp_version_tag)和一个“有效版本标签”标志到类型(PyTypeObject 结构)。
类型版本标签未在Python级别公开。
版本标签的C类型是 unsigned int。缓存是一个全局哈希表,有4096个条目,由所有类型共享。缓存是全局的,“以使其快速,具有确定性和低内存占用,并且易于失效”。每个缓存条目都有一个版本标签。一个全局版本标签用于创建下一个版本标签,它的C类型也是 unsigned int。
默认情况下,类型的“有效版本标签”标志被清除,表示版本标签无效。当类型的第一个方法被缓存时,版本标签和“有效版本标签”标志被设置。当类型被修改时,类型及其子类的“有效版本标签”标志被清除。稍后,当使用这些类型的缓存条目时,该条目将被删除,因为其版本标签已过时。
在整数溢出时,整个缓存被清除,全局版本标签被重置为 0。
请参阅 方法缓存(问题 #1685986) 和 Armin为Python 2.6更新的方法缓存优化(问题 #1700288)。
全局变量 / 内置变量缓存
2010年,Antoine Pitrou 提出了一个 全局变量 / 内置变量缓存(问题 #10401),它在 PyDictObject 结构(dict 类型)中添加了一个私有的 ma_version 字段,该字段的C类型为 Py_ssize_t。
该补丁为函数和帧添加了一个“全局和内置缓存”,并更改了 LOAD_GLOBAL 和 STORE_GLOBAL 指令以使用该缓存。
PyDictObject 结构的更改与本PEP非常相似。
缓存的全局变量+内置变量查找
2006年,Andrea Griffini 提出了一个实现 缓存全局变量+内置变量查找优化 的补丁。该补丁在 PyDictObject 结构(dict 类型)中添加了一个私有的 timestamp 字段,该字段的C类型为 size_t。
python-dev 上的讨论串:关于字典查找缓存 (2006年12月)。
防止在迭代期间更改字典
2013年,Serhiy Storchaka 提出了 防止在迭代期间更改字典(问题 #19332),该提案在 PyDictObject 结构(dict 类型)中添加了一个 ma_count 字段,该字段的C类型为 size_t。当字典被修改时,此字段递增。
PySizer
PySizer:一个Python内存分析器,Google Summer of Code 2005项目,由 Nick Smallbone 完成。
该项目有一个针对CPython 2.4的补丁,它为字典条目添加了 key_time 和 value_time 字段。它使用一个全局的、进程范围的字典计数器,每次修改字典时递增。这些时间用于决定子对象何时首次出现在其父对象中。
讨论
邮件列表上的讨论
- python-dev: 更新的PEP 509
- python-dev: RFC: PEP 509: 为 dict 添加私有版本
- python-dev: PEP 509: 为 dict 添加私有版本 (2016年1月)
- python-ideas: RFC: PEP: 添加 dict.__version__ (2016年1月)
接受
该PEP于 2016年9月7日由Guido van Rossum接受。此后,PEP的实现已提交到仓库。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0509.rst