هل تزال استدعاءات الدوال بطيئة في بايثون؟ تحليل التحسينات الأخيرة في CPython

لقد صادفت هذا المنشور على X/Twitter حيث وجد Pritam أن حل Leetcode الخاص به كان أبطأ عندما كان يستخدم دالة min المدمجة في بايثون وتحسن الأداء عندما نفذ min مضمنًا في كود بايثون الخاص به.

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

كان كاتب المنشور يستخدم بايثون 2 وهو تاريخ قديم في هذه المرحلة من الزمن. لكن بايثون 3 مر بإصدارات متعددة في العقد الماضي وكانت الإصدارات القليلة الأخيرة تركز بشكل كبير على تحسين أداء اللغة. فهل لا يزال صحيحًا أن استدعاءات الدوال مكلفة في بايثون؟

لقد كنت فضوليًا، لذا قمت بإنشاء ثلاثة معايير دقيقة لقياس ثلاثة أشياء:

  • ما هو تأثير استدعاء المدمجة في حلقة؟
  • ما هو تأثير استدعاء دالة بايثون في حلقة؟
  • وما هو تأثير تضمين تلك الدالة في الحلقة؟

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

في هذه المقالة، سأناقش التحسينات المحددة التي تم تقديمها في CPython والتي تساعد في تحسين أداء المترجم. سأشرح لماذا كانت الأمور بطيئة في السابق وكيف يساعد التغيير الجديد في ذلك. دعنا نتعمق في الأمر.

المعيار 1: قياس التكلفة الإجمالية لتنفيذ التعليمات البسيطة في حلقة

لنبدأ بالمعيار الأول حيث نقوم ببعض العمليات الحسابية البسيطة داخل حلقة، مثل حساب الحد الأدنى من القائمة. الكود أدناه يستخدم حلقة while بدلاً من حلقة for لأن الكود الأصلي في منشور Twitter كان يستخدم حلقة while وأردت الالتزام بذلك.

def benchmark1(heights):
    a = 1
    b = len(heights) - 1
    min_height = heights[0]
    while a < b:
        if heights[a] < min_height:
            min_height = heights[a]
        a += 1

    return min_height

فيما يلي أرقام الأداء لهذا المعيار لإصدارات CPython القليلة الماضية:

توقيتات حساب الحد الأدنى لقائمة القيم في حلقة دون استدعاء أي ادالة

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

مقدمة عن التعليمات الفائقة

كان أحد التحسينات البسيطة التي تم تقديمها في CPython هو التعليمات الفائقة. وهي عبارة عن تعليمات بايت كود خاصة يتم إنشاؤها عن طريق دمج تعليمتين متتاليتين من أنواع معينة والتي تميل إلى الظهور في أزواج في البرامج. دعنا نفهم كيف تعمل في سياق هذا المعيار المحدد.

تُظهر الصورة أدناه الكود الثنائي لجسم الحلقة لهذا المعيار القياسي لـبايثون 3.14.0a0 (على اليسار) وبايثون 3.10 (على اليمين). في الحلقة، يحتاج المترجم إلى تحميل قيم heights[a] وmin_height بشكل متكرر على المكدس قبل أن يتمكن من مقارنتها. لتحميل هذه القيم على المكدس، ينفذ المترجم تعليمة LOAD_FAST.

يمكننا أن نرى فرقًا واضحًا بين الكود الثنائي لإصداري بايثون. يحتوي الإصدار 3.10 على تعليمتين متتاليتين LOAD_FAST، بينما يحتوي الإصدار 3.14 على تعليمة LOAD_FAST_LOAD_FAST واحدة.

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

تنفيذ تحسين التعليمات الفائقة داخل مُجمِّع CPython. الملف: flowgraph.c

فوائد تحسين التعليمات الفائقة

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

بالإضافة إلى ذلك، يساعد هذا أيضًا وحدة المعالجة المركزية على تنفيذ حلقة المترجم بكفاءة. إن وجود عدد أقل من تعليمات البايت كود في المترجم يعني أيضًا عددًا أقل من القفزات لوحدة المعالجة المركزية. يؤدي وجود عدد أقل من القفزات في حلقة محكمة إلى تحسين موقع ذاكرة التخزين المؤقت للتعليمات، واستخدام أفضل لمتنبئ الفرع لأن إدخالات جدول الفرع المحررة يمكن استخدامها لفروع أخرى.

