为什么代码使用中间变量比没有代码更快?

Bha*_*rel 76 python cpython python-3.x python-internals

我遇到了这种奇怪的行为并且无法解释它.这些是基准:

py -3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 97.7 usec per loop
py -3 -m timeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 70.7 usec per loop
Run Code Online (Sandbox Code Playgroud)

为什么与变量赋值的比较比使用临时变量的单个衬里快27%以上?

通过Python文档,在timeit期间禁用垃圾收集,因此它不可能.这是某种优化吗?

结果也可以在Python 2.x中重现,但程度较小.

运行Windows 7,CPython 3.5.1,Intel i7 3.40 GHz,64位操作系统和Python.看起来像我尝试在Intel i7 3.60 GHz上使用Python 3.5.0运行的另一台机器不能重现结果.


使用与timeit.timeit()@ 10000循环相同的Python进程运行分别产生0.703和0.804.仍显示尽管程度较轻.(〜12.5%)

Ant*_*ala 106

我的结果与你的结果相似:使用中间变量的代码在我厌烦的Python 3.4中至少要快10-20%.但是当我在同一个Python 3.4解释器上使用IPython时,我得到了以下结果:

In [1]: %timeit -n10000 -r20 tuple(range(2000)) == tuple(range(2000))
10000 loops, best of 20: 74.2 µs per loop

In [2]: %timeit -n10000 -r20 a = tuple(range(2000));  b = tuple(range(2000)); a==b
10000 loops, best of 20: 75.7 µs per loop
Run Code Online (Sandbox Code Playgroud)

值得注意的是,当我-mtimeit从命令行使用时,我从未设法接近前者的74.2μs .

所以这个Heisenbug结果非常有趣.我决定运行命令,strace确实有一些可疑的事情:

% strace -o withoutvars python3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 134 usec per loop
% strace -o withvars python3 -mtimeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 75.8 usec per loop
% grep mmap withvars|wc -l
46
% grep mmap withoutvars|wc -l
41149
Run Code Online (Sandbox Code Playgroud)

现在这是差异的一个很好的理由.不使用变量的代码会导致mmap系统调用被调用的几乎比使用中间变量的调用多1000倍.

所述withoutvars充满mmap/ munmap用于256K区域; 这些相同的行一遍又一遍地重复:

mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
Run Code Online (Sandbox Code Playgroud)

mmap通话似乎是从功能来_PyObject_ArenaMmapObjects/obmalloc.c; 在obmalloc.c还包含宏ARENA_SIZE,这是#defined是(256 << 10)(即262144); 同样munmap匹配_PyObject_ArenaMunmap来自obmalloc.c.

obmalloc.c

在Python 2.5之前,arenas从未被free()编辑过.从Python 2.5开始,我们尝试free()竞技场,并使用一些温和的启发式策略来增加最终可以释放竞技场的可能性.

因此,这些启发式以及Python对象分配器一清空就释放这些免费场所的事实导致python3 -mtimeit 'tuple(range(2000)) == tuple(range(2000))'触发病理行为,其中一个256 kiB内存区域被重新分配和重复释放; 这种分配情况与mmap/ munmap,因为他们是系统调用,这是比较昂贵的-而且,mmapMAP_ANONYMOUS要求新映射的页面必须清零-尽管Python的也不会在意.

该行为是不存在在使用中间变量,因为它是使用略微代码更多存储器和一些对象在它仍然没有分配存储器竞技场可以被释放.那是因为timeit将它变成一个不同的循环

for n in range(10000)
    a = tuple(range(2000))
    b = tuple(range(2000))
    a == b
Run Code Online (Sandbox Code Playgroud)

现在的行为是两个a并且b将一直保持绑定直到它们被重新分配,所以在第二次迭代中,tuple(range(2000))将分配第三个元组,并且赋值a = tuple(...)将减少旧元组的引用计数,导致它被释放,并且增加新元组的引用计数; 然后同样的事情发生了b.因此,在第一次迭代之后,如果不是3,则总是存在至少2个这样的元组,因此不会发生颠簸.

