<p>werkzeug.formparser ist sehr langsam mit großen binären Uploads</p>

Erstellt am 3. März 2016  ·  25Kommentare  ·  Quelle: pallets/werkzeug

Wenn ich eine multipart/form-data -Upload einer großen Binärdatei in Flask durchführe, sind diese Uploads sehr leicht CPU-gebunden (wobei Python 100% CPU verbraucht), anstatt E / A-gebunden an eine relativ schnelle Netzwerkverbindung.

Ein wenig CPU-Profiling zeigt, dass fast die gesamte CPU-Zeit während dieser Uploads in werkzeug.formparser.MultiPartParser.parse_parts() . Der Grund dafür, dass die Methode parse_lines() eine Menge sehr kleiner Blöcke liefert, manchmal sogar nur einzelne Bytes :

# 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() durchläuft also viele kleine Iterationen (mehr als 2 Millionen für eine 100-MB-Datei), die einzelne "Zeilen" verarbeiten und immer nur sehr kurze Blöcke oder sogar einzelne Bytes in den Ausgabestream schreiben. Dies erhöht den Overhead erheblich und verlangsamt die CPU-Bindung sehr schnell.

Ein schneller Test zeigt, dass eine Beschleunigung sehr einfach möglich ist, indem zuerst die Daten in einem bytearray in parse_lines() gesammelt werden und diese Daten erst dann in parse_parts() wenn self.buffer_size wird überschritten. Etwas wie das:

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

Allein diese Änderung reduziert die Upload-Zeit für meine 34-MB-Testdatei von 4200 ms auf ca. 1100 ms über localhost auf meinem Computer, was einer fast vierfachen Leistungssteigerung entspricht. Alle Tests werden unter Windows (64-Bit-Python 3.4) durchgeführt. Ich bin mir nicht sicher, ob dies unter Linux genauso problematisch ist.

Es ist immer noch größtenteils CPU-gebunden, daher bin ich mir sicher, dass es noch mehr Optimierungspotential gibt. Ich denke, ich werde mich darum kümmern, wenn ich etwas mehr Zeit finde.

bug

Hilfreichster Kommentar

Ich wollte erwähnen, dass das Parsen des Streams in Blöcken durchgeführt wird, sobald er empfangen wird. @siddhantgoel hat diesen tollen kleinen Parser für uns geschrieben. Es funktioniert großartig für mich. https://github.com/siddhantgoel/streaming-form-data

Alle 25 Kommentare

Ich habe auch das gleiche Problem, wenn ich eine ISO-Datei (200 m) hochlade, dauert der erste Aufruf von request.form 7s

Zwei Dinge scheinen für die weitere Optimierung interessant zu sein: Experimentieren mit Cython und Experimentieren mit der Interpretation der Content-Site-Header für eine intelligentere Analyse von MIME-Nachrichten

(Sie müssen nicht nach Zeilen suchen, wenn Sie die Inhaltslänge einer Teilnachricht kennen.)

Nur eine kurze Anmerkung: Wenn Sie die Datei direkt im Anforderungshauptteil streamen (dh kein application/multipart-formdata ), umgehen Sie den Formularparser vollständig und lesen die Datei direkt von request.stream .

Ich habe das gleiche Problem mit langsamen Upload-Geschwindigkeiten bei mehrteiligen Uploads, wenn ich die Chunked-Upload-Methode von jQuery-File-Upload verwende. Bei Verwendung kleiner Blöcke (~ 10 MB) springt die Übertragungsgeschwindigkeit zwischen 0 und 12 MB / s, während das Netzwerk und der Server Geschwindigkeiten über 50 MB / s erreichen können. Die Verlangsamung wird durch die CPU-gebundene mehrteilige Analyse verursacht, die ungefähr dieselbe Zeit wie der eigentliche Upload benötigt. Leider ist die Verwendung von Streaming-Uploads zur Umgehung der mehrteiligen Analyse keine Option, da ich iOS-Geräte unterstützen muss, die kein Streaming im Hintergrund durchführen können.

Der von @sekrause bereitgestellte

