Когда я выполняю загрузку 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.
Это все еще в основном связано с процессором, поэтому я уверен, что есть еще больше возможностей для оптимизации. Думаю, я займусь этим, когда найду немного больше времени.
У меня также есть такая же проблема, когда я загружаю файл 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
Оба репо выглядят мертвыми.
Итак, нет ли известного решения этой проблемы?
Есть обходной путь 👆
В 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. Судя по отзывам здесь, я думаю, что это тоже решит эту проблему.
Самый полезный комментарий
Я хотел упомянуть, что парсинг потока выполняется по частям по мере его поступления. @siddhantgoel написал для нас этот замечательный маленький парсер. У меня это отлично работает. https://github.com/siddhantgoel/streaming-form-data