最值得注意的是,无法保证使用中间变量的代码总是更快 - 实际上在某些设置中,使用中间变量可能会导致额外的mmap调用,而直接比较返回值的代码可能会很好.


有人问为什么会发生这种情况,当timeit禁用垃圾收集时.这的确是事实,timeit做它:

注意

默认情况下,timeit()在计时期间暂时关闭垃圾回收.这种方法的优点在于它使独立时序更具可比性.该缺点是GC可能是所测量功能的重要组成部分.如果是这样,可以重新启用GC作为设置字符串中的第一个语句.例如:

然而,Python的垃圾收集器仅用于回收循环垃圾,即其引用形成循环的对象的集合.情况并非如此; 相反,当引用计数降至零时,这些对象立即被释放.

  • @Bharel:CPython中的垃圾收集器更恰当地称为"循环垃圾收集器"; 它只关心释放孤立的参考周期,而不是一般的垃圾收集.所有其他清理是同步和有序的; 如果释放了对竞技场中最后一个对象的最后一个引用,则立即删除该对象,并立即释放竞技场,不需要循环垃圾收集器参与.这就是为什么禁用`gc`是合法的.如果它禁用了一般的清理工作,你的内存就会很快耗尽. (7认同)
  • @Bharel更喜欢"像设计一样破碎" (6认同)
  • @jfs:行为上的差异很大程度上与启动行为的变化有关;如果启动创建并污染了足够的内存区域(但污染程度不足以阻止在剩余内存中连续分配“元组”),那么这些区域将永远不会被释放,并且始终可供重用。系统上默认的“python3”可能仍然在系统站点包目录中至少附带了一些东西,并且Python完成的隐式“导入站点”将执行各种会触发分配/释放的工作。 (2认同)
  • 例如,当我使用 `-E -S` 运行时(它禁用对 Python 环境变量的所有检查并完全阻止导入 `site`),我重现了 OP 观察到的性能差异(没有名称的 ~123 usec,有名称的 ~96 usec) ,但如果没有“-S”,运行时间本质上是相同的(都是~96 usec)。在“ipython”中运行它可以达到类似的效果,因为“ipython”本身在启动时会做大量的事情,它会生成一堆带有隔离的长寿命对象的竞技场,以使它们保持活动状态以供重用。重点是,这个问题在*任何*系统上都没有得到解决,它只是通常被掩盖了。 (2认同)

Dun*_*can 7

这里的第一个问题必须是,它是否可重复?对于我们中的一些人来说,至少它肯定是因为其他人说他们没有看到效果.在Fedora上,将相等测试更改is为实际进行比较似乎与结果无关,并且范围推高至200,000,因为这似乎最大化效果:

$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.03 msec per loop
$ python3 -m timeit "a = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 9.99 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.1 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.02 msec per loop
Run Code Online (Sandbox Code Playgroud)

我注意到运行之间的差异以及运行表达式的顺序对结果的影响非常小.

向慢速版本添加作业a并将b其添加到慢速版本中并不会加快速度.事实上,正如我们可能预期的那样,分配局部变量的效果可以忽略不计.唯一可以加快速度的是将表达式完全分成两部分.这应该是唯一的区别是它减少了Python在评估表达式时使用的最大堆栈深度(从4到3).

这为我们提供了线索,即效果与堆栈深度有关,也许额外的级别会将堆栈推送到另一个内存页面.如果是这样,我们应该看到做出影响堆栈的其他更改会发生变化(最有可能导致影响),事实上这就是我们所看到的:

$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 9.97 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 10 msec per loop
Run Code Online (Sandbox Code Playgroud)

因此,我认为效果完全是由于在计时过程中消耗了多少Python堆栈.但它仍然很奇怪.