<p>werkzeug.formparser est vraiment lent avec des téléchargements binaires volumineux</p>

Créé le 3 mars 2016  ·  25Commentaires  ·  Source: pallets/werkzeug

Lorsque j'effectue un téléchargement multipart/form-data de tout gros fichier binaire dans Flask, ces téléchargements sont très facilement liés au processeur (avec Python consommant 100% du processeur) au lieu des E / S liées à une connexion réseau raisonnablement rapide.

Un peu de profilage du processeur révèle que presque tout le temps processeur pendant ces téléchargements est dépensé en werkzeug.formparser.MultiPartParser.parse_parts() . La raison pour laquelle la méthode parse_lines() donne _un lot_ de très petits morceaux, parfois même juste des octets simples :

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

Donc parse_parts() passe par beaucoup de petites itérations (plus de 2 millions pour un fichier de 100 Mo) traitant des "lignes" uniques, écrivant toujours des morceaux très courts ou même des octets simples dans le flux de sortie. Cela ajoute beaucoup de frais généraux qui ralentissent l'ensemble du processus et le rendent très rapidement lié au processeur.

Un test rapide montre qu'une accélération est très facilement possible en collectant d'abord les données dans un bytearray en parse_lines() et en ne renvoyant ces données que dans parse_parts() quand self.buffer_size est dépassé. Quelque chose comme ça:

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

Ce changement à lui seul réduit le temps de téléchargement de mon fichier de test de 34 Mo de 4200 ms à environ 1100 ms sur localhost sur ma machine, soit une augmentation de presque 4X des performances. Tous les tests sont effectués sur Windows (Python 3.4 64 bits), je ne suis pas sûr que ce soit autant de problème sous Linux.

Il est encore principalement lié au processeur, donc je suis sûr qu'il y a encore plus de potentiel d'optimisation. Je pense que je l'examinerai quand je trouverai un peu plus de temps.

bug

Commentaire le plus utile

Je voulais mentionner l'analyse du flux par morceaux au fur et à mesure de sa réception. @siddhantgoel a écrit ce super petit analyseur pour nous. Cela fonctionne très bien pour moi. https://github.com/siddhantgoel/streaming-form-data

Tous les 25 commentaires

J'ai aussi le même problème, lorsque je télécharge un fichier iso (200m), le premier appel à request.form prendra 7s

2 choses semblent intéressantes pour une optimisation plus poussée - expérimenter avec cython et expérimenter avec l'interprétation des en-têtes de site de contenu pour une analyse plus intelligente des messages mime

(pas besoin de rechercher des lignes si vous connaissez la longueur du contenu d'un sous-message)

Juste une petite note, que si vous diffusez le fichier directement dans le corps de la requête (c'est-à-dire non application/multipart-formdata ), vous contournez complètement l'analyseur de formulaire et lisez le fichier directement depuis request.stream .

J'ai le même problème avec les vitesses de téléchargement lentes avec les téléchargements en plusieurs parties lors de l'utilisation de la méthode de téléchargement fragmenté de jQuery-File-Upload. Lors de l'utilisation de petits morceaux (~ 10 Mo), la vitesse de transfert saute entre 0 et 12 Mo / s tandis que le réseau et le serveur sont entièrement capables de débits supérieurs à 50 Mo / s. Le ralentissement est causé par l'analyse en plusieurs parties liée au processeur qui prend à peu près le même temps que le téléchargement réel. Malheureusement, utiliser des téléchargements en continu pour contourner l'analyse en plusieurs parties n'est pas vraiment une option car je dois prendre en charge les appareils iOS qui ne peuvent pas faire de streaming en arrière-plan.

Le correctif fourni par @sekrause a l' air bien mais ne fonctionne pas en python 2.7.

@carbn : J'ai pu faire fonctionner le correctif dans Python 2.7 en changeant la dernière ligne en collect = bytearray() . Cela crée simplement un nouveau bytearray au lieu d'effacer l'existant.

@cuibonobo : C'est la première chose que j'ai changée mais j'ai encore une autre erreur. Je ne peux pas vérifier le correctif de travail pour le moment, mais les rendements de l'IIRC ont dû être modifiés de yield _cont, collect à yield _cont, str(collect) . Cela a permis de tester le code et le patch a produit une augmentation d'environ 30% de la vitesse de traitement en plusieurs parties. C'est une belle accélération, mais les performances sont encore assez mauvaises.

Une petite enquête plus approfondie montre que werkzeug.wsgi.make_line_iter est déjà un goulot d'étranglement trop important pour vraiment pouvoir optimiser parse_lines() . Regardez ce script de test 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)

Pour un fichier vidéo de 923 Mo avec Python 3.5, la sortie ressemble à ceci sur mon ordinateur portable:

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

Donc, même si vous appliquez mon optimisation ci-dessus et l'optimisez davantage jusqu'à la perfection, vous serez toujours limité à ~ 45 Mo / s pour les gros téléchargements binaires simplement parce que make_line_iter ne peut pas vous donner les données assez rapidement et vous ll fera 7,5 millions d'itérations pour 923 Mo de données dans votre boucle qui vérifie la limite.

Je suppose que la seule bonne optimisation sera de remplacer complètement parse_lines() par autre chose. Une solution possible qui me vient à l'esprit est de lire un morceau raisonnablement grand du flux en mémoire puis d'utiliser string.find () (ou bytes.find () en Python 3) pour vérifier si la limite est dans le morceau. En Python, find () est un algorithme de recherche de chaînes hautement optimisé écrit en C, ce qui devrait vous donner des performances. Vous auriez juste à vous occuper du cas où la frontière pourrait être juste entre deux morceaux.

Je voulais mentionner l'analyse du flux par morceaux au fur et à mesure de sa réception. @siddhantgoel a écrit ce super petit analyseur pour nous. Cela fonctionne très bien pour moi. https://github.com/siddhantgoel/streaming-form-data

Je suppose que la seule bonne optimisation sera de remplacer complètement parse_lines ()

+1 pour cela.

J'écris un pont pour diffuser le téléchargement de l'utilisateur directement sur S3 sans aucun fichier temporaire intermédiaire, éventuellement avec une contre-pression, et je trouve la situation werkzeug et flask frustrante. Vous ne pouvez pas déplacer des données directement entre deux canaux.

@lambdaq Je suis d'accord que c'est un problème qui doit être corrigé. Si cela est important pour vous, je serais heureux de revoir un correctif modifiant le comportement.

@lambdaq Notez que si vous application/octet-stream alors l'analyseur de formulaire ne démarre pas du tout et vous pouvez utiliser request.stream (c'est-à-dire pas de fichiers temporaires, etc.) .

Le seul problème que nous ayons eu est que l'analyseur de formulaire werkzeug vérifie avec impatience savoir s'il doit réellement analyser le corps de la requête .

Cela vous empêche de définir la longueur maximale du contenu sur les données de forme normale, mais autorise également les téléchargements de fichiers très volumineux.

Nous l'avons corrigé en réorganisant un peu la fonction de

Notez que si vous diffusez simplement des données directement dans le corps de la requête et utilisez application / octet-stream, l'analyseur de formulaire ne démarre pas du tout et vous pouvez utiliser request.stream (c'est-à-dire pas de fichiers temporaires, etc.).