@carbn : Ich konnte den Patch in Python 2.7 zum collect = bytearray() änderte. Dadurch wird nur ein neuer Bytearray erstellt, anstatt den vorhandenen zu löschen.

@ Cuibonobo : Das ist das erste, was ich geändert habe, aber noch einen Fehler hatte. Ich kann den funktionierenden Patch im Moment nicht überprüfen, aber IIRC die Renditen mussten von yield _cont, collect auf yield _cont, str(collect) geändert werden. Dadurch konnte der Code getestet werden, und der Patch führte zu einer Erhöhung der mehrteiligen Verarbeitungsgeschwindigkeit um etwa 30%. Es ist eine schöne Beschleunigung, aber die Leistung ist immer noch ziemlich schlecht.

Eine weitere Untersuchung zeigt, dass werkzeug.wsgi.make_line_iter bereits ein zu großer Engpass ist, um parse_lines() wirklich optimieren zu können. Sehen Sie sich dieses Python 3-Testskript an:

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)

Bei einer 923 MB großen Videodatei mit Python 3.5 sieht die Ausgabe auf meinem Laptop ungefähr so ​​aus:

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

Selbst wenn Sie meine oben genannte Optimierung anwenden und sie bis zur Perfektion weiter optimieren, sind Sie für große binäre Uploads immer noch auf ~ 45 MB / s beschränkt, einfach weil make_line_iter Ihnen die Daten nicht schnell genug liefern kann und Sie ' Ich mache 7,5 Millionen Iterationen für 923 MB Daten in Ihrer Schleife, die nach der Grenze suchen.

Ich denke, die einzige großartige Optimierung wird darin bestehen, parse_lines() vollständig durch etwas anderes zu ersetzen. Eine mögliche Lösung besteht darin, einen relativ großen Teil des Streams in den Speicher einzulesen und dann mit string.find () (oder bytes.find () in Python 3) zu überprüfen, ob sich die Grenze im Block befindet. In Python ist find () ein hochoptimierter String-Suchalgorithmus, der in C geschrieben wurde, sodass Sie eine gewisse Leistung erzielen sollten. Sie müssten sich nur um den Fall kümmern, dass die Grenze zwischen zwei Stücken richtig sein könnte.

Ich wollte erwähnen, dass das Parsen des Streams in Blöcken durchgeführt wird, sobald er empfangen wird. @siddhantgoel hat diesen tollen kleinen Parser für uns geschrieben. Es funktioniert großartig für mich. https://github.com/siddhantgoel/streaming-form-data

Ich denke, die einzige gute Optimierung wird darin bestehen, parse_lines () vollständig zu ersetzen.

+1 dafür.

Ich schreibe eine Brücke, um den Upload des Benutzers direkt in S3 ohne temporäre Zwischendateien zu streamen, möglicherweise mit Gegendruck, und ich finde die Situation werkzeug und flask frustrierend. Sie können Daten nicht direkt zwischen zwei Pipes verschieben.

@lambdaq Ich stimme zu, es ist ein Problem, das behoben werden muss. Wenn dies für Sie wichtig ist, würde ich gerne einen Patch überprüfen, der das Verhalten ändert.

@lambdaq Beachten Sie, dass, wenn Sie nur Daten direkt im Anforderungshauptteil streamen und application/octet-stream der Formularparser überhaupt nicht aktiviert wird und Sie request.stream (dh keine temporären Dateien usw.) .

Das einzige Problem, das wir hatten, ist, dass der werkzeug-Formularparser die Inhaltslänge eifrig mit der zulässigen maximalen Inhaltslänge vergleicht, bevor er weiß, ob er den Anforderungshauptteil tatsächlich analysieren sollte .

Dies verhindert, dass Sie die maximale Inhaltslänge für normale Formulardaten festlegen, aber auch das Hochladen sehr großer Dateien zulassen.

Wir setzten es durch Neuordnen die Prüfung der Funktion ein bisschen. Ich bin mir nicht sicher, ob es sinnvoll ist, diesen Upstream bereitzustellen, da einige Apps möglicherweise auf dem vorhandenen Verhalten beruhen.

