Maximera hastigheten i MicroPython

Denna handledning beskriver sätt att förbättra prestandan hos MicroPython-kod. Optimeringar som involverar andra språk behandlas på andra ställen, närmare bestämt användningen av moduler skrivna i C och MicroPythons inbyggda assemblerare.

Processen att utveckla högpresterande kod omfattar följande steg, som bör utföras i den ordning som anges.

  • Designa för hastighet.

  • Koda och felsök.

Optimeringssteg:

  • Identifiera den långsammaste delen av koden.

  • Förbättra effektiviteten hos Python-koden.

  • Använd den inbyggda kodgeneratorn (native code emitter).

  • Använd Viper-kodgeneratorn (viper code emitter).

  • Använd hårdvaruspecifika optimeringar.

Designa för hastighet

Prestandafrågor bör övervägas redan från början. Detta innebär att man bildar sig en uppfattning om de delar av koden som är mest prestandakritiska och ägnar särskild uppmärksamhet åt deras design. Optimeringsprocessen inleds när koden har testats: om designen är korrekt från början blir optimeringen okomplicerad och kan faktiskt visa sig vara onödig.

Algoritmer

Den viktigaste aspekten vid utformningen av en rutin för prestanda är att säkerställa att den bästa algoritmen används. Detta är ett ämne för läroböcker snarare än för en MicroPython-guide, men spektakulära prestandavinster kan ibland uppnås genom att man tillämpar algoritmer som är kända för sin effektivitet.

RAM-allokering

För att designa effektiv MicroPython-kod är det nödvändigt att förstå hur interpretatorn allokerar RAM. När ett objekt skapas eller växer i storlek (till exempel när ett element läggs till i en lista) allokeras det nödvändiga RAM-minnet från ett block som kallas heapen. Detta tar avsevärd tid; dessutom utlöser det ibland en process som kallas skräpinsamling (garbage collection), vilken kan ta flera millisekunder.

Följaktligen kan prestandan hos en funktion eller metod förbättras om ett objekt skapas endast en gång och inte tillåts växa i storlek. Detta innebär att objektet består under hela sin användningstid: vanligtvis instansieras det i en klasskonstruktor och används i olika metoder.

Detta behandlas mer ingående i Controlling garbage collection nedan.

Buffertar

Ett exempel på ovanstående är det vanliga fallet där en buffert krävs, såsom en som används för kommunikation med en enhet. En typisk drivrutin skapar bufferten i konstruktorn och använder den i sina I/O-metoder som anropas upprepade gånger.

MicroPython-biblioteken tillhandahåller vanligtvis stöd för förallokerade buffertar. Till exempel tillhandahåller objekt som stöder strömgränssnitt (t.ex. fil eller UART) en read()-metod som allokerar en ny buffert för läst data, men även en readinto()-metod för att läsa data in i en befintlig buffert.

Några användbara klasser för att skapa återanvändbara buffertobjekt:

Flyttal

Vissa MicroPython-portar allokerar flyttal på heapen. Vissa andra portar kan sakna en dedikerad flyttalskoprocessor och utför aritmetiska operationer på dem i ”mjukvara” med betydligt lägre hastighet än på heltal. Där prestanda är viktig, använd heltalsoperationer och begränsa användningen av flyttal till de delar av koden där prestanda inte är avgörande. Fånga till exempel ADC-avläsningar som heltalsvärden in i en array i en snabb omgång, och konvertera dem först därefter till flyttal för signalbehandling.

Arrayer

Överväg att använda de olika typerna av arrayklasser som ett alternativ till listor. Modulen array stöder olika elementtyper, där 8-bitars element stöds av Pythons inbyggda klasser bytes och bytearray. Dessa datastrukturer lagrar samtliga sina element på sammanhängande minnesplatser. Återigen, för att undvika minnesallokering i kritisk kod bör dessa förallokeras och skickas som argument eller som bundna objekt.

Memoryviews

