<p>werkzeug.formparser بطيء حقًا مع التحميلات الثنائية الكبيرة</p>

تم إنشاؤها على ٣ مارس ٢٠١٦  ·  25تعليقات  ·  مصدر: pallets/werkzeug

عندما أقوم بتحميل multipart/form-data لأي ملف ثنائي كبير في Flask ، فإن هذه التحميلات تكون مرتبطة بسهولة بوحدة المعالجة المركزية (مع Python تستهلك 100٪ من وحدة المعالجة المركزية) بدلاً من الإدخال / الإخراج المرتبط بأي اتصال شبكة سريع بشكل معقول.

يكشف القليل من التنميط عن وحدة المعالجة المركزية أن معظم وقت وحدة المعالجة المركزية تقريبًا أثناء عمليات التحميل هذه يتم إنفاقه في 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() بالعديد من التكرارات الصغيرة (أكثر من 2 مليون لملف 100 ميجابايت) لمعالجة "سطور" مفردة ، ودائمًا ما تكتب قطعًا قصيرة جدًا أو حتى وحدات بايت مفردة في تدفق الإخراج. هذا يضيف الكثير من النفقات العامة لإبطاء هذه العملية برمتها ويجعلها مرتبطة بسرعة كبيرة بوحدة المعالجة المركزية.

يُظهر اختبار سريع أن التسريع ممكن جدًا بسهولة من خلال جمع البيانات أولاً في bytearray في parse_lines() و إرجاع هذه البيانات فقط إلى 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 ميغابايت من 4200 مللي ثانية إلى حوالي 1100 مللي ثانية عبر المضيف المحلي على جهازي ، وهو ما يمثل زيادة في الأداء بمقدار 4 أضعاف تقريبًا. يتم إجراء جميع الاختبارات على Windows (64 بت Python 3.4) ، ولست متأكدًا مما إذا كانت مشكلة كبيرة على Linux.

لا يزال معظمها مقيدًا بوحدة المعالجة المركزية ، لذلك أنا متأكد من أن هناك إمكانية أكبر للتحسين. أعتقد أنني سأبحث في الأمر عندما أجد المزيد من الوقت.

bug

التعليق الأكثر فائدة

أردت أن أذكر إجراء التحليل على الدفق في أجزاء عند استلامها. siddhantgoel كتب https://github.com/siddhantgoel/streaming-form-data

ال 25 كومينتر

لدي أيضًا نفس المشكلة ، عندما أقوم بتحميل ملف iso (200 م) ، ستستغرق المكالمة الأولى لطلب النموذج 7 ثوانٍ

شيئان مثيران للاهتمام لمزيد من التحسين - تجربة السيثون ، وتجربة تفسير رؤوس موقع المحتوى لتحليل رسائل التمثيل الصامت بطريقة أكثر ذكاء

(لا حاجة للبحث عن الخطوط إذا كنت تعرف طول محتوى الرسالة الفرعية)

مجرد ملاحظة سريعة ، أنه إذا قمت بدفق الملف مباشرة في نص الطلب (على سبيل المثال ، لا يوجد application/multipart-formdata ) ، فإنك تتجاوز تمامًا محلل النموذج وتقرأ الملف مباشرة من request.stream .

لدي نفس المشكلة مع سرعات التحميل البطيئة مع عمليات التحميل متعددة الأجزاء عند استخدام طريقة التحميل المقسم لـ jQuery-File-Upload. عند استخدام أجزاء صغيرة (~ 10 ميجابايت) ، تقفز سرعة النقل بين 0 و 12 ميجابايت / ثانية بينما الشبكة والخادم قادران تمامًا على سرعات تزيد عن 50 ميجابايت / ثانية. يحدث التباطؤ بسبب التحليل متعدد الأجزاء المرتبط بوحدة المعالجة المركزية والذي يستغرق نفس وقت التحميل الفعلي تقريبًا. للأسف ، استخدام التحميلات المتدفقة لتجاوز التحليل متعدد الأجزاء ليس خيارًا حقًا حيث يجب أن أدعم أجهزة iOS التي لا يمكنها القيام بالبث في الخلفية.

يبدو التصحيح المقدم من sekrause جيدًا ولكنه لا يعمل في

carbn : لقد تمكنت من تشغيل التصحيح في Python 2.7 من خلال تغيير السطر الأخير إلى collect = bytearray() . هذا فقط يخلق bytearray جديدًا بدلاً من مسح الموجود.

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)

بالنسبة لملف فيديو بحجم 923 ميجا بايت مع Python 3.5 ، يبدو الإخراج مثل هذا على الكمبيوتر المحمول الخاص بي:

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 ميجابايت / ثانية لعمليات التحميل الثنائية الكبيرة لمجرد أن make_line_iter لا يمكنه منحك البيانات بالسرعة الكافية وأنت سنقوم بعمل 7.5 مليون تكرار ل 923 ميجابايت من البيانات في الحلقة الخاصة بك التي تتحقق من الحدود.

