>我对这两个函数进行了基准测试(它们将对解压缩回源列表,来自这里):
n = 10**7
a = list(range(n))
b = list(range(n))
pairs = list(zip(a, b))
def f1(a, b, pairs):
a[:], b[:] = zip(*pairs)
def f2(a, b, pairs):
for i, (a[i], b[i]) in enumerate(pairs):
pass
结果有timeit.timeit
(五轮,数字是秒):
f1 1.06 f2 1.57
f1 0.96 f2 1.69
f1 1.00 f2 1.85
f1 1.11 f2 1.64
f1 0.95 f2 1.63
很明显f1
比f2
快得多,对吧?
但后来我也用timeit.default_timer
测量,得到了完全不同的画面:
f1 7.28 f2 1.92
f1 5.34 f2 1.66
f1 6.46 f2 1.70
f1 6.82 f2 1.59
f1 5.88 f2 1.63
很明显f2
要快得多,对吧?
叹息。为什么时间完全不同,我应该相信哪种计时方法?
完整的基准代码:
from timeit import timeit, default_timer
n = 10**7
a = list(range(n))
b = list(range(n))
pairs = list(zip(a, b))
def f1(a, b, pairs):
a[:], b[:] = zip(*pairs)
def f2(a, b, pairs):
for i, (a[i], b[i]) in enumerate(pairs):
pass
print('timeit')
for _ in range(5):
for f in f1, f2:
t = timeit(lambda: f(a, b, pairs), number=1)
print(f.__name__, '%.2f' % t, end=' ')
print()
print('default_timer')
for _ in range(5):
for f in f1, f2:
t0 = default_timer()
f(a, b, pairs)
t = default_timer() - t0
print(f.__name__, '%.2f' % t, end=' ')
print()
正如Martijn评论的那样,区别在于Python的垃圾收集,timeit.timeit
运行期间禁用。zip
创建 1000 万个迭代器对象,每个对象对应给定的 1000 万个迭代对象。
所以,垃圾收集1000万个物体只需要很多时间,对吧?谜团解开了!
井。。。不。事实并非如此,而且比这更有趣。在现实生活中使这样的代码更快,有一个教训需要吸取。
Python 丢弃不再需要的对象的主要方法是引用计数。此处禁用的垃圾回收器用于引用周期,引用计数不会捕获该周期。而且这里没有任何循环,所以它都被引用计数丢弃了,垃圾收集器实际上并没有收集任何垃圾。
让我们看几件事。首先,让我们通过自己禁用垃圾回收器来重现更快的时间。
通用设置代码(所有其他代码块都应在此之后直接在全新运行中运行,不要将它们组合在一起):
import gc
from timeit import default_timer as timer
n = 10**7
a = list(range(n))
b = list(range(n))
pairs = list(zip(a, b))
启用垃圾回收的计时(默认值):
t0 = timer()
a[:], b[:] = zip(*pairs)
t1 = timer()
print(t1 - t0)
我跑了三次,花了 7.09、7.03 和 7.09 秒。
禁用垃圾回收的计时:
t0 = timer()
gc.disable()
a[:], b[:] = zip(*pairs)
gc.enable()
t1 = timer()
print(t1 - t0)
耗时 0.96、1.02 和 0.99 秒。
所以现在我们知道,确实是垃圾收集以某种方式占用了大部分时间,即使它没有收集任何东西。
这里有一些有趣的事情:大部分时间都只负责创建zip
迭代器:
t0 = timer()
z = zip(*pairs)
t1 = timer()
print(t1 - t0)
这需要6.52、6.51和6.50秒。
请注意,我将zip
迭代器保留在一个变量中,因此甚至还没有任何东西可以丢弃,无论是通过引用计数还是垃圾收集!
什么?!那么时间去哪儿了呢?
井。。。正如我所说,没有参考周期,因此垃圾收集器实际上不会收集任何垃圾。但是垃圾收集器不知道!为了弄清楚这一点,它需要检查!
由于迭代器可能成为参考周期的一部分,因此它们已注册以进行垃圾回收跟踪。让我们看看由于zip
创建而还跟踪了多少对象(在通用设置代码之后执行此操作):
gc.collect()
tracked_before = len(gc.get_objects())
z = zip(*pairs)
print(len(gc.get_objects()) - tracked_before)
输出:跟踪10000003
个新对象。我相信这是zip
对象本身,它的内部元组来保存迭代器,它的内部结果持有者元组,以及 1000 万个迭代器。
好的,所以垃圾回收器跟踪所有这些对象。但这意味着什么?好吧,时不时地,在创建一定数量的新对象之后,收集器会浏览跟踪的对象,看看有些对象是否是垃圾并且可以丢弃。收集器保留三"代"跟踪对象。新对象进入第 0 代。如果他们在那里的集合中幸存下来,他们就会进入第 1 代。如果他们在那里的收藏中幸存下来,他们就会进入第 2 代。如果它们在那里进一步收集运行中幸存下来,它们将留在第 2 代。让我们检查一下之前和之后的世代:
gc.collect()
print('collections:', [stats['collections'] for stats in gc.get_stats()])
print('objects:', [len(gc.get_objects(i)) for i in range(3)])
z = zip(*pairs)
print('collections:', [stats['collections'] for stats in gc.get_stats()])
print('objects:', [len(gc.get_objects(i)) for i in range(3)])
输出(每行显示三代的值):
collections: [13111, 1191, 2]
objects: [17, 0, 13540]
collections: [26171, 2378, 20]
objects: [317, 2103, 10011140]
10011140显示,1000 万个迭代器中的大多数不仅注册用于跟踪,而且已经处于第 2 代。因此,它们至少是两次垃圾收集运行的一部分。第 2 代集合的数量从 2 个增加到 20 个,因此我们的数百万个迭代器参与了多达 20 次垃圾收集运行(其中两次进入第 2 代,在第 2 代中还有多达 18 次)。我们还可以注册一个回调来更精确地计数:
checks = 0
def count(phase, info):
if phase == 'start':
global checks
checks += len(gc.get_objects(info['generation']))
gc.callbacks.append(count)
z = zip(*pairs)
gc.callbacks.remove(count)
print(checks)
这告诉我总共有 63,891,314 次检查(即,平均而言,每个迭代器都是超过 6 次垃圾回收运行的一部分)。这是很多工作。而这一切只是为了在使用它之前创建zip
迭代器。
同时,循环
for i, (a[i], b[i]) in enumerate(pairs):
pass
几乎不会创建新对象。让我们检查一下有多少跟踪enumerate
导致:
gc.collect()
tracked_before = len(gc.get_objects())
e = enumerate(pairs)
print(len(gc.get_objects()) - tracked_before)
输出:跟踪3
新对象(enumerate
迭代器对象本身,它为迭代pairs
而创建的单个迭代器,以及它将使用的结果元组(此处的代码))。
我想说这回答了"为什么时间完全不同">的问题。zip
解决方案创建数百万个经过多次垃圾回收运行的对象,而循环解决方案则不会。因此,禁用垃圾收集器极大地帮助了zip
解决方案,而循环解决方案则不在乎。
现在关于第二个问题:">我应该相信哪种计时方法?".以下是文档对此的看法(强调我的):
默认情况下,
timeit()
在计时期间暂时关闭垃圾回收。这种方法的优点是它使独立时序更具可比性。缺点是GC可能是被测量函数性能的重要组成部分。如果是这样,则可以重新启用 GC 作为安装字符串中的第一个语句。例如:timeit.Timer('for i in range(10): oct(i)', 'gc.enable()').timeit()
在我们的例子中,垃圾回收的成本并非源于其他一些不相关的代码。这是由zip
呼叫直接引起的。当你运行它时,你确实在现实中付出了这个代价。因此,在这种情况下,我确实认为它是"被测量函数性能的重要组成部分"。直接回答问题:在这里,我相信default_timer
方法,而不是timeit
方法。或者换一种说法:在这里,timeit
方法应该与文档中建议的启用垃圾收集一起使用。
或者,我们实际上可以禁用垃圾回收作为解决方案的一部分(而不仅仅是为了基准测试):
def f1(a, b, pairs):
gc.disable()
a[:], b[:] = zip(*pairs)
gc.enable()
但这是个好主意吗?以下是gc
文档的内容:
由于收集器补充了 Python 中已使用的引用计数,因此,如果您确定程序不创建引用周期,则可以禁用收集器。
听起来这是一件可以做的事情。但我不确定我是否在程序的其他地方创建引用循环,所以我完成了gc.enable()
,以便在完成后重新打开垃圾回收。此时,由于引用计数,所有这些临时对象都已被丢弃。所以我所做的就是避免很多毫无意义的垃圾收集检查。我发现这是一个宝贵的教训,如果我知道我只是暂时创建很多对象,我将来可能会这样做。
最后,我强烈建议阅读 Python 开发人员指南中的gc
模块文档和 CPython 垃圾收集器的设计。其中大部分都很容易理解,我发现它非常有趣和有启发性。