Malheureusement non. Il s'agit simplement de téléchargements de formulaires normaux avec multipart.

Je serais heureux de revoir un correctif modifiant le comportement.

J'ai essayé de pirater werkzeug.wsgi.make_line_iter ou parse_lines() utilisant les send() générateurs, afin que nous puissions signaler à _iter_basic_lines() d'émettre des morceaux entiers au lieu de lignes. Ce n'est pas si facile.

Fondamentalement, le lapin entier commence par 'itertools.chain' object has no attribute 'send' .... 😂

Je me demande à quel point ce code pourrait être accéléré en utilisant des accélérations natives écrites en C (ou Cython, etc.). Je pense que gérer plus efficacement des fichiers semi-volumineux (quelques 100 Mo, mais pas énormes comme dans de nombreux Go) est important sans avoir à changer la façon dont l'application les utilise (c'est-à-dire les diffuser directement au lieu de la mise en mémoire tampon) - pour de nombreuses applications, ce serait exagéré et n'est pas absolument nécessaire (en fait, même les performances actuelles un peu lentes sont probablement acceptables pour eux) mais rendre les choses plus rapides est toujours agréable!

Une autre solution possible est de décharger le travail d'analyse multipart vers nginx

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

Les deux dépôts semblent morts.

alors n'y a-t-il pas de solution connue à cela?

Il y a une solution de contournement 👆

Sous uwsgi, nous utilisons sa fonction intégrée chunked_read() et analysons le flux par nous-mêmes au fur et à mesure qu'il entre. Cela fonctionne 99% du temps, mais il y a un bogue que je n'ai pas encore retrouvé. Voir mon commentaire précédent pour un analyseur de formulaire en continu prêt à l'emploi. Sous python2, c'était lent, donc nous avons roulé le nôtre et c'est rapide. :)

Citant d'en haut:

Je conviens que c'est un problème qui doit être résolu. Si cela est important pour vous, je serais heureux de revoir un correctif modifiant le comportement.

Je n'ai pas vraiment le temps de travailler là-dessus pour le moment. Si c'est quelque chose sur lequel vous passez du temps, pensez à apporter un correctif. Les contributions sont les bienvenues.

@sdizazzo

mais il y a un bug que je n'ai pas encore retrouvé

parlez-vous de streaming-form-data ? si tel est le cas, j'aimerais savoir quel est le bogue.

Notre problème était que la lenteur du traitement du formulaire empêchait la gestion des requêtes simultanées, ce qui faisait que nomad pensait que le processus était bloqué et le tuait.

Ma solution consistait à ajouter un sleep(0) dans 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)

recherchez unexpected end of stream si vous souhaitez appliquer ce patch.

Je voulais mentionner l'analyse du flux par morceaux au fur et à mesure de sa réception. @siddhantgoel a écrit ce super petit analyseur pour nous. Cela fonctionne très bien pour moi. https://github.com/siddhantgoel/streaming-form-data

secondé.
cela accélère les téléchargements de fichiers sur mon application Flask de plus d'un facteur 10

@siddhantgoel
Merci beaucoup pour votre solution avec les données de formulaire en continu. Je peux enfin télécharger des fichiers de la taille d'un gigaoctet à bonne vitesse et sans que la mémoire ne se remplisse!

Voir # 1788 qui traite de la réécriture de l'analyseur en sans-io. Sur la base des commentaires ici, je pense que cela réglerait également ce problème.

Cette page vous a été utile?
0 / 5 - 0 notes