<p>werkzeug.formparser es muy lento con cargas binarias grandes</p>

Creado en 3 mar. 2016  ·  25Comentarios  ·  Fuente: pallets/werkzeug

Cuando realizo una carga multipart/form-data de cualquier archivo binario grande en Flask, esas cargas están muy fácilmente vinculadas a la CPU (con Python consumiendo el 100% de la CPU) en lugar de las E / S vinculadas a cualquier conexión de red razonablemente rápida.

Un poco de perfilado de CPU revela que casi todo el tiempo de CPU durante estas cargas se gasta en werkzeug.formparser.MultiPartParser.parse_parts() . La razón es que el método parse_lines() produce _muchos_ fragmentos muy pequeños, a veces incluso solo bytes :

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

Así que parse_parts() pasa por muchas pequeñas iteraciones (más de 2 millones para un archivo de 100 MB) procesando "líneas" individuales, siempre escribiendo fragmentos muy cortos o incluso bytes individuales en el flujo de salida. Esto agrega mucha sobrecarga, lo que ralentiza todo el proceso y hace que la CPU se vincule muy rápidamente.

Una prueba rápida muestra que una aceleración es muy fácil al recopilar primero los datos en bytearray en parse_lines() y solo devolver esos datos a parse_parts() cuando self.buffer_size Se supera

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()

Este cambio por sí solo reduce el tiempo de carga de mi archivo de prueba de 34 MB de 4200 ms a alrededor de 1100 ms sobre localhost en mi máquina, eso es casi un aumento de 4 veces en el rendimiento. Todas las pruebas se realizan en Windows (Python 3.4 de 64 bits), no estoy seguro de si es un problema tan grande en Linux.

Todavía está vinculado principalmente a la CPU, por lo que estoy seguro de que hay aún más potencial de optimización. Creo que lo investigaré cuando tenga un poco más de tiempo.

bug

Comentario más útil

Quería mencionar el análisis sintáctico de la secuencia en partes a medida que se recibe. @siddhantgoel escribió este gran analizador para nosotros. Me está funcionando muy bien. https://github.com/siddhantgoel/streaming-form-data

Todos 25 comentarios

También tengo el mismo problema, cuando subo un archivo iso (200 m), la primera llamada a request.form tomará 7 s

2 cosas parecen interesantes para una mayor optimización: experimentar con cython y experimentar con la interpretación de los encabezados del sitio de contenido para un análisis más inteligente de los mensajes mime

(no es necesario buscar líneas si conoce la longitud del contenido de un submensaje)

Solo una nota rápida, que si transmite el archivo directamente en el cuerpo de la solicitud (es decir, sin application/multipart-formdata ), omite por completo el analizador de formularios y lee el archivo directamente desde request.stream .

Tengo el mismo problema con velocidades de carga lentas con cargas de varias partes cuando uso el método de carga fragmentada de jQuery-File-Upload. Cuando se utilizan trozos pequeños (~ 10 MB), la velocidad de transferencia salta entre 0 y 12 MB / s, mientras que la red y el servidor son totalmente capaces de alcanzar velocidades superiores a 50 MB / s. La ralentización se debe al análisis de varias partes vinculado a la CPU, que tarda aproximadamente el mismo tiempo que la carga real. Lamentablemente, el uso de cargas de transmisión para evitar el análisis de varias partes no es realmente una opción, ya que debo admitir dispositivos iOS que no pueden transmitir en segundo plano.

El parche proporcionado por @sekrause se ve bien pero no funciona en Python 2.7.

@carbn : pude hacer que el parche funcionara en Python 2.7 cambiando la última línea a collect = bytearray() . Esto solo crea un nuevo bytearray en lugar de borrar el existente.

@cuibonobo : Eso es lo primero que cambié pero todavía tenía otro error. No puedo verificar el parche de trabajo en este momento, pero IIRC tuvo que cambiar los rendimientos de yield _cont, collect a yield _cont, str(collect) . Esto permitió probar el código y el parche produjo un aumento de aproximadamente un 30% en la velocidad de procesamiento de varias partes. Es una buena aceleración, pero el rendimiento sigue siendo bastante malo.

Un poco más de investigación muestra que werkzeug.wsgi.make_line_iter ya es demasiado cuello de botella para poder optimizar realmente parse_lines() . Mire este script de prueba de 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 un archivo de video de 923 MB con Python 3.5, la salida se ve así en mi computadora portátil:

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

Entonces, incluso si aplica mi optimización anterior y la optimiza aún más hasta la perfección, aún estará limitado a ~ 45 MB / s para cargas binarias grandes simplemente porque make_line_iter no puede brindarle los datos lo suficientemente rápido y usted ' Estaremos haciendo 7.5 millones de iteraciones para 923 MB de datos en su ciclo que verifica el límite.

