<p>werkzeug.formparser очень медленный с большими двоичными загрузками</p>

Созданный на 3 мар. 2016  ·  25Комментарии  ·  Источник: pallets/werkzeug

Когда я выполняю загрузку multipart/form-data любого большого двоичного файла во Flask, эти загрузки очень легко связаны с ЦП (при этом Python потребляет 100% ЦП) вместо ограничения ввода-вывода при любом достаточно быстром сетевом соединении.

Небольшое профилирование ЦП показывает, что почти все время ЦП во время этих загрузок тратится на werkzeug.formparser.MultiPartParser.parse_parts() . Причина в том, что метод parse_lines() выдает много_ очень маленьких фрагментов, иногда даже отдельных байтов :

# we have something in the buffer from the last iteration.
# this is usually a newline delimiter.
if buf:
    yield _cont, buf
    buf = b''

Итак, parse_parts() проходит через множество небольших итераций (более 2 миллионов для файла 100 МБ), обрабатывая отдельные «строки», всегда записывая в выходной поток только очень короткие фрагменты или даже отдельные байты. Это добавляет много накладных расходов, замедляя весь этот процесс и очень быстро заставляя его загружать процессор.

Быстрый тест показывает, что ускорение очень легко возможно, если сначала собрать данные в bytearray в parse_lines() и вернуть эти данные обратно в parse_parts() тогда, когда self.buffer_size превышено. Что-то вроде этого:

buf = b''
collect = bytearray()
for line in iterator:
    if not line:
        self.fail('unexpected end of stream')

    if line[:2] == b'--':
        terminator = line.rstrip()
        if terminator in (next_part, last_part):
            # yield remaining collected data
            if collect:
                yield _cont, collect
            break

    if transfer_encoding is not None:
        if transfer_encoding == 'base64':
            transfer_encoding = 'base64_codec'
        try:
            line = codecs.decode(line, transfer_encoding)
        except Exception:
            self.fail('could not decode transfer encoded chunk')

    # we have something in the buffer from the last iteration.
    # this is usually a newline delimiter.
    if buf:
        collect += buf
        buf = b''

    # If the line ends with windows CRLF we write everything except
    # the last two bytes.  In all other cases however we write
    # everything except the last byte.  If it was a newline, that's
    # fine, otherwise it does not matter because we will write it
    # the next iteration.  this ensures we do not write the
    # final newline into the stream.  That way we do not have to
    # truncate the stream.  However we do have to make sure that
    # if something else than a newline is in there we write it
    # out.
    if line[-2:] == b'\r\n':
        buf = b'\r\n'
        cutoff = -2
    else:
        buf = line[-1:]
        cutoff = -1

    collect += line[:cutoff]

    if len(collect) >= self.buffer_size:
        yield _cont, collect
        collect.clear()

Одно только это изменение сокращает время загрузки моего тестового файла размером 34 МБ с 4200 мс до примерно 1100 мс через localhost на моем компьютере, что почти в 4 раза выше производительности. Все тесты проводятся в Windows (64-битный Python 3.4), я не уверен, что это такая же проблема в Linux.

Это все еще в основном связано с процессором, поэтому я уверен, что есть еще больше возможностей для оптимизации. Думаю, я займусь этим, когда найду немного больше времени.

Самый полезный комментарий

Я хотел упомянуть, что парсинг потока выполняется по частям по мере его поступления. @siddhantgoel написал для нас этот замечательный маленький парсер. У меня это отлично работает. https://github.com/siddhantgoel/streaming-form-data

Все 25 Комментарий

У меня также есть такая же проблема, когда я загружаю файл iso (200 м), первый вызов request.form займет 7 секунд

Две вещи кажутся интересными для дальнейшей оптимизации - экспериментирование с cython и эксперименты с интерпретацией заголовков контент-сайта для более разумного синтаксического анализа сообщений mime

(нет необходимости сканировать строки, если вы знаете длину содержимого вложенного сообщения)

Небольшое замечание: если вы транслируете файл прямо в теле запроса (т.е. без application/multipart-formdata ), вы полностью обойдете синтаксический анализатор формы и прочитаете файл прямо из request.stream .

