有没有办法绕过 Python list.append() 随着列表的增长而在循环中逐渐变慢?

Posted

技术标签:

【中文标题】有没有办法绕过 Python list.append() 随着列表的增长而在循环中逐渐变慢?【英文标题】:Is there a way to circumvent Python list.append() becoming progressively slower in a loop as the list grows? 【发布时间】:2011-01-29 05:58:21 【问题描述】:

我有一个正在读取的大文件,每隔几行就将其转换为一个 Object 的实例。

由于我正在循环文件,我使用 list.append(instance) 将实例存储到列表中,然后继续循环。

这是一个大约 100MB 左右的文件,所以它不会太大,但随着列表变大,循环会逐渐变慢。 (我打印循环中每一圈的时间)。

这不是循环固有的 ~ 当我在循环文件时打印每个新实例时,程序以恒定速度运行 ~ 只有当我将它们附加到列表时它才会变慢。

我的朋友建议在 while 循环之前禁用垃圾收集并在之后启用它并进行垃圾收集调用。

有没有其他人观察到 list.append 变慢的类似问题?有没有其他方法可以规避这个问题?


我将尝试以下建议的以下两件事。

(1)“预分配”内存~最好的方法是什么? (2) 尝试使用双端队列

多个帖子(请参阅 Alex Martelli 的评论)建议内存碎片(他像我一样拥有大量可用内存)〜但没有明显的性能修复。

要复制该现象,请运行答案中提供的测试代码,并假设列表有有用的数据。


gc.disable() 和 gc.enable() 有助于计时。我还会仔细分析所有时间都花在了哪里。

【问题讨论】:

列表中有多少项?给我们看一些代码。我怀疑你可能正在做一些你没有意识到的事情,我们没有了解整个故事。 S.Lott 总是质疑动机,而不是直接回答问题。 (有时他可能是有道理的,但通常只是烦人,就像在这种情况下一样。) @FogleBird,了解我们负责帮助解决的问题始终很重要。这里的大部分问题(可能是大多数)都是 XY 问题。帮助人们、解决问题、制作更好的程序都比盲目回答问题要好。 我没有不理解潜在问题的哲学。即使在这个问题上,我也要求以代码的形式进行澄清。我不喜欢经常和居高临下地质疑别人,尤其是当他们显然不是在问无耻的问题时。 回复您自己问题中的评论时,请回复评论。当 cmets 中的问题的答案不在其后的评论中时,就不可能跟进。如果它是重要信息,当然也要更新问题。 【参考方案1】:

您可以尝试 http://docs.python.org/release/2.5.2/lib/deque-objects.html 在列表中分配预期数量的必需元素吗? ?我敢打赌,该列表是一个连续的存储,每隔几次迭代就必须重新分配和复制。 (类似于 c++ 中一些流行的 std::vector 实现)

编辑:由http://www.python.org/doc/faq/general/#how-are-lists-implemented支持

【讨论】:

我将如何进行分配?我知道手头有多少项目,我可以很容易地尝试这个。 不是每几个操作——每 n 个操作。 (FAQ 简化为“few”,隐藏了“clever”的实现。)这允许 append 摊销 O(1) 的复杂性。 我知道增加了*2。所以它是 O(1) 与“尖峰”,对吧?【参考方案2】:

没有什么可以规避的:附加到列表是 O(1) 摊销的。

列表(在 CPython 中)是一个数组,其长度至少与列表一样长,最多两倍。如果数组未满,则追加到列表就像分配数组成员之一一样简单 (O(1))。每次数组满时,它的大小都会自动加倍。这意味着有时需要 O(n) 操作,但只需要每 n 次操作,并且随着列表变大,它越来越少需要。 O(n) / n ==> O(1)。 (在其他实现中,名称和细节可能会发生变化,但同时必须维护属性。)

追加到列表已经扩展。

是否有可能当文件变大时,您无法将所有内容都保存在内存中,并且您面临操作系统分页到磁盘的问题?是否有可能是算法的不同部分无法很好地扩展?

【讨论】:

感谢您的澄清~所以偶尔,我有一个 O(n) 操作,但下一次循环发生时,它又恢复了正常吗?这几天我会对我的代码做详细的时序分析,再发一次。 是的,O(n) 操作只是偶尔发生。它们之间的附加数以与 n 相同的速率增长,因此平均起来只有一个恒定的影响。 256 GigaBYTES 的 ram 或 128 或 64 但不低于 64 GIGABYTES。例如:top - 02:36:31 up 36 days, 11:21, 7 users, load average: 0.84, 0.31, 0.11 Tasks: 274 total, 2 running, 272 sleep, 0 stop, 0 zombie Cpu(s): 6.2%us, 0.1%sy, 0.0%ni, 93.8%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Mem: 总共 132370600k, 使用了 7819100k, 124551500k free, 481084k buffers Swap: 总共 2031608k, 3780k已使用,2027828k 免费,5256144k 缓存 谢谢 Mike ~ 下面你提到关闭 gc 对你有帮助 ~ 有任何副作用吗?我应该什么时候重新打开它? @Deniz,关闭 GC 意味着不会发现和收集任何引用循环。如果您要存储大量字符串,那么看起来这对您没有帮助。 (至少从在我的机器上快速运行 FogleBird 的测试来看,它看起来不会因为创建数百万个列表而受到检测到的惩罚。大概 Python 知道它不需要搜索字符串以查找引用循环。)跨度> 【参考方案3】:

