اذهب إلى المحتوى

قياس أداء وسرعة تنفيذ شيفرة بايثون


Naser Dakhel

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

سنتعلم في هذا الفصل -إضافةً إلى قياس سرعة البرنامج- إلى كيفية قياس الزيادات النظرية theoretical increases في وقت التنفيذ runtime مع نمو حجم البيانات الخاصة ببرنامجك. يُطلق على ذلك في علوم الحاسوب ترميز O الكبير big O notation.

وحدة timeit

تُعد مقولة "التحسين السابق لأوانه هو أصل كل شر Premature optimization is the root of all evil" مقولةً شائعةً في تطوير البرمجيات، والتي تُنسب إلى عالم الحاسوب دونالد نوث Donald Knuth، الذي ينسبها بدوره إلى طوني هوري Tony Hoare. وهو بدوره ينسبها إلى دونالد نوث Donald Knuth.

تظهر أهمية التحسين السابق لأوانه Premature optimization أو التحسين قبل معرفة ما يجب تحسينه، عندما يستخدم المبرمجون خدعًا ذكيةً لتوفير الذاكرة وكتابة الشيفرة بصورةٍ أسرع. مثال عن هذه الخدع هي استخدام خوارزمية XOR للتبديل بين عددين صحيحين دون استخدام عدد ثالث مثل متغير مؤقت.

>>> a, b = 42, 101  # ضبط المتغيرَين
>>> print(a, b)
42 101
>>> # ‫ستبدّل سلسلة من عمليات XOR قيمتَي المتغيرَين
>>> a = a ^ b
>>> b = a ^ b
>>> a = a ^ b
>>> print(a, b)  # بُدّلت القيم الآن
101 42

