【问题标题】:Why is using a key function so much slower?为什么使用按键功能这么慢?
【发布时间】:2018-11-16 12:55:19
【问题描述】:

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 从根本上改变了算法?

相比之下,sorted 在相同数据和 lambda 下的命中率约为 30%。

【问题讨论】:

  • 你使用的是哪个 Python 版本?
  • @user2357112 Python 3.7(在 3.6.4 中看到相同)
  • 尝试使用 line_profiler 运行它表明它确实应该是大约 30% 的命中(迭代、lambda 和比较每个都占用大约 30% 的时间)。所以,我猜在这种情况下,运行分析器的额外开销正在改变事情。

标签: python python-3.x heap


【解决方案1】:

调用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 的关键评估成本同样高,但由于排序的总工作成本更高,关键调用的开销占总数的百分比更小。您应该将使用密钥的绝对开销与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 &lt; elemtop &lt; k 分支几乎从未被采用。一旦算法找到了 10 个相当大的元素,剩下的大部分元素将小于当前的 10 个候选元素。在需要替换堆元素的极少数情况下,这只会使其他元素更难通过调用heapreplace 所需的栏。

在随机输入上,nlargest 进行的 heapreplace 调用次数预计与输入大小成对数。具体来说,对于nlargest(10, x),除了x 的前10 个元素之外,元素x[i]10/(i+1) 的概率位于l[:i+1] 的前10 个元素中,这是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 倍以上!

【讨论】:

  • 看起来不错。我应该点击nlargest(1234567, data) 本质上是一种排序(reverse=True),然后尝试一下。现在我看到 maxmin 与 keyfunc 一样受到了巨大的打击。
【解决方案2】:

假设您的可迭代对象具有 N 元素。无论是排序还是做nlargest,key函数都会被调用N次。排序时,该开销主要隐藏在大致N * log2(N) 其他操作之下。但是在做nlargest of k的项目时,其他操作大概只有N * log2(k),当kN小很多的时候就小很多了。

在您的示例中,N = 1234567k = 10,因此其他操作的比率,对nlargest 进行排序,大致为:

>>> log2(1234567) / log2(10)
6.0915146640862625

接近 6 纯属巧合 ;-) 重要的是定性点:对于 nlargest,使用关键函数的开销比对随机排序的数据排序要重要得多,前提是 k 是很多小于N

事实上,这大大低估了nlargest 的相对负担,因为O(log2(k)) heapreplace 仅在下一个元素大于k'迄今为止看到的最大时才会在后者中调用。大多数时候不是这样,因此这种迭代的循环几乎是纯粹的开销,调用 Python 级别的键函数只是为了发现结果并不有趣。

但是,量化这超出了我的范围;例如,在我的 Python 3.6.5 下的 Win10 机器上,我只看到代码中的时间差异小于 3 倍。这并不让我感到惊讶 - 调用 Python 级别的函数是 much 比戳列表迭代器和进行整数比较(都“以 C 速度”)更昂贵。

【讨论】:

  • @wim:不,迭代器由列表推导推进,因此后续循环不会重新考虑这些元素。
  • 不。每个输入元素只调用一次。在for elem in it: 中,由于早期的zip,迭代器(it)已经超越了第一个k 元素。
猜你喜欢
  • 1970-01-01
  • 2014-09-15
  • 1970-01-01
  • 2022-07-29
  • 2022-01-09
  • 2023-04-06
  • 2017-02-11
  • 2023-04-04
  • 1970-01-01
相关资源
最近更新 更多