У меня такая же проблема с медленной скоростью загрузки при многокомпонентной загрузке при использовании метода фрагментированной загрузки jQuery-File-Upload. При использовании небольших фрагментов (~ 10 МБ) скорость передачи изменяется от 0 до 12 МБ / с, в то время как сеть и сервер полностью поддерживают скорость более 50 МБ / с. Замедление вызвано многочастным синтаксическим анализом, связанным с процессором, который занимает примерно то же время, что и фактическая загрузка. К сожалению, использование потоковой загрузки для обхода многостраничного синтаксического анализа на самом деле не вариант, поскольку я должен поддерживать устройства iOS, которые не могут выполнять потоковую передачу в фоновом режиме.

Патч, предоставленный @sekrause, выглядит красиво, но не работает в python 2.7.

@carbn : Мне удалось заставить патч работать в Python 2.7, изменив последнюю строку на collect = bytearray() . Это просто создает новый массив байтов вместо очистки существующего.

@cuibonobo : Это первое, что я изменил, но все же осталась другая ошибка. На данный момент я не могу проверить рабочий патч, но, IIRC, доходность пришлось изменить с yield _cont, collect на yield _cont, str(collect) . Это позволило протестировать код, и исправление дало примерно 30% -ное увеличение скорости обработки нескольких частей. Это хорошее ускорение, но производительность по-прежнему оставляет желать лучшего.

Небольшое дальнейшее исследование показывает, что werkzeug.wsgi.make_line_iter уже является слишком узким местом, чтобы можно было действительно оптимизировать parse_lines() . Взгляните на этот тестовый сценарий Python 3:

import io
import time
from werkzeug.wsgi import make_line_iter

filename = 'test.bin' # Large binary file
lines = 0

# load a large binary file into memory
with open(filename, 'rb') as f:
    data = f.read()
    stream = io.BytesIO(data)
    filesize = len(data) / 2**20 # MB

start = time.perf_counter()
for _ in make_line_iter(stream):
    lines += 1
stop = time.perf_counter()
delta = stop - start

print('File size: %.2f MB' % filesize)
print('Time: %.1f seconds' % delta)
print('Read speed: %.2f MB/s' % (filesize / delta))
print('Number of lines yielded by make_line_iter: %d' % lines)

Для видеофайла размером 923 МБ с Python 3.5 результат на моем ноутбуке выглядит примерно так:

File size: 926.89 MB
Time: 20.6 seconds
Read speed: 44.97 MB/s
Number of lines yielded by make_line_iter: 7562905

Так что даже если вы примените мою оптимизацию, описанную выше, и оптимизируйте ее дальше до совершенства, вы все равно будете ограничены ~ 45 МБ / с для больших бинарных загрузок просто потому, что make_line_iter не может предоставить вам данные достаточно быстро, а вы ' Я буду делать 7,5 миллионов итераций для 923 МБ данных в вашем цикле, который проверяет границу.

Думаю, единственной хорошей оптимизацией будет полная замена parse_lines() чем-то другим. Возможное решение, которое приходит на ум, - это прочитать достаточно большой кусок потока в память, а затем использовать string.find () (или bytes.find () в Python 3), чтобы проверить, находится ли граница в фрагменте. В Python find () - это высокооптимизированный алгоритм поиска строк, написанный на C, поэтому он должен дать вам некоторую производительность. Вам просто нужно позаботиться о случае, когда граница может быть прямо между двумя кусками.

Я хотел упомянуть, что парсинг потока выполняется по частям по мере его поступления. @siddhantgoel написал для нас этот замечательный маленький парсер. У меня это отлично работает. https://github.com/siddhantgoel/streaming-form-data

Думаю, единственной отличной оптимизацией будет полная замена parse_lines ()

+1 за это.

Я пишу мост для потоковой загрузки пользователя непосредственно в S3 без каких-либо промежуточных временных файлов, возможно, с противодавлением, и я считаю, что ситуация werkzeug и flask разочаровывает. Вы не можете перемещать данные напрямую между двумя каналами.

@lambdaq Я согласен, это проблема, которую необходимо исправить. Если это важно для вас, я буду рад просмотреть патч, изменяющий поведение.

@lambdaq Обратите внимание, что если вы просто транслируете данные непосредственно в теле запроса и используете application/octet-stream тогда анализатор формы вообще не запускается, и вы можете использовать request.stream (т.е. без временных файлов и т. д.) .

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

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

