מיצוי המהירות המרבית של MicroPython

מדריך זה מתאר דרכים לשיפור הביצועים של קוד MicroPython. אופטימיזציות הכרוכות בשפות אחרות מכוסות במקום אחר, ובפרט השימוש במודולים הכתובים ב-C והassembler המוטמע (inline) של MicroPython.

תהליך פיתוח הקוד בעל הביצועים הגבוהים כולל את השלבים הבאים, שיש לבצעם בסדר המפורט.

  • תכנון לשם מהירות.

  • כתיבה וניפוי שגיאות בקוד.

שלבי אופטימיזציה:

  • זיהוי המקטע האיטי ביותר בקוד.

  • שיפור היעילות של קוד ה-Python.

  • שימוש ב-native code emitter.

  • שימוש ב-viper code emitter.

  • שימוש באופטימיזציות ייעודיות לחומרה.

תכנון לשם מהירות

יש לשקול סוגיות ביצועים כבר בשלב הראשון. הדבר כרוך בגיבוש תמונה לגבי מקטעי הקוד הקריטיים ביותר מבחינת ביצועים והקדשת תשומת לב מיוחדת לתכנונם. תהליך האופטימיזציה מתחיל לאחר שהקוד נבדק: אם התכנון נכון מההתחלה, האופטימיזציה תהיה פשוטה ועשויה אף להתברר כמיותרת.

אלגוריתמים

ההיבט החשוב ביותר בתכנון של כל שגרה לשם ביצועים הוא הבטחה שנעשה שימוש באלגוריתם הטוב ביותר. זהו נושא לספרי לימוד ולא למדריך של MicroPython, אך לעיתים ניתן להשיג שיפורי ביצועים מרשימים על ידי אימוץ אלגוריתמים הידועים ביעילותם.

הקצאת RAM

כדי לתכנן קוד MicroPython יעיל, יש להבין את האופן שבו המפרש מקצה זיכרון RAM. כאשר אובייקט נוצר או גדל בגודלו (לדוגמה כאשר פריט מתווסף לרשימה), ה-RAM הנדרש מוקצה מבלוק הידוע בשם heap. פעולה זו אורכת זמן ניכר; יתרה מכך, היא תפעיל לעיתים תהליך הידוע בשם איסוף אשפה (garbage collection) שעשוי לארוך מספר אלפיות שנייה.

כתוצאה מכך, ניתן לשפר את הביצועים של פונקציה או מתודה אם אובייקט נוצר רק פעם אחת ואינו מורשה לגדול בגודלו. הדבר משתמע שהאובייקט נשמר לאורך כל משך השימוש בו: בדרך כלל הוא ייווצר בבנאי (constructor) של מחלקה ויהיה בשימוש במתודות שונות.

נושא זה מכוסה בפירוט רב יותר בקטע בקרת איסוף האשפה להלן.

חוצצים

דוגמה למצב המתואר לעיל היא המקרה הנפוץ שבו נדרש חוצץ (buffer), כגון כזה המשמש לתקשורת עם התקן. דרייבר טיפוסי ייצור את החוצץ בבנאי וישתמש בו במתודות הקלט/פלט שלו, שייקראו שוב ושוב.

ספריות MicroPython בדרך כלל מספקות תמיכה בחוצצים שהוקצו מראש. לדוגמה, אובייקטים התומכים בממשק stream (כגון קובץ או UART) מספקים מתודה read() המקצה חוצץ חדש לנתונים הנקראים, אך גם מתודה readinto() הקוראת נתונים אל תוך חוצץ קיים.

כמה מחלקות שימושיות ליצירת אובייקטי חוצץ שניתן לעשות בהם שימוש חוזר:

נקודה צפה (floating point)

חלק מהפורטים של MicroPython מקצים מספרים בנקודה צפה ב-heap. פורטים אחרים עשויים להיות חסרי מעבד-עזר ייעודי לנקודה צפה, ומבצעים עליהם פעולות אריתמטיות ב“תוכנה“ במהירות נמוכה משמעותית בהשוואה למספרים שלמים. כאשר הביצועים חשובים, השתמשו בפעולות על מספרים שלמים והגבילו את השימוש בנקודה צפה למקטעי קוד שבהם הביצועים אינם בעלי חשיבות עליונה. לדוגמה, קלטו קריאות ADC כערכים שלמים אל מערך בפעולה מהירה אחת, ורק לאחר מכן המירו אותם למספרים בנקודה צפה לצורך עיבוד אות.

מערכים

