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 个全局标识符的本地副本,则此特定基准测试的性能会提高约 2%(在我的笔记本电脑上从 5561 个 pystone 提高到 5685 个)。这表明通过缓存大多数全局变量访问可以提高性能。还要注意,pystone 基准测试基本上不访问全局模块属性,这是本 PEP 预计的改进领域。

提议的更改

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

线程

此代码在多线程程序中的操作与在单线程程序中的操作没有区别。如果您需要锁定对象以访问它,则必须在执行 TRACK_OBJECT 之前执行此操作,并在停止使用它之后保留该锁。

待办事项:我怀疑这里需要更多内容。

基本原理

全局变量和属性很少发生变化。例如,一旦函数导入 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 [1] 发送到 python-dev 的帖子,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 或它们的 String 变体需要进行一些更改。理想情况下,应该有一个相当集中的位置来本地化这些更改。如果您开始考虑跟踪局部变量的属性,您就会遇到修改 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

上次修改:2023-09-09 17:39:29 GMT