Time performance in Generating very large text file in Python
我需要生成一个非常大的文本文件。每行都有一个简单的格式:
1 2 | Seq_num<SPACE>num_val 12343234 759 |
假设我要生成一个包含1亿行的文件。我尝试了两种方法,令人惊讶的是,它们给出的时间表现非常不同。
对于超过100米的循环,在每一个循环中,我用
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米的循环,在每一个循环中,我用
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必须花费更少的时间。有没有暗示我遗漏了什么?
从技术上讲,很多甚至更少是非常模糊的术语:)基本上,如果你不能衡量它,你就不能改进它。
为了简单起见,让我们有一个简单的基准,
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个回路的
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是如何实现写入文件的。对于文本文件,
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.
因此,我们可以修改
1 | data_file = open('file.txt', 'w', 1) |
结果证明这是非常缓慢的:
1 2 | $ python3 loop3.py elapsed time 0:00:02.470757 |
为了优化写入时间,我们可以根据需要调整缓冲区大小。首先我们以字节为单位检查行大小:
将缓冲区大小更新为预期的行大小(以字节为单位)后:
1 | data_file = open('file.txt', 'w', 11) |
我写得很快:
1 | elapsed time 0:00:00.669622 |
根据你提供的细节,很难估计会发生什么。也许估计块大小的启发式方法在您的计算机上不太管用。不管怎样,如果你写的是固定的行长度,那么很容易优化缓冲区的大小。您可以利用
结论:通常,为了更快地写入文件,您应该尝试写入与文件系统上的块大小相对应的大量数据——这正是Python方法
您分配了大量需要由GC收集的字符串对象。如@kevmo314所建议的,为了进行公平比较,您应该禁用
1 | gc.disable() |
因为GC可能在循环中迭代时尝试删除字符串对象(您不保留任何引用)。而seconds方法保留对所有字符串对象的引用,GC在最后收集它们。
下面是@tombart对优雅答案的扩展,以及一些进一步的观察。好的。
考虑到一个目标:优化从循环读取数据的过程,然后将其写入文件,让我们开始:好的。
在所有情况下,我将使用
另一个需要考虑的重要问题是,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 |
注:在下面的两个
使用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 |
现在问题出现了:为什么列表理解(和一般的列表)比顺序的
分析顺序
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 |
下面是在函数
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 |
上面的比较显示了更多的"活动",如果我可以的话,在一个
最后,如前所述,我还用
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) |
使用
进一步优化:可以进一步优化工艺。有支持
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 |
结论:与顺序的
考虑到方法2,我认为在您需要将数据写入文件之前,可以假设您拥有所有行的数据(或者至少是大数据块)。
其他答案都很好,阅读它们确实很有形式化,但都集中在优化文件写入或避免第一个for循环替换为列表理解(这是众所周知的更快)。
他们忽略了这样一个事实,即您在for循环中迭代以写入文件,这不是真正必要的。
与其这样做,不如增加内存的使用量(在这种情况下是可以负担的,因为一个1亿行文件大约是600 MB),您可以使用python str的格式化或连接功能以更高效的方式创建一个字符串,然后将大字符串写入文件。还依赖列表理解来获取要格式化的数据。
有了@tombart答案的loop1和loop2,我分别得到了
使用此代码时:
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)) |
我得到的是
注意,
如果文本太大,无法在内存中完成所有工作,则可以始终分块进行。也就是说,格式化字符串并每隔百万行左右写入文件。
结论:
- 始终尝试进行列表理解,而不是简单的for循环(对于筛选列表,列表理解甚至比
filter 更快,请参见此处)。 - 如果可能的话,通过内存或实现约束,尝试使用
format 或join 函数一次性创建和编码字符串内容。 - 如果可能并且代码仍然可读,请使用内置函数来避免
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倍以上的范围,没有什么区别。