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

Python 增强提案

PEP 266 – 优化全局变量/属性访问

作者:
Skip Montanaro <skip at pobox.com>
状态:
已撤回
类型:
标准跟踪
创建日期:
2001 年 8 月 13 日
Python 版本:
2.3
发布历史:


目录

摘要

大多数全局变量和其它模块属性的绑定通常在 Python 程序执行期间不会改变,但由于 Python 的动态特性,每次需要访问这些全局对象时,代码都必须进行完整的查找。本 PEP 提出了一种机制,允许访问大多数全局对象的代码将其视为局部对象,并将更新引用的负担放在更改此类对象的名称绑定的代码上。

引言

考虑主力函数 sre_compile._compile。它是 sre 模块的内部编译函数。它几乎完全由一个循环组成,该循环遍历正在编译的模式元素,将操作码与已知常量值进行比较,并将令牌附加到输出列表。大多数比较都是与从 sre_constants 模块导入的常量进行的。这意味着该模块的编译输出中有很多 LOAD_GLOBAL 字节码。仅从代码中就可以看出,作者希望 LITERALNOT_LITERALOPCODES 和许多其他符号都是常量。然而,每次它们参与表达式时,都必须重新查找。

大多数全局访问实际上是针对“几乎是常量”的对象。这包括当前模块中的全局变量以及其他导入模块的属性。由于它们很少改变,因此将更新此类对象引用的负担放在更改名称绑定的代码上似乎是合理的。如果 sre_constants.LITERAL 更改为引用另一个对象,那么修改 sre_constants 模块字典的代码纠正对该对象的任何活动引用可能是有益的。通过这样做,在许多情况下,全局变量和许多对象的属性可以作为局部变量进行缓存。如果对象所给名称与对象本身之间的绑定很少改变,则跟踪此类对象的成本应该很低,并且潜在的回报相当可观。

为了评估此提案的效果,我修改了 Python 发行版中包含的 Pystone 基准程序以缓存全局函数。它的主函数 Proc0 在其 for 循环中调用了十个不同的函数。此外,Func2 在循环中重复调用 Func1。如果在函数循环进入之前创建这些 11 个全局标识符的本地副本,则此特定基准的性能提高了大约百分之二(在我的笔记本电脑上从 5561 pystones 增加到 5685 pystones)。这表明缓存大多数全局变量访问会提高性能。另请注意,pystone 基准本质上没有访问全局模块属性,而这是本 PEP 预期改进的领域。

提议的更改

我建议修改 Python 虚拟机以包含 TRACK_OBJECTUNTRACK_OBJECT 操作码。TRACK_OBJECT 将全局名称或全局名称的属性与局部变量数组中的一个槽位关联起来,并执行对关联对象的初始查找以用有效值填充该槽位。它创建的关联将由负责更改名称到对象绑定的代码记录,以导致关联的局部变量被更新。UNTRACK_OBJECT 操作码将删除名称与局部变量槽位之间的任何关联。

线程

此代码在线程程序中的操作与非线程程序中的操作没有区别。如果您需要锁定一个对象才能访问它,那么您必须在执行 TRACK_OBJECT 之前完成此操作,并保留该锁直到您停止使用它。

FIXME: 我怀疑这里需要更多内容。

基本原理

全局变量和属性很少改变。例如,一旦一个函数导入了 math 模块,名称 math 和它引用的模块之间的绑定就不太可能改变。同样,如果使用 math 模块的函数引用其 sin 属性,它也不太可能改变。尽管如此,每次模块想要调用 math.sin 函数时,它都必须首先执行一对指令

LOAD_GLOBAL     math
LOAD_ATTR       sin

如果客户端模块总是假设 math.sin 是一个局部常量,并且由函数外部的“外部力量”负责保持引用正确,我们可能会有这样的代码

TRACK_OBJECT       math.sin
...
LOAD_FAST          math.sin
...
UNTRACK_OBJECT     math.sin

如果 LOAD_FAST 在一个循环中,那么减少全局加载和属性查找的回报可能会非常显著。

这种技术理论上可以应用于任何全局变量访问或属性查找。考虑以下代码

l = []
for i in range(10):
    l.append(math.sin(i))
return l

即使 l 是局部变量,在循环中你仍然要为加载 l.append 十次付出代价。编译器(或优化器)可以识别出 math.sinl.append 都在循环中被调用,并决定生成跟踪的局部代码,而避免对内置的 range() 函数这样做,因为它只在循环设置期间被调用一次。与访问局部变量相关的性能问题使得跟踪 l.append 不如跟踪 math.sin 等全局变量有吸引力。

根据 Marc-Andre Lemburg 在 python-dev 上发布的一篇文章 [1]LOAD_GLOBAL 操作码占 Python 虚拟机执行的所有指令的 7% 以上。这可能是一个非常昂贵的指令,至少相对于 LOAD_FAST 指令而言,后者只是一个简单的数组索引,不需要虚拟机进行额外的函数调用。我相信许多 LOAD_GLOBAL 指令以及 LOAD_GLOBAL/LOAD_ATTR 对可以转换为 LOAD_FAST 指令。

