关于算法:用python生成超大文本文件的时间性能

Time performance in Generating very large text file in Python

我需要生成一个非常大的文本文件。每行都有一个简单的格式:

1
2
Seq_num<SPACE>num_val
12343234 759

假设我要生成一个包含1亿行的文件。我尝试了两种方法,令人惊讶的是,它们给出的时间表现非常不同。

  • 对于超过100米的循环,在每一个循环中,我用seq_numnum_val做一个短字符串,然后把它写到一个文件中。这种方法需要很多时间。

    1
    2
    3
    4
    5
    ## APPROACH 1  
    for seq_id in seq_ids:
        num_val=rand()
        line=seq_id+' '+num_val
        data_file.write(line)
  • 对于超过100米的循环,在每一个循环中,我用seq_numnum_val做一个短字符串,然后将它附加到一个列表中。循环结束后,我迭代列表项并将每个项写入一个文件。这种方法花费的时间要少得多。

    1
    2
    3
    4
    5
    6
    7
    8
    ## APPROACH 2  
    data_lines=list()
    for seq_id in seq_ids:
        num_val=rand()
        l=seq_id+' '+num_val
        data_lines.append(l)
    for line in data_lines:
        data_file.write(line)
  • 注意:

    • 方法2有2个循环,而不是1个循环。
    • 对于方法1和方法2,我都在循环中写入文件。所以这两个步骤必须相同。

    因此,方法1必须花费更少的时间。有没有暗示我遗漏了什么?


    从技术上讲,很多甚至更少是非常模糊的术语:)基本上,如果你不能衡量它,你就不能改进它。

    为了简单起见,让我们有一个简单的基准,loop1.py

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import random
    from datetime import datetime

    start = datetime.now()
    data_file = open('file.txt', 'w')
    for seq_id in range(0, 1000000):
            num_val=random.random()
            line="%i %f
    "
    % (seq_id, num_val)
            data_file.write(line)

    end = datetime.now()
    print("elapsed time %s" % (end - start))

    带2个回路的loop2.py

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import random
    from datetime import datetime

    start = datetime.now()
    data_file = open('file.txt', 'w')
    data_lines=list()
    for seq_id in range(0, 1000000):
        num_val=random.random()
        line="%i %f
    "
    % (seq_id, num_val)
        data_lines.append(line)
    for line in data_lines:
        data_file.write(line)

    end = datetime.now()
    print("elapsed time %s" % (end - start))

    当我在我的计算机上运行这两个脚本(使用SSD驱动器)时,我得到如下信息:

    1
    2
    3
    4
    $ python3 loop1.py
    elapsed time 0:00:00.684282
    $ python3 loop2.py
    elapsed time 0:00:00.766182

    每种测量方法可能略有不同,但正如直觉所示,第二种测量方法稍慢一些。

    如果我们想要优化写入时间,我们需要查看手册,看看Python是如何实现写入文件的。对于文本文件,open()函数应使用BufferedWriteropen函数接受缓冲区大小的第三个参数。以下是有趣的部分:

    Pass 0 to switch buffering off (only allowed in binary mode), 1 to
    select line buffering (only usable in text mode), and an integer > 1
    to indicate the size in bytes of a fixed-size chunk buffer. When no
    buffering argument is given, the default buffering policy works as
    follows:

    Binary files are buffered in fixed-size chunks; the size of the buffer
    is chosen using a heuristic trying to determine the underlying
    device’s"block size" and falling back on io.DEFAULT_BUFFER_SIZE. On
    many systems, the buffer will typically be 4096 or 8192 bytes long.

    因此,我们可以修改loop1.py并使用行缓冲:

    1
    data_file = open('file.txt', 'w', 1)

    结果证明这是非常缓慢的:

    1
    2
    $ python3 loop3.py
    elapsed time 0:00:02.470757

    为了优化写入时间,我们可以根据需要调整缓冲区大小。首先我们以字节为单位检查行大小:len(line.encode('utf-8')),这给了我11字节。

    将缓冲区大小更新为预期的行大小(以字节为单位)后:

    1
    data_file = open('file.txt', 'w', 11)

    我写得很快:

    1
    elapsed time 0:00:00.669622

    根据你提供的细节,很难估计会发生什么。也许估计块大小的启发式方法在您的计算机上不太管用。不管怎样,如果你写的是固定的行长度,那么很容易优化缓冲区的大小。您可以利用flush()进一步优化对文件的写入。

    结论:通常,为了更快地写入文件,您应该尝试写入与文件系统上的块大小相对应的大量数据——这正是Python方法open('file.txt', 'w')所要做的。在大多数情况下,使用默认值是安全的,微基准的差异是微不足道的。

    您分配了大量需要由GC收集的字符串对象。如@kevmo314所建议的,为了进行公平比较,您应该禁用loop1.py的gc:

    1
    gc.disable()

    因为GC可能在循环中迭代时尝试删除字符串对象(您不保留任何引用)。而seconds方法保留对所有字符串对象的引用,GC在最后收集它们。


    下面是@tombart对优雅答案的扩展,以及一些进一步的观察。好的。

    考虑到一个目标:优化从循环读取数据的过程,然后将其写入文件,让我们开始:好的。

    在所有情况下,我将使用with语句打开/关闭文件test.txt。此语句在执行文件中的代码块时自动关闭文件。好的。

    另一个需要考虑的重要问题是,Python基于操作系统处理文本文件的方式。来自文档:好的。

    Note: Python doesn’t depend on the underlying operating system’s notion of text files; all the processing is done by Python itself, and is therefore platform-independent.

    Ok.

    这意味着当在Linux/Mac或Windows操作系统上执行时,这些结果可能只有轻微的变化。这种微小的变化可能是由于其他进程同时使用同一个文件,或者在脚本执行期间在文件上发生多个IO进程,以及一般的CPU处理速度等原因造成的。好的。

    我提出了3个案例,每个案例都有执行时间,最后找到了进一步优化最高效和最快速案例的方法:好的。

    第一种情况:循环范围(110000000)并写入文件好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import time
    import random

    start_time = time.time()
    with open('test.txt' ,'w') as f:
        for seq_id in range(1,1000000):
            num_val = random.random()    
            line ="%i %f
    "
    %(seq_id, num_val)
            f.write(line)

    print('Execution time: %s seconds' % (time.time() - start_time))

    #Execution time: 2.6448447704315186 seconds

    注:在下面的两个list场景中,我已经初始化了一个空列表data_lines,类似:[],而不是使用list()。原因是:[]list()快3倍。下面是对这种行为的解释:为什么[]比list()快?.讨论的主要症结是:虽然[]是作为字节码对象创建的,并且是一条单指令,但list()是一个单独的python对象,它还需要名称解析、全局函数调用和堆栈来推送参数。好的。

    使用timeit模块中的timeit()函数,比较如下:好的。

    1
    2
    3
    import timeit                 import timeit                    
    timeit.timeit("[]")           timeit.timeit("list()")
    #0.030497061136874608         #0.12418613287039193

    第二种情况:循环范围(110000000),将值附加到空列表,然后写入文件好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import time
    import random

    start_time = time.time()
    data_lines = []
    with open('test.txt' ,'w') as f:
        for seq_id in range(1,1000000):
            num_val = random.random()    
            line ="%i %f
    "
    %(seq_id, num_val)
            data_lines.append(line)
        for line in data_lines:
            f.write(line)

    print('Execution time: %s seconds' % (time.time() - start_time))

    #Execution time: 2.6988046169281006 seconds

    第三种情况:循环列表理解并写入文件好的。

    通过python强大而紧凑的列表理解,可以进一步优化流程:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import time
    import random

    start_time = time.time()

    with open('test.txt' ,'w') as f:
            data_lines = ["%i %f
    "
    %(seq_id, random.random()) for seq_id in range(1,1000000)]
            for line in data_lines:
                f.write(line)

    print('Execution time: %s seconds' % (time.time() - start_time))

    #Execution time: 2.464804172515869 seconds

    在多次迭代中,与前两种情况相比,在本例中,我总是收到较低的执行时间值。好的。

    1
    #Iteration 2: Execution time: 2.496004581451416 seconds

    现在问题出现了:为什么列表理解(和一般的列表)比顺序的for循环更快?好的。

    分析顺序for循环执行和list执行时发生的情况的一种有趣的方法是,dis组装每个循环生成的code对象并检查其内容。下面是一个分解的列表理解代码对象的示例:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #disassemble a list code object
    import dis
    l ="[x for x in range(10)]"
    code_obj = compile(l, '<list>', 'exec')
    print(code_obj)  #<code object <module> at 0x000000058DA45030, file"<list>", line 1>
    dis.dis(code_obj)

     #Output:
        <code object <module> at 0x000000058D5D4C90, file"<list>", line 1>
      1           0 LOAD_CONST               0 (<code object <listcomp> at 0x000000058D5D4ED0, file"<list>", line 1>)
              2 LOAD_CONST               1 ('<listcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_NAME                0 (range)
              8 LOAD_CONST               2 (10)
             10 CALL_FUNCTION            1
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 POP_TOP
             18 LOAD_CONST               3 (None)
             20 RETURN_VALUE

    下面是在函数test中分解的for循环代码对象的示例:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    #disassemble a function code object containing a `for` loop
    import dis
    test_list = []
    def test():
        for x in range(1,10):
            test_list.append(x)


    code_obj = test.__code__ #get the code object <code object test at 0x000000058DA45420, file"<ipython-input-19-55b41d63256f>", line 4>
    dis.dis(code_obj)
    #Output:
           0 SETUP_LOOP              28 (to 30)
                  2 LOAD_GLOBAL              0 (range)
                  4 LOAD_CONST               1 (1)
                  6 LOAD_CONST               2 (10)
                  8 CALL_FUNCTION            2
                 10 GET_ITER
            >>   12 FOR_ITER                14 (to 28)
                 14 STORE_FAST               0 (x)

      6          16 LOAD_GLOBAL              1 (test_list)
                 18 LOAD_ATTR                2 (append)
                 20 LOAD_FAST                0 (x)
                 22 CALL_FUNCTION            1
                 24 POP_TOP
                 26 JUMP_ABSOLUTE           12
            >>   28 POP_BLOCK
            >>   30 LOAD_CONST               0 (None)
                 32 RETURN_VALUE

    上面的比较显示了更多的"活动",如果我可以的话,在一个for循环的情况下。例如,请注意在the for循环函数调用中对append()方法的附加函数调用。为了进一步了解dis调用输出中的参数,这里是官方文档。好的。

    最后,如前所述,我还用file.flush()进行了测试,执行时间超过了11 seconds。我在file.write()语句之前添加了f.flush():好的。

    1
    2
    3
    4
    5
    6
    7
    8
    import os
    .
    .
    .
    for line in data_lines:
            f.flush()                #flushes internal buffer and copies data to OS buffer
            os.fsync(f.fileno())     #the os buffer refers to the file-descriptor(fd=f.fileno()) to write values to disk
            f.write(line)

    使用flush()的较长执行时间可以归因于处理数据的方式。此函数将数据从程序缓冲区复制到操作系统缓冲区。这意味着,如果一个文件(在本例中称为test.txt)正被多个进程使用,并且有大量数据被添加到该文件中,您将不必等待整个数据被写入该文件,信息将随时可用。但是,为了确保缓冲区数据实际写入磁盘,还需要添加:os.fsync(f.fileno())。现在,添加os.fsync()至少会增加10倍的执行时间(我没有一直坐着!)因为它涉及到将数据从缓冲区复制到硬盘内存。有关详细信息,请访问此处。好的。

    进一步优化:可以进一步优化工艺。有支持multithreading、创建Process Pools和执行asynchronous任务的库。当函数同时执行CPU密集型任务和写入文件时,这尤其有用。例如,threadinglist comprehensions的组合给出了最快的可能结果:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import time
    import random
    import threading

    start_time = time.time()

    def get_seq():
        data_lines = ["%i %f
    "
    %(seq_id, random.random()) for seq_id in range(1,1000000)]
        with open('test.txt' ,'w') as f:
            for line in data_lines:
                f.write(line)

    set_thread = threading.Thread(target=get_seq)
    set_thread.start()

    print('Execution time: %s seconds' % (time.time() - start_time))

    #Execution time: 0.015599966049194336 seconds

    结论:与顺序的for循环和listappend循环相比,list理解提供了更好的性能。这背后的主要原因是,在list理解的情况下,单个指令字节码的执行比顺序的迭代调用(如EDOCX1)更快。5个循环。可以使用asyncio、threading&processPoolexecutor()进一步优化。您还可以使用这些组合来实现更快的结果。使用file.flush()取决于您的要求。当一个文件被多个进程使用时,需要异步访问数据时,可以添加此函数。但是,如果您还使用os.fsync(f.fileno())将数据从程序的缓冲存储器写入操作系统的磁盘存储器,则此过程可能需要很长时间。好的。好啊。


    考虑到方法2,我认为在您需要将数据写入文件之前,可以假设您拥有所有行的数据(或者至少是大数据块)。

    其他答案都很好,阅读它们确实很有形式化,但都集中在优化文件写入或避免第一个for循环替换为列表理解(这是众所周知的更快)。

    他们忽略了这样一个事实,即您在for循环中迭代以写入文件,这不是真正必要的。

    与其这样做,不如增加内存的使用量(在这种情况下是可以负担的,因为一个1亿行文件大约是600 MB),您可以使用python str的格式化或连接功能以更高效的方式创建一个字符串,然后将大字符串写入文件。还依赖列表理解来获取要格式化的数据。

    有了@tombart答案的loop1和loop2,我分别得到了elapsed time 0:00:01.028567elapsed time 0:00:01.017042

    使用此代码时:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    start = datetime.now()

    data_file = open('file.txt', 'w')
    data_lines = ( '%i %f
    '
    %(seq_id, random.random())
                                for seq_id in xrange(0, 1000000) )
    contents = ''.join(data_lines)
    data_file.write(contents)

    end = datetime.now()
    print("elapsed time %s" % (end - start))

    我得到的是elapsed time 0:00:00.722788,大约快25%。

    注意,data_lines是一个生成器表达式,因此列表实际上并不存储在内存中,并且行是由join方法根据需要生成和消耗的。这意味着唯一显著占用内存的变量是contents。这也会稍微缩短运行时间。

    如果文本太大,无法在内存中完成所有工作,则可以始终分块进行。也就是说,格式化字符串并每隔百万行左右写入文件。

    结论:

    • 始终尝试进行列表理解,而不是简单的for循环(对于筛选列表,列表理解甚至比filter更快,请参见此处)。
    • 如果可能的话,通过内存或实现约束,尝试使用formatjoin函数一次性创建和编码字符串内容。
    • 如果可能并且代码仍然可读,请使用内置函数来避免for循环。例如,使用列表的extend函数而不是迭代和使用append。事实上,前面的两个观点都可以看作是这句话的例子。

    备注。尽管这个答案本身可以被认为是有用的,但它并没有完全解决这个问题,这就是为什么问题中的双循环选项在某些环境中运行得更快。为此,也许下面的@aiken drum的答案可以为这个问题提供一些线索。


    这里的其他答案给出了很好的建议,但我认为实际问题可能不同:

    我认为真正的问题是一代垃圾收集器使用单循环代码运行得更频繁。世代GC与refcounting系统一起存在,用于定期检查具有非零自/循环引用的孤立对象。

    发生这种情况的原因可能很复杂,但我的最佳猜测是:

    • 对于单循环代码,每个迭代都隐式地分配一个新的字符串,然后将其发送给一个文件,在该文件被放弃之后,其refcount变为零,因此它被释放。我相信累积的alloc/dealloc流量是决定何时完成GC的启发式方法的一部分,因此这种行为足以在如此多的迭代中设置该标志。反过来,当线程被迫等待某些东西时,可能会检查这个标志,因为这是一个很好的机会,可以用垃圾收集来填充浪费的时间。同步文件写入正是这种机会。

    • 使用双循环代码,您将创建一个字符串并将其添加到列表中,一次又一次,没有其他内容。分配,分配,分配。如果内存不足,您将触发GC,但否则我怀疑您是否在做任何设置来检查GC的机会。没有什么可以导致线程等待、上下文切换等。同步文件I/O中的第二个循环调用,我认为可能会发生机会主义GC,但只有第一个调用可能触发一个调用,因为此时没有进一步的内存分配/释放。只有在整个列表被写入之后,列表本身才会被释放,所有操作都会同时进行。

    不幸的是,我现在还不能亲自测试这个理论,但是您可以尝试禁用世代垃圾收集,看看它是否会改变单循环版本的执行速度:

    1
    2
    import gc
    gc.disable()

    我想你只需要做这些就可以证实或反驳我的理论。


    它可以通过更改以下内容来减少大约一半的时间成本

    1
    2
    for line in data_lines:
        data_file.write(line)

    进入:

    1
    2
    data_file.write('
    '
    .join(data_lines))

    这是我的测试运行范围(1000000)

    1
    2
    3
    4
    elapsed time 0:00:04.653065
    elapsed time 0:00:02.471547

    2.471547 / 4.653065 = 53 %

    然而,如果10倍以上的范围,没有什么区别。