ما تعلمته عن التحسين في بيثون

مرحبا بالجميع. نود اليوم أن نشارك ترجمة أخرى تم إعدادها عشية إطلاق دورة مطور بايثون . دعنا نذهب!



لقد استخدمت بايثون أكثر من أي لغة برمجة أخرى في آخر 4-5 سنوات. Python هي اللغة السائدة للإنشاءات تحت Firefox والاختبار وأداة CI. زئبقي هو أيضا مكتوب في الغالب في بيثون. كتبت أيضًا الكثير من مشاريع الأطراف الثالثة الخاصة بي.

خلال عملي ، اكتسبت القليل من المعرفة حول أداء Python وأدوات التحسين الخاصة به. في هذه المقالة ، أود أن أشارك هذه المعرفة.

ترتبط تجربتي مع Python بشكل رئيسي بمترجم CPython ، خاصة CPython 2.7. ليست كل ملاحظاتي عالمية لجميع توزيعات Python ، أو تلك التي لها نفس الخصائص في إصدارات مماثلة من Python. سأحاول أن أذكر هذا خلال السرد. ضع في اعتبارك أن هذه المقالة ليست نظرة عامة مفصلة عن أداء بيثون. سأتحدث فقط عما صادفته بمفردي.

الحمل بسبب خصوصيات بدء واستيراد الوحدات النمطية


يعد بدء تشغيل مترجم Python واستيراد الوحدات عملية طويلة إلى حد ما عندما يتعلق الأمر بالميلي ثانية.

إذا كنت بحاجة إلى بدء المئات أو الآلاف من عمليات Python في أي من مشاريعك ، فإن هذا التأخير بالميلي ثانية سوف يتحول إلى تأخير يصل إلى عدة ثوانٍ.

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

لقد كتبت بالفعل عن هذه المشكلة. تتحدث بعض ملاحظاتي السابقة عن هذا ، على سبيل المثال ، في عام 2014 ، في مايو 2018 وأكتوبر 2018 .

لا توجد أشياء كثيرة يمكنك القيام بها لتقليل تأخير بدء التشغيل: يشير إصلاح هذه الحالة إلى معالجة مترجم Python ، لأنه هو الذي يتحكم في تنفيذ التعليمات البرمجية التي تستغرق الكثير من الوقت. أفضل شيء يمكنك القيام به هو تعطيل استيراد وحدة الموقع في المكالمات لتجنب تنفيذ شفرة Python الإضافية عند بدء التشغيل. من ناحية أخرى ، تستخدم العديد من التطبيقات وظيفة وحدة site.py ، بحيث يمكنك استخدامها على مسؤوليتك الخاصة.

يجب أن نفكر أيضًا في مشكلة استيراد الوحدات النمطية. ما الفائدة من مترجم بايثون إذا لم يعالج أي كود؟ الحقيقة هي أن الشفرة متاحة للمترجم الفوري في كثير من الأحيان من خلال استخدام الوحدات النمطية.

لاستيراد الوحدات ، تحتاج إلى اتخاذ عدة خطوات. وفي كل منها يوجد مصدر محتمل للأحمال والتأخير.

يحدث تأخير معين بسبب البحث عن الوحدات النمطية وقراءة بياناتها. كما أوضحت مع PyOxidizer ، استبدال بحث وحدة نمطية وتحميلها من نظام ملفات بحل أبسط معمارياً ، والذي يتكون من قراءة بيانات الوحدة النمطية من بنية بيانات في الذاكرة ، يمكنك استيراد مكتبة Python القياسية لمدة 70-80٪ من وقت الحل الأولي لهذه المهمة. إن وجود وحدة واحدة لكل ملف نظام ملف يزيد من الحمل على نظام الملفات ويمكن أن يبطئ تطبيق Python خلال المللي ثانية الأولى من التنفيذ. يمكن أن تساعد حلول مثل PyOxidizer في تجنب هذا. آمل أن يرى مجتمع Python تكاليف النهج الحالي ويفكر في الانتقال إلى آليات توزيع الوحدات ، والتي لا تعتمد كثيرًا على الملفات الفردية في الوحدة النمطية.

