<p>werkzeug.formparser é muito lento com grandes uploads binários</p>

Criado em 3 mar. 2016  ·  25Comentários  ·  Fonte: pallets/werkzeug

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.

bug

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

Todos 25 comentários

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

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

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.

Esta página foi útil?
0 / 5 - 0 avaliações

Questões relacionadas

Nessphoro picture Nessphoro  ·  6Comentários

lepture picture lepture  ·  6Comentários

taion picture taion  ·  7Comentários

masklinn picture masklinn  ·  11Comentários

sorenh picture sorenh  ·  4Comentários