在 Python 中生成超大文本文件的时间性能

Time performance in Generating very large text file in Python

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

Seq_num<SPACE>num_val
12343234 759

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

  1. 循环超过 100 米。在每个循环中,我制作 seq_num<SPACE>num_val 的短字符串,然后将其写入文件。 这种方法需要 很多 的时间。

    ## APPROACH 1  
    for seq_id in seq_ids:
        num_val=rand()
        line=seq_id+' '+num_val
        data_file.write(line)
    
  2. 循环超过 100 米。在每个循环中,我制作 seq_num<SPACE>num_val 的短字符串,然后将其附加到列表中。 当循环结束时,我遍历列表项并将每个项目写入一个文件。 这种方法花费 少得多 时间。

    ## 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)
    

注意:

所以方法 1 必须花费更少的时间。有什么提示我遗漏了什么吗?

很多在技术上是非常模糊的术语:)基本上如果你不能测量它,你就不能改进一下。

为简单起见,让我们做一个简单的基准测试,loop1.py:

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\n" % (seq_id, num_val)
        data_file.write(line)

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

loop2.py 有 2 个 for 循环:

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\n" % (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 驱动器)上运行这两个脚本时,我得到类似的东西:

$ python3 loop1.py 
elapsed time 0:00:00.684282
$ python3 loop2.py 
elapsed time 0:00:00.766182

每次测量可能略有不同,但直觉表明,第二次测量稍慢。

如果我们想优化写入时间,我们需要检查the manual how Python implements writing into files. For text files the open() function should use 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并使用行缓冲:

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

事实证明这很慢:

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

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

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

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

我的写入速度非常快:

elapsed time 0:00:00.669622

根据您提供的详细信息,很难估计发生了什么。也许用于估计块大小的启发式方法在您的计算机上效果不佳。不管怎样,如果你写的是固定行长,优化缓冲区大小很容易。您可以利用 flush().

进一步优化文件写入

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

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

的 GC
gc.disable()

因为 GC 可能会在遍历循环时尝试删除字符串对象(您没有保留任何引用)。虽然秒方法保留对所有字符串对象的引用,但 GC 在最后收集它们。

下面是对@Tombart 的优雅回答的扩展和一些进一步的观察。

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

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

另一个需要考虑的重点是 Python 基于操作系统处理文本文件的方式。来自 docs

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.

这意味着在 Linux/Mac 或 Windows OS 上执行时,这些结果可能只会略有不同。 轻微的差异可能是由于其他进程同时使用同一文件或在脚本执行期间文件上发生的多个 IO 进程,一般 CPU 处理速度等。

我展示了 3 个案例以及每个案例的执行时间,最终找到了一种进一步优化最有效和最快速案例的方法:

第一种情况:循环范围(1,1000000)并写入文件

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\n" %(seq_id, num_val)
        f.write(line)

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

#Execution time: 2.6448447704315186 seconds

注意:在下面的两个list场景中,我已经初始化了一个空列表data_lines like:[] 而不是使用list()。原因是:[]list()快3倍左右。以下是对此行为的解释:. The main crux of the discussion is: While [] is created as bytecode 对象是一个 单指令 list() 是一个单独的 Python 对象,它也需要名称解析、全局函数调用,并且必须涉及堆栈以推送参数。

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

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

第二种情况:在范围(1,1000000)上循环,将值附加到空列表,然后写入文件

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\n" %(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强大而紧凑的列表理解,可以进一步优化流程:

import time
import random

start_time = time.time()

