关于python:为什么代码使用中间变量比没有中间变量的代码更快?

Why is code using intermediate variables faster than code without?

我遇到了这种奇怪的行为,但没能解释清楚。这些是基准:

1
2
3
4
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期间被禁用,因此不能是这样。这是某种优化吗?

结果也可以在python2.x中复制,但复制程度较低。

运行windows7、cpython3.5.1、intel i7 3.40GHz、64位操作系统和python。我尝试在Inteli7 3.60GHz上运行的python3.5.0似乎是另一台机器,但它无法复制结果。

使用同一个python进程运行timeit.timeit()@10000个循环分别产生0.703和0.804。虽然程度较低,但仍有显示。(~12.5%)


我的结果与您的类似:使用中间变量的代码在python3.4中的速度一致,至少快了10-20%,这让我很累。但是,当我在同一个python3.4解释器上使用ipython时,得到了以下结果:

1
2
3
4
5
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。

所以这只海森氏虫很有趣。我决定和strace一起执行这个命令,但确实发生了一些可疑的事情:

1
2
3
4
5
6
7
8
% 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区域,withoutvars中充满了mmap/munmap;这些相同的行反复出现:

1
2
3
4
5
6
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调用似乎来自Objects/obmalloc.c的函数_PyObject_ArenaMmapobmalloc.c还包含宏ARENA_SIZE,即#defined为(256 << 10)(即262144;同样,munmapobmalloc.c的函数_PyObject_ArenaMunmap相匹配。

obmalloc.c

Prior to Python 2.5, arenas were never free()'ed. Starting with Python 2.5,
we do try to free() arenas, and use some mild heuristic strategies to increase
the likelihood that arenas eventually can be freed.

因此,这些启发式方法和python对象分配器一清空就释放这些空闲区域的事实导致python3 -mtimeit 'tuple(range(2000)) == tuple(range(2000))'触发病理行为,其中一个256 kib的内存区域被重复分配和释放;这种分配发生在mmapmunmap上,这是相对昂贵的,因为它们此外,使用MAP_ANONYMOUSmmap系统调用要求新映射的页面必须归零,即使python不在乎。

行为不存在于使用中间变量的代码中,因为它使用的内存稍多一些,并且由于一些对象仍在其中分配,因此无法释放内存区域。这是因为timeit将使它成为一个循环,这与

1
2
3
4
for n in range(10000)
    a = tuple(range(2000))
    b = tuple(range(2000))
    a == b

现在的行为是,ab都将保持绑定,直到它们被*重新分配,所以在第二次迭代中,tuple(range(2000))将分配第三个元组,而分配a = tuple(...)将减少旧元组的引用计数,从而释放它,并增加新元组的引用计数;然后e同样的情况也发生在b上。因此,在第一次迭代之后,这些元组中总是至少有2个,如果不是3个,那么不会发生震荡。

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

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

Note

By default, timeit() temporarily turns off garbage collection during the timing. The advantage of this approach is that it makes independent timings more comparable. This disadvantage is that GC may be an important component of the performance of the function being measured. If so, GC can be re-enabled as the first statement in the setup string. For example:

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


这里的第一个问题是,它是可复制的吗?对我们中的一些人来说,至少这是肯定的,尽管其他人说他们没有看到效果。在Fedora上,平等测试改为is,因为实际进行比较似乎与结果无关,范围扩大到200000,因为这似乎最大化了效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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的分配并不能加快速度。事实上,正如我们预期的那样,分配给局部变量的效果可以忽略不计。唯一能加快速度的是将表达式完全拆分为两部分。唯一不同的是,它在计算表达式(从4到3)时减少了python使用的最大堆栈深度。

这就给了我们一个线索,即效果与堆栈深度有关,也许额外的级别会将堆栈推送到另一个内存页中。如果是这样的话,我们应该看到进行影响堆栈的其他更改将发生更改(很可能会消除此影响),事实上,这就是我们看到的:

1
2
3
4
5
6
7
8
9
10
11
12
$ 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堆栈。不过,这仍然很奇怪。