مصدر آخر لتكاليف الاستيراد الإضافية لوحدة نمطية هو تنفيذ التعليمات البرمجية في تلك الوحدة أثناء الاستيراد. تحتوي بعض الوحدات على أجزاء من الكود في منطقة خارج وظائف وفئات الوحدة ، والتي يتم تنفيذها عند استيراد الوحدة. تنفيذ هذا الرمز يزيد من تكلفة الاستيراد. الحل البديل: لا تنفذ كل التعليمات البرمجية في وقت الاستيراد ، ولكن فقط قم بتنفيذها إذا لزم الأمر. يدعم Python 3.7 الوحدة النمطية __getattr__ ، والتي سيتم استدعاؤها إذا لم يتم العثور على سمة الوحدة النمطية. يمكن استخدام هذا لملء سمات الوحدة النمطية بتكاسل عند الوصول الأول.

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

يمكنك حفظ عشرات الميلي ثانية مع التطبيقات التي تستورد عدة عشرات من الوحدات النمطية إذا تجاوزت نظام الملفات وتجنب تشغيل الأجزاء غير الضرورية من الوحدة النمطية (عادة ما يتم استيراد الوحدات النمطية على المستوى العالمي ، ولكن يتم استخدام وظائف معينة فقط من الوحدات).

استيراد كسول من الوحدات هو شيء هش. تحتوي العديد من الوحدات النمطية على قوالب تحتوي على الأشياء التالية: try: import foo ؛ except ImportError: قد لا يقوم مستورد الوحدة البطيئة مطلقًا باستيراد ImportError ، لأنه إذا فعل ذلك ، فسيتعين عليه البحث في نظام الملفات عن وحدة نمطية لمعرفة ما إذا كان موجودًا من حيث المبدأ. سيضيف هذا حمولة إضافية ويزيد من الوقت المستغرق ، لذلك المستوردون البطيئون لا يقومون بهذا من حيث المبدأ! هذه المشكلة مزعجة جدا. مستورد الوحدات النمطية الكسولة تقوم Mercurial بمعالجة قائمة الوحدات النمطية التي لا يمكن استيرادها بشكل كسول ، ويجب تجاوزها. هناك مشكلة أخرى هي بناء الجملة from foo import x, y ، والذي يقاطع أيضًا استيراد الوحدة النمطية البطيئة ، في الحالات التي تكون فيها foo عبارة عن وحدة نمطية (على عكس الحزمة) ، حيث لا يزال يجب استيراد الوحدة النمطية لإرجاع مرجع إلى x و y.

يحتوي PyOxidizer على مجموعة ثابتة من الوحدات السلكية في الملف الثنائي ، بحيث يمكن أن تكون فعالة في رفع ImportError. توفر الوحدة __getattr__ من Python 3.7 مرونة إضافية للمستوردين من كسول الوحدة. أتمنى دمج مستورد موثوق به كسول في PyOxidizer لأتمتة بعض العمليات.

أفضل حل لتجنب بدء المترجم الفوري والتسبب في تأخير الوقت هو بدء عملية الخلفية في بيثون. إذا بدأت عملية Python كعملية خفية ، لنقل عن خادم ويب ، فيمكنك القيام بذلك. الحل الذي يقدمه Mercurial هو بدء عملية خلفية توفر بروتوكول خادم أمر . hg هو الملف القابل للتنفيذ C (أو الآن Rust) ، والذي يتصل بعملية الخلفية هذه ويرسل أمرًا. لإيجاد نهج لخادم الأوامر ، تحتاج إلى القيام بالكثير من العمل ، إنه غير مستقر للغاية ولديه مشاكل أمنية. أنا أفكر في فكرة تقديم خادم أوامر باستخدام PyOxidizer حتى يتمتع الملف القابل للتنفيذ بمزاياه ، وتم حل مشكلة تكلفة حل البرنامج نفسه عن طريق إنشاء مشروع PyOxidizer.

