【问题标题】:How is the s=s+c string concat optimization decided?s=s+c 字符串 concat 优化是如何决定的?
【发布时间】:2021-11-03 19:37:11
【问题描述】:

短版:如果s 是一个字符串,那么s = s + 'c' 可能会修改字符串,而t = s + 'c' 不能。但是s + 'c'这个操作怎么知道是在哪个场景呢?

加长版:

t = s + 'c' 需要创建一个单独的字符串,因为之后程序希望旧字符串为s,新字符串为t

如果s 是唯一的引用,s = s + 'c' 可以就地修改字符串,因为程序只希望s 成为扩展字符串。如果末尾有多余的字符空间,CPython 实际上会进行此优化。

考虑这些函数,它们会重复添加一个字符:

def fast(n):
    s = ''
    for _ in range(n):
        s = s + 'c'
        t = s
        del t

def slow(n):
    s = ''
    for _ in range(n):
        t = s + 'c'
        s = t
        del t

使用n = 100_000 (Try it online!) 对结果进行基准测试:

fast :   9 ms    9 ms    9 ms    9 ms   10 ms 
slow : 924 ms  927 ms  931 ms  933 ms  945 ms

请注意,额外的t = ss = t 使这两个变量等效引用字符串,然后del t 只留下s,因此在下一次循环迭代中,s 再次成为对字符串的唯一引用细绳。因此,这两个函数之间的唯一区别是s + 'c' 分配给st 的顺序。

让我们也反汇编字节码。我在中间用!= 标记了仅有的三个区别。正如预期的那样,只有STORE_FASTLOAD_FAST 的变量不同。但直到并包括BINARY_ADD,字节码是相同的。 那么BINARY_ADD 是如何知道是否优化的呢?

      import dis                                 import dis
      dis.dis(fast)                              dis.dis(slow)
---------------------------------------------------------------------------
    0 LOAD_CONST     1 ('')                    0 LOAD_CONST     1 ('')
    2 STORE_FAST     1 (s)                     2 STORE_FAST     1 (s)
                                                                 
    4 LOAD_GLOBAL    0 (range)                 4 LOAD_GLOBAL    0 (range)
    6 LOAD_FAST      0 (n)                     6 LOAD_FAST      0 (n)
    8 CALL_FUNCTION  1                         8 CALL_FUNCTION  1
   10 GET_ITER                                10 GET_ITER      
>> 12 FOR_ITER      18 (to 32)             >> 12 FOR_ITER      18 (to 32)
   14 STORE_FAST     2 (_)                    14 STORE_FAST     2 (_)
                                                               
   16 LOAD_FAST      1 (s)                    16 LOAD_FAST      1 (s)
   18 LOAD_CONST     2 ('c')                  18 LOAD_CONST     2 ('c')
   20 BINARY_ADD                              20 BINARY_ADD    
   22 STORE_FAST     1 (s)        !=          22 STORE_FAST     3 (t)
                                                               
   24 LOAD_FAST      1 (s)        !=          24 LOAD_FAST      3 (t)
   26 STORE_FAST     3 (t)        !=          26 STORE_FAST     1 (s)
                                                               
   28 DELETE_FAST    3 (t)                    28 DELETE_FAST    3 (t)
   30 JUMP_ABSOLUTE 12                        30 JUMP_ABSOLUTE 12
>> 32 LOAD_CONST     0 (None)              >> 32 LOAD_CONST     0 (None)
   34 RETURN_VALUE                            34 RETURN_VALUE  

【问题讨论】:

  • 我的猜测是在 Python 字节码组装完成后 会发生一些 JIT 优化。一些类似于 LLVM 的低级系统正在查看字节码,决定可以做些什么,并在编译后或运行前透明地进行。
  • Binary_add 不知道,它调用的 c 代码执行了优化,因为它看到了引用计数(因此知道它是安全的)
  • 可能值得将其限制为特定版本。即将进行一些非常重要的优化(针对特定内置类型的操作码的专门化已经在 CPython 存储库中),但它们不适用于您为此计时的版本 - 坦率地说,在查看 3.8 源代码之后这似乎确实令人惊讶(也很有趣!)。
  • @ead 但是那个 C 代码不是在两个版本中看到相同的引用计数吗?
  • 魔法发生在string_concatenate()(在 Python/ceval.c 中),它又被调用为两个字符串的 BINARY_ADD。它向前查看下一个字节码指令,并且仅当该指令是加法的第一个操作数的 STORE 时才考虑这种就地修改。

标签: python performance cpython internals


【解决方案1】:

这是来自 Python 3.10 分支的code in question(在ceval.c 中,并从同一文件的BINARY_ADD 操作码实现中调用)。正如@jasonharper 在评论中指出的那样,它会提前查看BINARY_ADD 的结果是否会被绑定到与左加数来源相同的名称。在fast() 中,它是(操作数来自s,结果存储到s),但在slow() 中不是(操作数来自s,但存储到t)。

但不能保证这种优化会持续下去。例如,我注意到在当前开发 CPython main 分支上,您的 fast() 并不比您的 slow() 快(这是目前正在开发的最终 3.11 版本)。

人们应该依赖这个吗?

如上所述,不能保证这种优化会持续下去。 “认真”的 Python 程序员应该知道不要依赖于狡猾的 CPython 特定技巧,事实上,PEP 8 明确警告不要依赖这个特定技巧:

代码的编写方式不应损害 Python 的其他实现(PyPy、Jython、IronPython、Cython、Psyco 等)。

例如,对于a += ba = a + b 形式的语句,不要依赖CPython 高效实现就地字符串连接...

【讨论】:

  • 谢谢,现在查看详细信息。在main 中,fast 变慢了还是slow 变快了?
  • fast() 变慢了 - mainBINARY_ADD 实现中不再存在对 Unicode 操作数的特殊情况代码检查。没有什么是“免费的”:检查会稍微减慢每个添加,包括像1 + 2 这样的东西。 3.11 将开始尝试即时重写操作码,以适应运行时发现的类型。正在进行中。
  • 是的,我一直想知道它是否会减慢其他事情的速度。那么,这是否意味着大量以这种方式构建字符串的旧代码会突然变慢,或者正在进行的工作可能会重新引入优化?
  • 不知道。请参阅刚才的编辑以获取更多信息。
猜你喜欢
  • 1970-01-01
  • 2020-09-07
  • 2017-05-28
  • 2020-03-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-09-26
相关资源
最近更新 更多