很多这些答案只是疯狂的猜测。我最喜欢 Mike Graham,因为他对列表的实现方式是正确的。但是我已经编写了一些代码来重现您的声明并进一步研究它。以下是一些发现。

这是我开始的。

import time
x = []
for i in range(100):
    start = time.clock()
    for j in range(100000):
        x.append([])
    end = time.clock()
    print end - start

我只是将空列表附加到列表x。我打印出每 100,000 个追加的持续时间,100 次。它确实像你声称的那样慢下来。 (第一次迭代 0.03 秒,最后一次迭代 0.84 秒……差别很大。)

显然,如果您实例化一个列表但不将其附加到 x,它会运行得更快,并且不会随着时间的推移而扩大。

但如果你将x.append([]) 更改为x.append('hello world'),则根本不会提高速度。同一个对象被添加到列表中 100 * 100,000 次。

我对此的看法:

速度下降与列表大小无关。这与实时 Python 对象的数量有关。 如果您根本不将项目附加到列表中,它们只会立即被垃圾收集,不再由 Python 管理。 如果您一遍又一遍地附加相同的项目,实时 Python 对象的数量不会增加。但该列表确实必须不时调整自身大小。但这不是性能问题的根源。 由于您正在创建大量新创建的对象并将其添加到列表中,因此它们保持活动状态并且不会被垃圾回收。减速可能与此有关。

至于可以解释这一点的 Python 内部结构,我不确定。但我很确定列表数据结构不是罪魁祸首。

【讨论】:

如果您正在形成一个由 100000 * 100 个空列表组成的列表,每条 36 字节,那么您试图在大列表和其他开销之上存储 360 MB 的列表。你确定你没有遇到内存碎片或磁盘交换吗? 为了确定两个相似代码中哪一个更快,时间是关键(在 Python 中,您可以使用 timeit 模块来帮助您测量。)为了确定事物的扩展方式,您必须从数学上理解正在发生的事情来表征算法的属性;时机可能很重要,但次要。为操作计时时最重要的事情之一是确保您控制测试以准确捕获您想要的内容。 这不是磁盘交换或其他任何事情。它会立即按比例放大并且非常线性。不确定您在第二条评论中要表达什么观点?随意运行我为自己发布的示例,看看它是如何运行的。你不同意我的结论吗? @FogleBird,您如何确定您的计算机上没有内存碎片或交换?这些通常是您的操作系统在没有真正告诉您的情况下所做的事情。如果发生这种情况,它使这个例子比什么都更能代表这一点。生命 Python 对象的数量并非天生就不可扩展,而且您还没有证明它是不可扩展的。您已经证明该测试的某些方面会降低您的计算机速度。 此基准测试的变体仅测量附加时间(而不是创建空列表的时间)表明它非常稳定。仅测量创建空列表的时间(而不是追加时间)表明它在增长……但前提是追加也存在(虽然没有被测量),否则创建空列表的时间(如旧的会被回收),本身也是稳定的(将列表保存到预先分配的列表也会使列表创建速度变慢)。看起来像内存碎片(分配成本更高且成本更高)。 (MacOSX 10.5,4G RAM,试过 Python 2.5 和 2.6)。【参考方案4】:

您观察到的性能不佳是由您正在使用的版本中的 Python 垃圾收集器中的错误引起的。 升级到 Python 2.7 或 3.1 或更高版本以重新获得 0(1) 行为期望在 Python 中追加列表。

如果您无法升级,请在构建列表时禁用垃圾收集,并在完成后将其打开。

(您还可以调整垃圾收集器的触发器或在您进行过程中选择性地调用 collect,但我不会在此答案中探讨这些选项,因为它们更复杂,我怀疑您的用例适合上述解决方案。)

背景:

请参阅:https://bugs.python.org/issue4074 和 https://docs.python.org/release/2.5.2/lib/module-gc.html

记者观察到,将复杂对象(不是数字或字符串的对象)附加到列表中会随着列表长度的增加而线性减慢。

