【问题标题】:Unexpected performance curve from CPython merge sort来自 CPython 合并排序的意外性能曲线
【发布时间】:2012-04-17 08:50:37
【问题描述】:

我在 Python 中实现了一个简单的合并排序算法。算法和测试代码如下:

import time
import random
import matplotlib.pyplot as plt
import math
from collections import deque

def sort(unsorted):
    if len(unsorted) <= 1:
        return unsorted
    to_merge = deque(deque([elem]) for elem in unsorted)
    while len(to_merge) > 1:
        left = to_merge.popleft()
        right = to_merge.popleft()
        to_merge.append(merge(left, right))
    return to_merge.pop()

def merge(left, right):
    result = deque()
    while left or right:
        if left and right:
            elem = left.popleft() if left[0] > right[0] else right.popleft()
        elif not left and right:
            elem = right.popleft()
        elif not right and left:
            elem = left.popleft()
        result.append(elem)
    return result

LOOP_COUNT = 100
START_N = 1
END_N = 1000

def test(fun, test_data):
    start = time.clock()
    for _ in xrange(LOOP_COUNT):
        fun(test_data)
    return  time.clock() - start

def run_test():
    timings, elem_nums = [], []
    test_data = random.sample(xrange(100000), END_N)
    for i in xrange(START_N, END_N):
        loop_test_data = test_data[:i]
        elapsed = test(sort, loop_test_data)
        timings.append(elapsed)
        elem_nums.append(len(loop_test_data))
        print "%f s --- %d elems" % (elapsed, len(loop_test_data))
    plt.plot(elem_nums, timings)
    plt.show()

run_test()

据我所知,一切都很好,因此我应该得到一个漂亮的 N*logN 曲线。但是图片有点不同:

我试图调查该问题的事情:

  1. PyPy。曲线没问题。
  2. 使用 gc 模块禁用了 GC。猜错了。调试输出显示它甚至直到测试结束才运行。
  3. 使用 meliae 进行内存分析 - 没有什么特别或可疑的。 ` 我有另一种实现(使用相同合并函数的递归实现),它的行为方式类似。我创建的完整测试周期越多 - 曲线中的“跳跃”就越多。

那么如何解释并 - 希望 - 修复这种行为?

UPD:将列表更改为 collections.deque

UPD2:添加了完整的测试代码

UPD3:我在 Ubuntu 11.04 操作系统上使用 Python 2.7.1,使用四核 2Hz 笔记本。我试图关闭所有其他进程中的大多数:峰值的数量减少了,但至少其中一个仍然存在。

【问题讨论】:

  • 您在列表中使用.pop(0)。虽然我不确定这是否是这个特定运行时问题的原因,但它非常次优:列表在 CPython 中被实现为数组,如果你删除第一个元素,整个事情必须转移(这是一个 O(n) 操作)。您应该从最后弹出或使用链接列表,如collections.deque
  • 您正在查看极少数的元素。要获得对渐近运行时间的有用估计,您需要更大的数字。
  • @vkazanov:“list”是描述某些类型集合的通用术语:数组、单链表、双链表……但你说得对,Python 列表不是链表,可以假设。
  • 你能否设置 python 进程的 CPU 亲和性(将其提取到单核),例如 taskset 1 python yourscript.py。这不应该是必要的,但以防万一。
  • @J.F.Sebastian 我已经设置了关联掩码,使用相同的数据多次运行代码(没有酸洗 - 在同一个过程中)。尖峰一直存在,在不同的地方。

标签: python sorting merge garbage-collection


【解决方案1】:

您只是在了解其他进程对您机器的影响。

您为输入大小 1 运行排序函数 100 次,并记录在此花费的总时间。然后对输入大小 2 运行 100 次,并记录花费的总时间。您继续这样做,直到达到输入大小 1000。

假设您的操作系统(或您自己)偶尔会开始执行 CPU 密集型操作。假设只要您运行排序功能 5000 次,这个“尖峰”就会持续。这意味着对于 5000 / 100 = 50 个连续输入大小,执行时间看起来会很慢。过了一会儿,又出现了一个峰值,另一个输入大小范围看起来很慢。这正是您在图表中看到的内容。

我可以想出一种方法来避免这个问题。对每个输入大小只运行一次排序函数:1、2、3、...、1000。使用相同的 1000 个输入重复此过程 100 次(这很重要,请参阅最后的说明)。现在将每个输入大小所花费的最少时间作为图表的最终数据点。

这样,在 100 次运行中,您的尖峰只会影响每个输入大小的几次;而且由于您采用的是最低限度,因此它们可能对最终图表完全没有影响。

如果您的尖峰非常长且频繁,您当然可能希望将重复次数增加到当前每个输入大小的 100 次以上。

查看您的峰值,我注意到在峰值期间执行速度正好减慢了 3 倍。我猜操作系统在高负载期间为您的 python 进程提供了三分之一的插槽。无论我的猜测是否正确,我推荐的方法都应该可以解决问题。

编辑:

我意识到在我为您的问题提出的解决方案中我没有澄清一点。

对于给定的输入大小,您是否应该在每 100 次运行中使用相同的输入?还是应该使用 100 个不同的(随机)输入?

由于我建议采用最短的执行时间,因此输入应该相同(否则您将得到不正确的输出,因为您将测量最佳情况的算法复杂度而不是平均复杂度!)。

但是,当您采用相同的输入时,您会在图表中产生一些噪音,因为某些输入只是比其他输入快。

所以更好的解决方案是解决系统负载问题,而不会产生每个输入大小只有一个输入的问题(这显然是伪代码):

seed = 'choose whatever you like'
repeats = 4
inputs_per_size = 25
runtimes = defaultdict(lambda : float('inf'))
for r in range(repeats):
  random.seed(seed)
  for i in range(inputs_per_size):
    for n in range(1000):
      input = generate_random_input(size = n)
      execution_time = get_execution_time(input)
      if runtimes[(n, i)] > execution_time:
        runtimes[(n,i)] = execution_time
for n in range(1000):
  runtimes[n] = sum(runtimes[(n,i)] for i in range(inputs_per_size))/inputs_per_size

现在您可以使用 runtimes[n] 来构建您的情节。

当然,根据您的系统是否超级嘈杂,您可以将 (repeats, inputs_per_size)(4,25) 更改为 (10,10),甚至是 (25,4)

【讨论】:

  • 虽然使用 time.clock() 不是计时 Python 代码的最佳方式,但它仅测量处理器在进程本身上花费的时间,而不是墙上时间。其他流程不应产生太大影响。
  • 然而,OP 观察到的尖峰似乎清楚地表明,他的程序有一段时间运行速度慢了 3 倍,然后又回到“正常”状态。我不知道 OP 使用什么系统,以及多核或超线程是否会影响此功能。我的观点是,无论原因如何,问题在于他将相同输入大小的所有运行聚集在一起;解决方案是跨时间传播它们。
  • 我什至可以看到系统峰值是如何每次都在同一时间持续的;因为随着输入大小的增加,尖峰的水平宽度会下降,从而使尖峰的总长度(以秒为单位)大致保持不变。
  • @max,我也在想同样的事情——尖峰的布局非常引人注目,强烈表明它们在固定的时期和固定的时间间隔内发生。
  • @max 我相信这与操作系统调度程序以及其他干扰 Python 的进程有关。但是怎么会..?所有其他内核都是免费的,平均 CPU 负载为 1-3%(Python 进程占用 100%),城里什么都没有发生。有没有办法调试这种情况?
【解决方案2】:

我可以使用您的代码重现峰值:

您应该选择适当的计时函数(time.time()time.clock() -- from timeit import default_timer)、测试中的重复次数(每次测试需要多长时间)和测试次数来选择 最少时间。它为您提供更好的精度和更少的外部影响结果。阅读来自timeit.Timer.repeat() docs 的注释:

根据结果计算均值和标准差是很诱人的 矢量并报告这些。但是,这不是很有用。在一个 典型情况下,最低值给出了你的速度有多快的下限 机器可以运行给定的代码sn -p;结果中的更高值 矢量通常不是由 Python 速度的可变性引起的,而是 其他进程干扰您的计时精度。所以 min() 的结果可能是您应该感兴趣的唯一数字。 之后,您应该查看整个向量并应用 common 感觉而不是统计。

timeit模块可以为你选择合适的参数:

$ python -mtimeit -s 'from m import testdata, sort; a = testdata[:500]' 'sort(a)'

这是基于timeit 的性能曲线:

图中显示sort()行为与O(n*log(n))一致:

|------------------------------+-------------------|
| Fitting polynom              | Function          |
|------------------------------+-------------------|
| 1.00  log2(N)   +  1.25e-015 | N                 |
| 2.00  log2(N)   +  5.31e-018 | N*N               |
| 1.19  log2(N)   +      1.116 | N*log2(N)         |
| 1.37  log2(N)   +      2.232 | N*log2(N)*log2(N) |

要生成我使用的图形make-figures.py

$ python make-figures.py --nsublists 1 --maxn=0x100000 -s vkazanov.msort -s vkazanov.msort_builtin 

地点:

# adapt sorting functions for make-figures.py
def msort(lists):
    assert len(lists) == 1
    return sort(lists[0]) # `sort()` from the question

def msort_builtin(lists):
    assert len(lists) == 1
    return sorted(lists[0]) # builtin

输入列表描述为here(注意:输入已排序,因此内置sorted() 函数显示预期的O(N) 性能)。

【讨论】:

  • 您观察到的尖峰本质上与 OP 观察到的不同。当您改变输入时,您的噪声是正常的随机噪声。它不是持久的;事实上,它看起来像白噪声(没有自相关)。 OP 观察到持续的峰值,在整个输入大小范围内都保持不变,然后立即消失。只有当他及时分开他的跑步时,使用最小值才会有所帮助。如果他至少跑了 100 次以上,但所有 100 次跑步都是一个接一个地完成,他的尖刺不会消失。幸运的是,timeit 实现了repeat 方法,因此所有重复都不会一个接一个地发生。
  • 是的,尖峰的性质不同,OP 可能使用单个 CPU 计算机和/或 Windows (time.clock()),但解决方法是相同的:使用段落中描述的最小值里面有timeit.Timer.repeat()
  • 我不同意。如果 OP 在 timeit 函数之外迭代他的输入大小,则按规定使用 timeit 不会解决问题。他的峰值显然在数千个单独的排序运行时范围内,所以他仍然会看到与他最初看到的大致相同的画面。如果尖峰持续时间比整个 timeit.repeat 调用长几十倍,那么取最小值将无济于事。而从他的情节来看,确实是这样的
  • 无需猜测。 @vkazanov 可以运行基于timeitmake-figures.py 并查看结果。鉴于他使用多核 CPU、Ubuntu 和计算机并没有承受重负载,那么情节就没有什么明显的了(重新阅读您关于答案中第一个数字的第一个评论,该答案是在类似环境中由完全相同的代码产生的)。
最近更新 更多