Maksymalizacja szybkości MicroPython

Ten samouczek opisuje sposoby poprawy wydajności kodu MicroPython. Optymalizacje wykorzystujące inne języki zostały omówione w innym miejscu, mianowicie użycie modułów napisanych w C oraz wbudowanego asemblera MicroPython.

Proces tworzenia kodu o wysokiej wydajności obejmuje następujące etapy, które należy wykonać w podanej kolejności.

  • Projektowanie z myślą o szybkości.

  • Kodowanie i debugowanie.

Kroki optymalizacji:

  • Zidentyfikuj najwolniejszą sekcję kodu.

  • Popraw wydajność kodu Python.

  • Użyj natywnego emitera kodu (native).

  • Użyj emitera kodu Viper.

  • Zastosuj optymalizacje specyficzne dla sprzętu.

Projektowanie z myślą o szybkości

Kwestie wydajności należy rozważyć już na samym początku. Wiąże się to z określeniem sekcji kodu, które są najbardziej krytyczne dla wydajności, oraz poświęceniem szczególnej uwagi ich projektowaniu. Proces optymalizacji rozpoczyna się po przetestowaniu kodu: jeśli projekt jest poprawny od samego początku, optymalizacja będzie prosta, a może nawet okazać się zbędna.

Algorytmy

Najważniejszym aspektem projektowania dowolnej procedury pod kątem wydajności jest zapewnienie zastosowania najlepszego algorytmu. Jest to temat raczej dla podręczników niż dla przewodnika po MicroPython, jednak czasami można osiągnąć spektakularne wzrosty wydajności dzięki zastosowaniu algorytmów znanych ze swojej efektywności.

Alokacja pamięci RAM

Aby zaprojektować wydajny kod MicroPython, konieczne jest zrozumienie sposobu, w jaki interpreter alokuje pamięć RAM. Gdy obiekt jest tworzony lub powiększa się (na przykład gdy element jest dołączany do listy), niezbędna pamięć RAM jest przydzielana z bloku zwanego stertą (heap). Zajmuje to znaczną ilość czasu; ponadto czasami wyzwala proces zwany odśmiecaniem pamięci (garbage collection), który może zająć kilka milisekund.

W konsekwencji wydajność funkcji lub metody można poprawić, jeśli obiekt jest tworzony tylko raz i nie ma możliwości powiększania się. Oznacza to, że obiekt istnieje przez cały czas swojego użytkowania: zwykle jest tworzony w konstruktorze klasy i używany w różnych metodach.

Zostało to omówione bardziej szczegółowo w sekcji Sterowanie odśmiecaniem pamięci poniżej.

Bufory

Przykładem powyższego jest częsty przypadek, gdy wymagany jest bufor, taki jak ten używany do komunikacji z urządzeniem. Typowy sterownik utworzy bufor w konstruktorze i będzie go używał w swoich metodach wejścia/wyjścia, które będą wielokrotnie wywoływane.

Biblioteki MicroPython zwykle zapewniają obsługę wstępnie zaalokowanych buforów. Na przykład obiekty obsługujące interfejs strumieniowy (np. plik lub UART) udostępniają metodę read(), która alokuje nowy bufor na odczytywane dane, ale także metodę readinto() do odczytu danych do istniejącego bufora.

Kilka przydatnych klas do tworzenia obiektów buforów wielokrotnego użytku:

Liczby zmiennoprzecinkowe

Niektóre porty MicroPython alokują liczby zmiennoprzecinkowe na stercie. Niektóre inne porty mogą nie mieć dedykowanego koprocesora zmiennoprzecinkowego i wykonują na nich operacje arytmetyczne „programowo” ze znacznie mniejszą szybkością niż na liczbach całkowitych. Tam, gdzie wydajność jest istotna, używaj operacji na liczbach całkowitych i ogranicz użycie liczb zmiennoprzecinkowych do sekcji kodu, w których wydajność nie ma kluczowego znaczenia. Na przykład przechwyć odczyty ADC jako wartości całkowite do tablicy w jednym szybkim ruchu, a dopiero potem przekonwertuj je na liczby zmiennoprzecinkowe na potrzeby przetwarzania sygnału.

Tablice

Rozważ użycie różnych typów klas tablic jako alternatywy dla list. Moduł array obsługuje różne typy elementów, przy czym elementy 8-bitowe są obsługiwane przez wbudowane w Pythona klasy bytes i bytearray. Wszystkie te struktury danych przechowują elementy w ciągłych obszarach pamięci. Po raz kolejny, aby uniknąć alokacji pamięci w krytycznym kodzie, należy je wstępnie zaalokować i przekazywać jako argumenty lub jako powiązane obiekty.

