Pylint: على محاولة خفض استهلاك الذاكرة

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











  • ما الذي يستهلك الكثير من الذاكرة بالضبط؟
  • هل هناك أي طريقة لتجنب هذا؟


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



بدأت في تحليل Pylint ، بدءًا من نقطة إدخال البرنامج ( pylint/__main__.py) ، ووصلت إلى الحلقة "الأساسية" forالتي تتوقعها في برنامج يقوم بفحص ملفات متعددة:



def _check_files(self, get_ast, file_descrs):
            #   pylint/lint/pylinter.py
            with self._astroid_module_checker() as check_astroid_module:
                for name, filepath, modname in file_descrs:
                    self._check_file(get_ast, check_astroid_module, name, filepath, modname)


بادئ ذي بدء ، قمت فقط بوضع عبارة في هذه الحلقة print(«HI»)للتأكد من أن هذه هي بالفعل الحلقة التي تبدأ عند تنفيذ الأمر pylint my_code. سارت هذه التجربة بسلاسة.



بعد ذلك ، قررت معرفة ما يتم تخزينه بالضبط في الذاكرة أثناء عمل Pylint. لذلك استخدمته heapyوقمت بعمل "تفريغ كومة" بسيط ، على أمل تحليل هذا التفريغ بحثًا عن أي شيء غير عادي:



        from guppy import hpy
        hp = hpy()
        i = 0
        for name, filepath, modname in file_descrs:
            self._check_file(get_ast, check_astroid_module, name, filepath, modname)
            i += 1
            if i % 10 == 0:
                print("HEAP")
                print(hp.heap())
            if i == 100:
                raise ValueError("Done")


انتهى ملف تعريف الكومة بالكامل تقريبًا من إطارات مكدس الاستدعاء ( types.FrameType). لسبب ما ، توقعت شيئًا كهذا. جعلتني أشياء كثيرة مماثلة في مكب النفايات أعتقد أنه يبدو أن هناك الكثير منها أكثر مما ينبغي أن يكون.



    Partition of a set of 2751394 objects. Total size = 436618350 bytes.
    Index  Count   %     Size   % Cumulative  % Kind (class / dict of class)
        0 429084  16 220007072  50 220007072  50 types.FrameType
        1 535810  19 30005360   7 250012432  57 types.TracebackType
        2 516282  19 29719488   7 279731920  64 tuple
        3 101904   4 29004928   7 308736848  71 set
        4 185568   7 21556360   5 330293208  76 dict (no owner)
        5 206170   7 16304240   4 346597448  79 list
        6 117531   4  9998322   2 356595770  82 str
        7  38582   1  9661040   2 366256810  84 dict of astroid.node_classes.Name
        8  76755   3  6754440   2 373011250  85 tokenize.TokenInfo


في هذه اللحظة وجدت أداة متصفح الملف الشخصي ، والتي تتيح لك العمل بسهولة مع هذه البيانات.



لقد قمت بتكوين محرك التفريغ بحيث تتم كتابة البيانات في ملف كل 10 تكرار حلقة. ثم قمت ببناء رسم تخطيطي يوضح سلوك البرنامج أثناء التشغيل.



    for name, filepath, modname in file_descrs:
        self._check_file(get_ast, check_astroid_module, name, filepath, modname)
        i += 1
        if i % 10 == 0:
            hp.heap().stat.dump("/tmp/linting.stats")
        if i == 100:
            hp.pb("/tmp/linting.stats")
            raise ValueError("Done")


انتهى بي الأمر مع ما هو مبين أدناه. يؤكد هذا الرسم التخطيطي أن الكائنات type.FrameTypeو type.TracebackType(معلومات التتبع) استهلكت قدرًا كبيرًا من الذاكرة أثناء تشغيل Pylint الذي تم استكشافه.





تحليل البيانات



المرحلة التالية من الدراسة كانت تحليل الأشياءtypes.FrameType. نظرًا لأن آليات إدارة الذاكرة في Python تعتمد على حساب عدد المراجع للكائنات ، يتم الاحتفاظ بالبيانات في الذاكرة طالما أن هناك شيئًا يشير إليها. قررت أن أعرف بالضبط ما "يحمل" البيانات في الذاكرة.



لقد استخدمت هنا مكتبة ممتازة تقدمobjgraph، باستخدام إمكانيات مدير ذاكرة Python ، معلومات حول الكائنات الموجودة في الذاكرة ، وتتيح لك معرفة ما يشير بالضبط إلى هذه الكائنات.



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



في البداية ، عثرت بالصدفة على بحث عن كائن ، حيث objgraph.by_type('types.TracebackType')لم يتمكن الفريق من العثور على أي شيء على الإطلاق. وهذا على الرغم من حقيقة أنني كنت أعرف أن هناك عددًا كبيرًا من هذه الأشياء. اتضح أنه يجب استخدام سلسلة كاسم للنوع traceback. سبب ذلك ليس واضحًا تمامًا بالنسبة لي ، ولكن ما هو - هذا هو. الأمر الصحيح ، في النهاية ، يبدو كما يلي:



random.choice(objgraph.by_type('traceback'))


هذا البناء يختار الكائنات بشكل عشوائي traceback. وبمساعدة objgraph.show_backrefsيمكنك بناء رسم تخطيطي لما يشير إلى هذه الكائنات.