أعتقد أن التحسين الرائع الوحيد هو استبدال parse_lines() بشيء آخر تمامًا. الحل المحتمل الذي يتبادر إلى الذهن هو قراءة جزء كبير بشكل معقول من التدفق في الذاكرة ثم استخدام string.find () (أو bytes.find () في Python 3) للتحقق مما إذا كانت الحدود في المقطع. في Python ، يعد البحث () خوارزمية بحث سلسلة محسّنة للغاية مكتوبة بلغة C ، لذا من المفترض أن يمنحك ذلك بعض الأداء. سيكون عليك فقط الاهتمام بالحالة التي قد تكون فيها الحدود صحيحة بين قطعتين.

أردت أن أذكر إجراء التحليل على الدفق في أجزاء عند استلامها. 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 (أي لا توجد ملفات مؤقتة وما إلى ذلك).

للأسف لا. إنها مجرد عمليات تحميل عادية ذات أجزاء متعددة.

سأكون سعيدًا لمراجعة التصحيح الذي يغير السلوك.

حاولت اختراق werkzeug.wsgi.make_line_iter أو parse_lines() باستخدام المولدات send() ، لذا يمكننا الإشارة إلى _iter_basic_lines() لإصدار أجزاء كاملة بدلاً من الخطوط. اتضح أنه ليس بهذه السهولة.

في الأساس ، يبدأ الأرنب كله بـ 'itertools.chain' object has no attribute 'send' .... 😂

أتساءل إلى أي مدى يمكن تسريع هذا الرمز باستخدام عمليات التعجيل الأصلية المكتوبة بلغة C (أو Cython ، إلخ). أعتقد أن التعامل مع الملفات شبه الكبيرة (بضع 100 ميجابايت ، ولكن ليس ضخمًا كما هو الحال في العديد من الجيجابايت) أمر مهم دون الحاجة إلى تغيير كيفية استخدام التطبيق لها (أي دفقها مباشرة بدلاً من التخزين المؤقت) - بالنسبة للعديد من التطبيقات ، سيكون هذا مبالغة وليست ضرورية تمامًا (في الواقع ، حتى الأداء الحالي البطيء إلى حد ما ربما يكون جيدًا بالنسبة لهم) ولكن جعل الأمور أسرع أمرًا رائعًا دائمًا!

حل آخر ممكن هو إلغاء تحميل مهمة التحليل multipart إلى nginx

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

كلا الريبو تبدو ميتة.

فهل لا يوجد حل معروف لهذا؟

هناك حل بديل 👆

في إطار uwsgi ، نستخدم وظيفة مدمجة في chunked_read() ونحلل البث بمفردنا عندما يأتي. إنه يعمل 99٪ من الوقت ، لكن به خطأ لم أتتبعه بعد. انظر تعليقي السابق للحصول على محلل نموذج متدفق خارج الصندوق. تحت python2 كان الأمر بطيئًا ، لذلك قمنا بتدوير منطقتنا وهو سريع. :)

نقلا عن أعلاه:

أوافق على أنها مشكلة تحتاج إلى إصلاح. إذا كان هذا مهمًا بالنسبة لك ، فسيسعدني مراجعة التصحيح الذي يغير السلوك.

ليس لدي الوقت حقًا للعمل على هذا الآن. إذا كان هذا شيء تقضي وقتًا فيه ، فيرجى التفكير في المساهمة في تصحيح. المساهمات موضع ترحيب كبير.

تضمين التغريدة

ولكن هناك خطأ لا يزال يتعين علي تعقبه

هل تتحدث عن تدفق البيانات ؟ إذا كان الأمر كذلك ، فأنا أحب أن أعرف ما هو الخطأ.

كانت مشكلتنا أن المعالجة البطيئة للنموذج منعت معالجة الطلبات المتزامنة مما جعل nomad يعتقد أن العملية قد توقفت وألغيت.

كان الإصلاح الذي أجريته هو إضافة 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

تضمين التغريدة
شكرا جزيلا لإصلاحك مع تدفق البيانات. يمكنني أخيرًا تحميل ملفات بحجم غيغا بايت بسرعة جيدة وبدون ملء الذاكرة!

راجع # 1788 الذي يناقش إعادة كتابة المحلل اللغوي ليكون sans-io. بناءً على التعليقات الواردة هنا ، أعتقد أن هذا من شأنه معالجة هذه المشكلة أيضًا.

هل كانت هذه الصفحة مفيدة؟
0 / 5 - 0 التقييمات