När man skickar utsnitt (slices) av objekt såsom bytearray-instanser skapar Python en kopia, vilket innebär allokering av en storlek proportionell mot utsnittets storlek. Detta kan lindras med hjälp av ett memoryview-objekt. Själva memoryview-objektet allokeras på heapen, men är ett litet objekt med fast storlek, oavsett storleken på det utsnitt det pekar på. Att ta ett utsnitt av ett memoryview skapar ett nytt memoryview, så detta kan inte göras i en avbrottsrutin. Vidare orsakar utsnittssyntaxen a:b ytterligare allokering genom att ett slice(a, b)-objekt instansieras.

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

Ett memoryview kan endast tillämpas på objekt som stöder buffertprotokollet - detta inkluderar arrayer men inte listor. Ett litet förbehåll är att medan ett memoryview-objekt är aktivt håller det även det ursprungliga buffertobjektet vid liv. Ett memoryview är alltså inget universalmedel. Om du till exempel i exemplet ovan är klar med 10K-bufferten och bara behöver de bytes från 30:2000 av den, kan det vara bättre att göra ett utsnitt och låta 10K-bufferten släppas (vara redo för skräpinsamling), istället för att göra ett långlivat memoryview och hålla 10K blockerat för GC.

Icke desto mindre är memoryview oumbärligt för avancerad hantering av förallokerade buffertar. Metoden readinto() som diskuterats ovan placerar data i början av bufferten och fyller hela bufferten. Vad gör du om du behöver placera data mitt i en befintlig buffert? Skapa bara ett memoryview in i det önskade avsnittet av bufferten och skicka det till readinto().

Strängar kontra bytes

MicroPython använder string interning för att spara utrymme när det finns flera identiska strängar. Varje gång en ny sträng allokeras vid körtid (till exempel när två andra strängar sammanfogas) kontrollerar MicroPython om den nya strängen kan interneras för att spara RAM.

Om du har kod som utför prestandakritiska strängoperationer bör du överväga att använda bytes-objekt och -litteraler (dvs. b"abc"). Detta hoppar över interneringskontrollen och kan vara flera gånger snabbare än att utföra samma operationer med strängobjekt.

Anteckning

Den snabbaste prestandan uppnås alltid genom att helt undvika skapandet av nya objekt, till exempel med en återanvändbar buffer as described above.

Identifiera den långsammaste delen av koden

Detta är en process som kallas profilering och behandlas i läroböcker samt (för standard-Python) stöds av olika programvaruverktyg. För den typ av mindre inbyggda applikationer som sannolikt körs på MicroPython-plattformar kan den långsammaste funktionen eller metoden vanligtvis fastställas genom omdömesgill användning av gruppen tidsmätningsfunktioner ticks som dokumenteras i time. Kodens exekveringstid kan mätas i ms, us eller CPU-cykler.

Följande gör det möjligt att tidsmäta vilken funktion eller metod som helst genom att lägga till en @timed_function-dekorator:

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

Förbättringar av MicroPython-kod

const()-deklarationen

MicroPython tillhandahåller en const()-deklaration. Denna fungerar på ett liknande sätt som #define i C, genom att kompilatorn vid kompilering av koden till bytecode ersätter identifieraren med det numeriska värdet. Detta undviker en uppslagning i ett lexikon vid körtid. Argumentet till const() får vara vad som helst som vid kompileringstid utvärderas till ett heltal, t.ex. 0x100 eller 1 << 8.

Cachning av objektreferenser

Där en funktion eller metod upprepade gånger kommer åt objekt förbättras prestandan genom att cacha objektet i en lokal variabel:

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

Detta undviker behovet av att upprepade gånger slå upp self.ba och obj_display.framebuffer i kroppen av metoden bar().

Styra skräpinsamling

