当我在Flask中执行任何大型二进制文件的multipart/form-data
上传时,这些上传非常容易受CPU限制(Python占用100%CPU),而不是受任何合理的快速网络连接限制。
一点CPU分析显示,在这些上载期间,几乎所有CPU时间都花费在werkzeug.formparser.MultiPartParser.parse_parts()
。 方法parse_lines()
产生很多小块的原因,有时甚至是单个字节:
# 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万次),处理单个“行”,始终仅将非常短的块甚至单个字节写入输出流。 这增加了很多开销,减慢了整个过程的速度,并使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的第一次调用将花费7s
有2件事对于进一步优化似乎很有趣-试用cython,并尝试解释内容站点标头以进行更智能的mime消息解析
(如果您知道子消息的内容长度,则无需扫描行)
简要说明一下,如果直接在请求正文中流式传输文件(即,没有application/multipart-formdata
),则将完全绕过表单解析器并直接从request.stream
读取文件。
使用jQuery-File-Upload的分块上载方法时,分段上传的上载速度较慢时,我也遇到同样的问题。 当使用小块数据块(〜10MB)时,传输速度在0到12MB / s之间跳跃,而网络和服务器完全可以超过50MB / s。 速度减慢是由CPU绑定的多部分解析引起的,该解析与实际上载大约需要相同的时间。 遗憾的是,使用流上传来绕过多部分解析并不是一个真正的选择,因为我必须支持无法在后台进行流传输的iOS设备。
@sekrause提供的
@carbn :通过将最后一行更改为collect = bytearray()
我能够使补丁在Python 2.7中工作。 这只是创建一个新的字节数组,而不是清除现有的字节数组。
@cuibonobo :这是我更改的第一件事,但仍然有另一个错误。 我目前无法检查工作补丁,但是IIRC的收益必须从yield _cont, collect
更改yield _cont, str(collect)
。 这样就可以测试代码,并且该补丁使多部分处理速度提高了约30%。 这是一个不错的加速,但是性能仍然很差。
进一步的调查显示, werkzeug.wsgi.make_line_iter
已经是一个瓶颈,无法真正优化parse_lines()
。 查看以下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)
对于带有Python 3.5的923 MB视频文件,输出在我的笔记本电脑上如下所示:
File size: 926.89 MB
Time: 20.6 seconds
Read speed: 44.97 MB/s
Number of lines yielded by make_line_iter: 7562905
因此,即使您在上面应用了我的优化并对其进行了进一步优化,直到完美为止,对于大型二进制文件上传,您仍然会被限制为〜45 MB / s,这仅仅是因为make_line_iter
无法给您足够快的数据,将为检查边界的923 MB数据执行750万次迭代。
我猜唯一的好优化是将parse_lines()
完全替换
我想提一下在接收到流时对流进行大块解析。 @siddhantgoel为我们编写了这个很棒的小解析器。 这对我来说很棒。 https://github.com/siddhantgoel/streaming-form-data
我猜唯一的好选择是完全替换parse_lines()
为此+1。
我正在编写一个桥接器,将用户的上传直接流式传输到S3,而没有任何中间临时文件,可能会有背压,并且我发现werkzeug
和flask
情况令人沮丧。 您不能在两个管道之间直接移动数据。
@lambdaq我同意这是一个必须解决的问题。 如果这对您很重要,我很乐意查看更改行为的补丁。
@lambdaq请注意,如果您只是直接在请求正文中流式传输数据并使用application/octet-stream
则表单解析器根本不会启动,您可以使用request.stream
(即,没有临时文件等) 。
我们遇到的唯一问题是werkzeug表单解析器正在急切地根据允许的最大内容长度检查内容长度,然后才知道它是否应实际解析请求正文。
这样可以防止您在常规表单数据上设置最大内容长度,但也允许上传非常大的文件。
我们通过对检查功能
请注意,如果您只是直接在请求正文中流式传输数据并使用application / octet-stream,则表单解析器根本不会启动,您可以使用request.stream(即没有临时文件等)。
不幸的是没有。 这只是具有多部分内容的正常形式上载。
我很乐意查看更改行为的补丁。
我尝试使用生成器的send()
破解werkzeug.wsgi.make_line_iter
或parse_lines()
send()
,所以我们可以发出信号_iter_basic_lines()
发出整个块而不是行。 事实并非如此简单。
基本上,兔子整体以'itertools.chain' object has no attribute 'send'
....😂开始
我不知道使用C(或Cython等)编写的本机加速程序可以提高多少代码。 我认为更有效地处理半大文件(几百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
非常感谢您对流式表单数据的修复。 我终于可以在不占用内存的情况下,以良好的速度上传技嘉大小的文件!
请参阅#1788,其中讨论了将解析器重写为sans-io。 根据这里的反馈,我认为这也可以解决此问题。
最有用的评论
我想提一下在接收到流时对流进行大块解析。 @siddhantgoel为我们编写了这个很棒的小解析器。 这对我来说很棒。 https://github.com/siddhantgoel/streaming-form-data