علاوة على ذلك، فإن تنفيذ تعليمة LOAD_FAST_LOAD_FAST في المفسّر يوفر لوحدة المعالجة المركزية فرصة لزيادة معدل إنتاجها من التعليمات. تستطيع وحدات المعالجة المركزية الحديثة معالجة تعليمات متعددة بالتوازي، وهي القدرة المعروفة باسم التوازي على مستوى التعليمات، بشرط توفر تعليمات مستقلة كافية. وفي حالة LOAD_FAST_LOAD_FAST، يحتوي تنفيذها على عدة تعليمات مستقلة عن بعضها البعض، مما يسمح بتنفيذها في وقت واحد.

تنفيذ تعليمة LOAD_FAST_LOAD_FAST في مفسّر البايت كود CPython. يتيح التحميل المتزامن لقيمتين على المكدس لوحدة المعالجة المركزية تنفيذ هذه التعليمات بالتوازي بسبب التوازي على مستوى التعليمات

تخصص تعليمات البايت كود

التحسين الثاني الذي يساعد بشكل كبير في تحسين الأداء لهذا المعيار هو التخصص في التعليمات المقدم في إصدار CPython 3.12.

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

إن تنفيذ هذه التعليمات مكلف نسبيًا لأنها تتضمن إرسالًا ديناميكيًا. لقد ناقشت ما يحدث بالضبط خلف الكواليس هنا في مقالتي “كم عدد أسطر لغة C اللازمة لتنفيذ a + b في بايثون؟“. لكن دعني أقدم لك الملخص.

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

  • يحتاج المترجم أولاً إلى إلغاء الرجوع إلى كائن المتعامل
  • بعد ذلك، يحتاج إلى إلغاء مرجع المؤشر إلى حقل PyTypeObject (ob_type) الذي يحتوي على جداول مؤشرات الدالة
  • ثم يحتاج المترجم إلى إلغاء الرجوع إلى جدول مؤشر الدالة والبحث عن مؤشر الدالة
  • أخيرًا، يتعين عليك إلغاء مرجع مؤشر الدالة نفسه لاستدعاء الدالة.

الشكل التالي يوضح عملية مطاردة المؤشر.

كمية الالتباس المتضمنة عندما يحتاج مفسّر البايت كود إلى التعامل مع عملية ثنائية أو عملية مقارنة

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

ولكن بفضل التخصص في التعليمات، يتم تحويل تعليمات BINARY_OP و COMPARE_OP البطيئة إلى تعليمات متخصصة مثل BINARY_ADD_INT حيث تتم عملية الإضافة مباشرة في المترجم دون إجراء أي عمليات بحث عن المؤشر.

المعيار الثاني: قياس تكلفة إستدعاء الدالة المدمجة

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

def benchmark2(heights):
    a = 1
    b = len(heights) - 1
    min_height = heights[0]
    while a < b:
        min_height = min(heights[a], min_height)
        a += 1

    return min_height

يقيس هذا المعيار التكلفة الإضافية المترتبة على استدعاء وظيفة مدمجة. يوضح الجدول التالي التحسن في أداء CPython لهذا الغرض عبر الإصدارات.

لقد حدث تغييران يمكن أن يُعزَيا إلى تحسين هذا الإصدار من 17.33 ثانية في بايثون 3.10 إلى 6.7 ثانية في بايثون 3.14.0a0. فدعونا نناقشهما.

تحميل العناصر العالمية بشكل أسرع

دعونا نلقي نظرة على الكود الثانوي الخاص بكود هذا المعيار.

البايت كود للإصدار 2 من معيار CPython 3.14.0a0

عند تنفيذ هذا الكود، يحتاج المترجم إلى تحميل الدالة المدمجة min() على المكدس. للقيام بذلك، فإنه ينفذ تعليمة LOAD_GLOBAL.

تحتاج تعليمات LOAD_GLOBAL إلى البحث عن الكائن العالمي المسمى في قاموسين. يحتوي القاموس الأول على جميع الكائنات العالمية في النطاق الحالي، ويحتوي القاموس الثاني على جميع العناصر المدمجة.

