A MicroPython sebességének maximalizálása

Ez az útmutató a MicroPython kód teljesítményének javítását szolgáló módszereket ismerteti. Az egyéb nyelveket érintő optimalizálásokat máshol tárgyaljuk, nevezetesen a C nyelven írt modulok és a MicroPython beépített assemblerének használatát.

A nagy teljesítményű kód fejlesztésének folyamata az alábbi szakaszokból áll, amelyeket a felsorolt sorrendben kell elvégezni.

  • Tervezés a sebességre.

  • Kódolás és hibakeresés.

Optimalizálási lépések:

  • A kód leglassabb részének azonosítása.

  • A Python kód hatékonyságának javítása.

  • A natív kód-kibocsátó (native code emitter) használata.

  • A viper kód-kibocsátó (viper code emitter) használata.

  • Hardverspecifikus optimalizálások használata.

Tervezés a sebességre

A teljesítménnyel kapcsolatos kérdéseket már a kezdetektől figyelembe kell venni. Ez azt jelenti, hogy átgondoljuk, mely kódrészek a legkritikusabbak a teljesítmény szempontjából, és különös figyelmet fordítunk a tervezésükre. Az optimalizálás folyamata akkor kezdődik, amikor a kódot teszteltük: ha a tervezés a kezdetektől helyes, az optimalizálás egyszerű lesz, sőt, akár szükségtelen is.

Algoritmusok

Bármely rutin teljesítményre tervezésének legfontosabb szempontja annak biztosítása, hogy a legjobb algoritmust alkalmazzuk. Ez inkább tankönyvekbe, mintsem egy MicroPython útmutatóba illő téma, de a hatékonyságukról ismert algoritmusok alkalmazásával olykor látványos teljesítményjavulás érhető el.

RAM-foglalás

A hatékony MicroPython kód tervezéséhez szükséges megérteni, ahogyan az értelmező a RAM-ot foglalja. Amikor egy objektum létrejön vagy mérete nő (például amikor egy elemet egy listához fűzünk), a szükséges RAM-ot egy halom (heap) néven ismert blokkból foglalja le. Ez jelentős időt vesz igénybe; továbbá alkalmanként elindít egy szemétgyűjtés (garbage collection) néven ismert folyamatot, amely több ezredmásodpercet is igénybe vehet.

Következésképpen egy függvény vagy metódus teljesítménye javítható, ha egy objektumot csak egyszer hozunk létre, és nem engedjük, hogy a mérete nőjön. Ez azt jelenti, hogy az objektum a teljes használat idejére fennmarad: jellemzően egy osztály konstruktorában példányosítjuk, és különböző metódusokban használjuk.

Ezt részletesebben az alábbi A szemétgyűjtés vezérlése című részben tárgyaljuk.

Pufferek

A fentiekre példa az a gyakori eset, amikor egy pufferre van szükség, például egy eszközzel való kommunikációhoz használt pufferre. Egy tipikus meghajtó a konstruktorban hozza létre a puffert, és az I/O metódusaiban használja, amelyeket ismételten meghív.

A MicroPython könyvtárak jellemzően támogatják az előre lefoglalt puffereket. Például a stream interfészt támogató objektumok (pl. fájl vagy UART) biztosítanak egy read() metódust, amely új puffert foglal le a beolvasott adatoknak, de egy readinto() metódust is, amely egy meglévő pufferbe olvassa be az adatokat.

Néhány hasznos osztály újrafelhasználható pufferobjektumok létrehozásához:

Lebegőpontos számok

Egyes MicroPython portok a lebegőpontos számokat a halmon (heap) foglalják le. Néhány más portból hiányozhat a dedikált lebegőpontos koprocesszor, így az aritmetikai műveleteket „szoftveresen” végzik el rajtuk, lényegesen lassabban, mint az egészeken. Ahol a teljesítmény fontos, használj egész számos műveleteket, és a lebegőpontos számok használatát korlátozd a kód olyan szakaszaira, ahol a teljesítmény nem elsődleges. Például rögzítsd az ADC-leolvasásokat egész értékként egy tömbbe egyetlen gyors lépésben, és csak ezután alakítsd át őket lebegőpontos számokká a jelfeldolgozáshoz.

Tömbök

Fontold meg a különféle tömbosztályok használatát a listák alternatívájaként. Az array modul különféle elemtípusokat támogat, a 8 bites elemeket pedig a Python beépített bytes és bytearray osztályai is támogatják. Ezek az adatszerkezetek mind egymást követő (összefüggő) memóriahelyeken tárolják az elemeket. Ismét: a kritikus kódban történő memóriafoglalás elkerülése érdekében ezeket előre le kell foglalni, és argumentumként vagy kötött objektumként kell átadni.