שקלו את השימוש בסוגים השונים של מחלקות מערך כחלופה לרשימות. המודול array תומך בסוגי אלמנטים שונים, כאשר אלמנטים בני 8 ביט נתמכים על ידי המחלקות המובנות bytes ו-bytearray של Python. מבני נתונים אלה כולם מאחסנים אלמנטים במיקומי זיכרון רציפים. שוב, כדי להימנע מהקצאת זיכרון בקוד קריטי, יש להקצותם מראש ולהעבירם כארגומנטים או כאובייקטים מקושרים.

Memoryviews

בעת העברת חתכים (slices) של אובייקטים כגון מופעים של bytearray, Python יוצר עותק הכרוך בהקצאה בגודל פרופורציונלי לגודל החתך. ניתן להקל על כך באמצעות אובייקט memoryview. ה-memoryview עצמו מוקצה ב-heap, אך הוא אובייקט קטן בעל גודל קבוע, ללא קשר לגודל החתך שאליו הוא מצביע. חיתוך של memoryview יוצר memoryview חדש, ולכן לא ניתן לבצע זאת בשגרת שירות פסיקה. יתרה מכך, תחביר החיתוך 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 רק על אובייקטים התומכים בפרוטוקול החוצץ - אלה כוללים מערכים אך לא רשימות. הסתייגות קטנה היא שכל עוד אובייקט memoryview חי, הוא גם משאיר חי את אובייקט החוצץ המקורי. לכן, memoryview אינו פתרון פלא אוניברסלי. לדוגמה, בדוגמה לעיל, אם סיימתם עם חוצץ בגודל 10K ואתם זקוקים רק לבייטים 30:2000 ממנו, ייתכן שעדיף ליצור חתך ולתת לחוצץ ה-10K ללכת (להיות מוכן לאיסוף אשפה), במקום ליצור memoryview ארוך-חיים ולשמור על 10K חסומים מפני GC.

עם זאת, memoryview הוא חיוני לניהול מתקדם של חוצצים שהוקצו מראש. המתודה readinto() שנדונה לעיל מציבה נתונים בתחילת החוצץ וממלאת את החוצץ כולו. מה אם אתם צריכים להציב נתונים באמצע חוצץ קיים? פשוט צרו memoryview אל המקטע הנדרש בחוצץ והעבירו אותו ל-readinto().

מחרוזות מול בייטים

MicroPython משתמש באיגום מחרוזות (string interning) כדי לחסוך מקום כאשר ישנן מספר מחרוזות זהות. בכל פעם שמחרוזת חדשה מוקצית בזמן ריצה (לדוגמה, כאשר שתי מחרוזות אחרות משורשרות), MicroPython בודק האם ניתן לאגום את המחרוזת החדשה כדי לחסוך RAM.

אם יש לכם קוד המבצע פעולות מחרוזת קריטיות מבחינת ביצועים, שקלו להשתמש באובייקטים מסוג bytes ובליטרלים (כלומר b"abc"). הדבר מדלג על בדיקת האיגום, ויכול להיות מהיר פי כמה מביצוע אותן פעולות עם אובייקטי מחרוזת.

הערה

הביצועים המהירים ביותר יושגו תמיד על ידי הימנעות מוחלטת מיצירת אובייקט חדש, לדוגמה באמצעות חוצץ הניתן לשימוש חוזר כמתואר לעיל.

זיהוי המקטע האיטי ביותר בקוד

זהו תהליך הידוע בשם פרופיילינג (profiling) המכוסה בספרי לימוד ו(עבור Python סטנדרטי) נתמך על ידי כלי תוכנה שונים. עבור סוג היישומים המוטמעים הקטנים יותר שצפויים לרוץ על פלטפורמות MicroPython, ניתן בדרך כלל לזהות את הפונקציה או המתודה האיטית ביותר על ידי שימוש מושכל בקבוצת פונקציות התזמון ticks המתועדות ב-time. ניתן למדוד את זמן ביצוע הקוד באלפיות שנייה, מיליוניות שנייה, או מחזורי CPU.

הדוגמה הבאה מאפשרת לתזמן כל פונקציה או מתודה על ידי הוספת מעטר (decorator) @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.

שמירת הפניות לאובייקטים במטמון