وظيفة الدعوة تأخير


استدعاء وظائف في بيثون هي عملية بطيئة نسبيا. (هذه الملاحظة أقل انطباقًا على PyPy ، والتي يمكنها تنفيذ كود JIT.)

رأيت العشرات من التصحيحات لبرنامج Mercurial ، والتي مكنت من محاذاة الكود ودمجه بطريقة تتجنب التحميل غير الضروري عند استدعاء الوظائف. في دورة التطوير الحالية ، تم بذل بعض الجهود لتقليل عدد الوظائف المدعوة عند تحديث شريط التقدم. (نحن نستخدم أشرطة التقدم لأية عمليات قد تستغرق بعض الوقت ، حتى يدرك المستخدم ما يحدث). يؤدي الحصول على نتائج استدعاء الوظائف وتجنب البحث البسيط بين الوظائف إلى توفير عشرات مئات الميلي ثانية عند تنفيذها ، عندما نتحدث عن مليون عملية إعدام ، على سبيل المثال.

إذا كانت لديك حلقات ضيقة أو وظائف متكررة في Python حيث يمكن أن تحدث مئات الآلاف أو أكثر من مكالمات الوظائف ، فيجب أن تكون على دراية بالحمل الزائد للاتصال بوظيفة فردية ، لأن هذا له أهمية كبيرة. ضع في اعتبارك الوظائف المدمجة البسيطة والقدرة على الجمع بين الوظائف لتجنب الحمل.

سمة البحث الحمل


تشبه هذه المشكلة الحمل الزائد بسبب استدعاء دالة ، لأن المعنى هو نفسه تقريبًا!

يمكن أن يكون العثور على حل السمات في Python بطيئًا. (ومرة أخرى ، في PyPy ، هذا أسرع). ومع ذلك ، فإن معالجة هذه المشكلة هو ما نفعله غالبًا في Mercurial.

لنفترض أن لديك الكود التالي:

 obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i] 

لقد أغفلنا وجود طرق أكثر فاعلية لكتابة هذا المثال (على سبيل المثال ، total = sum(obj.member) ) ، ولاحظ أن الحلقة تحتاج إلى تحديد obj.member في كل تكرار. بيثون لديه آلية متطورة نسبيا لتحديد السمات . لأنواع بسيطة ، يمكن أن يكون بسرعة كافية. ولكن بالنسبة للأنواع المعقدة ، يمكن لهذا الوصول إلى السمة الاتصال تلقائيًا بـ __getattr__ ، __getattribute__ ، وطرق dunder المختلفة dunder وحتى الوظائف التي يحددها المستخدم @property . يشبه هذا البحث السريع عن سمة يمكنها إجراء العديد من استدعاءات الوظائف ، مما سيؤدي إلى تحميل إضافي. ويمكن أن يتفاقم هذا الحمل إذا كنت تستخدم أشياء مثل obj.member1.member2.member3 ، إلخ.

يؤدي كل تعريف للسمة إلى تحميل إضافي. ونظرًا لأن كل شيء تقريبًا في Python عبارة عن قاموس ، فيمكننا القول أن كل عملية بحث عن السمات هي بحث في القاموس. من المفاهيم العامة حول بنيات البيانات الأساسية ، نعلم أن بحث القاموس ليس بالسرعة ، على سبيل المثال ، البحث في الفهرس. نعم ، بالطبع هناك بعض الحيل في CPython التي يمكنها التخلص من النفقات العامة بسبب عمليات البحث في القاموس. ولكن الموضوع الرئيسي الذي أريد أن أتطرق إليه هو أن أي بحث في السمة هو تسرب محتمل للأداء.

