在heapq.nlargest
中使用keyfunc时,性能大幅下降:
>>> from random import random
>>> from heapq import nlargest
>>> data = [random() for _ in range(1234567)]
>>> %timeit nlargest(10, data)
30.2 ms ± 1.19 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> %timeit nlargest(10, data, key=lambda n: n)
159 ms ± 6.32 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
我预计会有一些额外的成本,可能是30% - 而不是400%。这种降级似乎可以在几种不同的数据大小上重现。您可以在源代码中看到if key is None
有一个特殊情况处理,但是实现看起来或多或少相同。
为什么使用键功能会降低性能?是仅仅是由于额外的函数调用开销,还是算法通过使用keyfunc从根本上改变了?
为了进行比较,使用相同的数据和lambda sorted
约占30%。
答案 0 :(得分:8)
调用lambda n: n
这么多次的额外开销真的很贵。
In [17]: key = lambda n: n
In [18]: x = [random() for _ in range(1234567)]
In [19]: %timeit nlargest(10, x)
33.1 ms ± 2.71 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [20]: %timeit nlargest(10, x, key=key)
133 ms ± 3.7 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [21]: %%timeit
...: for i in x:
...: key(i)
...:
93.2 ms ± 978 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [22]: %%timeit
...: for i in x:
...: pass
...:
10.1 ms ± 298 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
正如您所看到的,在所有元素上调用key
的成本几乎占据了整个开销。
sorted
的关键评估同样昂贵,但由于排序的总工作成本较高,因此关键调用的开销占总数的较小百分比。您应该将使用密钥的绝对开销与nlargest
或sorted
进行比较,而不是将开销作为基数的百分比。
In [23]: %timeit sorted(x)
542 ms ± 13.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [24]: %timeit sorted(x, key=key)
683 ms ± 12.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
正如您所看到的,key
调用的成本约占此输入上使用此密钥sorted
的一半的开销,其余的开销可能来自于更多的改组工作排序本身的数据。
你可能想知道nlargest
如何设法为每个元素做这么少的工作。对于无键情况,大多数迭代发生在以下循环中:
for elem in it:
if top < elem:
_heapreplace(result, (elem, order))
top = result[0][0]
order -= 1
或关键字的情况:
for elem in it:
k = key(elem)
if top < k:
_heapreplace(result, (k, order, elem))
top = result[0][0]
order -= 1
关键的实现是top < elem
和top < k
分支几乎从不被采用。一旦算法找到了10个相当大的元素,大多数剩余元素将小于10个当前候选元素。在极少数需要替换堆元素的情况下,这使得更多元素更难以通过调用heapreplace
所需的条。
在随机输入上,heapreplace调用nlargest
生成的数量与输入的大小呈预期对数关系。具体来说,对于nlargest(10, x)
,除x
的前10个元素外,元素x[i]
有10/(i+1)
概率位于l[:i+1]
的前10个元素中,这是heapreplace调用所必需的条件。通过期望的线性,预期的heapreplace调用数是这些概率的总和,并且该和是O(log(len(x)))。 (此分析用10替换为任何常量,但n
中的变量nlargest(n, l)
需要稍微复杂的分析。)
对于排序输入,性能故事会有很大不同,其中每个元素都会通过if
检查:
In [25]: sorted_x = sorted(x)
In [26]: %timeit nlargest(10, sorted_x)
463 ms ± 26 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
比未分类的情况贵10倍!
答案 1 :(得分:8)
说你的iterable有N
个元素。无论是排序还是执行nlargest
,关键功能都将被称为N
次。在排序时,这种开销大部分都隐藏在大约N * log2(N)
个其他操作中。但是,在执行nlargest
个k
项时,只有大约N * log2(k)
个其他操作,当k
远小于N
时,这些操作要小得多。
在您的示例中,N = 1234567
和k = 10
,以及nlargest
排序的其他操作的比例大致为:
>>> log2(1234567) / log2(10)
6.0915146640862625
这接近于6纯粹是巧合;-)这是重要的定性点:使用关键函数的开销对nlargest
比对排序随机排序的数据更重要,提供{{1 }}远小于k
。
事实上,这大大低估了N
的相对负担,因为只有当下一个元素大于nlargest
时才会在后者中调用O(log2(k))
heapreplace
到目前为止看到的最大。大多数情况下它不是,因此这种迭代的循环几乎是纯粹的开销,调用Python级别的键函数只是为了发现结果没有意义。
量化这是超越我的;例如,在Python 3.6.5下的Win10框中,我只看到代码中的时序差异小于3倍。这并不让我感到惊讶 - 调用Python级函数很多< / em>比调用列表迭代器和进行整数比较(两者都是“C速度”)更贵。