إن عمليات البحث في القاموس سريعة ولكنها ليست مجانية. ومرة ​​أخرى، بفضل التخصص في التعليمات، يقوم المترجم بتحسين ذلك وتحويله إلى تعليمات متخصصة: LOAD_GLOBAL_BUILTIN.

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

تنفيذ تعليمة LOAD_GLOBAL_BUILTIN في مفسّر البايت كود CPython

تحسين الحد الأدنى والحد الأقصى للقيم المدمجة

لكن تخصص LOAD_GLOBAL في LOAD_GLOBAL_BUILTIN ليس هو المساهم الرئيسي في التحسن المذهل لهذا المعيار. السبب الحقيقي هو التحسين المحدد المطبق على الحد الأدنى والحد الأقصى المدمجين.

هناك اتفاقيتان للاتصال داخل المترجم لاستدعاء الدوال، الأولى هي الاتفاقية القديمة المسماة tp_call والأخرى هي vectorcall.

عند استخدام tp_call، يتم إنشاء مجموعات وسيطة وقواميس لتمرير وسيطات الدالة وقد تكون هناك تكلفة إضافية لكائنات وسيطة أخرى أيضًا (مزيد من التفاصيل موصوفة في PEP 0590). في اتفاقية vectorcall، يتم تمرير الوسيطات كجزء من متجه مما يلغي الكثير من إنشاء الكائن الوسيط.

قبل إصدار CPython 3.13، كان يتم استدعاء الحد الأدنى والحد الأقصى باستخدام اتفاقية tp_call. وهذا يعني أن استدعاء هذه داخل حلقة من شأنه تخصيص وإلغاء تخصيص عدد كبير من الكائنات الوسيطة. وبالتحول إلى اتفاقية vectorcall، تم الإبلاغ عن تحسن أداء هذه العناصر المضمنة بنسبة تصل إلى 200%، وحتى في هذا المعيار، يظهر تحسنًا يزيد عن 150%.

المعيار 3: قياس التكلفة الإجمالية لاستدعاء دالة بايثون

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

def pymin(a, b):
    if a <= b:
        return a
    return b
	

def benchmark3(heights):
    a = 1
    b = len(heights) - 1
    min_height = heights[0]
    while a < b:
        min_height = pymin(heights[a], min_height)
        a += 1

    return min_height

يوضح الجدول التالي أداء إصدارات CPython المختلفة لهذا المعيار:

وفقًا للأرقام الموجودة في الجدول لإصدار المعيار هذا، تحسن الأداء بشكل كبير من 3.10 إلى 3.12 ثم بشكل طفيف إلى 3.14.0a0.

يقوم هذا المعيار بشكل أساسي بقياس التكلفة الإضافية المتضمنة في تنفيذ إستدعاء دالة من بايثون إلى بايثون (لأن كل من المتصل والمستدعى يتم تنفيذهما في بايثون).

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

التنفيذ المباشر لإستدعاءات الدوال من بايثون إلى بايثون

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

تحتوي دالة _PyEval_EvalFrameDefault على حالة تبديل عملاقة للتعامل مع جميع تعليمات البايت كود التي يدعمها المترجم. تتكرر الدالة خلال تعليمات الدالة المحددة وتنفذ حالة التبديل المقابلة.

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

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

في CPython 3.10 والإصدارات الأقدم، كانت تعليمات CALL تُستخدم لإنشاء إطار مكدس مترجم جديد للدالة التي يتم استدعاؤها، ثم كانت تُستخدم لإعادة الدخول بشكل متكرر إلى المترجم عن طريق استدعاء نقطة دخوله _PyEval_EvalFrameDefault.

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

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

ملاحظة: قد يستدعي الكود دالة تم تنفيذها بلغة C، أو قد يستدعي دالة مكتوبة بلغة بايثون. المناقشة أعلاه تتعلق باستدعاء دالة بلغة بايثون. في حالة استدعاء دالة بلغة C، لن يكون المترجم بحاجة إلى القيام بكل الحركات التي تمت مناقشتها أعلاه.

يمكنك قراءة المناقشة حول هذا التغيير في متعقب أخطاء CPython.

تخصص تعليم CALL

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

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

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


اكتشاف المزيد من بايثون العربي

اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *

Scroll to Top

اكتشاف المزيد من بايثون العربي

اشترك الآن للاستمرار في القراءة والحصول على حق الوصول إلى الأرشيف الكامل.

Continue reading