相同的 Python 代码似乎具有不同的性能特征

Same Python code appears to have different performance characteristics

描述

在我创建 N 运行dom 字符串长度 k 的代码中,生成器表达式创建 运行dom k 字符串被调用 N+1(N+1)*k(又名 N*k+N)次。 exact same code 似乎正在发生这种情况,我可以通过重新定义 chrs() 辅助函数来看似不确定地来回切换。我怀疑分析器,但对机制没有任何线索。

系统详细信息,如果它们很重要:CPython、Python 2.7.17 和 Python 3.6.9 on Ubuntu 18.04 on HP Envy 15k (i7 -4720)

重现问题

不是合适的 MCVE,因为我无法确定地重现该问题;相反,运行宁这将有望导致下面两个分析器输出之一。如果您尝试 运行 在 REPL 中使用它并使用相同的代码重复重新定义 chrs() 函数,您可能能够获得两个分析器输出。

我在对各种生成 N (num_strings) 运行dom 字符串长度 k (len_string):

import cProfile
import random
import string

def chrs(bits, len_string):
    return ''.join(chr((bits >> (8*x)) & 0xff) for x in range(len_string))

def bits_strings(num_strings, len_string):
    return list(
        chrs(random.getrandbits(len_string*8), len_string)
        for x in range(num_strings)
    )

cProfile.run('bits_strings(1000, 2000)')

为了对其进行基准测试,我使用了 cProfile.run()

当我第一次 运行 代码时,它看起来相当快:

cProfile.run('bits_strings(1000, 2000)')
         2005005 function calls in 0.368 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    1.970    1.970 <stdin>:1(bits_strings)
     1000    0.001    0.000    1.963    0.002 <stdin>:1(chrs)
     1001    0.001    0.000    1.970    0.002 <stdin>:2(<genexpr>)
        1    0.000    0.000    1.970    1.970 <string>:1(<module>)
  2000000    0.173    0.000    0.173    0.000 {chr}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
     1000    0.005    0.000    0.005    0.000 {method 'getrandbits' of '_random.Random' objects}
     1000    0.178    0.000    1.953    0.002 {method 'join' of 'str' objects}
     1001    0.009    0.000    0.009    0.000 {range}

但当天晚些时候:

cProfile.run('bits_strings(1000, 2000)')

         4005004 function calls in 1.960 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    1.961    1.961 <stdin>:1(bits_strings)
     1000    0.001    0.000    1.954    0.002 <stdin>:1(chrs)
  2001000    1.593    0.000    1.762    0.000 <stdin>:2(<genexpr>)
        1    0.000    0.000    1.961    1.961 <string>:1(<module>)
  2000000    0.170    0.000    0.170    0.000 {chr}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
     1000    0.005    0.000    0.005    0.000 {method 'getrandbits' of '_random.Random' objects}
     1000    0.182    0.000    1.944    0.002 {method 'join' of 'str' objects}
     1001    0.009    0.000    0.009    0.000 {range}

差异看起来像是来自 <genexpr>,从 cumtime 和各种类似测试中的位置,我推断是 chrs() 中的那个. (我的直觉是 (chr((bits >> (8*x)) & 0xff) for x in range(len_string)) 应该被调用 N 次,而 chr((bits >> (8*x)) & 0xff) 应该被调用 N*k 次。

那么这两个bits_strings()函数有什么区别呢?当试图找出两次尝试之间的代码差异时,我发现 none。事实上,我有一个 Python REPL 会话,我在其中定义了函数,运行 分析器两次(第二次,检查这是否是缓存问题),然后重新定义函数使用向上箭头检索我之前输入的代码。它表现出相同的差异:https://pastebin.com/1u1j1ZUt

理论与思想

我怀疑是 cProfile。

通过重新定义 chrs() 在“快”和“慢”版本之间切换,但 cProfile.run() 已被 cProfile.run() 分析。根据外部 cProfile.run() 的报告,在观察时间 中,看起来内部 cProfile.run()(描述 bits_strings() 的那个)已经偶尔少报 bits_strings() 需要多长时间:https://pastebin.com/C4W4FEjJ

我还使用了 time.time() before/after cProfile.run('bits_strings(1000,1000)) 调用并注意到“快速”配置文件 cumtime 与 [= 时实际挂钟时间之间存在差异161=]宁方法。

但我还不清楚其中的机制。

我很想相信 bits_strings(),在不同的时间(相同代码的相同函数的不同定义,没有不同 运行s),实际上已经表现不同。但我不知道为什么会这样。我怀疑这与 chrs() 的关系比 bits_strings().

的关系更密切

我不清楚为什么重新定义 chrs() 似乎会不可预测地影响探查器输出。

这是 cProfile 结果报告的产物,无法区分具有相同名称、文件名和行号的两个代码对象。

在收集统计信息时,Python 函数(以及 C 函数的 PyMethodDef 内存地址)的 randomly-rebalancing binary search tree, apparently a randomized splay tree variant. Code object memory addresses are used as keys 中维护了统计信息。

但是,当分析完成后,数据会 reorganized 到字典中,使用(文件名、行号、代码对象名)元组作为 Python 函数的键。在这些键不是唯一的情况下(例如您的示例),这会丢失信息。在这种情况下,具有最高内存地址的代码对象最后被处理,因此该代码对象是其统计信息出现在结果字典中的代码对象。


在您的测试中,您会看到 chrs 中的 genexp 或 bits_strings 中的 genexp 的统计信息。您看到的统计信息取决于哪个 genexp 的代码对象具有更高的内存地址。

如果您 运行 您的代码是脚本而不是交互代码,genexps 将可以通过行号区分,并且两个 genexps 都会出现在结果中。交互模式为交互输入的每个 "unit" 代码重新开始行号(一个 >>> 提示和所有后续 ... 提示)。但是,在某些情况下,此分析工件会以非交互方式出现,例如同一行上有两个 genexp,或者两个文件具有相同的名称。