繁体   English   中英

为什么使用键功能这么慢?

[英]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的总工作成本更高,因此关键调用的开销占总数的较小百分比。 您应该将使用密钥的绝对开销与nlargestsorted ,而不是将开销作为基数的百分比。

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 < elemtop < 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个nlargestk项时,只有大约N * log2(k)其他操作,当k远小于N时,这个操作要小得多。

在您的示例中, N = 1234567k = 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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM