Quando eu executo um upload multipart/form-data
de qualquer arquivo binário grande no Flask, esses uploads são facilmente limitados pela CPU (com Python consumindo 100% da CPU) em vez de I / O limitados em qualquer conexão de rede razoavelmente rápida.
Um pouco de criação de perfil de CPU revela que quase todo o tempo de CPU durante esses uploads é gasto em werkzeug.formparser.MultiPartParser.parse_parts()
. A razão é que o método parse_lines()
produz _um monte_ de pedaços muito pequenos, às vezes até mesmo bytes únicos :
# we have something in the buffer from the last iteration.
# this is usually a newline delimiter.
if buf:
yield _cont, buf
buf = b''
Portanto, parse_parts()
passa por várias pequenas iterações (mais de 2 milhões para um arquivo de 100 MB) processando "linhas" simples, sempre gravando apenas pedaços muito curtos ou mesmo bytes únicos no fluxo de saída. Isso adiciona muita sobrecarga, desacelerando todo o processo e tornando-o limitado pela CPU muito rapidamente.
Um teste rápido mostra que uma aceleração é muito facilmente possível, primeiro coletando os dados em bytearray
em parse_lines()
e apenas retornando esses dados em parse_parts()
quando self.buffer_size
foi excedido. Algo assim:
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()
Essa mudança por si só reduz o tempo de upload para meu arquivo de teste de 34 MB de 4200 ms para cerca de 1100 ms em localhost na minha máquina, que é quase um aumento de 4 vezes no desempenho. Todos os testes são feitos no Windows (Python 3.4 de 64 bits), não tenho certeza se é um problema tanto no Linux.
Ainda está vinculado principalmente à CPU, então tenho certeza de que há ainda mais potencial para otimização. Acho que vou averiguar quando tiver mais tempo.
Eu também tenho o mesmo problema, quando eu carrego um arquivo iso (200m), a primeira chamada para request.form levará 7s
Duas coisas parecem interessantes para uma otimização posterior - experimentar com cython e experimentar interpretar os cabeçalhos do site de conteúdo para uma análise mais inteligente de mensagem mímica
(não há necessidade de procurar por linhas se você souber o comprimento do conteúdo de uma sub-mensagem)
Apenas uma nota rápida, se você transmitir o arquivo diretamente no corpo da solicitação (ou seja, sem application/multipart-formdata
), você ignora completamente o analisador de formulários e lê o arquivo diretamente de request.stream
.
Eu tenho o mesmo problema com velocidades lentas de upload com uploads de várias partes ao usar o método de upload em partes do jQuery-File-Upload. Ao usar pequenos pedaços (~ 10 MB), a velocidade de transferência pula entre 0 e 12 MB / s, enquanto a rede e o servidor são totalmente capazes de velocidades acima de 50 MB / s. A lentidão é causada pela análise multiparte ligada à CPU, que leva quase o mesmo tempo que o upload real. Infelizmente, usar uploads de streaming para contornar a análise multiparte não é realmente uma opção, pois devo oferecer suporte a dispositivos iOS que não podem fazer streaming em segundo plano.
O patch fornecido por @sekrause parece bom, mas não funciona no python 2.7.
@carbn : Consegui fazer o patch funcionar no Python 2.7 alterando a última linha para collect = bytearray()
. Isso apenas cria um novo bytearray em vez de limpar o existente.
@cuibonobo : Essa foi a primeira coisa que mudei, mas ainda tinha outro erro. Não posso verificar o patch de trabalho no momento, mas IIRC os rendimentos tiveram que ser alterados de yield _cont, collect
para yield _cont, str(collect)
. Isso permitiu que o código fosse testado e o patch gerou um aumento de cerca de 30% na velocidade de processamento de várias partes. É um bom aumento de velocidade, mas o desempenho ainda é muito ruim.
Um pouco mais de investigação mostra que werkzeug.wsgi.make_line_iter
já é um gargalo demais para realmente ser capaz de otimizar parse_lines()
. Veja este script de teste 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)
Para um arquivo de vídeo de 923 MB com Python 3.5, a saída é parecida com esta no meu laptop:
File size: 926.89 MB
Time: 20.6 seconds
Read speed: 44.97 MB/s
Number of lines yielded by make_line_iter: 7562905
Portanto, mesmo se você aplicar minha otimização acima e otimizá-la ainda mais até a perfeição, você ainda estará limitado a ~ 45 MB / s para uploads binários grandes simplesmente porque make_line_iter
não pode fornecer os dados rápido o suficiente e você ' Estarei fazendo 7,5 milhões de iterações para 923 MB de dados em seu loop que verifica o limite.
Acho que a única grande otimização será substituir completamente parse_lines()
por outra coisa. Uma possível solução que vem à mente é ler um pedaço razoavelmente grande do fluxo na memória e usar string.find () (ou bytes.find () em Python 3) para verificar se o limite está no pedaço. Em Python, find () é um algoritmo de pesquisa de string altamente otimizado escrito em C, de modo que deve fornecer algum desempenho. Você apenas teria que cuidar do caso em que o limite poderia estar entre dois blocos.
Eu gostaria de mencionar fazer a análise no fluxo em partes à medida que é recebido. @siddhantgoel escreveu este excelente pequeno analisador para nós. Está funcionando muito bem para mim. https://github.com/siddhantgoel/streaming-form-data
Acho que a única ótima otimização será substituir completamente parse_lines ()
1 para isso.
Estou escrevendo uma ponte para transmitir o upload do usuário diretamente para o S3 sem nenhum arquivo temporário intermediário, possivelmente com contrapressão, e acho a situação werkzeug
e flask
frustrante. Você não pode mover dados diretamente entre dois tubos.
@lambdaq Concordo que é um problema que precisa ser corrigido. Se isso for importante para você, ficaria feliz em revisar um patch que altera o comportamento.
@lambdaq Observe que se você apenas transmitir dados diretamente no corpo da solicitação e usar application/octet-stream
, o analisador de formulários não entra em ação e você pode usar request.stream
(ou seja, nenhum arquivo temporário, etc.) .
O único problema que tivemos é que o analisador de formulários werkzeug está verificando avidamente o saber se ele deve realmente analisar o corpo da solicitação .
Isso evita que você defina o comprimento máximo do conteúdo nos dados do formulário normal, mas também permite uploads de arquivos muito grandes.
Consertamos isso reordenando um pouco a função de
Observe que se você apenas transmitir dados diretamente no corpo da solicitação e usar application / octet-stream, o analisador de formulário não será ativado e você poderá usar request.stream (ou seja, sem arquivos temporários, etc.).
Infelizmente não. É apenas uploads de formulários normais com várias partes.
Eu ficaria feliz em revisar um patch que altera o comportamento.
Eu tentei hackear werkzeug.wsgi.make_line_iter
ou parse_lines()
usando geradores send()
, então podemos sinalizar _iter_basic_lines()
para emitir pedaços inteiros em vez de linhas. Acontece que não é tão fácil.
Basicamente, o coelho inteiro começa com 'itertools.chain' object has no attribute 'send'
.... 😂
Eu me pergunto o quanto este código poderia ser acelerado usando speedups nativos escritos em C (ou Cython etc.). Acho que lidar com arquivos semi-grandes (alguns 100 MB, mas não enormes como em muitos GB) com mais eficiência é importante, sem ter que alterar a forma como o aplicativo os usa (ou seja, fazer streaming diretamente em vez de buffer) - para muitos aplicativos, isso seria exagero e não é absolutamente necessário (na verdade, mesmo o atual desempenho um pouco lento é provavelmente OK para eles), mas tornar as coisas mais rápidas é sempre bom!
Outra solução possível é descarregar o trabalho de análise multipart
para nginx
Ambos os repositórios parecem mortos.
então não há solução conhecida para isso?
Existe uma solução alternativa 👆
No uwsgi, usamos a função incorporada em chunked_read()
e analisamos o fluxo por conta própria à medida que ele chega. Funciona 99% do tempo, mas tem um bug que ainda não rastreei. Veja meu comentário anterior para um analisador de formulário de streaming pronto para uso. Em python2 era lento, então lançamos nosso próprio e é rápido. :)
Citando acima:
Eu concordo que é um problema que precisa ser corrigido. Se isso for importante para você, ficaria feliz em revisar um patch que altera o comportamento.
Eu realmente não tenho tempo para trabalhar nisso agora. Se isso é algo que você está gastando tempo, por favor, considere contribuir um patch. As contribuições são muito bem-vindas.
@sdizazzo
mas tem um bug que ainda não rastreei
você está falando sobre streaming-form-data ? em caso afirmativo, adoraria saber qual é o bug.
Nosso problema era que o processamento lento do formulário impedia o tratamento de solicitações simultâneas, o que fazia com que nomad
pensasse que o processo estava suspenso e o encerrou.
Minha solução foi adicionar sleep(0)
em 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)
pesquise unexpected end of stream
se quiser aplicar este patch.
Eu gostaria de mencionar fazer a análise no fluxo em partes à medida que é recebido. @siddhantgoel escreveu este excelente pequeno analisador para nós. Está funcionando muito bem para mim. https://github.com/siddhantgoel/streaming-form-data
destacado.
isso acelera o upload de arquivos para meu aplicativo Flask em mais de 10 vezes
@siddhantgoel
Muito obrigado por sua correção com streaming-form-data. Posso finalmente fazer upload de arquivos do tamanho de um gigabyte em boa velocidade e sem encher a memória!
Veja # 1788 que discute a reescrita do analisador para ser sans-io. Com base no feedback aqui, acho que isso também resolveria esse problema.
Comentários muito úteis
Eu gostaria de mencionar fazer a análise no fluxo em partes à medida que é recebido. @siddhantgoel escreveu este excelente pequeno analisador para nós. Está funcionando muito bem para mim. https://github.com/siddhantgoel/streaming-form-data