När minnesallokering krävs försöker MicroPython lokalisera ett tillräckligt stort block på heapen. Detta kan misslyckas, vanligtvis eftersom heapen är belamrad med objekt som inte längre refereras av koden. Om ett fel inträffar återvinner processen som kallas skräpinsamling det minne som dessa överflödiga objekt använt, varefter allokeringen försöks på nytt - en process som kan ta flera millisekunder.

Det kan finnas fördelar med att föregripa detta genom att periodiskt anropa gc.collect(). För det första går det snabbare att göra en insamling innan den faktiskt behövs - vanligtvis i storleksordningen 1 ms om det görs ofta. För det andra kan du bestämma den punkt i koden där denna tid används, istället för att en längre fördröjning inträffar vid slumpmässiga punkter, möjligen i en hastighetskritisk del. Slutligen kan regelbundna insamlingar minska fragmenteringen i heapen. Allvarlig fragmentering kan leda till oåterkalleliga allokeringsfel.

Den inbyggda kodgeneratorn (native code emitter)

Detta får MicroPython-kompilatorn att generera inbyggda CPU-opkoder istället för bytecode. Den täcker huvuddelen av MicroPythons funktionalitet, så de flesta funktioner kräver ingen anpassning (men se nedan). Den anropas med hjälp av en funktionsdekorator:

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

Det finns vissa begränsningar i den nuvarande implementationen av den inbyggda kodgeneratorn.

  • Om raise används måste ett argument anges.

  • Bakgrundsschemaläggaren (se micropython.schedule) körs inte under exekvering av inbyggd kod.

  • På målplattformar med trådning och GIL släpps inte GIL under exekvering av inbyggd kod.

För att mildra de två sista punkterna bör långkörande inbyggda funktioner periodiskt anropa time.sleep(0), vilket kör schemaläggaren och frigör GIL tillfälligt.

Avvägningen för den förbättrade prestandan (ungefär dubbelt så snabb som bytecode) är en ökning av storleken på den kompilerade koden.

Viper-kodgeneratorn

De optimeringar som diskuterats ovan involverar standardkompatibel Python-kod. Viper-kodgeneratorn är inte fullt kompatibel. Den stöder särskilda inbyggda Viper-datatyper i jakten på prestanda. Heltalsbehandling är icke-kompatibel eftersom den använder maskinord: aritmetik på 32-bitars hårdvara utförs modulo 2**32.

Liksom den inbyggda kodgeneratorn producerar Viper maskininstruktioner, men ytterligare optimeringar utförs, vilket avsevärt ökar prestandan särskilt för heltalsaritmetik och bitmanipulationer. Den anropas med en dekorator:

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

Som fragmentet ovan illustrerar är det fördelaktigt att använda Python-typtips för att hjälpa Viper-optimeraren. Typtips ger information om datatyperna för argument och för returvärdet; dessa är en standardfunktion i språket Python som formellt definieras här PEP0484. Viper stöder sin egen uppsättning typer, nämligen int, uint (heltal utan tecken), ptr, ptr8, ptr16 och ptr32. Typerna ptrX diskuteras nedan. För närvarande tjänar typen uint ett enda syfte: som ett typtips för en funktions returvärde. Om en sådan funktion returnerar 0xffffffff tolkar Python resultatet som 2**32 -1 snarare än som -1.

Utöver de restriktioner som den inbyggda kodgeneratorn medför gäller följande begränsningar:

  • Standardvärden för argument är inte tillåtna.

  • Flyttal får användas men optimeras inte.

Viper tillhandahåller pekartyper för att hjälpa optimeraren. Dessa omfattar

  • ptr Pekare till ett objekt.

  • ptr8 Pekar på en byte.

  • ptr16 Pekar på ett 16-bitars halvord.

  • ptr32 Pekar på ett 32-bitars maskinord.

