相同的 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=]宁方法。
但我还不清楚其中的机制。
缓存:相同的代码,运行s 之间的性能截然不同可能是缓存问题。然而,快速版本是我用这段代码所做的第一个 运行(尽管我之前在同一个 REPL 会话中 运行 其他代码)。我可以通过简单地重新定义 chrs()
和 bits_strings()
从慢到快或从快到慢切换,尽管我是否真的切换似乎 运行dom。另外值得注意的是,我只能通过重新定义函数来切换 (afaict),而不是简单地重新 运行ning 它们。所以我不确定 什么 会被缓存,或者它如何在重新定义函数时将 <genexpr>
调用的数量改变 k
倍。
cProfile 怪癖:我 运行 没有 cProfile.run()
的相同代码并通过获取当前 time.time()
对其进行计时在 bits_strings
里面两次;对于慢速情况,所花费的总时间与 cProfile.run()
相当。所以 cProfile.run()
至少不会减慢速度。快速结果有可能被 cProfile.run()
、 污染,但有趣的是,对于 N
和 k
((10000, 10000)
) 的大值,我注意到bits_strings()
的第一个 运行 和当天晚些时候的慢 .
之间的明显差异
解释器怪癖:我认为这可能是因为解释器错误地优化了 chrs()
和 bits_strings()
中的生成器表达式, 但这是你为你写的东西付费的 CPython 解释器,所以这似乎不太可能。
Python 2.7 怪癖:重新定义chrs()
(使用向上箭头检索之前的定义)不一致地做同样的事情python3
中的技巧:https://pastebin.com/Uw7PgF7i
Zalgo 来报复我用正则表达式解析 HTML 的那一次:不排除
我很想相信 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,或者两个文件具有相同的名称。
描述
在我创建 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=]宁方法。
但我还不清楚其中的机制。
缓存:相同的代码,运行s 之间的性能截然不同可能是缓存问题。然而,快速版本是我用这段代码所做的第一个 运行(尽管我之前在同一个 REPL 会话中 运行 其他代码)。我可以通过简单地重新定义
chrs()
和bits_strings()
从慢到快或从快到慢切换,尽管我是否真的切换似乎 运行dom。另外值得注意的是,我只能通过重新定义函数来切换 (afaict),而不是简单地重新 运行ning 它们。所以我不确定 什么 会被缓存,或者它如何在重新定义函数时将<genexpr>
调用的数量改变k
倍。cProfile 怪癖:我 运行 没有
之间的明显差异cProfile.run()
的相同代码并通过获取当前time.time()
对其进行计时在bits_strings
里面两次;对于慢速情况,所花费的总时间与cProfile.run()
相当。所以cProfile.run()
至少不会减慢速度。快速结果有可能被cProfile.run()
、污染,但有趣的是,对于.N
和k
((10000, 10000)
) 的大值,我注意到bits_strings()
的第一个 运行 和当天晚些时候的慢解释器怪癖:我认为这可能是因为解释器错误地优化了
chrs()
和bits_strings()
中的生成器表达式, 但这是你为你写的东西付费的 CPython 解释器,所以这似乎不太可能。Python 2.7 怪癖:重新定义
chrs()
(使用向上箭头检索之前的定义)不一致地做同样的事情python3
中的技巧:https://pastebin.com/Uw7PgF7iZalgo 来报复我用正则表达式解析 HTML 的那一次:不排除
我很想相信 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,或者两个文件具有相同的名称。