关于python:为什么variable1 + = variable2比variable1 = variable1 + variable2快得多?

Why is variable1 += variable2 much faster than variable1 = variable1 + variable2?

我继承了一些用于创建大型表(宽达19列、宽达5000行)的python代码。在屏幕上画桌子花了九秒钟。我注意到每行都是使用以下代码添加的:

1
2
sTable = sTable + '
'
+ GetRow()

其中,sTable是一个字符串。

我把它改为:

1
2
sTable += '
'
+ GetRow()

我注意到桌子现在六秒钟后就出现了。

然后我把它改成:

1
2
sTable += '
%s'
% GetRow()

基于这些python性能提示(还有6秒)。

由于调用了大约5000次,它突出了性能问题。但为什么会有如此大的差异呢?为什么编译器没有在第一个版本中发现问题并优化它呢?


这不是使用就地+=+二进制加法。你没有告诉我们整个故事。原始版本连接了3个字符串,而不仅仅是两个:

1
2
sTable = sTable + '
'
+ sRow  # simplified, sRow is a function call

python试图帮助并优化字符串连接;在使用strobj += otherstrobjstrobj = strobj + otherstringobj时,都会这样做,但当涉及到两个以上的字符串时,它不能应用这种优化。

通常情况下,python字符串是不可变的,但是如果没有对左侧字符串对象的其他引用,并且仍然在反弹,那么python就会欺骗并改变字符串。这样就避免了每次连接时都必须创建一个新字符串,从而可以大大提高速度。

这在字节码评估循环中实现。在两个字符串上使用BINARY_ADD和在两个字符串上使用INPLACE_ADD时,python都将连接委托给一个特殊的助手函数string_concatenate()。为了能够通过改变字符串来优化连接,首先需要确保字符串没有其他引用;如果只有堆栈和原始变量引用了它,那么就可以这样做,下一个操作将替换原始变量引用。

因此,如果只有2个对字符串的引用,下一个运算符是STORE_FAST(设置一个局部变量)、STORE_DEREF(设置一个由封闭函数引用的变量)或STORE_NAME(设置一个全局变量),并且受影响的变量当前引用同一个字符串,则清除该目标变量以减少该数字。只引用了1,堆栈。

这就是为什么原始代码不能完全使用这种优化的原因。表达式的第一部分是sTable + '
'
,下一个操作是另一个BINARY_ADD

1
2
3
4
5
6
7
8
9
10
11
12
>>> import dis
>>> dis.dis(compile(r"sTable = sTable + '
' + sRow"
, '<stdin>', 'exec'))
  1           0 LOAD_NAME                0 (sTable)
              3 LOAD_CONST               0 ('
'
)
              6 BINARY_ADD          
              7 LOAD_NAME                1 (sRow)
             10 BINARY_ADD          
             11 STORE_NAME               0 (sTable)
             14 LOAD_CONST               1 (None)
             17 RETURN_VALUE

第一个BINARY_ADD后面跟着一个LOAD_NAME来访问sRow变量,而不是存储操作。第一个BINARY_ADD必须总是生成一个新的字符串对象,随着sTable的增长,这个新的字符串对象的创建时间越来越长。

您将此代码更改为:

1
2
sTable += '
%s'
% sRow

删除了第二个连接。现在字节码是:

1
2
3
4
5
6
7
8
9
10
11
>>> dis.dis(compile(r"sTable += '
%s' % sRow"
, '<stdin>', 'exec'))
  1           0 LOAD_NAME                0 (sTable)
              3 LOAD_CONST               0 ('
%s'
)
              6 LOAD_NAME                1 (sRow)
              9 BINARY_MODULO      
             10 INPLACE_ADD        
             11 STORE_NAME               0 (sTable)
             14 LOAD_CONST               1 (None)
             17 RETURN_VALUE

我们只剩下一个INPLACE_ADD,后面是一家商店。现在,sTable可以在适当的位置进行更改,而不会产生更大的新字符串对象。

你会得到相同的速度差:

1
2
sTable = sTable + ('
%s'
% sRow)

在这里。

时间试验显示不同之处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> import random
>>> from timeit import timeit
>>> testlist = [''.join([chr(random.randint(48, 127)) for _ in range(random.randrange(10, 30))]) for _ in range(1000)]
>>> def str_threevalue_concat(lst):
...     res = ''
...     for elem in lst:
...         res = res + '
'
+ elem
...
>>> def str_twovalue_concat(lst):
...     res = ''
...     for elem in lst:
...         res = res + ('
%s'
% elem)
...
>>> timeit('f(l)', 'from __main__ import testlist as l, str_threevalue_concat as f', number=10000)
6.196403980255127
>>> timeit('f(l)', 'from __main__ import testlist as l, str_twovalue_concat as f', number=10000)
2.3599119186401367

这个故事的寓意是,首先不应该使用字符串串联。从其他字符串的负载构建新字符串的正确方法是使用列表,然后使用str.join()

1
2
3
4
5
table_rows = []
for something in something_else:
    table_rows += ['
'
, GetRow()]
sTable = ''.join(table_rows)

这还是更快的:

1
2
3
4
5
6
>>> def str_join_concat(lst):
...     res = ''.join(['
%s'
% elem for elem in lst])
...
>>> timeit('f(l)', 'from __main__ import testlist as l, str_join_concat as f', number=10000)
1.7978830337524414

但你不能只使用'
'.join(lst)

1
2
>>> timeit('f(l)', 'from __main__ import testlist as l, nl_join_concat as f', number=10000)
0.23735499382019043