Почему копирование файла по строкам значительно влияет на скорость копирования в Python?

Некоторое время назад я создал Python script, который выглядел примерно так:

with open("somefile.txt", "r") as f, open("otherfile.txt", "a") as w:
    for line in f:
        w.write(line)

Что, конечно, довольно медленно работало в файле 100mb.

Однако я изменил программу, чтобы сделать это

ls = []
with open("somefile.txt", "r") as f, open("otherfile.txt", "a") as w:
    for line in f:
        ls.append(line)
        if len(ls) == 100000:
            w.writelines(ls)
            del ls[:]

И файл копируется намного быстрее. Мой вопрос: почему второй метод работает быстрее, хотя программа копирует одно и то же количество строк (хотя и собирает их и печатает их по одному)?

Ответ 1

Возможно, я нашел причину, по которой write медленнее, чем writelines. При просмотре источника CPython (3.4.3) я нашел код для функции write (вынул нерелевантные части).

Modules/_io/fileio.c

static PyObject *
fileio_write(fileio *self, PyObject *args)
{
    Py_buffer pbuf;
    Py_ssize_t n, len;
    int err;
    ...
    n = write(self->fd, pbuf.buf, len);
    ...

    PyBuffer_Release(&pbuf);

    if (n < 0) {
        if (err == EAGAIN)
            Py_RETURN_NONE;
        errno = err;
        PyErr_SetFromErrno(PyExc_IOError);
        return NULL;
    }

    return PyLong_FromSsize_t(n);
}

Если вы заметили, эта функция фактически возвращает значение, размер строки, которая была написана, которая является вызовом другой функции.

Я проверил это, чтобы узнать, действительно ли оно имеет возвращаемое значение, и это произошло.

with open('test.txt', 'w+') as f:
    x = f.write("hello")
    print(x)

>>> 5

Ниже приведен код реализации функции writelines в CPython (выведены нерелевантные части).

Modules/_io/iobase.c

static PyObject *
iobase_writelines(PyObject *self, PyObject *args)
{
    PyObject *lines, *iter, *res;

    ...

    while (1) {
        PyObject *line = PyIter_Next(iter);
        ...
        res = NULL;
        do {
            res = PyObject_CallMethodObjArgs(self, _PyIO_str_write, line, NULL);
        } while (res == NULL && _PyIO_trap_eintr());
        Py_DECREF(line);
        if (res == NULL) {
            Py_DECREF(iter);
            return NULL;
        }
        Py_DECREF(res);
    }
    Py_DECREF(iter);
    Py_RETURN_NONE;
}

Если вы заметили, есть нет возвращаемого значения! Он просто имеет Py_RETURN_NONE вместо другого вызова функции для вычисления размера записанного значения.

Итак, я пошел и проверил, что на самом деле не было возвращаемого значения.

with open('test.txt', 'w+') as f:
    x = f.writelines(["hello", "hello"])
    print(x)

>>> None

Дополнительное время, которое занимает write, похоже, связано с дополнительным вызовом функции, выполненным в реализации, для получения возвращаемого значения. Используя writelines, вы пропустите этот шаг, и файл является единственным узким местом.

Изменить: write документация

Ответ 2

Я не согласен с другим ответом здесь.

Это просто совпадение. Это сильно зависит от вашей среды:

  • Какая ОС?
  • Какой жесткий диск/процессор?
  • Какой формат файловой системы HDD?
  • Насколько занят ваш процессор/жесткий диск?
  • Что такое версия Python?

Оба фрагмента кода выполняют ту же самую вещь с небольшими различиями в производительности.

Для меня лично .writelines() выполняется больше времени, чем первый пример с использованием .write(). Протестировано текстовым файлом 110 МБ.

Я не буду публиковать спецификации своих машин специально.

Test.write(): ------ копирование заняло 0.934000015259 секунд (тире для чтения)

Тест .writelines(): копирование заняло 0.936999797821 секунд

Также тестируется с небольшими и полными файлами размером 1,5 ГБ с теми же результатами. (сценарии всегда немного медленнее, до 0,5 с разница для 1,5 ГБ файла).

Ответ 3

Из-за этого в первой части вы должны вызвать метод write для всех строк на каждой итерации, что заставляет вашу программу занять много времени. Но во втором коде, хотя у вас больше памяти, но он работает лучше, потому что вы вызвали метод writelines() для каждой линии 100000.

Посмотрим, что это источник, это источник функции writelines:

def writelines(self, list_of_data):
    """Write a list (or any iterable) of data bytes to the transport.

    The default implementation concatenates the arguments and
    calls write() on the result.
    """
    if not _PY34:
        # In Python 3.3, bytes.join() doesn't handle memoryview.
        list_of_data = (
            bytes(data) if isinstance(data, memoryview) else data
            for data in list_of_data)
    self.write(b''.join(list_of_data))

Как вы можете видеть, он объединяет все элементы списка и вызывает функцию write один раз.

Обратите внимание, что объединение данных здесь требует времени, но меньше времени для вызова функции write для каждой строки. Но поскольку вы используете python 3.4 в, он записывает строки по одному, а не присоединяется к ним, поэтому будет намного быстрее, чем write в этом случае:

  • cStringIO.writelines() теперь принимает любой итеративный аргумент и записывает линии по одному, а не присоединяться к ним и писать один раз. Сделано параллельное изменение на StringIO.writelines(). Сохраняет память и подходит для использования с выражениями генератора.