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

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

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 文档,垃圾收集在此期间被禁用,因此不可能是这样。这是某种优化吗?

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

运行 Windows 7/10,CPython 3.5.1到3.10.1,Intel i73.40 GHz,64位操作系统和 Python。似乎我曾经在 Intel i73.60 GHz 和 Python 3.5.0上运行过的另一台机器并不能重现这个结果。


使用相同的 Python 进程和 timeit.timeit()@10000循环运行,结果分别是0.703和0.804。仍然显示,虽然程度较轻。(? 12.5%)

6737 次浏览

这里的第一个问题是,它是否可以复制?至少对于我们中的一些人来说,尽管其他人说他们没有看到这种影响,但这种影响确实存在。 在 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的作业并不能加快速度。事实上,我们可能期望赋值给局部变量的效果可以忽略不计。唯一能加快速度的方法就是将表达式完全分成两部分。这样做的唯一不同之处是,在计算表达式(从4到3)时,它减少了 Python 使用的最大堆栈深度。

这为我们提供了线索,说明这种效果与堆栈深度有关,也许额外的级别会将堆栈推到另一个内存页面。如果是这样的话,我们应该看到,进行影响堆栈的其他更改将会发生更改(很可能会终止这种效果) ,实际上我们看到的是:

$ 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 堆栈造成的。不过还是很奇怪。

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

所以这个海森堡变得非常有趣。我决定用 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 的区域,withoutvars充满了 mmap/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调用似乎来自 Objects/obmalloc.c的函数 _PyObject_ArenaMmap; obmalloc.c也包含宏 ARENA_SIZE,即 #defined 为 (256 << 10)(即 262144) ; 类似地,munmap匹配 obmalloc.c_PyObject_ArenaMunmap

obmalloc.c

在 Python 2.5之前,竞技场从未进行过 free()化, 我们尝试 free()竞技场,并使用一些温和的启发式策略来增加 竞技场最终被解放的可能性。

因此,这些启发式方法和 Python 对象分配器一旦清空这些空闲区域就释放这些空闲区域的事实导致 python3 -mtimeit 'tuple(range(2000)) == tuple(range(2000))'触发病理行为,其中一个256kiB 的内存区域被重新分配并重复释放; 这种分配发生在 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))将分配第三个元组,而分配 a = tuple(...)将减少旧元组的引用计数,导致它被释放,并增加新元组的引用计数; 然后同样的情况发生在 b上。因此,在第一次迭代之后,总是有至少2个这样的元组(如果不是3个的话) ,所以不会发生颠簸。

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


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

注意

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

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