Supongo que la única gran optimización será reemplazar completamente parse_lines() con otra cosa. Una posible solución que me viene a la mente es leer una porción razonablemente grande de la secuencia en la memoria y luego usar string.find () (o bytes.find () en Python 3) para verificar si el límite está en la porción. En Python find () es un algoritmo de búsqueda de cadenas altamente optimizado escrito en C, por lo que debería darle algo de rendimiento. Solo tendría que ocuparse del caso en el que el límite podría estar justo entre dos partes.

Quería mencionar el análisis sintáctico de la secuencia en partes a medida que se recibe. @siddhantgoel escribió este gran analizador para nosotros. Me está funcionando muy bien. https://github.com/siddhantgoel/streaming-form-data

Supongo que la única gran optimización será reemplazar completamente parse_lines ()

+1 para esto.

Estoy escribiendo un puente para transmitir la carga del usuario directamente a S3 sin ningún archivo temporal intermedio, posiblemente con contrapresión, y encuentro frustrante la situación werkzeug y flask . No puede mover datos directamente entre dos conductos.

@lambdaq Estoy de acuerdo en que es un problema que debe solucionarse. Si esto es importante para usted, me complacerá revisar un parche que cambia el comportamiento.

@lambdaq Tenga en cuenta que si solo transmite datos directamente en el cuerpo de la solicitud y usa application/octet-stream , el analizador de formularios no se activa en absoluto y puede usar request.stream (es decir, sin archivos temporales, etc.) .

El único problema que tuvimos es que el analizador de formularios werkzeug está comprobando ansiosamente la saber si realmente debería analizar el cuerpo de la solicitud .

Esto le impide establecer la longitud máxima del contenido en los datos de formularios normales, pero también permite cargas de archivos muy grandes.

Lo arreglamos reordenando un poco la función de

Tenga en cuenta que si solo transmite datos directamente en el cuerpo de la solicitud y usa application / octet-stream, el analizador de formularios no se activa en absoluto y puede usar request.stream (es decir, sin archivos temporales, etc.).

Lamentablemente no. Son solo cargas de formularios normales con varias partes.

Me complacería revisar un parche que cambia el comportamiento.

Traté de hackear werkzeug.wsgi.make_line_iter o parse_lines() usando send() generadores, para que podamos enviar _iter_basic_lines() señal a

Básicamente, el conejo entero comienza con 'itertools.chain' object has no attribute 'send' .... 😂

Me pregunto cuánto podría acelerarse este código usando aceleraciones nativas escritas en C (o Cython, etc.). Creo que manejar archivos semi-grandes (unos pocos 100 MB, pero no enormes como en muchos GB) de manera más eficiente es importante sin tener que cambiar la forma en que la aplicación los usa (es decir, transmitirlos directamente en lugar de almacenarlos en búfer); para muchas aplicaciones, esto sería exagerado y no es absolutamente necesario (en realidad, incluso el rendimiento actual algo lento probablemente esté bien para ellos), ¡pero hacer las cosas más rápido siempre es bueno!

Otra posible solución es descargar el trabajo de análisis multipart a nginx

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

Ambos repositorios parecen muertos.

entonces, ¿no hay una solución conocida para esto?

Hay una solución alternativa 👆

Bajo uwsgi, usamos la función chunked_read() incorporada y analizamos el flujo por nuestra cuenta a medida que ingresa. Funciona el 99% del tiempo, pero tiene un error que aún no he localizado. Vea mi comentario anterior para un analizador de formularios de transmisión listo para usar. En python2 era lento, así que hicimos el nuestro y es rápido. :)

Citando desde arriba:

Estoy de acuerdo en que es un problema que debe solucionarse. Si esto es importante para usted, me complacerá revisar un parche que cambia el comportamiento.

Realmente no tengo tiempo para trabajar en esto ahora mismo. Si esto es algo que está pasando el tiempo, por favor, considere contribuir un parche. Las contribuciones son bienvenidas.

@sdizazzo

pero tiene un error que aún no he localizado

¿Estás hablando de datos de formulario de transmisión ? si es así, me encantaría saber cuál es el error.

Nuestro problema fue que el procesamiento lento de formularios impidió el manejo de solicitudes simultáneas, lo que causó que nomad pensara que el proceso se había bloqueado y lo mató.

Mi solución fue agregar sleep(0) en 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)

busque unexpected end of stream si desea aplicar este parche.

Quería mencionar el análisis sintáctico de la secuencia en partes a medida que se recibe. @siddhantgoel escribió este gran analizador para nosotros. Me está funcionando muy bien. https://github.com/siddhantgoel/streaming-form-data

secundado.
esto acelera la carga de archivos a mi aplicación Flask en más de un factor 10

@siddhantgoel
Muchas gracias por su corrección con streaming-form-data. ¡Finalmente puedo subir archivos de tamaño gigabyte a buena velocidad y sin que la memoria se llene!

Consulte el n.º 1788, que trata sobre la reescritura del analizador para que sea sans-io. Según los comentarios aquí, creo que también abordaría este problema.

¿Fue útil esta página
0 / 5 - 0 calificaciones