[英]Why is using a key function so much slower?
在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%。
调用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
,关键评估同样昂贵,但由于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)
除了第一10种元素x
,元素x[i]
具有10/(i+1)
的中的顶部的10个元素是概率l[:i+1]
这是heapreplace调用所必需的条件。 通过期望的线性,预期的heapreplace调用数是这些概率的总和,并且该和是O(log(len(x)))。 (这个分析用10替换为任何常量,但是nlargest(n, l)
的变量n
需要稍微复杂的分析。)
对于已排序的输入,性能故事会有很大不同,其中每个元素都会通过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倍!
假设你的iterable有N
元素。 无论是排序还是做nlargest
,关键功能都会被调用N
次。 在排序时,该开销主要埋在大约N * log2(N)
其他操作中。 但是当做n个nlargest
的k
项时,只有大约N * log2(k)
其他操作,当k
远小于N
时,这个操作要小得多。
在您的示例中, N = 1234567
且k = 10
,因此其他操作的比例(在nlargest
排序)大致为:
>>> log2(1234567) / log2(10)
6.0915146640862625
这接近于6纯粹是巧合;-)这是重要的定性点:使用关键函数的开销对于nlargest
比对于随机排序的数据排序更重要,前提是k
远小于N
实际上,这大大低估了nlargest
的相对负担,因为只有当下一个元素大于到目前为止看到的第k
个最大元素时,才会在后者中调用O(log2(k))
heapreplace
。 大部分时间它不是,因此这种迭代的循环几乎是纯粹的开销,调用Python级别的键函数只是为了发现结果不感兴趣。
但是,量化这一点超出了我的意义; 例如,在Python 3.6.5下的我的Win10盒子里,我只看到你的代码中的时序差异小于3倍。这并不让我感到惊讶 - 调用Python级别的函数比戳戳要贵得多列表迭代器并进行整数比较(两者都是“C速度”)。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.