Memoryview-k

Olyan objektumok szeleteinek átadásakor, mint a bytearray példányok, a Python másolatot készít, ami a szelet méretével arányos méretű foglalással jár. Ez enyhíthető egy memoryview objektum használatával. Maga a memoryview a halmon (heap) foglalódik le, de egy kicsi, rögzített méretű objektum, függetlenül attól, hogy mekkora szeletre mutat. Egy memoryview szeletelése új memoryview-t hoz létre, így ez nem végezhető el egy megszakításkiszolgáló rutinban. Továbbá az a:b szeletszintaxis további foglalást okoz egy slice(a, b) objektum példányosításával.

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

A memoryview csak a pufferprotokollt támogató objektumokra alkalmazható – ez magában foglalja a tömböket, de a listákat nem. Egy apró fenntartás, hogy amíg egy memoryview objektum él, addig az eredeti pufferobjektumot is életben tartja. Tehát a memoryview nem egyetemes csodaszer. Például a fenti esetben, ha végeztél a 10K pufferrel, és csak a 30:2000 bájtokra van szükséged belőle, jobb lehet készíteni egy szeletet, és hagyni, hogy a 10K puffer elengedhető legyen (készen álljon a szemétgyűjtésre), ahelyett, hogy egy hosszú életű memoryview-t készítenél, és 10K-t blokkolva tartanál a GC elől.

Mindazonáltal a memoryview nélkülözhetetlen a fejlett, előre lefoglalt pufferkezeléshez. A fent tárgyalt readinto() metódus az adatokat a puffer elejére teszi, és kitölti a teljes puffert. Mi van akkor, ha egy meglévő puffer közepére kell adatot tenned? Csak hozz létre egy memoryview-t a puffer kívánt szakaszába, és add át a readinto() metódusnak.

Sztringek és bájtok

A MicroPython sztring-internálást használ a helytakarékosság érdekében, amikor több azonos sztring is van. Minden alkalommal, amikor futásidőben egy új sztring foglalódik le (például amikor két másik sztringet összefűzünk), a MicroPython ellenőrzi, hogy az új sztring internálható-e a RAM megtakarítása érdekében.

Ha olyan kódod van, amely teljesítménykritikus sztringműveleteket végez, akkor fontold meg a bytes objektumok és literálok (pl. b"abc") használatát. Ez kihagyja az internálási ellenőrzést, és többszörösen gyorsabb lehet, mint ugyanezen műveletek elvégzése sztringobjektumokkal.

Megjegyzés

A leggyorsabb teljesítményt mindig az új objektum létrehozásának teljes elkerülésével érheted el, például egy újrafelhasználható fentebb leírt pufferrel.

A kód leglassabb részének azonosítása

Ez egy profilozás (profiling) néven ismert folyamat, amelyet tankönyvek tárgyalnak, és (a standard Python esetében) különféle szoftvereszközök támogatnak. A MicroPython platformokon valószínűleg futó kisebb beágyazott alkalmazások esetében a leglassabb függvény vagy metódus általában a time modulban dokumentált, az időmérést végző ticks függvénycsoport körültekintő használatával állapítható meg. A kód végrehajtási ideje ms-ban, us-ban vagy CPU-ciklusokban mérhető.

Az alábbi kód lehetővé teszi bármely függvény vagy metódus időzítését egy @timed_function dekorátor hozzáadásával:

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 kódfejlesztések

A const() deklaráció

A MicroPython biztosít egy const() deklarációt. Ez a C nyelvbeli #define direktívához hasonlóan működik, amennyiben amikor a kódot bájtkóddá fordítják, a fordító a numerikus értéket helyettesíti be az azonosító helyére. Ez elkerüli a futásidejű szótár-keresést. A const() argumentuma bármi lehet, ami fordítási időben egész számmá értékelődik ki, pl. 0x100 vagy 1 << 8.

Objektumhivatkozások gyorsítótárazása

Ahol egy függvény vagy metódus ismételten hozzáfér objektumokhoz, a teljesítmény javul, ha az objektumot egy lokális változóban gyorsítótárazzuk:

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

Ez elkerüli, hogy a self.ba és az obj_display.framebuffer ismételten kikeresésre kerüljön a bar() metódus törzsében.

A szemétgyűjtés vezérlése