大量使用全局变量的代码通常会采取各种技巧来避免全局变量和属性查找。前面提到的 sre_compile._compile 函数缓存了不断增长的输出列表的 append 方法。许多人通常滥用函数的默认参数功能来缓存全局变量查找。这两种方案都有些投机取巧,很少能解决所有可用的优化机会。(例如,sre_compile._compile 没有缓存它最常用的两个全局变量:内置的 len 函数和它从 sre_constants.py 导入的全局 OPCODES 数组。)

问题

线程怎么办?如果在缓存中时 math.sin 发生变化怎么办?

我相信全局解释器锁将保护值免受损坏。无论如何,情况不会比现在更糟。如果一个线程在另一个线程已经执行 LOAD_GLOBAL math 但在它执行 LOAD_ATTR sin 之前修改了 math.sin,客户端线程将看到 math.sin 的旧值。

想法是这样的。我下面使用多属性加载作为示例,不是因为它会经常发生,而是因为通过展示递归性质以及额外调用,希望能更清楚地表达我的想法。假设模块 foo 中定义的一个函数想要访问 spam.eggs.ham,并且 spam 是在 foo 的模块级别导入的模块

import spam
...
def somefunc():
...
x = spam.eggs.ham

进入 somefunc 时,将执行 TRACK_GLOBAL 指令

TRACK_GLOBAL spam.eggs.ham n

spam.eggs.ham 是存储在函数常量数组中的字符串文字。n 是一个 fastlocals 索引。&fastlocals[n] 是对正在执行帧的 fastlocals 数组中槽位 n 的引用,该位置将存储 spam.eggs.ham 引用。以下是我设想发生的事情

  1. TRACK_GLOBAL 指令找到由名称 spam 引用的对象,并在其模块作用域中找到它。然后它执行一个 C 函数,例如
    _PyObject_TrackName(m, "spam.eggs.ham", &fastlocals[n])
    

    其中 m 是具有属性 spam 的模块对象。

  2. 模块对象剥离前导 spam. 并存储必要的信息(eggs.ham&fastlocals[n]),以防其 eggs 名称的绑定发生变化。然后它在其字典中找到由键 eggs 引用的对象,并递归调用
    _PyObject_TrackName(eggs, "eggs.ham", &fastlocals[n])
    
  3. eggs 对象剥离前导 eggs.,存储 (ham, &fastlocals[n]) 信息,在其命名空间中找到名为 ham 的对象,然后再次调用 _PyObject_TrackName
    _PyObject_TrackName(ham, "ham", &fastlocals[n])
    
  4. ham 对象剥离前导字符串(这次没有“.”,但这只是一个小问题),看到结果为空,然后使用自己的值(可能是 self)来更新它所获得的位置
    Py_XDECREF(&fastlocals[n]);
    &fastlocals[n] = self;
    Py_INCREF(&fastlocals[n]);
    

    此时,参与解析 spam.eggs.ham 的每个对象都知道其命名空间中哪个条目需要被跟踪,以及如果该名称发生更改,哪个位置需要更新。此外,如果它在本地存储中跟踪的某个名称发生更改,它可以在更改后使用新对象调用 _PyObject_TrackName。在食物链的最低端,最后一个对象将始终剥离一个名称,看到空字符串,并知道其值应该填充到它被传递的位置。

    当由点分表达式 spam.eggs.ham 引用的对象将超出作用域时,将执行 UNTRACK_GLOBAL spam.eggs.ham n 指令。它的作用是删除 TRACK_GLOBAL 建立的所有跟踪信息。

    跟踪操作可能看起来很昂贵,但请记住,被跟踪的对象被假定为“几乎是常量”,因此设置成本将与希望的多次局部而不是全局加载进行权衡。对于带有属性的全局变量,跟踪设置成本会增加,但可以通过避免额外的 LOAD_ATTR 成本来抵消。 TRACK_GLOBAL 指令需要对链中的第一个名称执行 PyDict_GetItemString 以确定顶级对象所在的位置。链中的每个对象都必须在某个地方存储一个字符串和一个地址,可能在一个使用存储位置作为键(例如 &fastlocals[n])和字符串作为值的字典中。(这个字典可能是字典的中心字典,其键是对象地址而不是每个对象的字典。)它不应该是反过来的,因为多个活动帧可能想要跟踪 spam.eggs.ham,但只有一个帧会想要将该名称与它的一个快速局部变量槽相关联。

未解决的问题

线程

这个(愚蠢的)代码怎么样?

l = []
lock = threading.Lock()
...
def fill_l()::
   for i in range(1000)::
      lock.acquire()
      l.append(math.sin(i))
      lock.release()
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

从代码的静态分析来看,锁保护什么并不清楚。(你在编译时无法知道是否涉及线程,对吗?)它是否应该影响在 fill_l 函数中尝试跟踪 l.appendmath.sin

如果我们用神话般的 track_objectuntrack_object 内置函数来注释代码(我不是在提议这个,只是为了说明代码会放在哪里!),我们得到

l = []
lock = threading.Lock()
...
def fill_l()::
   track_object("l.append", append)
   track_object("math.sin", sin)
   for i in range(1000)::
      lock.acquire()
      append(sin(i))
      lock.release()
   untrack_object("math.sin", sin)
   untrack_object("l.append", append)