Beachten Sie, dass, wenn Sie nur Daten direkt im Anforderungshauptteil streamen und application / octet-stream verwenden, der Formularparser überhaupt nicht aktiviert wird und Sie request.stream verwenden können (dh keine temporären Dateien usw.).

Leider nicht. Es ist nur normales Hochladen von Formularen mit mehreren Teilen.

Ich würde gerne einen Patch überprüfen, der das Verhalten ändert.

Ich habe versucht, werkzeug.wsgi.make_line_iter oder parse_lines() mit send() Generators zu hacken, damit wir _iter_basic_lines() signalisieren können, ganze Blöcke anstelle von Zeilen auszugeben. Es stellt sich nicht so einfach heraus.

Grundsätzlich beginnt das ganze Kaninchen mit 'itertools.chain' object has no attribute 'send' .... 😂

Ich frage mich, wie viel dieser Code mit nativen Beschleunigungen in C (oder Cython usw.) beschleunigt werden könnte. Ich denke, dass es wichtig ist, halbgroße Dateien (einige 100 MB, aber nicht so groß wie in vielen GB) effizienter zu verarbeiten, ohne die Verwendung durch die App ändern zu müssen (dh sie direkt zu streamen, anstatt sie zu puffern) - für viele Anwendungen wäre dies der Fall Overkill und ist nicht unbedingt notwendig (eigentlich ist sogar die derzeitige etwas langsame Leistung für sie wahrscheinlich in Ordnung), aber es ist immer schön, die Dinge schneller zu machen!

Eine andere mögliche Lösung besteht darin, den Parsing-Job multipart nach nginx zu verlagern

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

Beide Repos sehen tot aus.

Gibt es dafür keine bekannte Lösung?

Es gibt eine Abhilfe 👆

Unter uwsgi verwenden wir die in chunked_read() Funktion und analysieren den Stream selbst, sobald er eingeht. Es funktioniert 99% der Zeit, aber es gibt einen Fehler, den ich noch nicht gefunden habe. In meinem früheren Kommentar finden Sie einen sofort einsatzbereiten Streaming-Formular-Parser. Unter Python2 war es langsam, also haben wir unser eigenes gerollt und es ist schnell. :) :)

Zitat von oben:

Ich bin damit einverstanden, dass es ein Problem ist, das behoben werden muss. Wenn dies für Sie wichtig ist, würde ich gerne einen Patch überprüfen, der das Verhalten ändert.

Ich habe momentan nicht wirklich Zeit, daran zu arbeiten. Wenn Sie Zeit damit verbringen, sollten Sie einen Patch beisteuern. Beiträge sind sehr willkommen.

@sdizazzo

aber es hat einen Fehler, den ich noch nicht gefunden habe

Sprechen Sie über Streaming-Formulardaten ? Wenn ja, würde ich gerne wissen, was der Fehler ist.

Unser Problem war, dass die langsame Formularverarbeitung die gleichzeitige Bearbeitung von Anforderungen verhinderte, was dazu führte, dass nomad glaubte, der Prozess sei hängen geblieben und beendet worden.

Mein Fix war das Hinzufügen eines sleep(0) in 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)

Suchen Sie nach unexpected end of stream wenn Sie diesen Patch anwenden möchten.

Ich wollte erwähnen, dass das Parsen des Streams in Blöcken durchgeführt wird, sobald er empfangen wird. @siddhantgoel hat diesen tollen kleinen Parser für uns geschrieben. Es funktioniert großartig für mich. https://github.com/siddhantgoel/streaming-form-data

abgeordnet.
Dies beschleunigt das Hochladen von Dateien in meine Flask-App um mehr als den Faktor 10

@siddhantgoel
Vielen Dank für Ihre Korrektur mit Streaming-Formulardaten. Ich kann endlich Dateien in Gigabyte-Größe mit guter Geschwindigkeit und ohne Speicherplatz hochladen!

Siehe # 1788, in dem das Umschreiben des Parsers in Sans-Io erläutert wird. Aufgrund des Feedbacks hier denke ich, dass dies auch dieses Problem angehen würde.

War diese Seite hilfreich?
0 / 5 - 0 Bewertungen