Мы исправили это, немного изменив порядок проверки функции. Не уверен, имеет ли смысл предоставлять этот восходящий поток, поскольку некоторые приложения могут полагаться на существующее поведение.

Обратите внимание, что если вы просто выполняете потоковую передачу данных непосредственно в теле запроса и используете application / octet-stream, тогда анализатор формы вообще не запускается, и вы можете использовать request.stream (т.е. без временных файлов и т. Д.).

К сожалению нет. Это просто загрузка обычной формы с использованием multipart.

Буду рад пересмотреть патч, меняющий поведение.

Я попытался взломать werkzeug.wsgi.make_line_iter или parse_lines() с помощью send() генераторов, чтобы мы могли сигнализировать _iter_basic_lines() чтобы испускать целые куски вместо строк. Оказывается, не все так просто.

Обычно весь кролик начинается с 'itertools.chain' object has no attribute 'send' .... 😂

Интересно, насколько можно ускорить этот код, используя собственные ускорения, написанные на C (или Cython и т. Д.). Я думаю, что более эффективная обработка полу-больших (несколько 100 МБ, но не огромных, как во многих ГБ) файлов важна без изменения того, как приложение их использует (т.е. потоковая передача напрямую вместо буферизации) - для многих приложений это было бы излишний и не является абсолютно необходимым (на самом деле, даже текущая несколько низкая производительность, вероятно, для них нормально), но делать вещи быстрее всегда приятно!

Другое возможное решение - выгрузить задание синтаксического анализа multipart на nginx

https://www.nginx.com/resources/wiki/modules/upload/

Оба репо выглядят мертвыми.

Итак, нет ли известного решения этой проблемы?

В uwsgi мы используем встроенную функцию chunked_read() и самостоятельно разбираем поток по мере его поступления. Он работает 99% времени, но в нем есть ошибка, которую мне еще предстоит отследить. См. Мой предыдущий комментарий о нестандартном парсере потоковой формы. Под python2 это было медленно, поэтому мы свернули свои собственные, и это быстро. :)

Цитата сверху:

Я согласен, это проблема, которую необходимо исправить. Если это важно для вас, я буду рад просмотреть патч, изменяющий поведение.

У меня сейчас нет времени над этим работать. Если это то , что вы тратите время, пожалуйста рассмотреть вопрос о внесении исправления. Взносы очень приветствуются.

@sdizazzo

но в нем есть ошибка, которую я еще не обнаружил

вы говорите о потоковой форме данных ? если да, то я хотел бы знать, в чем ошибка.

Наша проблема заключалась в том, что медленная обработка формы препятствовала одновременной обработке запросов, из-за чего nomad решил, что процесс завис, и убил его.

Мое исправление заключалось в том, чтобы добавить sleep(0) в werkzeug/formparser.py:MutlipartParser.parse_lines() :

            for i, line in enumerate(iterator):
                if not line:
                    self.fail('unexpected end of stream')

                # give other greenlets a chance to run every 100 lines
                if i % 100 == 0:
                    time.sleep(0)

найдите unexpected end of stream если хотите применить этот патч.

Я хотел упомянуть, что парсинг потока выполняется по частям по мере его поступления. @siddhantgoel написал для нас этот замечательный маленький парсер. У меня это отлично работает. https://github.com/siddhantgoel/streaming-form-data

прикомандирован.
это ускоряет загрузку файлов в мое приложение Flask более чем в 10 раз

@siddhantgoel
Большое спасибо за ваше исправление с потоковыми данными формы. Наконец-то я могу загружать файлы размером в гигабайт на хорошей скорости и без переполнения памяти!

См. # 1788, в котором обсуждается переписывание синтаксического анализатора без использования io. Судя по отзывам здесь, я думаю, что это тоже решит эту проблему.

Была ли эта страница полезной?
0 / 5 - 0 рейтинги

Смежные вопросы

lepture picture lepture  ·  6Комментарии

masklinn picture masklinn  ·  11Комментарии

golf-player picture golf-player  ·  10Комментарии

SimonSapin picture SimonSapin  ·  12Комментарии

Nessphoro picture Nessphoro  ·  6Комментарии