כאשר פונקציה או מתודה ניגשת שוב ושוב לאובייקטים, ניתן לשפר את הביצועים על ידי שמירת האובייקט במטמון במשתנה מקומי:

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 מנסה לאתר בלוק בגודל מתאים ב-heap. ניסיון זה עשוי להיכשל, בדרך כלל מכיוון שה-heap עמוס באובייקטים שכבר אינם מופנים על ידי הקוד. במקרה של כישלון, התהליך הידוע בשם איסוף אשפה משחזר את הזיכרון שנעשה בו שימוש על ידי האובייקטים המיותרים הללו, ואז ההקצאה מנוסה שוב - תהליך שעשוי לארוך מספר אלפיות שנייה.

ייתכנו יתרונות בהקדמת תהליך זה על ידי הפעלת gc.collect() מעת לעת. ראשית, ביצוע איסוף לפני שהוא נדרש בפועל מהיר יותר - בדרך כלל בסדר גודל של 1ms אם נעשה בתדירות גבוהה. שנית, אתם יכולים לקבוע את הנקודה בקוד שבה משך זמן זה מנוצל, במקום שהשהיה ארוכה יותר תתרחש בנקודות אקראיות, אולי במקטע קריטי מבחינת מהירות. לבסוף, ביצוע איסופים באופן סדיר יכול להפחית פיצול ב-heap. פיצול חמור יכול להוביל לכשלי הקצאה בלתי ניתנים לשחזור.

ה-Native code emitter

זה גורם לקומפיילר של MicroPython לפלוט אופקודים (opcodes) מקוריים של ה-CPU במקום bytecode. הוא מכסה את עיקר הפונקציונליות של MicroPython, כך שרוב הפונקציות לא יצרכו התאמה (אך ראו להלן). הוא מופעל באמצעות מעטר פונקציה:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # Cached object
    # code

ישנן מגבלות מסוימות במימוש הנוכחי של ה-native code emitter.

  • אם נעשה שימוש ב-raise יש לספק ארגומנט.

  • המתזמן ברקע (ראו micropython.schedule) אינו פועל במהלך ביצוע קוד מקורי (native).

  • ביעדים בעלי threading ו-GIL, ה-GIL אינו משוחרר במהלך ביצוע קוד מקורי.

כדי להקל על שתי הנקודות האחרונות, פונקציות מקוריות ארוכות-ריצה צריכות לקרוא ל-time.sleep(0) מעת לעת, מה שיריץ את המתזמן וישחרר זמנית את ה-GIL.

התמורה לשיפור הביצועים (מהיר בערך פי שניים מ-bytecode) היא גידול בגודל הקוד המהודר.

ה-Viper code emitter

האופטימיזציות שנדונו לעיל כרוכות בקוד Python התואם לתקנים. ה-Viper code emitter אינו תואם במלואו. הוא תומך בסוגי נתונים מקוריים מיוחדים של Viper בשאיפה לביצועים. עיבוד מספרים שלמים אינו תואם מכיוון שהוא משתמש במילות מכונה: אריתמטיקה על חומרה של 32 ביט מבוצעת מודולו 2**32.

בדומה ל-Native emitter, Viper מייצר הוראות מכונה, אך מבוצעות אופטימיזציות נוספות, המגדילות באופן משמעותי את הביצועים במיוחד עבור אריתמטיקה של מספרים שלמים ומניפולציות ביט. הוא מופעל באמצעות מעטר:

@micropython.viper
def foo(self, arg: int) -> int:
    # code

כפי שהמקטע לעיל ממחיש, מועיל להשתמש ברמזי טיפוס (type hints) של Python כדי לסייע לאופטימייזר של Viper. רמזי טיפוס מספקים מידע על סוגי הנתונים של הארגומנטים ושל ערך ההחזרה; אלה הם מאפיין סטנדרטי של שפת Python המוגדר רשמית כאן PEP0484. Viper תומך בקבוצת הטיפוסים שלו, דהיינו int, uint (מספר שלם ללא סימן), ptr, ptr8, ptr16 ו-ptr32. הטיפוסים ptrX נדונים להלן. כעת הטיפוס uint משרת מטרה יחידה: כרמז טיפוס לערך החזרה של פונקציה. אם פונקציה כזו מחזירה 0xffffffff, Python יפרש את התוצאה כ-2**32 -1 ולא כ–1.

בנוסף למגבלות שמטיל ה-native emitter, חלות האילוצים הבאים:

  • ערכי ברירת מחדל לארגומנטים אינם מותרים.

  • ניתן להשתמש בנקודה צפה אך היא אינה עוברת אופטימיזציה.

Viper מספק טיפוסי מצביעים (pointer) כדי לסייע לאופטימייזר. אלה כוללים

  • ptr מצביע לאובייקט.

  • ptr8 מצביע לבייט.

  • ptr16 מצביע לחצי-מילה בת 16 ביט.

  • ptr32 מצביע למילת מכונה בת 32 ביט.

