Производительность времени при создании очень большого текстового файла в Python

Мне нужно создать очень большой текстовый файл. Каждая строка имеет простой формат:

Seq_num<SPACE>num_val
12343234 759

Предположим, что я собираюсь создать файл со 100 миллионами строк. Я попробовал 2 подхода и, на удивление, они дают очень разную производительность.

  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)
    

Обратите внимание, что:

  • Подход 2 имеет 2 контура вместо 1 циклы.
  • Я пишу в файл в цикле как для подхода 1, так и для подхода 2. Таким образом, этот шаг должен быть одинаковым для обоих.

Поэтому подход 1 должен занимать меньше времени. Любые намеки на то, что мне не хватает?

Ответы

Ответ 1

Учитывая ПОДХОД 2, я думаю, что могу предположить, что у вас есть данные для всех строк (или, по крайней мере, в больших кусках), прежде чем вам нужно будет записать их в файл.

Другие ответы велики, и их действительно было очень важно читать, но оба были сосредоточены на оптимизации записи файла или избегании первого для цикла замены с пониманием списка (это, как известно, быстрее).

Они упустили тот факт, что вы повторяете цикл for для записи файла, что на самом деле не нужно.

Вместо этого, увеличивая использование памяти (в этом случае доступно, поскольку 100-миллионный файл линии будет около 600 МБ), вы можете создать только одну строку более эффективным способом, используя функции форматирования или объединения python str, а затем записать большую строку в файл. Также полагается на понимание списка, чтобы получить данные для форматирования.

С loop1 и loop2 ответа @Tombart, я получаю elapsed time 0:00:01.028567 и elapsed 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. Это также немного сокращает время работы.

Если текст будет большим для выполнения всей работы в памяти, вы всегда можете разделить его на куски. То есть, форматирование строки и запись в файл каждые миллионы строк или около того.

Выводы:

  • Всегда старайтесь выполнять проверку списка вместо простой для циклов (понимание списков происходит даже быстрее, чем filter для списков фильтрации, см. Здесь).
  • Если это возможно из-за ограничений памяти или реализации, попробуйте создать и закодировать содержимое строки сразу, используя функции format или join.
  • Если это возможно, и код остается читаемым, использовать встроенные функции, чтобы избежать for петель. Например, используя функцию extend списка вместо итерации и использования append. Фактически, оба предыдущих пункта можно рассматривать как примеры этого замечания.

Примечание. Хотя этот ответ можно считать полезным сам по себе, он не полностью решает вопрос, поэтому опция двух циклов в вопросе, по-видимому, работает быстрее в некоторых средах. Для этого, возможно, ответ @Aiken Drum ниже может принести некоторый свет по этому поводу.

Ответ 2

Много и гораздо меньше технически очень расплывчатые термины :) В принципе, если вы не можете измерить его, вы не сможете его улучшить.

Для простоты пусть имеет простой ориентир, 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 для циклов:

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

Каждое измерение может немного отличаться, но, как подсказывает интуиция, второе немного медленнее.

Если мы хотим оптимизировать время записи, нам нужно проверить руководство, как Python реализует запись в файлы. Для текстовых файлов функция open() должна использовать BufferedWriter. Функция open принимает три аргумента, которые являются размером буфера. Здесь интересная часть:

Передайте 0, чтобы отключить буферизацию (разрешено только в двоичном режиме), 1 выбрать буферизацию строки (только для использования в текстовом режиме) и целое число> 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().

Вывод: Как правило, для более быстрой записи в файл вы должны попытаться записать большую часть данных, которые соответствуют размеру блока в вашей файловой системе - это именно то, что open('file.txt', 'w') метод Python open('file.txt', 'w') пытаясь сделать. В большинстве случаев вы в безопасности с настройками по умолчанию, различия в микрообъектах несущественны.

Вы выделяете большое количество строковых объектов, которые должны собираться GC. Как было предложено @kevmo314, для проведения справедливого сравнения вы должны отключить GC для loop1.py:

gc.disable()

Поскольку GC может попытаться удалить строковые объекты во время итерации по циклу (вы не храните ссылки). В то время как подход секунд сохраняет ссылки на все строковые объекты, и GC собирает их в конце.

Ответ 3

Ниже приведен пример элегантного ответа @Tombart и несколько дополнительных замечаний.

С одной целью: оптимизировать процесс чтения данных из цикла (ов), а затем записать его в файл, пусть начнется:

Я буду использовать оператор with для открытия/закрытия файла test.txt во всех случаях. Этот оператор автоматически закрывает файл, когда выполняется блок кода внутри него.

Еще один важный момент, который следует учитывать, - это то, как Python обрабатывает текстовые файлы на основе операционной системы. Из документов:

Примечание. Python не зависит от представления текстовых файлов базовыми операционными системами; вся обработка выполняется самим Python и поэтому не зависит от платформы.