Memoryview

Przy przekazywaniu wycinków obiektów, takich jak instancje bytearray, Python tworzy kopię, co wiąże się z alokacją o rozmiarze proporcjonalnym do rozmiaru wycinka. Można to złagodzić, używając obiektu memoryview. Sam memoryview jest alokowany na stercie, ale jest małym obiektem o stałym rozmiarze, niezależnie od rozmiaru wycinka, na który wskazuje. Tworzenie wycinka z memoryview tworzy nowy memoryview, więc nie można tego robić w procedurze obsługi przerwania. Ponadto składnia wycinka a:b powoduje dalszą alokację poprzez utworzenie obiektu 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 można zastosować tylko do obiektów obsługujących protokół bufora — obejmuje to tablice, ale nie listy. Drobne zastrzeżenie polega na tym, że dopóki obiekt memoryview jest aktywny, utrzymuje też przy życiu oryginalny obiekt bufora. Tak więc memoryview nie jest uniwersalnym panaceum. Na przykład w powyższym przykładzie, jeśli skończyłeś korzystać z bufora 10K i potrzebujesz z niego tylko bajtów 30:2000, lepiej może być utworzyć wycinek i pozwolić, by bufor 10K został zwolniony (gotowy do odśmiecenia), zamiast tworzyć długo żyjący memoryview i blokować 10K przed GC.

Niemniej jednak memoryview jest niezbędny do zaawansowanego zarządzania wstępnie zaalokowanymi buforami. Omówiona powyżej metoda readinto() umieszcza dane na początku bufora i wypełnia cały bufor. Co zrobić, jeśli musisz umieścić dane w środku istniejącego bufora? Wystarczy utworzyć memoryview na potrzebną sekcję bufora i przekazać go do readinto().

Ciągi znaków a bajty

MicroPython używa internowania ciągów znaków, aby oszczędzać miejsce, gdy istnieje wiele identycznych ciągów. Za każdym razem, gdy nowy ciąg jest alokowany w czasie wykonywania (na przykład gdy dwa inne ciągi są łączone), MicroPython sprawdza, czy nowy ciąg można zinternować, aby zaoszczędzić pamięć RAM.

Jeśli masz kod wykonujący operacje na ciągach znaków krytyczne dla wydajności, rozważ użycie obiektów bytes i literałów (tj. b"abc"). Pomija to sprawdzanie internowania i może być kilkukrotnie szybsze niż wykonywanie tych samych operacji na obiektach ciągów znaków.

Informacja

Najszybszą wydajność zawsze osiąga się poprzez całkowite unikanie tworzenia nowych obiektów, na przykład za pomocą bufora wielokrotnego użytku opisanego powyżej.

Identyfikowanie najwolniejszej sekcji kodu

Jest to proces zwany profilowaniem, opisany w podręcznikach i (w przypadku standardowego Pythona) wspierany przez różne narzędzia programistyczne. W przypadku mniejszych aplikacji wbudowanych, które prawdopodobnie będą uruchamiane na platformach MicroPython, najwolniejszą funkcję lub metodę można zwykle ustalić poprzez rozsądne użycie grupy funkcji pomiaru czasu ticks udokumentowanych w time. Czas wykonania kodu można mierzyć w ms, us lub cyklach CPU.

Poniższe umożliwia mierzenie czasu dowolnej funkcji lub metody poprzez dodanie dekoratora @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

Usprawnienia kodu MicroPython

Deklaracja const()

MicroPython udostępnia deklarację const(). Działa ona w sposób podobny do #define w C, w tym sensie, że gdy kod jest kompilowany do kodu bajtowego, kompilator zastępuje identyfikator wartością liczbową. Pozwala to uniknąć wyszukiwania w słowniku w czasie wykonywania. Argumentem const() może być cokolwiek, co w czasie kompilacji daje w wyniku liczbę całkowitą, np. 0x100 lub 1 << 8.

Buforowanie referencji do obiektów

Tam, gdzie funkcja lub metoda wielokrotnie uzyskuje dostęp do obiektów, wydajność poprawia się poprzez buforowanie obiektu w zmiennej lokalnej:

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

Pozwala to uniknąć konieczności wielokrotnego wyszukiwania self.ba i obj_display.framebuffer w treści metody bar().

Sterowanie odśmiecaniem pamięci