המושג של מצביע עשוי להיות לא מוכר למתכנתי Python. הוא דומה לאובייקט memoryview של Python בכך שהוא מספק גישה ישירה לנתונים המאוחסנים בזיכרון. ניגשים לפריטים באמצעות סימון מנוי (subscript), אך חתכים (slices) אינם נתמכים: מצביע יכול להחזיר פריט בודד בלבד. מטרתו לספק גישה אקראית מהירה לנתונים המאוחסנים במיקומי זיכרון רציפים - כגון נתונים המאוחסנים באובייקטים התומכים בפרוטוקול החוצץ, ואוגרים של התקנים היקפיים הממופים לזיכרון במיקרו-בקר. יש לציין שתכנות באמצעות מצביעים הוא מסוכן: בדיקת גבולות אינה מבוצעת והקומפיילר אינו עושה דבר כדי למנוע שגיאות גלישת חוצץ.

השימוש הטיפוסי הוא לשמירת משתנים במטמון:

@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, יש לבצען בתחילת הפונקציה ולא בלולאות תזמון קריטיות, מכיוון שפעולת ההמרה עשויה לארוך מספר מיליוניות שנייה. כללי ההמרה הם כדלקמן:

  • אופרטורי ההמרה כעת הם: int, bool, uint, ptr, ptr8, ptr16 ו-ptr32.

  • תוצאת ההמרה תהיה משתנה מקורי של Viper.

  • הארגומנטים להמרה יכולים להיות אובייקט Python או משתנה מקורי של Viper.

  • אם הארגומנט הוא משתנה מקורי של Viper, אזי ההמרה היא no-op (כלומר אינה עולה דבר בזמן ריצה) שרק משנה את הטיפוס (למשל מ-uint ל-ptr8) כך שתוכלו לאחר מכן לאחסן/לטעון באמצעות מצביע זה.

  • אם הארגומנט הוא אובייקט Python וההמרה היא int או uint, אזי אובייקט ה-Python חייב להיות מטיפוס שלם, וערך אותו אובייקט שלם מוחזר.

  • הארגומנט להמרת bool חייב להיות מטיפוס שלם (בוליאני או מספר שלם); כאשר נעשה בו שימוש כטיפוס החזרה, פונקציית ה-viper תחזיר אובייקטי True או False.

  • אם הארגומנט הוא אובייקט Python וההמרה היא ptr, ptr8, ptr16 או ptr32, אזי אובייקט ה-Python חייב או לתמוך בפרוטוקול החוצץ (ובמקרה זה מוחזר מצביע לתחילת החוצץ) או להיות מטיפוס שלם (ובמקרה זה מוחזר ערך אותו אובייקט שלם).

כתיבה למצביע המצביע לאובייקט לקריאה בלבד תוביל להתנהגות לא מוגדרת.

הערה

דוגמאות הקוד שלהלן ניתנות עבור OpenMV Cams מבוססות STM32, המספקות את המודול stm. הטכניקות המתוארות ישימות באופן כללי.

המודול stm חושף את כתובות הזיכרון של אוגרי ההתקנים ההיקפיים של ה-MCU. לכל פורט GPIO יש אוגר נתוני פלט (ODR) שהביטים שלו ממופים אחד-לאחד לפינים של אותו פורט: כתיבה לאוגר מניעה את הפינים הללו ישירות, ללא התקורה של קריאה למתודת machine.Pin, וביצוע XOR על ביט מחליף את מצב הפין שלו. ב-OpenMV Cam המקורית הנורית הכחולה מחווטת ל-GPIOC פין 2, ולכן הדוגמה הבאה משתמשת בהמרת ptr16 כדי להחליף את מצב הנורית הכחולה 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

תיאור טכני מפורט של שלושת ה-code emitters ניתן למצוא ב-Kickstarter כאן הערה 1 וכאן הערה 2

גישה ישירה לחומרה

זה נכנס לקטגוריה של תכנות מתקדם יותר וכרוך בידע מסוים על ה-MCU היעד. שקלו את הדוגמה של החלפת מצב פין פלט ב-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 היא ההיסט של אוגר נתוני הפלט שלו). כפי שצוין לעיל, הנורית הכחולה ב-OpenMV Cam המקורית היא GPIOC פין 2, ולכן ניתן לבצע החלפת מצב מהירה שלה כדלקמן:

import machine
import stm

BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2