Amikor memóriafoglalásra van szükség, a MicroPython megpróbál egy megfelelő méretű blokkot keresni a halmon (heap). Ez meghiúsulhat, általában azért, mert a halom tele van olyan objektumokkal, amelyekre a kód már nem hivatkozik. Ha hiba lép fel, a szemétgyűjtés néven ismert folyamat visszanyeri az ezen felesleges objektumok által használt memóriát, majd a foglalást újra megpróbálja – ez a folyamat több ezredmásodpercet is igénybe vehet.

Előnyös lehet ezt megelőzni azzal, hogy időszakosan kiadjuk a gc.collect() parancsot. Egyrészt a szemétgyűjtés elvégzése azelőtt, hogy ténylegesen szükség lenne rá, gyorsabb – jellemzően 1 ms körüli, ha gyakran végezzük. Másrészt te határozhatod meg a kódban azt a pontot, ahol ez az idő felhasználódik, ahelyett, hogy hosszabb késleltetés következne be véletlenszerű pontokon, esetleg egy sebességkritikus szakaszban. Végül a szemétgyűjtés rendszeres elvégzése csökkentheti a halom töredezettségét. A súlyos töredezettség visszafordíthatatlan foglalási hibákhoz vezethet.

A natív kód-kibocsátó

Ez arra készteti a MicroPython fordítót, hogy bájtkód helyett natív CPU-utasításokat (opkódokat) bocsásson ki. Lefedi a MicroPython funkcionalitásának nagy részét, így a legtöbb függvény nem igényel átalakítást (de lásd lentebb). Egy függvénydekorátor segítségével hívható meg:

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

A natív kód-kibocsátó jelenlegi implementációjának vannak bizonyos korlátai.

  • Ha a raise használatban van, argumentumot kell megadni.

  • A háttérütemező (lásd micropython.schedule) nem fut a natív kód végrehajtása során.

  • A szálkezeléssel és a GIL-lel rendelkező célplatformokon a GIL nem szabadul fel a natív kód végrehajtása során.

Az utóbbi két pont enyhítése érdekében a hosszan futó natív függvényeknek időszakosan meg kell hívniuk a time.sleep(0) függvényt, amely lefuttatja az ütemezőt és felszabadítja a GIL-t.

A javított teljesítményért (durván kétszer olyan gyors, mint a bájtkód) cserébe a lefordított kód mérete megnő.

A Viper kód-kibocsátó

A fent tárgyalt optimalizálások szabványoknak megfelelő Python kódot érintenek. A Viper kód-kibocsátó nem teljesen szabványkövető. A teljesítmény érdekében speciális Viper natív adattípusokat támogat. Az egészszám-feldolgozás nem szabványkövető, mivel gépi szavakat használ: a 32 bites hardveren az aritmetika modulo 2**32 történik.

A natív kibocsátóhoz hasonlóan a Viper is gépi utasításokat állít elő, de további optimalizálásokat végez, lényegesen növelve a teljesítményt, különösen az egészszám-aritmetika és a bitmanipulációk esetében. Egy dekorátorral hívható meg:

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

Ahogy a fenti részlet szemlélteti, hasznos a Python típusjelzéseinek (type hints) használata a Viper optimalizáló segítésére. A típusjelzések információt nyújtanak az argumentumok és a visszatérési érték adattípusairól; ezek egy standard Python nyelvi jellemzők, amelyeket formálisan itt definiálnak: PEP0484. A Viper saját típuskészletet támogat, nevezetesen az int, uint (előjel nélküli egész), ptr, ptr8, ptr16 és ptr32 típusokat. A ptrX típusokat az alábbiakban tárgyaljuk. Jelenleg a uint típus egyetlen célt szolgál: típusjelzésként egy függvény visszatérési értékéhez. Ha egy ilyen függvény 0xffffffff értéket ad vissza, a Python az eredményt 2**32 -1-ként értelmezi, nem pedig -1-ként.

A natív kibocsátó által támasztott korlátozásokon túl a következő megszorítások érvényesek:

  • Alapértelmezett argumentumértékek nem engedélyezettek.

  • Lebegőpontos számok használhatók, de nincsenek optimalizálva.

A Viper mutatótípusokat biztosít az optimalizáló segítésére. Ezek a következők:

  • ptr Mutató egy objektumra.

  • ptr8 Egy bájtra mutat.

  • ptr16 Egy 16 bites félszóra mutat.

  • ptr32 Egy 32 bites gépi szóra mutat.