这种行为的原因是垃圾收集器正在检查并重新检查列表中的每个对象,以查看它们是否有资格进行垃圾收集。此行为会导致将对象添加到列表的时间线性增加。预计会在 py3k 中进行修复,因此它不应该适用于您正在使用的解释器。

测试:

我进行了一个测试来证明这一点。对于 1k 次迭代,我将 10k 个对象附加到一个列表中,并记录每次迭代的运行时间。整体运行时差异立即显而易见。在测试的内部循环期间禁用垃圾收集,我的系统上的运行时间为 18.6 秒。为整个测试启用垃圾回收后,运行时间为 899.4s。

这是测试:

import time
import gc

class A:
    def __init__(self):
        self.x = 1
        self.y = 2
        self.why = 'no reason'

def time_to_append(size, append_list, item_gen):
    t0 = time.time()
    for i in xrange(0, size):
        append_list.append(item_gen())
    return time.time() - t0

def test():
    x = []
    count = 10000
    for i in xrange(0,1000):
        print len(x), time_to_append(count, x, lambda: A())

def test_nogc():
    x = []
    count = 10000
    for i in xrange(0,1000):
        gc.disable()
        print len(x), time_to_append(count, x, lambda: A())
        gc.enable()

完整来源:https://hypervolu.me/~erik/programming/python_lists/listtest.py.txt

图形结果:红色是 gc 开启,蓝色是 gc 关闭。 y 轴是对数刻度的秒数。

(来源:hypervolu.me)

由于这两个图在 y 分量上相差几个数量级,因此在这里它们是独立的,y 轴是线性缩放的。

(来源:hypervolu.me)

(来源:hypervolu.me)

有趣的是,在关闭垃圾收集的情况下,我们只看到每 10k 追加运行时的小峰值,这表明 Python 的列表重新分配成本相对较低。无论如何,它们比垃圾收集成本低许多数量级。

以上图的密度使得很难看出在垃圾收集器开启的情况下,大多数区间实际上都有很好的性能;只有当垃圾收集器循环时,我们才会遇到病态行为。您可以在这个 10k 附加时间的直方图中观察到这一点。大多数数据点每 10k 追加下降约 0.02 秒。

(来源:hypervolu.me)

用于生成这些图的原始数据可以在http://hypervolu.me/~erik/programming/python_lists/找到

【讨论】:

我还没有打算添加到社区 wiki 中……但不知何故,该帖子已添加。有谁知道怎么去掉? 您无法撤消社区 wiki。当您进行八次编辑时,它会自动触发 (meta.stackexchange.com/questions/11740)。 (我感受到了你的痛苦。社区维基太奇怪了。) 哦不!在这个网站潜伏一年多之后,我终于做出了贡献,我被一个隐藏的功能咬了!如果我知道这一点,我就不会做这么多小的格式编辑了。 这样的细节。这必须采取。很长时间。谢谢。 在 Python 3.4 和 CPython 中对此进行了测试,该错误已解决(使用 GC-990000 0.020383834838867188 和没有 GC 9990000 0.013748407363891602)时间相似【参考方案5】:

我在使用 Numpy 数组时遇到了这个问题,创建如下:

import numpy
theArray = array([],dtype='int32')

随着数组的增长,在循环中追加到这个数组所花费的时间越来越长,考虑到我有 14M 的追加,这是一个交易破坏者。

上面概述的垃圾收集器解决方案听起来很有希望,但没有奏效。

起作用的是创建具有预定义大小的数组,如下所示:

theArray = array(arange(limit),dtype='int32')

只需确保 limit 大于您需要的数组即可。

然后您可以直接设置数组中的每个元素:

theArray[i] = val_i

最后,如有必要,您可以删除数组中未使用的部分

theArray = theArray[:i]

这对我的情况产生了巨大的影响。

【讨论】:

为什么用0,1,2,3,..,limit-1初始化数组?如果您以后无论如何要更改值,那将是非常低效的。 numpy.zeros(limit) 大约快 30 倍,numpy.empty(limit) 是....几乎无限快。【参考方案6】:

改为使用集合,然后在最后将其转换为列表

my_set=set()
with open(in_file) as f:
    # do your thing
    my_set.add(instance)


my_list=list(my_set)
my_list.sort() # if you want it sorted

我有同样的问题,这解决了几个订单的时间问题。

【讨论】:

以上是关于有没有办法绕过 Python list.append() 随着列表的增长而在循环中逐渐变慢?的主要内容,如果未能解决你的问题,请参考以下文章

有没有办法绕过 Javascript / jQuery 的本地访问同源策略?

有没有办法绕过批量分配保护?

有没有办法绕过访问控制允许来源?

有没有办法绕过 Excel 查询中的基本身份验证?

有没有办法绕过量角器测试中的 Chrome 地理定位对话框?

python3 对list对象的增删改查