Flaskで大きなバイナリファイルのmultipart/form-data
アップロードを実行すると、それらのアップロードは、適度に高速なネットワーク接続でのI / Oバウンドではなく、非常に簡単にCPUバウンドになります(Pythonは100%CPUを消費します)。
CPUプロファイリングを少し行うと、これらのアップロード中のほぼすべてのCPU時間がwerkzeug.formparser.MultiPartParser.parse_parts()
費やされていることがわかります。 この理由は、メソッドparse_lines()
が、非常に小さなチャンクを_大量に_生成し、場合によっては1バイトだけを生成するためです。
# 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()
は、単一の「行」を処理する多くの小さな反復(100 MBファイルの場合は200万以上)を経て、常に非常に短いチャンクまたは1バイトさえも出力ストリームに書き込みます。 これにより、多くのオーバーヘッドが追加され、プロセス全体の速度が低下し、CPUが非常に高速にバインドされます。
簡単なテストでは、最初にparse_lines()
bytearray
にデータを収集し、 self.buffer_size
場合にのみそのデータをparse_parts()
に戻すことで、非常に簡単に高速化できることが示されています。 self.buffer_size
を超えています。 このようなもの:
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()
この変更だけで、34 MBのテストファイルのアップロード時間が4200ミリ秒から私のマシンのローカルホストで約1100ミリ秒に短縮され、パフォーマンスがほぼ4倍に向上します。 すべてのテストはWindows(64ビットPython 3.4)で実行されますが、Linuxでそれほど問題になるかどうかはわかりません。
それはまだほとんどCPUにバインドされているので、最適化の可能性はさらにあると確信しています。 もう少し時間があれば調べてみようと思います。
同じ問題があります。isoファイル(200m)をアップロードすると、request.formの最初の呼び出しに7秒かかります。
さらなる最適化には2つのことが興味深いようです-cythonの実験と、よりスマートなMIMEメッセージ解析のためのコンテンツサイトヘッダーの解釈の実験
(サブメッセージの内容の長さがわかっている場合は、行をスキャンする必要はありません)
簡単に言うと、ファイルをリクエスト本文で直接ストリーミングする場合(つまり、 application/multipart-formdata
がない場合)、フォームパーサーを完全にバイパスし、 request.stream
からファイルを直接読み取ります。
jQuery-File-Uploadのチャンクアップロード方法を使用すると、マルチパートアップロードでアップロード速度が遅くなるという同じ問題があります。 小さなチャンク(〜10MB)を使用する場合、転送速度は0〜12MB / sの間でジャンプしますが、ネットワークとサーバーは50MB / sを超える速度に完全に対応しています。 速度低下は、実際のアップロードとほぼ同じ時間がかかるCPUバウンドマルチパート解析によって引き起こされます。 残念ながら、ストリーミングアップロードを使用してマルチパート解析をバイパスすることは、バックグラウンドでストリーミングを実行できないiOSデバイスをサポートする必要があるため、実際にはオプションではありません。
@sekrauseによって提供されるパッチはPython2.7では機能しません。
@carbn :最後の行をcollect = bytearray()
変更することで、 Python2.7でパッチを機能させることができました。 これは、既存のバイト配列をクリアする代わりに、新しいバイト配列を作成するだけです。
@cuibonobo :それは私が最初に変更したことですが、それでも別のエラーがありました。 現時点では動作中のパッチを確認できませんが、IIRCの歩留まりをyield _cont, collect
からyield _cont, str(collect)
。 これにより、コードをテストでき、パッチによってマルチパート処理速度が約30%向上しました。 それは素晴らしいスピードアップですが、パフォーマンスはまだかなり悪いです。
もう少し調査すると、 werkzeug.wsgi.make_line_iter
はすでにボトルネックになりすぎて、 parse_lines()
を実際に最適化できないことがわかります。 このPython3テストスクリプトを見てください。
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)
Python3.5を使用した923MBのビデオファイルの場合、ラップトップでの出力は次のようになります。
File size: 926.89 MB
Time: 20.6 seconds
Read speed: 44.97 MB/s
Number of lines yielded by make_line_iter: 7562905
したがって、上記の最適化を適用し、完全になるまでさらに最適化しても、 make_line_iter
データを十分に高速に提供できないという理由だけで、大規模なバイナリアップロードの場合は最大45 MB / sに制限されます。境界をチェックするループ内の923MBのデータに対して750万回の反復を実行します。
唯一の素晴らしい最適化は、 parse_lines()
を他のものに完全に置き換えることだと思います。 考えられる解決策は、ストリームのかなり大きなチャンクをメモリに読み込んでから、string.find()(またはPython 3ではbytes.find())を使用して、境界がチャンク内にあるかどうかを確認することです。 Pythonでは、find()はCで記述された高度に最適化された文字列検索アルゴリズムであるため、ある程度のパフォーマンスが得られるはずです。 境界が2つのチャンクの間にある可能性がある場合に注意する必要があります。
受信時にストリームをチャンクで解析することについて言及したいと思います。 @siddhantgoelは、このすばらしい小さなパーサーを作成してくれました。 それは私にとって素晴らしい働きをしています。 https://github.com/siddhantgoel/streaming-form-data
唯一の優れた最適化は、parse_lines()を完全に置き換えることだと思います
このために+1。
中間の一時ファイルを使用せずに、おそらくバックプレッシャを使用して、ユーザーのアップロードをS3に直接ストリーミングするためのブリッジを作成していますが、 werkzeug
とflask
状況は苛立たしいものです。 2つのパイプ間でデータを直接移動することはできません。
@lambdaq私はそれが修正される必要がある問題であることに同意します。 これが重要な場合は、動作を変更するパッチを確認させていただきます。
@lambdaqリクエスト本文で直接データをストリーミングし、 application/octet-stream
を使用する場合、フォームパーサーはまったく起動せず、 request.stream
使用できることに注意してください(つまり、一時ファイルなどはありません)。 。
私たちが抱えていた唯一の問題は、werkzeugフォームパーサーが、リクエストの本文を実際に解析する必要があるかどうかを知る前に、許可された最大コンテンツ長に対してコンテンツ長を熱心に
これにより、通常のフォームデータに最大コンテンツ長を設定できなくなりますが、非常に大きなファイルのアップロードも可能になります。
チェック機能を少し
リクエスト本文でデータを直接ストリーミングし、application / octet-streamを使用する場合、フォームパーサーはまったく起動せず、request.streamを使用できることに注意してください(つまり、一時ファイルなどはありません)。
残念ながら違います。 これは、マルチパートを使用した通常のフォームのアップロードです。
動作を変更するパッチを確認できれば幸いです。
ジェネレーターのsend()
を使用してwerkzeug.wsgi.make_line_iter
またはparse_lines()
をハックしようとしたので、 _iter_basic_lines()
に信号を送って、行ではなくチャンク全体を出力できます。 それほど簡単ではないことがわかりました。
基本的に、ウサギ全体は'itertools.chain' object has no attribute 'send'
始まります....😂
C(またはCythonなど)で記述されたネイティブの高速化を使用して、このコードをどれだけ高速化できるのだろうか。 セミラージ(数100 MBですが、多くのGBほど大きくはありません)ファイルを、アプリの使用方法を変更せずに(つまり、バッファリングする代わりに直接ストリーミングする)、より効率的に処理することが重要だと思います-多くのアプリケーションでは、これはやり過ぎで絶対に必要というわけではありませんが(実際には、現在のやや遅いパフォーマンスでもおそらく問題ありません)、物事を速くすることは常に素晴らしいことです!
別の可能な解決策は、 multipart
解析ジョブをnginxにオフロードすることです
両方のレポは死んでいるように見えます。
それで、これに対する既知の解決策はありませんか?
回避策があります👆
uwsgiでは、組み込みのchunked_read()
関数を使用し、ストリームが入ってくると自分で解析します。99%の確率で機能しますが、まだ追跡していないバグがあります。 すぐに使用できるストリーミングフォームパーサーについては、以前のコメントを参照してください。 python2の下では遅いので、私たちは自分でロールしました、そしてそれは速いです。 :)
上からの引用:
私はそれが修正される必要がある問題であることに同意します。 これが重要な場合は、動作を変更するパッチを確認させていただきます。
今はこれに取り組む時間が本当にありません。 これがあなたが時間を費やしているものであるならば、パッチを提供することを検討して
私たちの問題は、フォーム処理が遅いために同時リクエスト処理が妨げられ、 nomad
がプロセスがハングして強制終了したと見なしてしまうことでした。
私の修正は、 werkzeug/formparser.py:MutlipartParser.parse_lines()
sleep(0)
を追加すること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)
このパッチを適用する場合は、 unexpected end of stream
検索してください。
受信時にストリームをチャンクで解析することについて言及したいと思います。 @siddhantgoelは、このすばらしい小さなパーサーを作成してくれました。 それは私にとって素晴らしい働きをしています。 https://github.com/siddhantgoel/streaming-form-data
出向。
これにより、Flaskアプリへのファイルのアップロードが10倍以上高速化されます
@siddhantgoel
ストリーミングフォームデータを修正していただき、ありがとうございます。 私はついにギガバイトサイズのファイルを高速でメモリをいっぱいにすることなくアップロードできるようになりました!
パーサーをsans-ioに書き換えることについて説明している#1788を参照してください。 ここでのフィードバックに基づいて、私はそれがこの問題にも対処すると思います。
最も参考になるコメント
受信時にストリームをチャンクで解析することについて言及したいと思います。 @siddhantgoelは、このすばらしい小さなパーサーを作成してくれました。 それは私にとって素晴らしい働きをしています。 https://github.com/siddhantgoel/streaming-form-data