Begreppet pekare kan vara obekant för Python-programmerare. Det har likheter med ett Python-objekt av typen memoryview såtillvida att det ger direkt åtkomst till data som lagras i minnet. Element kommer man åt med indexnotation, men utsnitt stöds inte: en pekare kan endast returnera ett enskilt element. Dess syfte är att ge snabb slumpmässig åtkomst till data som lagras på sammanhängande minnesplatser - såsom data lagrad i objekt som stöder buffertprotokollet, samt minnesmappade kringutrustningsregister i en mikrokontroller. Det bör noteras att programmering med pekare är riskfylld: gränskontroll utförs inte och kompilatorn gör ingenting för att förhindra fel orsakade av buffertöverskridning.

Typisk användning är att cacha variabler:

@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

I detta fall ”vet” kompilatorn att buf är adressen till en array av bytes; den kan generera kod för att snabbt beräkna adressen till buf[x] vid körtid. Där typomvandlingar (casts) används för att konvertera objekt till inbyggda Viper-typer bör dessa utföras i början av funktionen snarare än i tidskritiska loopar, eftersom castoperationen kan ta flera mikrosekunder. Reglerna för typomvandling är följande:

  • Castoperatorerna är för närvarande: int, bool, uint, ptr, ptr8, ptr16 och ptr32.

  • Resultatet av en cast blir en inbyggd Viper-variabel.

  • Argument till en cast kan vara ett Python-objekt eller en inbyggd Viper-variabel.

  • Om argumentet är en inbyggd Viper-variabel är casten en no-op (dvs. kostar ingenting vid körtid) som bara ändrar typen (t.ex. från uint till ptr8) så att du sedan kan lagra/läsa med hjälp av denna pekare.

  • Om argumentet är ett Python-objekt och casten är int eller uint måste Python-objektet vara av heltalstyp, och värdet av det heltalsobjektet returneras.

  • Argumentet till en bool-cast måste vara av heltalstyp (boolesk eller heltal); när den används som returtyp returnerar Viper-funktionen objekten True eller False.

  • Om argumentet är ett Python-objekt och casten är ptr, ptr8, ptr16 eller ptr32 måste Python-objektet antingen stödja buffertprotokollet (i vilket fall en pekare till buffertens början returneras) eller vara av heltalstyp (i vilket fall värdet av det heltalsobjektet returneras).

Att skriva till en pekare som pekar på ett skrivskyddat objekt leder till odefinierat beteende.

Anteckning

Kodexemplen nedan ges för de STM32-baserade OpenMV Cam-kameror som tillhandahåller modulen stm. De beskrivna teknikerna gäller generellt.

Modulen stm exponerar minnesadresserna för MCU:ns kringutrustningsregister. Varje GPIO-port har ett utdataregister (ODR) vars bitar mappar ett-till-ett mot portens stift: att skriva till registret driver dessa stift direkt, utan omkostnaden för ett metodanrop på machine.Pin, och att XOR:a en bit växlar dess stift. På den ursprungliga OpenMV Cam är den blå LED:en kopplad till GPIOC stift 2, så följande exempel använder en ptr16-cast för att växla den blå LED:en n gånger:

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

En detaljerad teknisk beskrivning av de tre kodgeneratorerna återfinns på Kickstarter här Note 1 och här Note 2

Direkt åtkomst till hårdvara

Detta faller inom kategorin mer avancerad programmering och kräver viss kännedom om den aktuella MCU:n. Betrakta exemplet med att växla ett utgångsstift på en OpenMV Cam. Det vanliga tillvägagångssättet vore att skriva

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

Detta innebär omkostnaden för två anrop till value()-metoden hos instansen av Pin. Denna omkostnad kan elimineras genom att utföra en läsning/skrivning till den relevanta biten i chippets GPIO-ports utdataregister (ODR). För att underlätta detta tillhandahåller modulen stm en uppsättning konstanter som anger adresserna till de relevanta registren (stm.GPIOC är basadressen för GPIOC-porten, stm.GPIO_ODR offseten för dess utdataregister). Som ovan är den blå LED:en på den ursprungliga OpenMV Cam GPIOC stift 2, så en snabb växling av den kan utföras enligt följande:

import machine
import stm

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