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

时间:2018-03-13 22:34:45

标签: python algorithm performance large-data large-files

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

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)
    
  3. 请注意:

    • 方法2有2个循环而不是1个循环。
    • 我为方法1和方法2写入循环文件。因此,这两个步骤必须相同。

    所以方法1必须花费更少的时间。任何提示我缺少什么?

5 个答案:

答案 0 :(得分:14)

很多远远不够在技术上是非常模糊的术语:)基本上,如果你无法衡量它,你就无法改进它。< / p>

为简单起见,我们有一个简单的基准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))
带有2个for循环的

loop2.py

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。对于文本文件,open()函数应使用BufferedWriteropen函数接受第三个参数,即缓冲区大小。这是有趣的部分:

  

传递0以关闭缓冲(仅在二进制模式下允许),1到   选择行缓冲(仅在文本模式下可用)和整数&gt; 1   指示固定大小的块缓冲区的大小(以字节为单位)。什么时候没有   给出缓冲参数,默认缓冲策略为   如下:

     

二进制文件以固定大小的块缓冲;缓冲区的大小   选择使用启发式试图确定底层证券   设备的“块大小”并回落到io.DEFAULT_BUFFER_SIZE。上   在许多系统中,缓冲区的长度通常为4096或8192字节。

因此,我们可以修改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方法{{1试图这样做。在大多数情况下,您使用默认设置是安全的,微基准测试的差异无关紧要。

您需要分配大量的字符串对象,这些对象需要由GC收集。根据@ kevmo314的建议,为了执行公平比较,您应该禁用open('file.txt', 'w')的GC:

loop1.py

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

答案 1 :(得分:12)

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

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

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

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

  

注意:Python不依赖于底层操作系统的文本文件概念;所有处理都由Python本身完成,因此与平台无关。

这意味着在Linux / Mac或Windows操作系统上执行时,这些结果可能只会略有不同。 稍微变化可能是由于在脚本执行期间同时使用相同文件的其他进程或文件上发生的多个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,例如:[]而不是list()。原因是:[]list()快约3倍。以下是对此行为的解释:Why is [] faster than list()?。讨论的主要症结是:[]创建为 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循环执行时和list执行时发生的事情的有趣方法是dis汇总每个code生成的对象并检查内容。以下是反汇编列表推导代码对象的示例:

#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中反汇编的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

上述比较显示了更多&#34;活动&#34;,如果可以的话,在for循环的情况下。例如,请注意append()循环函数调用中对for方法的附加函数调用。要详细了解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密集型任务时,这尤其有用。同时写入文件。例如,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相比,列表推导提供了更好的性能。这背后的主要原因是 单指令字节码执行 在列表推导的情况下比 顺序迭代调用将项目追加到列表<更快< / em> for循环的情况一样。使用asynciothreading&amp;进一步优化的余地可以ProcessPoolExecutor()。您还可以使用这些组合来实现更快的结果。使用file.flush()取决于您的要求。当您在多个进程使用文件时需要异步访问数据时,可以添加此函数。但是,如果您使用os.fsync(f.fileno())将数据从程序的缓冲存储器写入OS的磁盘存储器,则此过程可能需要很长时间。

答案 2 :(得分:5)

考虑到APPROACH 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。这也略微减少了运行时间。

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

结论:

  • 总是尝试进行列表理解而不是普通for循环(对于过滤列表see here,列表理解甚至比filter更快。)
  • 如果可能出现内存或实施限制,请尝试使用formatjoin函数一次创建和编码字符串内容。
  • 如果可能且代码仍然可读,请使用内置函数来避免for循环。例如,使用列表的extend函数而不是迭代并使用append。事实上,以前的两点都可以看作是这句话的例子。

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

答案 3 :(得分:2)

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

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

之所以会发生这种情况可能很复杂,但我最好的猜测是:

  • 使用单循环代码,每次迭代都隐式分配一个新字符串,然后发送它以写入文件,之后它被放弃,它的引用计数变为零,因此它被解除分配。我相信累积的alloc / dealloc流量是启发式的一部分,它决定GC何时完成,所以这种行为足以在每那么多次迭代中设置该标志。反过来,当你的线程被强制等待某个东西时,可能会检查该标志,因为这是一个用垃圾收集来填补浪费时间的绝佳机会。同步文件写入正是这种机会。

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

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

import gc
gc.disable()

我认为您需要做的就是确认或反驳我的理论。

答案 4 :(得分:0)

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

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倍,则没有太大区别。