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

Why is code using intermediate variables faster than code without?

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

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/10,CPython 3.5.1 一直到 3.10.1,Intel i7 3.40 GHz,64 位 OS 和Python。似乎是我在 Intel i7 3.60 GHz 运行 和 Python 3.5.0 上尝试过的另一台机器无法重现结果。


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

这里的第一个问题必须是,它是否可重现?至少对我们中的一些人来说,尽管其他人说他们没有看到效果,但肯定是这样。 这在 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堆栈。不过还是很奇怪。

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

withoutvarsmmap/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

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 内存区域的病态行为并反复释放;并且这种分配发生在 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 调用,而比较 return 值直接可能没问题。


有人问为什么会发生这种情况,当 timeit 禁用垃圾收集时。 timeit does it:

确实如此

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 的垃圾收集器只在那里回收 循环垃圾 ,即其引用形成循环的对象集合。这里不是这种情况;相反,当引用计数降为零时,这些对象会立即被释放。