【问题标题】:CPython string addition optimisation failure caseCPython字符串添加优化失败案例
【发布时间】:2014-06-04 14:28:25
【问题描述】:

问题

为什么在 CPython 中会这样做

def add_string(n):
    s = ''
    for _ in range(n):
        s += ' '

需要线性时间,但是

def add_string_in_list(n):
    l = ['']
    for _ in range(n):
        l[0] += ' '

采用二次方时间?


证明:

Timer(partial(add_string, 1000000)).timeit(1)
#>>> 0.1848409200028982
Timer(partial(add_string, 10000000)).timeit(1)
#>>> 1.1123797750042286
Timer(partial(add_string_in_list, 10000)).timeit(1)
#>>> 0.0033865350123960525
Timer(partial(add_string_in_list, 100000)).timeit(1)
#>>> 0.25131178900483064

我知道的

当要添加的字符串的引用计数为 1 时,CPython 对字符串添加进行了优化。

这是因为 Python 中的字符串是不可变的,因此通常无法对其进行编辑。如果一个字符串存在多个引用并且它发生了变异,both 引用将看到更改后的字符串。这显然是不希望的,因此多个引用不会发生突变。

但是,如果只有一个对字符串的引用,则更改值只会更改希望更改的那个引用的字符串。您可以测试这是否是可能的原因:

from timeit import Timer
from functools import partial

def add_string_two_references(n):
    s = ''
    for _ in range(n):
        s2 = s
        s += ' '

Timer(partial(add_string_two_references, 20000)).timeit(1)
#>>> 0.032532954995986074
Timer(partial(add_string_two_references, 200000)).timeit(1)
#>>> 1.0898985149979126

我不确定为什么这个因子只有 30 倍,而不是预期的 100 倍,但我相信这是开销。


我不知道的事情

那么为什么列表版本会创建两个引用?这甚至是阻止优化的原因吗?

您可以检查它对普通对象的处理方式是否有所不同:

class Counter:
    def __iadd__(self, other):
        print(sys.getrefcount(self))

s = Counter()
s += None
#>>> 6

class Counter:
    def __iadd__(self, other):
        print(sys.getrefcount(self))

l = [Counter()]
l[0] += None
#>>> 6

【问题讨论】:

  • "如果一个字符串存在多个引用并且它发生了变化,那么这两个引用都会看到变化的字符串。"这不是真的。
  • “它改变了”≜“变异了”;我会修正措辞。 // 固定。
  • 没有。 x = 'a'; y = x; x = 'b' - 和 y 仍然持有“a”。 x = 'a'; y = x; x += 'b' 也一样
  • 我同意。但在那种情况下,字符串不会发生变异。 CPython 仅在安全的情况下(当只有一个引用时)对字符串进行变异;否则y 改变,这很糟糕。
  • 虽然+=是一个特殊的部分优化案例,但推荐的方式是使用.join(),保证跨实现优化,并得到开发者的喜爱。

标签: python string optimization cpython


【解决方案1】:

在基于列表的方法中,列表索引 0 中的字符串在被放回索引 0 处的列表之前被提取和修改。
对于这个短暂的时刻,解释器仍然在列表中保留旧版本的字符串,并且无法执行就地修改。
如果您看一下Python's source,您会发现不支持就地修改列表元素。所以必须从列表中检索对象(在这种情况下为字符串),修改然后放回。
换句话说,list 类型完全不知道 str 类型对 += 运算符的支持。

并考虑以下代码:

l = ['abc', 'def']
def nasty():
    global l
    l[0] = 'ghi'
    l[1] = 'jkl'
    return 'mno'
l[0] += nasty()

l的值是['abcmno', 'jkl'],证明'abc'是从列表中取出的,然后nasty()被执行修改列表的内容,字符串'abc''mno'得到连接和结果分配给l[0]。如果 nasty() 在访问 l[0] 以对其进行就地修改之前进行了评估,则结果将是 'ghimno'

【讨论】:

    【解决方案2】:

    那么为什么列表版本会创建两个引用?

    l[0] += ' ' 中,l[0] 中有一个引用。临时创建一个引用来执行+= on。

    这里有两个更简单的函数来展示效果:

    >>> def f():
    ...     l = ['']
    ...     l[0] += ' '
    ...     
    >>> def g():
    ...     s = ''
    ...     s += ' '
    ...     
    

    拆开它们会得到

    >>> from dis import dis
    >>> dis(f)
      2           0 LOAD_CONST               1 ('')
                  3 BUILD_LIST               1
                  6 STORE_FAST               0 (l)
    
      3           9 LOAD_FAST                0 (l)
                 12 LOAD_CONST               2 (0)
                 15 DUP_TOPX                 2
                 18 BINARY_SUBSCR       
                 19 LOAD_CONST               3 (' ')
                 22 INPLACE_ADD         
                 23 ROT_THREE           
                 24 STORE_SUBSCR        
                 25 LOAD_CONST               0 (None)
                 28 RETURN_VALUE        
    >>> dis(g)
      2           0 LOAD_CONST               1 ('')
                  3 STORE_FAST               0 (s)
    
      3           6 LOAD_FAST                0 (s)
                  9 LOAD_CONST               2 (' ')
                 12 INPLACE_ADD         
                 13 STORE_FAST               0 (s)
                 16 LOAD_CONST               0 (None)
                 19 RETURN_VALUE        
    

    f 中,BINARY_SUBSCR(切片)指令将l[0] 放在 VM 堆栈的顶部。 DUP_TOPX 复制栈顶 n 项。这两个函数(参见ceval.c)都会增加引用计数; DUP_TOPX(Py3 中的DUP_TOP_TWO)直接执行此操作,而BINARY_SUBSCR 使用PyObject_GetItem。所以,字符串的引用计数现在至少是三个。

    g 没有这个问题。当使用LOAD_FAST 推送项目时,它会创建一个额外的引用,给出 2 的引用计数,这是 VM 堆栈上项目的最小数量,因此它可以进行优化。

    【讨论】:

    • +1 我也有同样的答案,但写下来的时间要长得多:)
    • @chepner 我很惊讶你提出了完全相同的测试功能——除了你的g是我的f和你的f is my g`:)
    • +1 为您解答。我的答案中的代码证明与您的反汇编输出完全相同。
    • 非常感谢。我没想到DUP_TOP_TWO 会增加引用计数。另外,非常感谢源链接,它们总是很好。
    • 我想我会接受 ElmoVanKielmo,但你们俩都应得的 :)。
    猜你喜欢
    • 2017-06-23
    • 2013-06-29
    • 1970-01-01
    • 2018-01-05
    • 1970-01-01
    • 2014-08-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多