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

Python 增强提案

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的静态优化器。

fatoptimizerFAT 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_GLOBALSTORE_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_timevalue_time 字段。它使用一个全局的、进程范围的字典计数器,每次修改字典时递增。这些时间用于决定子对象何时首次出现在其父对象中。

讨论

邮件列表上的讨论

接受

该PEP于 2016年9月7日由Guido van Rossum接受。此后,PEP的实现已提交到仓库。


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

最后修改:2025-02-01 08:55:40 GMT