A mutató fogalma ismeretlen lehet a Python programozók számára. Hasonlóságot mutat egy Python memoryview objektummal, amennyiben közvetlen hozzáférést biztosít a memóriában tárolt adatokhoz. Az elemekhez indexelő (subscript) jelöléssel lehet hozzáférni, de a szeletek nem támogatottak: egy mutató csak egyetlen elemet tud visszaadni. Célja, hogy gyors véletlenszerű hozzáférést biztosítson az egymást követő (összefüggő) memóriahelyeken tárolt adatokhoz – ilyenek a pufferprotokollt támogató objektumokban tárolt adatok, valamint egy mikrokontroller memóriába leképezett perifériaregiszterei. Megjegyzendő, hogy a mutatókkal való programozás veszélyes: nincs határellenőrzés, és a fordító semmit sem tesz a puffertúlcsordulási hibák megelőzéséért.

A tipikus felhasználás a változók gyorsítótárazása:

@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

Ebben az esetben a fordító „tudja”, hogy a buf egy bájttömb címe; olyan kódot tud kibocsátani, amely futásidőben gyorsan kiszámítja a buf[x] címét. Ahol típuskonverziókat (cast) használunk az objektumok Viper natív típusokká alakítására, ezeket a függvény elején kell elvégezni, nem pedig a kritikus időzítésű ciklusokban, mivel a konverziós művelet több mikroszekundumot is igénybe vehet. A konverzió szabályai a következők:

  • A konverziós operátorok jelenleg: int, bool, uint, ptr, ptr8, ptr16 és ptr32.

  • Egy konverzió eredménye egy natív Viper változó lesz.

  • Egy konverzió argumentuma lehet egy Python objektum vagy egy natív Viper változó.

  • Ha az argumentum egy natív Viper változó, akkor a konverzió üres művelet (azaz futásidőben semmibe sem kerül), amely csak a típust változtatja meg (pl. uint-ról ptr8-ra), hogy ezután ezzel a mutatóval tárolni/betölteni tudj.

  • Ha az argumentum egy Python objektum, és a konverzió int vagy uint, akkor a Python objektumnak egész típusúnak kell lennie, és ezen egész objektum értéke kerül visszaadásra.

  • Egy bool konverzió argumentumának egész típusúnak (logikai vagy egész) kell lennie; visszatérési típusként használva a viper függvény True vagy False objektumot ad vissza.

  • Ha az argumentum egy Python objektum, és a konverzió ptr, ptr8, ptr16 vagy ptr32, akkor a Python objektumnak vagy rendelkeznie kell a pufferprotokollal (ebben az esetben a puffer elejére mutató mutató kerül visszaadásra), vagy egész típusúnak kell lennie (ebben az esetben ezen egész objektum értéke kerül visszaadásra).

Egy csak olvasható objektumra mutató mutatóba való írás nem definiált viselkedéshez vezet.

Megjegyzés

Az alábbi kódpéldák az STM32-alapú OpenMV Camekhez vannak megadva, amelyek biztosítják az stm modult. A leírt technikák általánosan alkalmazhatók.

Az stm modul kiteszi az MCU perifériaregisztereinek memóriacímeit. Minden GPIO-portnak van egy kimeneti adatregisztere (ODR), amelynek bitjei egy-az-egyben leképeződnek az adott port lábaira: a regiszter írása közvetlenül vezérli ezeket a lábakat, egy machine.Pin metódushívás többletterhe nélkül, egy bit XOR-olása pedig átkapcsolja a hozzá tartozó lábat. Az eredeti OpenMV Camen a kék LED a GPIOC 2-es lábához van bekötve, így a következő példa egy ptr16 konverziót használ a kék LED n-szeri átkapcsolásához:

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

A három kód-kibocsátó részletes technikai leírása a Kickstarteren található itt: 1. megjegyzés és itt: 2. megjegyzés

A hardver közvetlen elérése

Ez a haladóbb programozás kategóriájába tartozik, és igényel némi ismeretet a célmikrokontrollerről (MCU). Vegyük egy kimeneti láb átkapcsolásának példáját egy OpenMV Camen. A szokásos megközelítés a következő lenne:

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

Ez két hívás többletterhével jár a Pin példány value() metódusához. Ez a többletteher kiküszöbölhető a chip GPIO-portjának kimeneti adatregiszterében (ODR) lévő megfelelő bit írásával/olvasásával. Ennek megkönnyítésére az stm modul biztosít egy sor konstanst, amelyek megadják a megfelelő regiszterek címeit (az stm.GPIOC a GPIOC port báziscíme, az stm.GPIO_ODR pedig a kimeneti adatregiszterének eltolása). Ahogy fentebb, az eredeti OpenMV Camen a kék LED a GPIOC 2-es lábához tartozik, így annak gyors átkapcsolása a következőképpen végezhető el:

import machine
import stm

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