Gdy wymagana jest alokacja pamięci, MicroPython próbuje zlokalizować na stercie blok o odpowiednim rozmiarze. Może się to nie powieść, zwykle dlatego, że sterta jest zaśmiecona obiektami, do których kod już się nie odwołuje. Jeśli wystąpi niepowodzenie, proces zwany odśmiecaniem pamięci odzyskuje pamięć używaną przez te zbędne obiekty, a następnie alokacja jest ponawiana — proces ten może zająć kilka milisekund.

Może być korzystne wyprzedzenie tego poprzez okresowe wywoływanie gc.collect(). Po pierwsze, wykonanie odśmiecania, zanim jest ono faktycznie wymagane, jest szybsze — zwykle rzędu 1 ms, jeśli robi się to często. Po drugie, możesz określić punkt w kodzie, w którym ten czas jest zużywany, zamiast doświadczać dłuższego opóźnienia w losowych miejscach, być może w sekcji krytycznej dla szybkości. Wreszcie regularne wykonywanie odśmiecania może zmniejszyć fragmentację sterty. Poważna fragmentacja może prowadzić do nieodwracalnych niepowodzeń alokacji.

Natywny emiter kodu (native)

Powoduje to, że kompilator MicroPython emituje natywne kody operacji CPU zamiast kodu bajtowego. Obejmuje on większość funkcjonalności MicroPython, więc większość funkcji nie będzie wymagała adaptacji (ale patrz poniżej). Wywołuje się go za pomocą dekoratora funkcji:

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

W obecnej implementacji natywnego emitera kodu istnieją pewne ograniczenia.

  • Jeśli używane jest raise, należy podać argument.

  • Harmonogram działający w tle (zobacz micropython.schedule) nie jest uruchamiany podczas wykonywania kodu natywnego.

  • Na platformach z wątkami i GIL, GIL nie jest zwalniany podczas wykonywania kodu natywnego.

Aby złagodzić dwa ostatnie punkty, długo działające funkcje natywne powinny okresowo wywoływać time.sleep(0), co uruchomi harmonogram i zwolni na chwilę GIL.

Kompromisem za zwiększoną wydajność (mniej więcej dwukrotnie szybszą niż kod bajtowy) jest wzrost rozmiaru skompilowanego kodu.

Emiter kodu Viper

Optymalizacje omówione powyżej dotyczą kodu Python zgodnego ze standardami. Emiter kodu Viper nie jest w pełni zgodny. Obsługuje specjalne natywne typy danych Viper w dążeniu do wydajności. Przetwarzanie liczb całkowitych jest niezgodne, ponieważ wykorzystuje słowa maszynowe: arytmetyka na sprzęcie 32-bitowym jest wykonywana modulo 2**32.

Podobnie jak emiter natywny, Viper generuje instrukcje maszynowe, ale wykonywane są dalsze optymalizacje, znacznie zwiększające wydajność, zwłaszcza w przypadku arytmetyki na liczbach całkowitych i manipulacji bitami. Wywołuje się go za pomocą dekoratora:

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

Jak ilustruje powyższy fragment, korzystne jest użycie podpowiedzi typów Pythona, aby wspomóc optymalizator Viper. Podpowiedzi typów dostarczają informacji o typach danych argumentów oraz wartości zwracanej; są one standardową funkcją języka Python, formalnie zdefiniowaną tutaj PEP0484. Viper obsługuje własny zestaw typów, mianowicie int, uint (liczba całkowita bez znaku), ptr, ptr8, ptr16 i ptr32. Typy ptrX zostały omówione poniżej. Obecnie typ uint służy jednemu celowi: jako podpowiedź typu dla wartości zwracanej przez funkcję. Jeśli taka funkcja zwróci 0xffffffff, Python zinterpretuje wynik jako 2**32 -1, a nie jako -1.

Oprócz ograniczeń narzuconych przez emiter natywny obowiązują następujące ograniczenia:

  • Domyślne wartości argumentów nie są dozwolone.

  • Można używać liczb zmiennoprzecinkowych, ale nie są one optymalizowane.

Viper udostępnia typy wskaźnikowe, aby wspomóc optymalizator. Obejmują one

  • ptr Wskaźnik do obiektu.

  • ptr8 Wskazuje na bajt.

  • ptr16 Wskazuje na 16-bitowe półsłowo.

  • ptr32 Wskazuje na 32-bitowe słowo maszynowe.