في النهاية ، بدلاً من مجرد طرح استثناء ، قررت التحقق مما يحدث في الحلقة for( import pdb; pdb.set_trace()) بعد 100 تكرار. بدأت في دراسة الأشياء المختارة بشكل عشوائي traceback.



def exclude(obj):
    return 'Pdb' in str(type(obj))

def f(depth=7):
    objgraph.show_backrefs([random.choice(objgraph.by_type('traceback'))],
                            max_depth=depth,
                            filter=lambda elt: not exclude(elt))


في البداية ، لم أر سوى سلاسل من الأشياء traceback، لذلك قررت أن أتسلق إلى عمق 100 عنصر ...





تحليل كائنات traceback



كما اتضح ،tracebackتشيربعض الكائناتإلى كائنات أخرى من نفس النوع. جيد جدا. وكان هناك الكثير من هذه السلاسل.



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





تحليل كائنات الإطار



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



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



ipdb> import ctypes
ipdb> ctypes.cast(0x7f187d22b880, ctypes.py_object)
py_object(<traceback object at 0x7f187d22b880>)
ipdb> ctypes.cast(0x7f187d22b880, ctypes.py_object).value
<traceback object at 0x7f187d22b880>
ipdb> my_tb = ctypes.cast(0x7f187d22b880, ctypes.py_object).value
ipdb> traceback.print_tb(my_tb, limit=20)


في الواقع ، يمكنك فقط أن تقول لبايثون ، "انظر إلى هذه الذاكرة. هناك بالتأكيد كائن بايثون عادي على الأقل هنا ".



أدركت لاحقًا أنه كان لدي بالفعل روابط لأشياء تهمني بفضل objgraph. هذا هو - يمكنني فقط استخدامها.



شعرت أن المكتبة astroid، محلل AST المستخدم في Pylint ، كانت تنشئ كائنات في كل مكان من tracebackخلال رمز معالجة الاستثناءات. أظن أنه عندما يستخدم شخص ما شيئًا يمكن تسميته "خدعة شيقة" في مكان ما ، فإنه ينسى كيف يمكن القيام بنفس الشيء بسهولة. لذلك أنا لا أشكو منه حقًا.



الكائنات tracebackلديها الكثير من البيانات المتعلقة بـ astroid. كان هناك بعض التقدم في بحثي! مكتبةastroidيشبه إلى حد بعيد برنامجًا يمكنه الاحتفاظ بكميات هائلة من البيانات في الذاكرة ، لأنه يوزع الملفات.



بحثت في الكود ووجدت الأسطر التالية في الملف astroid/manager.py:



except Exception as ex:
    raise exceptions.AstroidImportError(
    "Loading {modname} failed with:\n{error}",
    modname=modname,
    path=found_spec.location,
) from ex


"هذا هو ،" فكرت ، "هذا بالضبط ما أبحث عنه!" إنها سلسلة من الاستثناءات ينتج عنها أطول سلاسل من الكائنات traceback. وهنا ، من بين أشياء أخرى ، يتم تحليل الملفات ، لذلك يمكن أيضًا مواجهة الآليات العودية هنا. وشيء يشبه البناء raise thing from other_thingيربطها معًا.



أزلت from exو ... لم يحدث شيء. ظلت كمية الذاكرة التي يستهلكها البرنامج كما هي تقريبًا ، tracebackولم تذهب الأشياء إلى أي مكان أيضًا.



كنت أدرك أن الاستثناءات تخزن روابطها المحلية في كائنات traceback، حتى تتمكن من الوصول إليها ex. نتيجة لذلك ، لا يمكن محو ذكرياتهم.



لقد قمت بإعادة هيكلة ضخمة للرمز ، في محاولة للتخلص من الكتلةexcept، أو على الأقل من ارتباط إلى ex. لكن ، مرة أخرى ، لم أحصل على شيء. على



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



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



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



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



تم إنشاء الروابط من خلال خاصية tb_next، وكان تسلسل هذه الروابط عبارة عن سلسلة بسيطة. لذلك قررت أن ألقي نظرة على الأشياء tracebackفي نهاية السلاسل المعنية:



bottom_tbs = [tb for tb in objgraph.by_type('traceback') if tb.tb_next is None]


هناك شيء سحري يتعلق بدس طريقك عبر نصف مليون كائن بخط واحد وإيجاد ما تحتاجه.



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





البحث عن مصدر المشكلة



كان كل شيء عن ذاكرة التخزين المؤقت للملف!



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



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



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



نتيجة لذلك ، تمكنت من تقليل استهلاك الذاكرة عند العمل مع قاعدة الرموز الخاصة بنا من 500 ميجابايت إلى 100 ميجابايت.





أود أن أقول إن التحسن بنسبة 80٪ ليس سيئًا للغاية ،



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



نتيجة لذلك ، توصلت إلى الاستنتاجات التالية بنفسي:



  • تمنحنا Python إمكانات كبيرة في تحليل الذاكرة. يجب أن أستخدم هذه الميزات في كثير من الأحيان عند تصحيح التعليمات البرمجية.
  • , .
  • , -, « ». . , , , .
  • , (, , Git). , , . , .


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



من الصعب جدًا قول شيء محدد عن الأداء حتى بعد إجراء مثل هذه القياسات.



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



هل سبق لك أن اضطررت إلى تحسين أداء كود Python الخاص بك؟










All Articles