تبدو هذه الشيفرة مبهمة إذا لم تكن خوارزمية XOR مألوفة لديك (التي تستخدم المعامل الثنائي ^). المشكلة في استخدام خدع برمجية ذكية أنها تُنتج شيفرةً معقدةً وغير مقروءة، وذكرنا سابقًا أنّ أحد نقاط بايثون المهمة هي قابلية القراءة. في حالات أسوأ، يمكن ألا تكون الخدع الذكية ذكيةً إطلاقًا، إذ لا يمكن افتراض أن هذه الخدع أسرع أو أن الشيفرة التي تستبدلها هي بالأساس بطيئة. الطريقة الوحيدة لمعرفة ذلك هي قياس ومقارنة وقت التنفيذ، الذي هو الوقت الذي يستغرقه البرنامج لتنفيذ البرنامج أو قطعة من الشيفرة البرمجية. يجب أخذ العلم أن زيادة وقت التنفيذ يعني أن البرنامج يتباطأ؛ أي يستغرق وقتًا أطول لتنفيذ نفس كمية العمل (نستخدم أيضًا مصطلح "وقت التنفيذ" ليعني الوقت الذي يكون به البرنامج عاملًا. عندما نقول أن الخطأ قد حصل وقت التنفيذ، يعني أن الخطأ حصل عندما كان البرنامج يعمل وليس عندما كان يُصرّف إلى شيفرة ثنائية bytecode.

يمكن لوحدة timeit الخاصة بالمكتبة القياسية لبايثون قياس سرعة وقت التنفيذ لأجزاء صغيرة من الشيفرة عن طريق تنفيذ الشيفرة آلاف أو ملايين المرات والسماح لك بتحديد وقت التنفيذ الوسطي. تعطِّل أيضًا وحدة timeit كانس المهملات garbage collector التلقائي للحصول على أوقات تنفيذ ثابتة. يمكنك تمرير سلسلة نصية متعددة الأسطر أو فصل أسطر الشيفرة باستخدام الفاصلة المنقوطة إذا أردت اختبار عدة أسطر:

>>> import timeit
>>> timeit.timeit('a, b = 42, 101; a = a ^ b; b = a ^ b; a = a ^ b')
0.1307766629999998
>>> timeit.timeit("""a, b = 42, 101
... a = a ^ b
... b = a ^ b
... a = a ^ b""")
0.13515726800000039

تستغرق خوارزمية XOR على جهاز الحاسوب الخاص بي حوالي عشر الثانية لتنفيذ الشيفرة، هل هذا سريع؟ لنقارنها مع شيفرة تبديل الأعداد الصحيحة التي تستخدم متغير ثالث.

>>> import timeit
>>> timeit.timeit('a, b = 42, 101; temp = a; a = b; b = temp')
0.027540389999998638

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

المفاجأة الأفضل هي عند التبديل بين متغيرين باستخدام خدعة الإسناد المتعدد multiple assignment أو التفريغ المكرّر iterable unpacking التي تُنفذ أيضًا في وقت قصير:

>>> timeit.timeit('a, b = 42, 101; a, b = b, a')
0.024489236000007963

ليس هذه الشيفرة هي الأسهل للقراءة فقط، لكنها الأسرع. عرفنا ذلك ليس لأننا افترضنا ولكن لأننا قسنا ذلك بموضوعية.

يمكن أن تأخذ دالة timeit.timeit()‎ وسيط سلسلة نصية ثانٍ من شيفرة SETUP. تُنفَّذ شيفرة الإعداد هذه مرةً واحدةً قبل تنفيذ أول سلسلة نصية من الشيفرة. يمكن تغيير عدد المحاولات بتمرير عدد صحيح لوسيط الكلمة المفتاحية number. يختبر المثال التالي سرعة وحدة random الخاصة ببايثون لإنشاء عشرة ملايين رقم عشوائي من 1 إلى 100، وذد استغرق ذلك حوالي 10 ثوان على جهاز حاسوب ما.

>>> timeit.timeit('random.randint(1, 100)', 'import random', number=10000000)
10.020913950999784

قياسيًا، لا تستطيع الشيفرة في السلسلة النصية المُمررة إلى timeit.timeit()‎ الوصول إلى المتغيرات والدوال في باقي البرنامج:

>>> import timeit
>>> spam = 'hello'  #‫نعرّف المتغير spam
>>> timeit.timeit('print(spam)', number=1)  # نقيس الوقت المستغرق لطباعة المتغير‫ spam
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\Users\Al\AppData\Local\Programs\Python\Python37\lib\timeit.py", line 232, in timeit
    return Timer(stmt, setup, timer, globals).timeit(number)
  File "C:\Users\Al\AppData\Local\Programs\Python\Python37\lib\timeit.py", line 176, in timeit
    timing = self.inner(it, self.timer)
  File "<timeit-src>", line 6, in inner
NameError: name 'spam' is not defined

لإصلاح ذلك، مرّر الدالة والقيمة المُعادة للدالة globals()‎ إلى وسيط الكلمة المفتاحية globals:

>>> timeit.timeit('print(spam)', number=1, globals=globals())
hello
0.000994909999462834

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

فحص الأداء بواسطة cProfile

على الرغم من أن وحدة timeit مفيدة لقياس أجزاء صغيرة من الشيفرة، إلا أن وحدة cProfile مفيدةٌ أكثر في تحليل دوال أو برامج كاملة. يحلًل فحص الأداء Profiling سرعة واستخدام الذاكرة وبعض النواحي الأخرى للبرنامج الخاص بك. تُعد وحدة cProfile هي فاحص الأداء profiler الخاص ببايثون أو البرنامج الذي يستطيع قياس وقت تنفيذ البرنامج، إضافةً لإنشاء توصيف لأوقات تنفيذ استدعاءات دوال البرنامج كلٌّ على حِدى. تقدم هذه المعلومات قياسات أكثر دقة للشيفرة الخاصة بك.

يمرر محلًل cProfile سلسلةً نصيةً من الشيفرة التي تريد قياسها إلى cProfile.run()‎. لنتابع كيف يقيس cProfiler ويعطي تقريرًا عن تنفيذ دالة قصيرة تجمع كل الأرقام من 1 إلى 1,000,000:

import time, cProfile
def addUpNumbers():
    total = 0
    for i in range(1, 1000001):
        total += i

cProfile.run('addUpNumbers()')

يكون الخرج عند تنفيذ البرنامج على النحو التالي:

        4 function calls in 0.064 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.064    0.064 <string>:1(<module>)
        1    0.064    0.064    0.064    0.064 test1.py:2(addUpNumbers)
        1    0.000    0.000    0.064    0.064 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

يمثل كل سطر دالةً مختلفةً والوقت المستغرق في تلك الدالة. تكون الأعمدة في خرج cProfile.run()‎ على النحو التالي:

  • ncalls: عدد استدعاءات الدالة.
  • tottime: الوقت الكلي المستغرق في الدالة ما عدا الوقت في الدوال الفرعية.
  • percall: الوقت الكلي مقسومًا على عدد الاستدعاءات.
  • cumtime: الوقت التراكمي المستغرق في الدالة ولك الدوال الفرعية.
  • percall: الوقت التراكمي مقسومًا على عدد الاستدعاءات.
  • filename:lineno(function)‎: الملف الذي فيه الدالة وفي أي رقم سطر.

مثال: نزّل الملفين "rsaCipher.py" و "al_sweigart_pubkey.txt" من الموقع. أدخل ما يلي على الصدفة التفاعلية لتحليل دالة encryptAndWriteToFile()‎ أثناء تشفير رسالة مكونة من 300,000 محرفًا ومُنشأة باستخدام التعبير ‎'abc' * 100000:

>>> import cProfile, rsaCipher
>>> cProfile.run("rsaCipher.encryptAndWriteToFile('encrypted_file.txt', 'al_sweigart_pubkey.txt', 'abc'*100000)")
         11749 function calls in 28.900 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.001    0.001   28.900   28.900 <string>:1(<module>)
        2    0.000    0.000    0.000    0.000 _bootlocale.py:11(getpreferredencoding)
--snip--
        1    0.017    0.017   28.900   28.900 rsaCipher.py:104(encryptAndWriteToFile)
        1    0.248    0.248    0.249    0.249 rsaCipher.py:36(getBlocksFromText)
        1    0.006    0.006   28.873   28.873 rsaCipher.py:70(encryptMessage)
        1    0.000    0.000    0.000    0.000 rsaCipher.py:94(readKeyFile)
--snip--
     2347    0.000    0.000    0.000    0.000 {built-in method builtins.len}
     2344    0.000    0.000    0.000    0.000 {built-in method builtins.min}
     2344   28.617    0.012   28.617    0.012 {built-in method builtins.pow}
        2    0.001    0.000    0.001    0.000 {built-in method io.open}
     4688    0.001    0.000    0.001    0.000 {method 'append' of 'list' objects}
--snip--

يمكنك ملاحظة أن الشيفرة التي مررناها إلى cProfile.run()‎ استغرقت 28.9 ثانية لتنتهي. انتبه إلى الدوال بأطول الأوقات الكلية، وفي حالتنا هي الدالة pow()‎ التي تستغرق 28.617 ثانية، وهذا تقريبًا هو كل وقت تنفيذ الشيفرة. لا يمكن تعديل هذه الشيفرة (هي جزء من بايثون) ولكن ربما نستطيع الاعتماد بصورةٍ أقل عليها، وهذا غير ممكن في هذه الحالة، لأن برنامج rsaCipher.py مُحسن جيدًا، إلا أن تحليل هذه الشيفرة أعطانا نظرةً إلى أن عنق الزجاجة الأساسي هو pow()‎ لذا لا يوجد فائدة من محاولة تحسين الدالة readKeyFile()‎ التي لا تستغرق وقت تنفيذ أبدًا حتى أن cProfile أعطانا وقت لتنفيذها يبلغ 0.

هذه الفكرة موجودة في قانون أمدال Amdahl's Law وهي معادلة تحسب كيف يُسرّع البرنامج إذا حسّننا أحد أجزائه، فوفقًا لمعادلة أمدال يكون تسريع المهمة الكلي مساويًا إلى:

 1‎ / ((1 – p) + (p / s))‎

إذ يمثّل s التسريع الحاصل لأحد الأجزاء، و p هو نسبة ذلك الجزء من كل البرنامج، أي إذا ضاعفنا سرعة أحد الأجزاء الذي يشكل 90% من وقت تنفيذ البرنامج سنحصل على تسريع بنسبة 82% لكل البرنامج:

 ‎1 / ((1 – 0.9) + (0.9 / 2)) = 1.818 

وهذا أفضل من تسريع جزء بمقدار ثلاث أضعاف، ولكنه يشكّل 25% من وقت التنفيذ الكلي، الذي يعطي نسبة 14% تسريع كلي:

 1‎ / ((1 – 0.25) + (0.25 / 2)) = 1.143 

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

الخلاصة

تأتي مكتبة بايثون القياسية مع وحدتين للتحليل timeit و cProfiler. تفيد الدالة time.timeit()‎ في تنفيذ قطع صغيرة من الشيفرة للمقارنة بين سرعة كل قطعة منها. تقدم دالة cProfile.run()‎ تقريرًا مفصلًا للتوابع الأكبر وتدل على وجود عنق زجاجة.

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

ترجمة -وبتصرف- لقسم من الفصل Measuring Performance And Big O Algorithm Analysis من كتاب Beyond the Basic Stuff with Python.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...