为什么使用中间变量的代码要比不使用中间变量的代码快?


76

我遇到这种奇怪的行为,但无法解释。这些是基准:

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

与使用变量分配进行比较,为什么比使用带有临时变量的班轮快27%以上呢?

通过Python文档,垃圾回收在timeit期间被禁用,因此并非如此。这是某种优化吗?

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

运行Windows 7,CPython 3.5.1,Intel i7 3.40 GHz,64位OS和Python。似乎我尝试使用Python 3.5.0在Intel i7 3.60 GHz上运行的另一台机器无法重现结果。


使用具有timeit.timeit()10000个循环的相同Python进程运行分别产生0.703和0.804。仍然显示,尽管程度较小。(〜12.5%)


6
比较dis.dis("tuple(range(2000)) == tuple(range(2000))")dis.dis("a = tuple(range(2000)); b = tuple(range(2000)); a==b")。在我的配置中,第二个片段实际上包含第一个片段的所有字节码以及一些其他指令。很难相信更多的字节码指令会导致更快的执行速度。也许是特定Python版本中的某些错误?
卢卡斯Rogalski

1
如果尝试重现此内容,请以不同的执行顺序多次运行测试。–无论结果如何,也很奇怪,我认为这个问题对于SO来说并不是特别有价值。

3
我认为这很有趣。@poke,您需要记住,对于类似现象的答案现在是stackoverflow中最被推崇的答案。
Antti Haapala '04

3
另外,请尝试timeit直接使用该模块在单个Python进程中运行测试。两个单独的Python进程之间的比较可能会受到操作系统的任务计划程序或其他影响的影响。

1
@aluriak“ 3中最佳”表示3个平均值中的最佳。这样做是因为某些平均值可能包括意外的过程停顿。采取最好的平均数可以避免这种情况。
Veedrac '16

Answers:


107

我的结果与您的结果相似:使用中间变量的代码在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

值得注意的是,当我-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

现在,这是造成差异的一个很好的理由。不使用变量的代码导致mmap系统调用比使用中间变量的代码多近1000倍。

对于256k区域,其withoutvarsmmap/ munmap。这些相同的行一遍又一遍地重复:

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

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

obmalloc.c

在Python 2.5之前,竞技场从未被使用过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

现在的行为是 ab将保持约束,直到他们重新分配*,所以在第二次迭代,tuple(range(2000))将分配一个3元组,并分配a = tuple(...)将降低旧的元组的引用计数,导致它被释放,并提高新元组的引用计数;然后发生同样的事情b。因此,在第一次迭代之后,这些元组中始终至少有2个(如果不是3个),因此不会发生颠簸。

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


有人问为什么timeit禁用垃圾收集时会发生这种情况。确实timeit做到了

注意

默认, timeit()在计时期间临时关闭垃圾收集。这种方法的优势在于,它使独立计时更具可比性。这个缺点是GC可能是被测功能性能的重要组成部分。如果是这样,则可以将GC作为设置字符串中的第一条语句重新启用。例如:

但是,Python的垃圾收集器仅用于回收循环垃圾,即引用形成循环的对象的集合。这里不是这种情况。而是当引用计数降至零时立即释放这些对象。


1
哇,真有趣。垃圾收集器(在时间上禁用)不是应该照顾释放,还是至少应该照顾它?这就提出了另一个问题:那些重复的电话难道不是错误吗?
巴雷尔

6
@Bharel更像是“被设计打破”
Antti Haapala

1
@Bharel这取决于是否分配了新的内存空间。其他系统很有可能具有部分空闲的竞技场,这些竞技场的池中有足够的空闲内存,不需要更多空间。即使在表面上相似的系统上使用相同的Python版本,其行为也可能有所不同-诸如Python安装路径,中的软件包数site-packages,环境变量,当前工作目录之类的内容-它们都会影响进程的内存布局。
Antti Haapala

7
@Bharel:CPython中的垃圾收集器更恰当地称为“循环垃圾收集器”;它仅与释放孤立的参考周期有关,而不与常规垃圾回收有关。所有其他清理是同步且有序的;如果释放了对竞技场中最后一个对象的最后引用,则立即删除该对象,并立即释放竞技场,而无需循环垃圾回收器的参与。这就是为什么禁用是合法的gc;如果禁用了常规清理,则很快就会耗尽内存。
ShadowRanger 2016年

1
总结一下:对于/usr/bin/python3使用Ubuntu 16.04(python3-minimal软件包)发行的默认发行版,答案中的结果是不可重现的(mmap调用次数没有显着差异)。我还尝试了各种docker镜像,例如docker run --rm python:3.6.4 python -m timeit ...-无效(包括3.4)。如果从源代码编译python,则答案中的行为是可重现的(例如3.6.4-d48eceb,但对3.7-e3256087无影响)
jfs

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

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

将作业添加到ab进入缓慢的版本不加快速度。实际上,正如我们预期的那样,分配给局部变量的影响可以忽略不计。唯一可以提高速度的方法是将表达式一分为二。这应该做的唯一区别是,它减少了在评估表达式时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

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


但是,具有相同记忆棒和相同操作系统的2台机器会导致不同的结果。堆栈深度听起来像是一个很好的理论,但不能解释机器之间的区别。
巴雷尔
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.