Это означает, что эти результаты могут незначительно меняться при выполнении на Linux/Mac или ОС Windows. Небольшое изменение может быть результатом других процессов, использующих один и тот же файл одновременно, или нескольких процессов ввода-вывода, происходящих в файле во время выполнения сценария, общей скорости обработки процессора среди других.

Я представляю 3 случая с временем выполнения для каждого и, наконец, нахожу способ дальнейшей оптимизации наиболее эффективного и быстрого случая:

Первый случай: Loop over range (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(). Причина такова: [] примерно в 3 раза быстрее, чем list(). Вот объяснение этого поведения: почему [] быстрее, чем list()? , Основной темой обсуждения является: Хотя [] создается как объекты байт-кода и является одной инструкцией, list() представляет собой отдельный объект Python, которому также требуется разрешение имен, глобальные вызовы функций и стек должны быть задействованы для ввода аргументов.

Используя функцию timeit() в модуле timeit, здесь сравнение:

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

Второй случай: Loop over range (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

Приведенное выше сравнение показывает больше "активности", если можно, в случае цикла for. Например, обратите внимание на дополнительные вызовы функций к append() метод в for вызова функции цикла. Чтобы узнать больше о параметрах в выводе dis call, вот официальная документация.

Наконец, как было предложено ранее, я также тестировал file.flush() а время выполнения превысило 11 seconds. Я добавляю f.flush() перед file.write():

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 раз (я не сидел все время!), os.fsync() он включает в себя копирование данных из буфера в память на жестком диске. Для получения дополнительной информации перейдите сюда.

Дальнейшая оптимизация: возможно дальнейшая оптимизация процесса. Доступны библиотеки, поддерживающие multithreading, создание Process Pools и выполнение asynchronous задач. Это особенно полезно, когда функция выполняет задачу с интенсивным использованием ЦП и записывает ее в файл одновременно. Например, комбинация методов управления threading и list 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 циклов и append list. Основной причиной этого является то одного исполнения команд байт - кода в случае списковых, который быстрее, чем последовательные итерационных вызовов для добавления элементов в список, как и в случае for петель. Есть возможности для дальнейшей оптимизации с использованием asyncio, threading и ProcessPoolExecutor(). Вы также можете использовать их для достижения более быстрых результатов. Использование file.flush() зависит от вашего требования. Вы можете добавить эту функцию, если вам нужен асинхронный доступ к данным, когда файл используется несколькими процессами. Хотя этот процесс может занять много времени, если вы также записываете данные из буферной памяти программы в операционную память ОС, используя os.fsync(f.fileno()).

Ответ 4

Другие ответы здесь дают хороший совет, но я думаю, что проблема может быть иной:

Я думаю, что настоящая проблема здесь - сборщик мусора поколения, который работает чаще с одноконтурным кодом. GC GC поколения существует наряду с системой пересчета, периодически проверяя наличие потерянных объектов с ненулевыми self/циклическими ссылками.

Причина, по которой это происходит, вероятно, сложна, но я думаю,

  • В однопетлевом коде каждая итерация неявно выделяет новую строку, а затем отправляет ее для записи в файл, после чего она отказывается, ее пересчет обращается в ноль и, следовательно, он освобождается. Я считаю, что кумулятивный трафик alloc/dealloc является частью эвристики, которая решает, когда GC выполняется, поэтому этого поведения было бы достаточно, чтобы установить этот флаг для каждого такта итераций. Флаг, в свою очередь, вероятно, проверяется в любое время, когда ваш поток будет вынужден ждать чего-то в любом случае, потому что это отличная возможность заполнить потраченное время сбором мусора. Синхронная запись файлов - вот такая возможность.

  • С двойным циклом вы создаете строку и добавляете ее в список снова и снова, ничего больше. Выделять, выделять, выделять. Если у вас закончилась нехватка памяти, вы собираетесь запустить GC, но в остальном я сомневаюсь, что вы делаете все, что настроено для проверки возможностей GC. Там ничего нет, чтобы вызвать ожидание потока, переключатель контекста и т.д. Второй цикл вызывает синхронный ввод-вывод файлов, где, по-моему, может возникнуть оппортунистический GC, но только первый вызов может вызвать его, потому что нет дополнительной памяти распределение/освобождение в этой точке. Только после того, как весь список написан, сам список освобождается, все сразу.

К сожалению, я не могу проверить теорию прямо сейчас, но вы можете попытаться отключить коллекцию мусора генерации и посмотреть, изменит ли она скорость выполнения однопетлевой версии:

import gc
gc.disable()

Я думаю, что все, что вам нужно сделать, чтобы подтвердить или опровергнуть мою теорию.

Ответ 5

Это может сократить затраты времени примерно на половину, изменив следующее

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 раз выше указанного диапазона, то нет большой разницы.