تعظيم سرعة MicroPython¶
يشرح هذا الدرس طرق تحسين أداء كود MicroPython. أما التحسينات التي تتضمن لغات أخرى فتُغطى في مواضع أخرى، وتحديدًا استخدام الوحدات المكتوبة بلغة C ومُجمِّع MicroPython المضمّن (inline assembler).
تتألف عملية تطوير الكود عالي الأداء من المراحل التالية التي ينبغي تنفيذها بالترتيب المذكور.
التصميم من أجل السرعة.
الكتابة وتصحيح الأخطاء.
خطوات التحسين:
تحديد القسم الأبطأ من الكود.
تحسين كفاءة كود Python.
استخدام مُصدِّر الكود الأصلي (native).
استخدام مُصدِّر الكود viper.
استخدام تحسينات خاصة بالعتاد.
التصميم من أجل السرعة¶
ينبغي النظر في مسائل الأداء منذ البداية. ويتضمن ذلك تكوين رؤية حول أقسام الكود الأكثر حساسية من حيث الأداء وإيلاء عناية خاصة لتصميمها. تبدأ عملية التحسين عندما يكون الكود قد اختُبر: فإذا كان التصميم صحيحًا منذ البداية كان التحسين مباشرًا بل وربما غير ضروري أصلًا.
الخوارزميات¶
إن أهم جانب في تصميم أي روتين من أجل الأداء هو ضمان استخدام أفضل خوارزمية. وهذا موضوع للكتب الدراسية أكثر منه لدليل MicroPython، لكن يمكن أحيانًا تحقيق مكاسب مذهلة في الأداء باعتماد خوارزميات معروفة بكفاءتها.
تخصيص ذاكرة RAM¶
لتصميم كود MicroPython بكفاءة من الضروري فهم الطريقة التي يخصص بها المفسّر ذاكرة RAM. عند إنشاء كائن أو ازدياد حجمه (على سبيل المثال عند إلحاق عنصر بقائمة) تُخصَّص ذاكرة RAM اللازمة من كتلة تُعرف باسم الكومة (heap). وهذا يستغرق قدرًا كبيرًا من الوقت؛ كما أنه قد يُطلق أحيانًا عملية تُعرف باسم جمع المهملات (garbage collection) قد تستغرق عدة أجزاء من الثانية (milliseconds).
وبالتالي يمكن تحسين أداء دالة أو طريقة إذا أُنشئ الكائن مرة واحدة فقط ولم يُسمح له بالنمو في الحجم. وهذا يعني أن الكائن يبقى موجودًا طوال مدة استخدامه: فهو عادةً ما يُنشأ في باني الصنف (class constructor) ويُستخدم في طرق مختلفة.
يُغطى هذا بمزيد من التفصيل في التحكم في جمع المهملات أدناه.
المخازن المؤقتة¶
ومن أمثلة ما سبق الحالة الشائعة التي يُطلب فيها مخزن مؤقت، كذلك المستخدم في التواصل مع جهاز. سيُنشئ المشغّل النموذجي المخزن المؤقت في الباني ويستخدمه في طرق الإدخال/الإخراج (I/O) الخاصة به التي ستُستدعى بشكل متكرر.
توفر مكتبات MicroPython عادةً دعمًا للمخازن المؤقتة المخصَّصة مسبقًا. على سبيل المثال، توفر الكائنات التي تدعم واجهة التدفق (stream interface) (مثل ملف أو UART) طريقة read() التي تخصص مخزنًا مؤقتًا جديدًا للبيانات المقروءة، بالإضافة إلى طريقة readinto() لقراءة البيانات إلى مخزن مؤقت موجود.
بعض الأصناف المفيدة لإنشاء كائنات مخزن مؤقت قابلة لإعادة الاستخدام:
الفاصلة العائمة (Floating point)¶
تخصص بعض منافذ MicroPython أعداد الفاصلة العائمة على الكومة. وقد تفتقر بعض المنافذ الأخرى إلى معالج مساعد مخصص للفاصلة العائمة، فتجري العمليات الحسابية عليها "برمجيًا" بسرعة أقل بكثير من سرعتها على الأعداد الصحيحة. وحيثما كان الأداء مهمًا، استخدم عمليات الأعداد الصحيحة واقصر استخدام الفاصلة العائمة على أقسام الكود التي لا يكون الأداء فيها أمرًا بالغ الأهمية. على سبيل المثال، التقط قراءات ADC كقيم صحيحة إلى مصفوفة دفعة واحدة سريعة، وعندها فقط حوّلها إلى أعداد فاصلة عائمة لمعالجة الإشارة.
المصفوفات¶
ضع في اعتبارك استخدام الأنواع المختلفة من أصناف المصفوفات كبديل للقوائم. تدعم الوحدة array أنواع عناصر متنوعة مع دعم العناصر ذات 8 بت عبر صنفي bytes و bytearray المدمجين في Python. وتخزّن جميع هذه البنى البيانية عناصرها في مواضع ذاكرة متجاورة. ومرة أخرى، لتجنب تخصيص الذاكرة في الكود الحرج ينبغي تخصيص هذه العناصر مسبقًا وتمريرها كوسائط أو ككائنات مرتبطة.
Memoryviews¶
عند تمرير شرائح من كائنات مثل نسخ bytearray، ينشئ Python نسخة يتضمن إنشاؤها تخصيصًا بحجم متناسب مع حجم الشريحة. ويمكن التخفيف من ذلك باستخدام كائن memoryview. يُخصَّص كائن memoryview نفسه على الكومة، لكنه كائن صغير ثابت الحجم، بغض النظر عن حجم الشريحة التي يشير إليها. وتقطيع memoryview ينشئ memoryview جديدًا، لذا لا يمكن القيام بذلك في روتين خدمة المقاطعة (interrupt service routine). علاوة على ذلك، فإن صيغة التقطيع a:b تسبب تخصيصًا إضافيًا عبر إنشاء كائن slice(a, b).
ba = bytearray(10000) # big array
func(ba[30:2000]) # a copy is passed, ~2K new allocation
mv = memoryview(ba) # small object is allocated
func(mv[30:2000]) # a pointer to memory is passed
لا يمكن تطبيق memoryview إلا على الكائنات التي تدعم بروتوكول المخزن المؤقت (buffer protocol) - ويشمل ذلك المصفوفات دون القوائم. وثمة تحفظ صغير وهو أنه طالما كان كائن memoryview حيًا فإنه يُبقي أيضًا كائن المخزن المؤقت الأصلي حيًا. لذا فإن memoryview ليس علاجًا شاملًا لكل داء. على سبيل المثال، في المثال أعلاه، إذا كنت قد انتهيت من المخزن المؤقت ذي 10 آلاف بايت ولا تحتاج سوى البايتات 30:2000 منه، فقد يكون من الأفضل عمل شريحة وترك المخزن ذي 10 آلاف بايت يُحرَّر (مستعدًا لجمع المهملات)، بدلًا من إنشاء memoryview طويل العمر وإبقاء 10 آلاف بايت محجوزة عن جمع المهملات (GC).
ومع ذلك، فإن memoryview لا غنى عنه في الإدارة المتقدمة للمخازن المؤقتة المخصَّصة مسبقًا. تضع طريقة readinto() التي نُوقشت أعلاه البيانات في بداية المخزن المؤقت وتملأ المخزن بأكمله. فماذا لو احتجت إلى وضع البيانات في منتصف مخزن مؤقت موجود؟ ما عليك سوى إنشاء memoryview إلى القسم المطلوب من المخزن المؤقت وتمريره إلى readinto().
السلاسل النصية مقابل البايتات (Strings vs Bytes)¶
يستخدم MicroPython تجميع السلاسل النصية (string interning) لتوفير المساحة عند وجود سلاسل نصية متطابقة متعددة. في كل مرة تُخصَّص فيها سلسلة نصية جديدة أثناء التشغيل (على سبيل المثال، عند دمج سلسلتين أخريين)، يتحقق MicroPython مما إذا كان يمكن تجميع السلسلة الجديدة لتوفير ذاكرة RAM.
إذا كان لديك كود يؤدي عمليات على السلاسل النصية حساسة للأداء فضع في اعتبارك استخدام كائنات bytes والقيم الحرفية (أي b"abc"). فهذا يتجاوز فحص التجميع، وقد يكون أسرع عدة مرات من أداء العمليات نفسها مع كائنات السلاسل النصية.
ملاحظة
يتحقق أسرع أداء دائمًا بتجنب إنشاء كائنات جديدة بالكامل، على سبيل المثال باستخدام مخزن مؤقت قابل لإعادة الاستخدام كما هو موضح أعلاه.
تحديد القسم الأبطأ من الكود¶
هذه عملية تُعرف باسم التنميط (profiling) وتُغطى في الكتب الدراسية ومدعومة (بالنسبة إلى Python القياسي) بأدوات برمجية متنوعة. أما بالنسبة لنوع التطبيقات المضمَّنة الأصغر التي يُرجَّح تشغيلها على منصات MicroPython فيمكن عادةً تحديد الدالة أو الطريقة الأبطأ بالاستخدام الحصيف لمجموعة دوال التوقيت ticks الموثقة في time. ويمكن قياس زمن تنفيذ الكود بالميلي ثانية أو الميكرو ثانية أو دورات وحدة المعالجة المركزية (CPU).
يتيح ما يلي توقيت أي دالة أو طريقة بإضافة مُزخرِف @timed_function:
def timed_function(f, *args, **kwargs):
myname = str(f).split(' ')[1]
def new_func(*args, **kwargs):
t = time.ticks_us()
result = f(*args, **kwargs)
delta = time.ticks_diff(time.ticks_us(), t)
print('Function {} Time = {:6.3f}ms'.format(myname, delta/1000))
return result
return new_func
تحسينات كود MicroPython¶
تصريح const()¶
يوفر MicroPython تصريح const(). ويعمل هذا بطريقة مشابهة لـ #define في لغة C، إذ عند تجميع الكود إلى bytecode يستبدل المُجمِّع القيمة العددية بالمعرّف. وهذا يتجنب البحث في القاموس أثناء التشغيل. ويمكن أن تكون الوسيطة الممرَّرة إلى const() أي شيء يُقيَّم وقت التجميع إلى عدد صحيح مثل 0x100 أو 1 << 8.
تخزين مراجع الكائنات مؤقتًا (Caching)¶
حيثما تصل دالة أو طريقة إلى كائنات بشكل متكرر يتحسن الأداء بتخزين الكائن مؤقتًا في متغير محلي:
class foo(object):
def __init__(self):
self.ba = bytearray(100)
def bar(self, obj_display):
ba_ref = self.ba
fb = obj_display.framebuffer
# iterative code using these two objects
وهذا يتجنب الحاجة إلى البحث المتكرر عن self.ba و obj_display.framebuffer في جسم الطريقة bar().
التحكم في جمع المهملات¶
عندما يُطلب تخصيص ذاكرة، يحاول MicroPython تحديد موقع كتلة ذات حجم ملائم على الكومة. وقد يفشل ذلك، عادةً لأن الكومة مزدحمة بكائنات لم يعد الكود يشير إليها. وفي حال حدوث فشل، تستعيد العملية المعروفة باسم جمع المهملات الذاكرة التي تستخدمها هذه الكائنات الزائدة ثم يُعاد محاولة التخصيص - وهي عملية قد تستغرق عدة أجزاء من الثانية.
قد تكون هناك فوائد في استباق ذلك عبر إصدار gc.collect() بشكل دوري. أولًا، إجراء جمع للمهملات قبل أن يصبح مطلوبًا فعليًا أسرع - عادةً في حدود 1 ميلي ثانية إذا جرى بشكل متكرر. ثانيًا، يمكنك تحديد النقطة في الكود التي يُستهلك فيها هذا الوقت بدلًا من حدوث تأخير أطول في نقاط عشوائية، ربما في قسم حرج من حيث السرعة. وأخيرًا، إجراء عمليات الجمع بانتظام يمكن أن يقلل التجزئة في الكومة. والتجزئة الشديدة قد تؤدي إلى حالات فشل تخصيص غير قابلة للاسترداد.
مُصدِّر الكود الأصلي (Native)¶
يدفع هذا مُجمِّع MicroPython إلى إصدار أكواد تشغيل (opcodes) أصلية لوحدة المعالجة المركزية بدلًا من bytecode. وهو يغطي الجزء الأكبر من وظائف MicroPython، لذا لن تحتاج معظم الدوال إلى أي تكييف (لكن انظر أدناه). ويُستدعى عن طريق مُزخرِف دالة:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
هناك قيود معينة في التنفيذ الحالي لمُصدِّر الكود الأصلي.
إذا استُخدم
raiseفيجب توفير وسيطة.لا يعمل المُجدوِل الخلفي (انظر
micropython.schedule) أثناء تنفيذ الكود الأصلي.على الأهداف التي تدعم تعدد الخيوط وقفل المفسّر العام (GIL)، لا يُحرَّر الـ GIL أثناء تنفيذ الكود الأصلي.
للتخفيف من النقطتين الأخيرتين، ينبغي للدوال الأصلية طويلة التشغيل استدعاء time.sleep(0) بشكل دوري، مما سيشغّل المُجدوِل ويعيد تنشيط الـ GIL.
والمقابل لتحسين الأداء (أسرع تقريبًا بمرتين من bytecode) هو زيادة في حجم الكود المُجمَّع.
مُصدِّر الكود Viper¶
تتضمن التحسينات التي نُوقشت أعلاه كود Python متوافقًا مع المعايير. أما مُصدِّر الكود Viper فهو غير متوافق تمامًا. فهو يدعم أنواع بيانات Viper الأصلية الخاصة سعيًا وراء الأداء. ومعالجة الأعداد الصحيحة غير متوافقة لأنها تستخدم كلمات الآلة: فالحساب على العتاد ذي 32 بت يُجرى بنظام الباقي على 2**32.
كما هو الحال مع المُصدِّر الأصلي، ينتج Viper تعليمات آلة لكن تُجرى تحسينات إضافية، مما يزيد الأداء بشكل كبير خاصةً للحساب على الأعداد الصحيحة وعمليات معالجة البتات. ويُستدعى باستخدام مُزخرِف:
@micropython.viper
def foo(self, arg: int) -> int:
# code
كما يوضح المقتطف أعلاه، من المفيد استخدام تلميحات أنواع Python لمساعدة مُحسِّن Viper. توفر تلميحات الأنواع معلومات عن أنواع بيانات الوسائط وعن القيمة المُرجعة؛ وهذه ميزة لغوية قياسية في Python مُعرَّفة رسميًا هنا PEP0484. ويدعم Viper مجموعته الخاصة من الأنواع وهي int و uint (عدد صحيح غير موقّع) و ptr و ptr8 و ptr16 و ptr32. وتُناقش أنواع ptrX أدناه. حاليًا يخدم نوع uint غرضًا واحدًا: كتلميح نوع لقيمة دالة مُرجعة. فإذا أرجعت مثل هذه الدالة 0xffffffff فسيفسّر Python النتيجة على أنها 2**32 -1 بدلًا من -1.
بالإضافة إلى القيود التي يفرضها المُصدِّر الأصلي، تنطبق القيود التالية:
قيم الوسائط الافتراضية غير مسموح بها.
يمكن استخدام الفاصلة العائمة لكنها غير مُحسَّنة.
يوفر Viper أنواع المؤشرات لمساعدة المُحسِّن. وتتألف من
ptrمؤشر إلى كائن.ptr8يشير إلى بايت.ptr16يشير إلى نصف كلمة بحجم 16 بت.ptr32يشير إلى كلمة آلة بحجم 32 بت.
قد يكون مفهوم المؤشر غير مألوف لمبرمجي Python. وله أوجه تشابه مع كائن memoryview في Python من حيث أنه يوفر وصولًا مباشرًا إلى البيانات المخزَّنة في الذاكرة. ويُوصَل إلى العناصر باستخدام صيغة الفهرسة بالأقواس المعقوفة، لكن الشرائح غير مدعومة: فالمؤشر يمكنه إرجاع عنصر واحد فقط. والغرض منه هو توفير وصول عشوائي سريع إلى البيانات المخزَّنة في مواضع ذاكرة متجاورة - كذلك البيانات المخزَّنة في الكائنات التي تدعم بروتوكول المخزن المؤقت، وسجلات الأطراف الطرفية المُربطة بالذاكرة في متحكم دقيق. وتجدر الإشارة إلى أن البرمجة باستخدام المؤشرات محفوفة بالمخاطر: إذ لا يُجرى فحص للحدود ولا يفعل المُجمِّع شيئًا لمنع أخطاء تجاوز المخزن المؤقت.
الاستخدام النموذجي هو تخزين المتغيرات مؤقتًا:
@micropython.viper
def foo(self, arg: int) -> int:
buf = ptr8(self.linebuf) # self.linebuf is a bytearray or bytes object
for x in range(20, 30):
bar = buf[x] # Access a data item through the pointer
# code omitted
في هذه الحالة "يعرف" المُجمِّع أن buf هو عنوان مصفوفة من البايتات؛ ويمكنه إصدار كود لحساب عنوان buf[x] بسرعة أثناء التشغيل. وحيثما تُستخدم عمليات التحويل (casts) لتحويل الكائنات إلى أنواع Viper الأصلية ينبغي إجراؤها في بداية الدالة بدلًا من حلقات التوقيت الحرجة لأن عملية التحويل قد تستغرق عدة ميكرو ثوانٍ. وقواعد التحويل هي كما يلي:
معاملات التحويل (Casting) حاليًا هي:
intوboolوuintوptrوptr8وptr16وptr32.ستكون نتيجة التحويل متغير Viper أصلي.
يمكن أن تكون وسائط التحويل كائن Python أو متغير Viper أصلي.
إذا كانت الوسيطة متغير Viper أصلي، فإن التحويل عملية لا تفعل شيئًا (أي لا تكلف شيئًا أثناء التشغيل) وتغير النوع فقط (مثلًا من
uintإلىptr8) بحيث يمكنك بعدها التخزين/التحميل باستخدام هذا المؤشر.إذا كانت الوسيطة كائن Python والتحويل
intأوuint، فيجب أن يكون كائن Python من نوع صحيح وتُرجَع قيمة ذلك الكائن الصحيح.يجب أن تكون وسيطة التحويل bool من نوع صحيح (منطقي أو عدد صحيح)؛ وعند استخدامه كنوع مُرجَع ستُرجِع دالة viper كائني True أو False.
إذا كانت الوسيطة كائن Python والتحويل
ptrأوptr8أوptr16أوptr32، فيجب إما أن يكون كائن Python داعمًا لبروتوكول المخزن المؤقت (وفي هذه الحالة يُرجَع مؤشر إلى بداية المخزن المؤقت) أو أن يكون من نوع صحيح (وفي هذه الحالة تُرجَع قيمة ذلك الكائن الصحيح).
الكتابة إلى مؤشر يشير إلى كائن للقراءة فقط ستؤدي إلى سلوك غير معرَّف.
ملاحظة
أمثلة الكود أدناه مُقدَّمة لكاميرات OpenMV Cam المبنية على STM32، التي توفر الوحدة stm. وتنطبق التقنيات الموضحة بشكل عام.
تكشف الوحدة stm عناوين الذاكرة لسجلات الأطراف الطرفية للمتحكم الدقيق (MCU). يحتوي كل منفذ GPIO على سجل بيانات الخرج (ODR) تتطابق بتاته واحدًا لواحد مع دبابيس ذلك المنفذ: فالكتابة إلى السجل تقود تلك الدبابيس مباشرة، دون عبء استدعاء طريقة machine.Pin، وإجراء عملية XOR على بت يقلب حالة دبوسه. على كاميرا OpenMV Cam الأصلية يكون LED الأزرق موصولًا بالدبوس 2 من GPIOC، لذا يستخدم المثال التالي تحويل ptr16 لقلب حالة LED الأزرق n مرة:
BIT2 = const(1 << 2)
@micropython.viper
def toggle_n(n: int):
odr = ptr16(stm.GPIOC + stm.GPIO_ODR)
for _ in range(n):
odr[0] ^= BIT2
يمكن العثور على وصف تقني مفصل لمُصدِّرات الكود الثلاثة على Kickstarter هنا الملاحظة 1 وهنا الملاحظة 2
الوصول إلى العتاد مباشرة¶
يندرج هذا في فئة البرمجة الأكثر تقدمًا ويتطلب بعض المعرفة بالمتحكم الدقيق المستهدف. لننظر في مثال قلب حالة دبوس خرج على كاميرا OpenMV Cam. الأسلوب القياسي سيكون الكتابة
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
يتضمن هذا عبء استدعاءين لطريقة value() الخاصة بنسخة Pin. ويمكن التخلص من هذا العبء بإجراء قراءة/كتابة على البت المناسب من سجل بيانات الخرج (ODR) لمنفذ GPIO الخاص بالشريحة. ولتسهيل ذلك توفر الوحدة stm مجموعة من الثوابت تعطي عناوين السجلات المناسبة (stm.GPIOC هو العنوان الأساسي لمنفذ GPIOC، و stm.GPIO_ODR إزاحة سجل بيانات الخرج الخاص به). كما سبق، فإن LED الأزرق على كاميرا OpenMV Cam الأصلية هو الدبوس 2 من GPIOC، لذا يمكن إجراء قلب سريع لحالته كما يلي:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2