بالنسبة للحلقات الضيقة ، وخاصة تلك التي يحتمل أن تتجاوز مئات الآلاف من التكرارات ، يمكنك تجنب هذه النفقات العامة القابلة للقياس للعثور على سمات عن طريق تعيين قيمة لمتغير محلي. لنلقِ نظرة على المثال التالي:

 obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i] 

بالطبع ، لا يمكن القيام بذلك إلا بأمان إذا لم يتم استبداله في دورة. إذا حدث هذا ، فسوف يحتفظ التكرار بالرابط الخاص بالعنصر القديم وقد ينفجر كل شيء.
يمكن تنفيذ نفس الخدعة عند استدعاء طريقة الكائن. عوضا عن

 obj = MyObject() for i in range(1000000): obj.process(i) 

يمكنك القيام بما يلي:

 obj = MyObject() fn = obj.process for i in range(1000000:) fn(i) 

تجدر الإشارة أيضًا إلى أنه في الحالة التي يحتاج فيها البحث عن السمة إلى استدعاء طريقة (كما في المثال السابق) ، يكون Python 3.7 أسرع نسبيًا من الإصدارات السابقة. لكنني متأكد من أن الحمل الزائد هنا متصل ، أولاً وقبل كل شيء ، باستدعاء الوظيفة ، وليس بالتحميل على البحث عن السمة. لذلك ، سيعمل كل شيء بشكل أسرع إذا تخلت عن البحث الإضافي عن السمات.

أخيرًا ، نظرًا لأن بحث السمة يستدعي وظيفة لهذا ، يمكن القول أن البحث عن السمة يمثل عمومًا مشكلة أقل من التحميل بسبب استدعاء دالة. عادة ، لإشعار تغييرات كبيرة في السرعة ، ستحتاج إلى القضاء على الكثير من عمليات البحث عن السمات. في هذه الحالة ، بمجرد إتاحة الوصول إلى جميع السمات الموجودة داخل الحلقة ، يمكنك التحدث عن 10 أو 20 سمة فقط في الحلقة قبل استدعاء الوظيفة. ويمكن للحلقات التي تضم أقل من آلاف أو أقل من عشرات الآلاف من التكرار توفير مئات الآلاف أو ملايين من عمليات البحث عن السمات. لذا كن حذرا!

تحميل الكائن


من وجهة نظر مترجم Python ، كل القيم هي كائنات. في CPython ، كل عنصر عبارة عن بنية PyObject. كل كائن يتحكم فيه المترجم يكون على الكومة وله ذاكرته الخاصة التي تحتوي على عدد المرجع ونوع الكائن والمعلمات الأخرى. يتم التخلص من كل كائن بواسطة جامع البيانات المهملة. هذا يعني أن كل كائن جديد يضيف حمولة بسبب حساب المرجع ، وجمع القمامة ، وما إلى ذلك. (ومرة أخرى ، يمكن لـ PyPy تجنب هذا العبء غير الضروري ، لأنه أكثر "حذراً" بشأن عمر القيم قصيرة الأجل.)

بشكل عام ، كلما كانت القيم الفريدة والكائنات Python التي تقوم بإنشائها ، تعمل الأشياء الأبطأ من أجلك.

لنفترض أنك تتكرر على مجموعة من مليون كائن. يمكنك استدعاء دالة لجمع هذا الكائن في tuple:

 for x in my_collection: a, b, c, d, e, f, g, h = process(x) 

في هذا المثال ، ستُرجع process() tuple 8 tuple. لا يهم إذا قمنا بتدمير قيمة الإرجاع أم لا: تتطلب هذه المجموعة إنشاء 9 قيم على الأقل في Python: 1 للتوليف نفسه و 8 لأعضاءه الداخليين. حسنًا ، في الحياة الواقعية قد يكون هناك عدد أقل من القيم إذا أعادت process() إشارة إلى كائن موجود. أو ، على العكس من ذلك ، قد يكون هناك المزيد إذا كانت أنواعها ليست بسيطة وتتطلب العديد من PyObjects لتمثيلها. أريد فقط أن أقول أنه تحت غطاء المترجم الشفهي ، هناك شعوذة حقيقية من الأشياء للعرض الكامل لبعض المنشآت.