with open('test.txt' ,'w') as f: 
        data_lines = ["%i %f\n" %(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

在多次迭代中,与前两种情况相比,在这种情况下我总是收到更短的执行时间值。

#Iteration 2: Execution time: 2.496004581451416 seconds

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

分析顺序 for 循环执行时和 lists 执行时发生的情况的一种有趣方法是 disassemble code 对象由每个生成并检查内容。这是列表理解代码对象 disassembled:

的示例
#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

这是一个 for 循环代码对象 disassembled 在函数 test:

中的示例
#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

上面的比较显示了更多 "activity",如果可以的话,在 for 循环的情况下。例如,请注意在 for 循环函数调用中对 append() 方法的附加函数调用。要了解有关 dis 调用输出中参数的更多信息,请参阅官方 documentation

最后,按照之前的建议,我也用file.flush()进行了测试,执行时间超过了11 seconds。我在 file.write() 语句之前添加 f.flush() :

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 倍 (我没有一直坐着!),因为它涉及将数据从缓冲区复制到硬盘内存。有关更多详细信息,请转到 here.

进一步优化:可以进一步优化流程。有可用的库支持 multithreading、创建 Process Pools 和执行 asynchronous 任务。这在函数执行 CPU-intensive 任务并同时写入文件时特别有用。例如,threadinglist comprehensions 的组合给出了 最快的 可能结果:

import time
import random
import threading

start_time = time.time()

def get_seq():
    data_lines = ["%i %f\n" %(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 循环和 list append 相比,列表理解提供了更好的性能。这背后的主要原因是 单指令字节码执行 在列表理解的情况下比 将项目附加到列表的顺序迭代调用 for 循环的情况一样。使用 asyncio, threading & ProcessPoolExecutor() 可以进一步优化。您还可以结合使用这些方法来获得更快的结果。使用 file.flush() 取决于您的要求。当一个文件被多个进程使用时,当你需要异步访问数据时,你可以添加这个函数。虽然,这个过程可能需要很长时间如果您还使用 os.fsync(f.fileno()).

将数据从程序的缓冲内存写入 OS 的磁盘内存,请告诉我

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

其他答案都很棒,阅读它们真的很重要,但都侧重于优化文件编写或避免第一个 for 循环替换为列表理解(众所周知速度更快)。

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

不是那样做,通过增加内存的使用(在这种情况下是可以承受的,因为 1 亿行文件大约 600 MB),您可以通过使用格式化或加入 python str 的特征,然后将大字符串写入文件。还依靠列表理解来获取要格式化的数据。

使用@Tombart 的答案的 loop1 和 loop2,我分别得到 elapsed time 0:00:01.028567elapsed time 0:00:01.017042

使用此代码时:

start = datetime.now()

data_file = open('file.txt', 'w')
data_lines = ( '%i %f\n'%(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。这也略微减少了 运行ning 时间。

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

结论:

  • 总是尝试做列表推导而不是简单的 for 循环(列表推导甚至比 filter 过滤列表 see here 更快)。
  • 如果可能,由于内存或实现限制,请尝试使用 formatjoin 函数一次创建和编码字符串内容。
  • 如果可能并且代码保持可读性,请使用 built-in 函数来避免 for 循环。例如,使用列表的 extend 函数而不是迭代并使用 append。其实前面两点都可以看作是这句话的例子。

备注. 虽然这个答案本身可以被认为是有用的,但它并没有完全解决问题,这就是为什么问题中的 two loops 选项在某些环境中似乎 运行 更快.为此,也许下面@Aiken Drum 的回答可以为这件事带来一些启示。

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

我认为这里真正的问题是分代垃圾收集器 运行更频繁地使用 single-loop 代码。 分代 GC 与引用计数系统,定期检查非零 self/cyclic-references.

的孤立对象

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

  • 使用 single-loop 代码,每次迭代都隐式分配一个新字符串,然后将其发送以写入文件,之后它被放弃,其引用计数变为零,因此它被释放。我相信累积 alloc/dealloc 流量是决定 GC 何时完成的试探法的一部分,因此这种行为足以每隔这么多次迭代设置该标志。反过来,每当您的线程无论如何都将被迫等待某事时,可能会检查该标志,因为这是用垃圾收集来填补浪费时间的绝好机会。同步文件写入正是这种机会。

  • 使用 dual-loop 代码,您正在创建一个字符串并将其添加到列表中,一遍又一遍,仅此而已。分配,分配,分配。如果您 运行 内存不足,您将触发 GC,但除此之外,我怀疑您正在做任何设置来检查 GC 机会的事情。没有什么可以导致线程等待、上下文切换等。第二个循环调用同步文件 I/O,我认为可能会发生机会性 GC,但只有第一次调用可能会触发一个,因为没有进一步记忆 allocation/deallocation 在这一点上。只有在写入整个列表后,列表本身才会一次性全部释放。

不幸的是,我现在无法亲自测试该理论,但您可以尝试禁用分代垃圾收集,看看它是否会改变 single-loop 版本的执行速度:

import gc
gc.disable()

我认为这就是你需要做的所有事情来证实或反驳我的理论。

通过更改以下内容可以减少一半左右的时间成本

for line in data_lines:
    data_file.write(line)

进入:

data_file.write('\n'.join(data_lines))

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

elapsed time 0:00:04.653065
elapsed time 0:00:02.471547

2.471547 / 4.653065 = 53 %

不过如果是上述范围的10倍,差别不大