...
def consume_l()::
   while 1::
      lock.acquire()
      if l::
         elt = l.pop()
      lock.release()
      fiddle(elt)

无论有没有线程(或者至少在有和没有线程的情况下同样不正确),这都是正确的吗?

嵌套作用域

嵌套作用域的存在将影响 TRACK_GLOBAL 找到全局变量的位置,但之后不应影响任何其他内容。(我想是这样。)

缺失属性

假设我正在跟踪由 spam.eggs.ham 引用的对象,并且 spam.eggs 被重新绑定到一个没有 ham 属性的对象。很明显,如果程序员试图在当前 Python 虚拟机中解析 spam.eggs.ham,这将是一个 AttributeError,但假设程序员已经预料到这种情况

if hasattr(spam.eggs, "ham"):
    print spam.eggs.ham
elif hasattr(spam.eggs, "bacon"):
    print spam.eggs.bacon
else:
    print "what? no meat?"

重新计算跟踪信息时不能引发 AttributeError。如果它不引发 AttributeError 而让跟踪继续存在,则可能会导致程序员犯下一个非常细微的错误。

解决这个问题的一种方法是跟踪函数直接引用的每个点分表达式的最短可能根。在上面的例子中,spam.eggs 将被跟踪,但 spam.eggs.hamspam.eggs.bacon 不会。

谁来做这些繁琐的工作?

在“问题”部分,我假定存在一个 _PyObject_TrackName 函数。虽然 API 相当容易指定,但其幕后的实现却不那么明显。可以使用一个中心字典来跟踪名称/位置映射,但似乎所有 setattr 函数可能都需要修改以适应这个新功能。

如果所有类型都使用 PyObject_GenericSetAttr 函数来设置属性,那将一定程度上局部化更新代码。然而,它们并没有这样做(这并不太令人惊讶),所以看来所有 getattrfuncgetattrofunc 函数都必须更新。此外,这将对 C 扩展模块作者施加一个绝对要求,即当属性值改变时调用某个函数(PyObject_TrackUpdate?)。

最后,有些属性很可能是通过副作用而不是通过任何直接调用某种 setattr 方法来设置的。考虑一个设备接口模块,它有一个中断例程,每当设备寄存器的内容发生变化时,它就会将内容复制到对象 struct 中的一个槽位中。在这些情况下,模块作者将不得不进行更广泛的修改。在编译时识别这种情况是不可能的。我认为可以为 PyTypeObjects 添加一个额外的槽位,以指示对象的代码是否可以安全地进行全局跟踪。它将有一个默认值 0 (Py_TRACKING_NOT_SAFE)。如果扩展模块作者已经实现了必要的跟踪支持,则该字段可以初始化为 1 (Py_TRACKING_SAFE)。 _PyObject_TrackName 可以检查该字段,如果被要求跟踪一个作者尚未明确表示可以安全跟踪的对象,则发出警告。

讨论

Jeremy Hylton 提出了另一项提案 [2]。他的提案旨在创建一个混合字典/列表对象,用于全局名称查找,使全局变量访问更像局部变量访问。虽然没有可供检查的 C 代码,但他的提案中给出的 Python 实现似乎仍然需要字典键查找。他的提案似乎无法加速局部变量属性查找,尽管如果能解决潜在的性能负担,在某些情况下这可能是有价值的。

向后兼容性

我不认为会有任何严重的向后兼容性问题。显然,包含 TRACK_OBJECT 操作码的 Python 字节码不能被早期版本的解释器执行,但字节码级别的中断在版本之间通常是假定的。

实施

待定。这就是我需要帮助的地方。我相信应该有一个中央名称/位置注册表,或者修改修改对象属性的代码,但我不知道最好的方法是什么。如果你查看实现 STORE_GLOBALSTORE_ATTR 操作码的代码,似乎很可能需要对 PyDict_SetItemPyObject_SetAttr 或它们的字符串变体进行一些更改。理想情况下,应该有一个相当集中的地方来局部化这些更改。如果你开始考虑跟踪局部变量的属性,你还会遇到修改 STORE_FAST 的问题,这可能是一个问题,因为局部变量的名称绑定更改更频繁。(我认为优化器可以避免为任何局部变量的属性插入跟踪代码,如果该变量的名称绑定发生变化。)

性能

我相信(尽管目前我没有代码来证明这一点),实现 TRACK_OBJECT 通常不会比单个 LOAD_GLOBAL 指令或一对 LOAD_GLOBAL/LOAD_ATTR 成本高多少。一个优化器应该能够避免将 LOAD_GLOBALLOAD_GLOBAL/LOAD_ATTR 转换为新方案,除非对象访问发生在循环内部。再往下看,当前的 Python 虚拟机的面向寄存器的替换方案 [3] 也可以设想消除大多数 LOAD_FAST 指令。

跟踪对象的数量应该相对较少。所有活动线程的所有活动帧都可以设想跟踪对象,但这与给定应用程序中定义的函数数量相比似乎很小。

参考资料


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

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