من تجربتي الخاصة أستطيع أن أقول إن هذه النفقات العامة ذات صلة فقط بالعمليات التي توفر مكاسب السرعة عند تنفيذها بلغة أصلية مثل C أو Rust. المشكلة هي أن مترجم CPython غير قادر ببساطة على تنفيذ كود البايت بسرعة كبيرة لدرجة أن الحمل الزائد بسبب عدد الأشياء مهم. بدلاً من ذلك ، من الأرجح أن تقوم بتقليل الأداء عن طريق استدعاء وظيفة ، أو من خلال حسابات مرهقة ، إلخ. قبل أن تلاحظ الحمل الزائد بسبب الأشياء. هناك بالطبع بعض الاستثناءات ، وهي بناء التلاميذ أو قواميس متعددة القيم.

كمثال ملموس على الحمل ، يمكنك ذكر Mercurial باستخدام رمز C الذي يقوم بتوزيع بنيات البيانات منخفضة المستوى. للحصول على مزيد من السرعة في التحليل ، يعمل رمز C بترتيب حجم أسرع من CPython. ولكن بمجرد قيام رمز C بإنشاء PyObject لتمثيل النتيجة ، تنخفض السرعة عدة مرات. بمعنى آخر ، يتضمن التحميل إنشاء عناصر Python وإدارتها بحيث يمكن استخدامها في التعليمات البرمجية.

هناك طريقة لحل هذه المشكلة وهي إنتاج عناصر أقل في بيثون. إذا كنت بحاجة إلى الرجوع إلى عنصر واحد ، فقم ببدء الوظيفة وإعادتها ، وليس تلميحًا أو قاموسًا لعناصر N. ومع ذلك ، لا تتوقف عن مراقبة الحمل المحتمل بسبب استدعاءات الوظائف!

إذا كان لديك الكثير من التعليمات البرمجية التي تعمل بسرعة كافية باستخدام CPython C API ، والعناصر التي تحتاج إلى توزيع بين وحدات نمطية مختلفة ، فيمكنك الاستغناء عن أنواع Python التي تمثل بيانات مختلفة مثل بنيات C ولديها بالفعل تعليمات برمجية برمجية للوصول إلى هذه الهياكل بدلا من الذهاب من خلال CPython C API. عن طريق تجنب CPython C API للوصول إلى البيانات ، سوف تتخلص من الكثير من الحمل الزائد.

إن التعامل مع العناصر كبيانات (بدلاً من وجود وظائف للوصول إلى كل شيء على التوالي) سيكون أفضل طريقة لبيثون. حل آخر لرمز المترجمة بالفعل هو إنشاء كسول PyObject. إذا قمت بإنشاء نوع مخصص في Python (PyTypeObject) لتمثيل العناصر المعقدة ، فإنك تحتاج إلى تحديد حقول tp_members أو tp_getset لإنشاء وظائف C مخصصة للبحث عن قيمة السمة. إذا قمت ، على سبيل المثال ، بكتابة محلل ومعرفة أن العملاء لن يتمكنوا من الوصول إلا إلى مجموعة فرعية من الحقول التي تم تحليلها ، يمكنك إنشاء نوع يحتوي على بيانات أولية بسرعة وإرجاع هذا النوع واستدعاء دالة C للبحث عن سمات Python التي تعالج PyObject. يمكنك حتى تأخير التحليل حتى يتم استدعاء الوظيفة لحفظ الموارد إذا لم تكن هناك حاجة إلى التحليل! هذه التقنية نادرة جدًا ، لأنها تتطلب كتابة شفرة غير تافهة ، ولكنها تعطي نتيجة إيجابية.

تحديد حجم المجموعة الأولي


هذا ينطبق على CPython C API.