Pojęcie wskaźnika może być nieznane programistom Pythona. Ma ono podobieństwa do obiektu memoryview Pythona w tym sensie, że zapewnia bezpośredni dostęp do danych przechowywanych w pamięci. Do elementów uzyskuje się dostęp za pomocą notacji indeksowej, ale wycinki nie są obsługiwane: wskaźnik może zwrócić tylko pojedynczy element. Jego celem jest zapewnienie szybkiego losowego dostępu do danych przechowywanych w ciągłych obszarach pamięci — takich jak dane przechowywane w obiektach obsługujących protokół bufora oraz mapowane w pamięci rejestry urządzeń peryferyjnych w mikrokontrolerze. Należy zauważyć, że programowanie z użyciem wskaźników jest niebezpieczne: nie jest wykonywane sprawdzanie granic, a kompilator nie robi nic, aby zapobiec błędom przepełnienia bufora.

Typowym zastosowaniem jest buforowanie zmiennych:

@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

W tym przypadku kompilator „wie”, że buf jest adresem tablicy bajtów; może wyemitować kod do szybkiego obliczenia adresu buf[x] w czasie wykonywania. Tam, gdzie używane są rzutowania do konwersji obiektów na natywne typy Viper, należy je wykonywać na początku funkcji, a nie w krytycznych czasowo pętlach, ponieważ operacja rzutowania może zająć kilka mikrosekund. Reguły rzutowania są następujące:

  • Operatory rzutowania to obecnie: int, bool, uint, ptr, ptr8, ptr16 i ptr32.

  • Wynikiem rzutowania będzie natywna zmienna Viper.

  • Argumentem rzutowania może być obiekt Pythona lub natywna zmienna Viper.

  • Jeśli argumentem jest natywna zmienna Viper, to rzutowanie jest operacją pustą (tj. nic nie kosztuje w czasie wykonywania), która jedynie zmienia typ (np. z uint na ptr8), dzięki czemu można następnie zapisywać/odczytywać za pomocą tego wskaźnika.

  • Jeśli argumentem jest obiekt Pythona, a rzutowanie to int lub uint, to obiekt Pythona musi być typu całkowitego, a zwracana jest wartość tego obiektu całkowitego.

  • Argument rzutowania bool musi być typu całkowitego (logicznego lub całkowitego); gdy jest używany jako typ zwracany, funkcja viper zwróci obiekty True lub False.

  • Jeśli argumentem jest obiekt Pythona, a rzutowanie to ptr, ptr8, ptr16 lub ptr32, to obiekt Pythona musi albo obsługiwać protokół bufora (w którym to przypadku zwracany jest wskaźnik na początek bufora), albo musi być typu całkowitego (w którym to przypadku zwracana jest wartość tego obiektu całkowitego).

Zapis do wskaźnika, który wskazuje na obiekt tylko do odczytu, prowadzi do niezdefiniowanego zachowania.

Informacja

Poniższe przykłady kodu podano dla kamer OpenMV Cam opartych na STM32, które udostępniają moduł stm. Opisane techniki mają zastosowanie ogólne.

Moduł stm udostępnia adresy pamięci rejestrów urządzeń peryferyjnych MCU. Każdy port GPIO ma rejestr danych wyjściowych (ODR), którego bity odwzorowują się jeden do jednego na piny tego portu: zapis do rejestru steruje tymi pinami bezpośrednio, bez narzutu wywołania metody machine.Pin, a operacja XOR na bicie przełącza jego pin. W oryginalnej OpenMV Cam niebieska dioda LED jest podłączona do pinu 2 GPIOC, więc poniższy przykład używa rzutowania ptr16 do n-krotnego przełączenia niebieskiej diody LED:

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

Szczegółowy opis techniczny trzech emiterów kodu można znaleźć na Kickstarterze tutaj Nota 1 oraz tutaj Nota 2

Bezpośredni dostęp do sprzętu

Należy to do kategorii bardziej zaawansowanego programowania i wymaga pewnej wiedzy o docelowym MCU. Rozważ przykład przełączania pinu wyjściowego na OpenMV Cam. Standardowe podejście polegałoby na napisaniu

mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin

Wiąże się to z narzutem dwóch wywołań metody value() instancji Pin. Ten narzut można wyeliminować, wykonując odczyt/zapis odpowiedniego bitu rejestru danych wyjściowych (ODR) portu GPIO układu. Aby to ułatwić, moduł stm udostępnia zestaw stałych podających adresy odpowiednich rejestrów (stm.GPIOC to adres bazowy portu GPIOC, stm.GPIO_ODR to przesunięcie jego rejestru danych wyjściowych). Jak powyżej, niebieska dioda LED na oryginalnej OpenMV Cam to pin 2 GPIOC, więc jej szybkie przełączenie można wykonać w następujący sposób:

import machine
import stm

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