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

结果有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   

很明显f1f2快得多,对吧?

但后来我也用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 垃圾收集器的设计。其中大部分都很容易理解,我发现它非常有趣和有启发性。

相关内容

最新更新