عند إنشاء مجموعات ، مثل القوائم أو القواميس ، استخدم PyList_New() + PyList_SET_ITEM() لتعبئة مجموعة جديدة إذا كان حجمها محددًا بالفعل وقت الإنشاء. سيؤدي هذا إلى تحديد حجم المجموعة مسبقًا ليتمكن من الاحتفاظ بعدد محدود من العناصر فيه. هذا يساعد على تخطي التحقق من حجم مجموعة كافية عند إدراج العناصر. عند إنشاء مجموعة من آلاف العناصر ، سيوفر لك ذلك بعض الموارد!

باستخدام الصفر نسخة في C API


يحب Python C API حقًا إنشاء نسخ من الكائنات بدلاً من إرجاع المراجع إليها. على سبيل المثال ، ينسخ PyBytes_FromStringAndSize () char* في الذاكرة المحجوزة بواسطة Python. إذا قمت بذلك لعدد كبير من القيم أو البيانات الكبيرة ، فيمكننا الحديث عن غيغابايت من الذاكرة I / O والتحميل المقترن على المخصص.

إذا كنت بحاجة إلى كتابة تعليمات برمجية عالية الأداء بدون واجهة برمجة تطبيقات C ، فيجب عليك التعرف على بروتوكول التخزين المؤقت والأنواع ذات الصلة ، مثل memoryview .

Buffer protocol مضمّن في أنواع Python ويسمح للمترجمين الفوريين بإلقاء الكتابة من / إلى البايتات. كما يسمح لمترجم الشفرة C باستلام واصف void* فارغ بحجم معين. هذا يسمح لك بربط أي عنوان في الذاكرة مع PyObject. تقبل العديد من الوظائف التي تعمل مع البيانات الثنائية بشفافية أي كائن يقوم بتنفيذ buffer protocol . وإذا كنت تريد قبول أي كائن يمكن اعتباره بايتًا ، فأنت بحاجة إلى استخدام وحدات من تنسيق s* أو y* أو w* عند تلقي وسيطات دالة.

باستخدام buffer protocol ، فإنك تعطي المترجم أفضل فرصة متاحة لاستخدام عمليات zero-copy وترفض نسخ وحدات البايت الإضافية إلى الذاكرة.

باستخدام الأنواع الموجودة في Python من memoryview النموذج ، ستسمح أيضًا لـ Python بالوصول إلى مستويات الذاكرة حسب المرجع ، بدلاً من عمل نُسخ.

إذا كان لديك غيغا بايت من التعليمات البرمجية التي تمر عبر برنامج Python ، فإن الاستخدام الثاقب لأنواع Python التي تدعم النسخة الصفرية سيوفر لك من اختلافات الأداء. لقد لاحظت ذات مرة أن python-zstandard تبين أنها أسرع من أي ارتباطات لبيثون LZ4 (على الرغم من أنه ينبغي أن يكون العكس) ، لأنني كنت أستخدم buffer protocol كثيرًا وتجنب الذاكرة المفرطة I / O في python-zstandard !

استنتاج


في هذا المقال ، سعيت للحديث عن بعض الأشياء التي تعلمتها أثناء تحسين برامج بيثون الخاصة بي لعدة سنوات. أكرر وأقول إنها ليست نظرة شاملة على طرق تحسين أداء بايثون. أعترف أنني ربما أستخدم Python أكثر طلبًا من غيرها ، ولا يمكن تطبيق توصياتي على جميع البرامج. يجب ألا تصحح رمز Python الخاص بك على نطاق واسع وأن تزيل ، على سبيل المثال ، البحث عن السمات بعد قراءة هذه المقالة . كما هو الحال دائمًا ، عندما يتعلق الأمر بتحسين الأداء ، قم أولاً بإصلاح حيث يكون الرمز بطيئًا بشكل خاص. py-spy Python. , , Python, . , , , !

, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .

;-)

Source: https://habr.com/ru/post/ar457942/


All Articles