Maximaliseren van de snelheid van MicroPython¶
Deze tutorial beschrijft manieren om de prestaties van MicroPython-code te verbeteren. Optimalisaties waarbij andere talen betrokken zijn, worden elders behandeld, namelijk het gebruik van modules geschreven in C en de inline assembler van MicroPython.
Het proces van het ontwikkelen van hoogwaardige code bestaat uit de volgende fasen, die in de vermelde volgorde uitgevoerd moeten worden.
Ontwerpen voor snelheid.
Coderen en debuggen.
Optimalisatiestappen:
Identificeer het traagste deel van de code.
Verbeter de efficiëntie van de Python-code.
Gebruik de native code emitter.
Gebruik de viper code emitter.
Gebruik hardwarespecifieke optimalisaties.
Ontwerpen voor snelheid¶
Prestatieproblemen moeten van meet af aan in overweging worden genomen. Dit houdt in dat je een beeld vormt van de delen van de code die het meest prestatiekritisch zijn en bijzondere aandacht besteedt aan hun ontwerp. Het optimalisatieproces begint wanneer de code getest is: als het ontwerp van meet af aan correct is, zal de optimalisatie eenvoudig zijn en zelfs onnodig kunnen blijken.
Algoritmen¶
Het belangrijkste aspect van het ontwerpen van een routine voor prestaties is ervoor zorgen dat het beste algoritme wordt gebruikt. Dit is een onderwerp voor studieboeken in plaats van voor een MicroPython-gids, maar er kunnen soms spectaculaire prestatiewinsten worden behaald door algoritmen toe te passen die bekendstaan om hun efficiëntie.
RAM-toewijzing¶
Om efficiënte MicroPython-code te ontwerpen is het noodzakelijk om te begrijpen hoe de interpreter RAM toewijst. Wanneer een object wordt aangemaakt of groeit in omvang (bijvoorbeeld wanneer een item aan een lijst wordt toegevoegd), wordt het benodigde RAM toegewezen vanuit een blok dat bekendstaat als de heap. Dit kost een aanzienlijke hoeveelheid tijd; bovendien zal het soms een proces activeren dat bekendstaat als garbage collection, wat enkele milliseconden kan duren.
Bijgevolg kunnen de prestaties van een functie of methode worden verbeterd als een object slechts eenmaal wordt aangemaakt en niet mag groeien in omvang. Dit impliceert dat het object blijft bestaan gedurende de hele gebruiksduur: doorgaans wordt het geïnstantieerd in een class-constructor en gebruikt in verschillende methoden.
Dit wordt hieronder verder in detail behandeld onder Garbage collection beheren.
Buffers¶
Een voorbeeld van het bovenstaande is het veelvoorkomende geval waarin een buffer nodig is, zoals een buffer die wordt gebruikt voor communicatie met een apparaat. Een typische driver zal de buffer aanmaken in de constructor en deze gebruiken in zijn I/O-methoden, die herhaaldelijk worden aangeroepen.
De MicroPython-bibliotheken bieden doorgaans ondersteuning voor vooraf toegewezen buffers. Objecten die de stream-interface ondersteunen (bijvoorbeeld file of UART) bieden bijvoorbeeld een read()-methode die een nieuwe buffer toewijst voor gelezen gegevens, maar ook een readinto()-methode om gegevens in een bestaande buffer te lezen.
Enkele nuttige classes voor het maken van herbruikbare buffer-objecten:
Drijvende komma¶
Sommige MicroPython-ports wijzen drijvende-kommagetallen toe op de heap. Sommige andere ports missen mogelijk een speciale drijvende-kommacoprocessor en voeren rekenkundige bewerkingen daarop uit in “software” met een aanzienlijk lagere snelheid dan op gehele getallen. Wanneer prestaties belangrijk zijn, gebruik dan bewerkingen met gehele getallen en beperk het gebruik van drijvende komma tot delen van de code waar prestaties niet van het grootste belang zijn. Leg bijvoorbeeld ADC-metingen in één snelle stap vast als gehele waarden in een array, en converteer ze pas daarna naar drijvende-kommagetallen voor signaalverwerking.
Arrays¶
Overweeg het gebruik van de verschillende soorten array-classes als alternatief voor lijsten. De array-module ondersteunt verschillende elementtypes, waarbij 8-bits elementen worden ondersteund door de ingebouwde bytes- en bytearray-classes van Python. Deze datastructuren slaan alle elementen op in aaneengesloten geheugenlocaties. Nogmaals, om geheugentoewijzing in kritieke code te vermijden, moeten deze vooraf worden toegewezen en als argumenten of als gebonden objecten worden doorgegeven.
Memoryviews¶
Bij het doorgeven van slices van objecten zoals bytearray-instanties maakt Python een kopie, wat een toewijzing inhoudt waarvan de omvang evenredig is aan de grootte van de slice. Dit kan worden verlicht met behulp van een memoryview-object. De memoryview zelf wordt op de heap toegewezen, maar is een klein object met een vaste omvang, ongeacht de grootte van de slice waarnaar het wijst. Het slicen van een memoryview creëert een nieuwe memoryview, dus dit kan niet worden gedaan in een interrupt service routine. Bovendien veroorzaakt de slice-syntaxis a:b verdere toewijzing door een slice(a, b)-object te instantiëren.
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
Een memoryview kan alleen worden toegepast op objecten die het buffer-protocol ondersteunen - dit omvat arrays maar geen lijsten. Een kleine kanttekening is dat zolang een memoryview-object leeft, het ook het oorspronkelijke buffer-object in leven houdt. Een memoryview is dus geen universeel wondermiddel. Als je bijvoorbeeld in het bovenstaande voorbeeld klaar bent met de buffer van 10K en alleen die bytes 30:2000 ervan nodig hebt, kan het beter zijn om een slice te maken en de buffer van 10K te laten gaan (klaar voor garbage collection), in plaats van een langlevende memoryview te maken en 10K geblokkeerd te houden voor GC.
Niettemin is memoryview onmisbaar voor geavanceerd beheer van vooraf toegewezen buffers. De hierboven besproken readinto()-methode plaatst gegevens aan het begin van de buffer en vult de gehele buffer. Wat als je gegevens in het midden van een bestaande buffer moet plaatsen? Maak gewoon een memoryview naar het benodigde gedeelte van de buffer en geef deze door aan readinto().
Strings versus Bytes¶
MicroPython gebruikt string interning om ruimte te besparen wanneer er meerdere identieke strings zijn. Telkens wanneer er tijdens runtime een nieuwe string wordt toegewezen (bijvoorbeeld wanneer twee andere strings worden samengevoegd), controleert MicroPython of de nieuwe string kan worden geïnternd om RAM te besparen.
Als je code hebt die prestatiekritische stringbewerkingen uitvoert, overweeg dan het gebruik van bytes-objecten en -literals (d.w.z. b"abc"). Dit slaat de interning-controle over en kan meerdere keren sneller zijn dan het uitvoeren van dezelfde bewerkingen met string-objecten.
Notitie
De snelste prestaties worden altijd bereikt door het aanmaken van nieuwe objecten volledig te vermijden, bijvoorbeeld met een herbruikbare buffer zoals hierboven beschreven.
Het traagste deel van de code identificeren¶
Dit is een proces dat bekendstaat als profiling en dat wordt behandeld in studieboeken en (voor standaard Python) wordt ondersteund door verschillende softwaretools. Voor het soort kleinere embedded toepassing dat waarschijnlijk op MicroPython-platforms draait, kan de traagste functie of methode meestal worden vastgesteld door oordeelkundig gebruik van de timing ticks-groep van functies die gedocumenteerd zijn in time. De uitvoeringstijd van code kan worden gemeten in ms, us of CPU-cycli.
Het volgende maakt het mogelijk om elke functie of methode te timen door een @timed_function-decorator toe te voegen:
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
Verbeteringen aan MicroPython-code¶
De const()-declaratie¶
MicroPython biedt een const()-declaratie. Dit werkt op een vergelijkbare manier als #define in C, in die zin dat de compiler bij het compileren van de code naar bytecode de numerieke waarde voor de identifier substitueert. Dit vermijdt een dictionary-lookup tijdens runtime. Het argument voor const() mag alles zijn dat tijdens het compileren evalueert tot een geheel getal, bijvoorbeeld 0x100 of 1 << 8.
Objectreferenties cachen¶
Wanneer een functie of methode herhaaldelijk objecten benadert, worden de prestaties verbeterd door het object te cachen in een lokale variabele:
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
Dit voorkomt de noodzaak om herhaaldelijk self.ba en obj_display.framebuffer op te zoeken in de body van de methode bar().
Garbage collection beheren¶
Wanneer geheugentoewijzing vereist is, probeert MicroPython een blok van voldoende grootte op de heap te vinden. Dit kan mislukken, meestal omdat de heap vol staat met objecten waarnaar door de code niet langer wordt verwezen. Als er een fout optreedt, wint het proces dat bekendstaat als garbage collection het geheugen terug dat door deze overbodige objecten werd gebruikt, en wordt de toewijzing vervolgens opnieuw geprobeerd - een proces dat enkele milliseconden kan duren.
Het kan voordelig zijn hierop te anticiperen door periodiek gc.collect() uit te voeren. Ten eerste is het uitvoeren van een collection voordat deze daadwerkelijk vereist is sneller - doorgaans in de orde van 1 ms als dit regelmatig gebeurt. Ten tweede kun je het punt in de code bepalen waar deze tijd wordt gebruikt, in plaats van een langere vertraging op willekeurige punten te laten optreden, mogelijk in een snelheidskritisch deel. Ten slotte kan het regelmatig uitvoeren van collections de fragmentatie in de heap verminderen. Ernstige fragmentatie kan leiden tot onherstelbare toewijzingsfouten.
De Native code emitter¶
Dit zorgt ervoor dat de MicroPython-compiler native CPU-opcodes uitzendt in plaats van bytecode. Het dekt het grootste deel van de functionaliteit van MicroPython, dus de meeste functies vereisen geen aanpassing (maar zie hieronder). Het wordt aangeroepen door middel van een functie-decorator:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Er zijn bepaalde beperkingen in de huidige implementatie van de native code emitter.
Als
raisewordt gebruikt, moet er een argument worden opgegeven.De achtergrondscheduler (zie
micropython.schedule) wordt niet uitgevoerd tijdens de uitvoering van native code.Op targets met threading en de GIL wordt de GIL niet vrijgegeven tijdens de uitvoering van native code.
Om de laatste twee punten te verzachten, moeten langlopende native functies periodiek time.sleep(0) aanroepen, wat de scheduler uitvoert en de GIL laat doorschakelen.
De afweging voor de verbeterde prestaties (ongeveer twee keer zo snel als bytecode) is een toename van de gecompileerde codegrootte.
De Viper code emitter¶
De hierboven besproken optimalisaties hebben betrekking op Python-code die voldoet aan de standaarden. De Viper code emitter voldoet niet volledig. Hij ondersteunt speciale Viper-native datatypes om prestaties na te streven. Verwerking van gehele getallen is niet-conform omdat het machinewoorden gebruikt: rekenkunde op 32-bits hardware wordt uitgevoerd modulo 2**32.
Net als de Native emitter produceert Viper machine-instructies, maar er worden verdere optimalisaties uitgevoerd, die de prestaties aanzienlijk verhogen, vooral voor rekenkunde met gehele getallen en bitmanipulaties. Het wordt aangeroepen met behulp van een decorator:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Zoals het bovenstaande fragment illustreert, is het nuttig om Python type hints te gebruiken om de Viper-optimalisator te helpen. Type hints bieden informatie over de datatypes van argumenten en van de retourwaarde; dit is een standaardtaalfunctie van Python die hier formeel is gedefinieerd PEP0484. Viper ondersteunt zijn eigen set types, namelijk int, uint (unsigned integer), ptr, ptr8, ptr16 en ptr32. De ptrX-types worden hieronder besproken. Momenteel dient het uint-type een enkel doel: als type hint voor de retourwaarde van een functie. Als zo’n functie 0xffffffff retourneert, interpreteert Python het resultaat als 2**32 -1 in plaats van als -1.
Naast de beperkingen die door de native emitter worden opgelegd, gelden de volgende beperkingen:
Standaardwaarden voor argumenten zijn niet toegestaan.
Drijvende komma mag worden gebruikt, maar wordt niet geoptimaliseerd.
Viper biedt pointer-types om de optimalisator te helpen. Deze omvatten
ptrPointer naar een object.ptr8Wijst naar een byte.ptr16Wijst naar een 16-bits half-woord.ptr32Wijst naar een 32-bits machinewoord.
Het concept van een pointer is wellicht onbekend voor Python-programmeurs. Het vertoont overeenkomsten met een Python memoryview-object in die zin dat het directe toegang biedt tot gegevens die in het geheugen zijn opgeslagen. Items worden benaderd met subscript-notatie, maar slices worden niet ondersteund: een pointer kan slechts één item retourneren. Het doel ervan is snelle willekeurige toegang te bieden tot gegevens die zijn opgeslagen in aaneengesloten geheugenlocaties - zoals gegevens die zijn opgeslagen in objecten die het buffer-protocol ondersteunen, en geheugengemapte randapparaatregisters in een microcontroller. Er moet worden opgemerkt dat programmeren met pointers gevaarlijk is: er wordt geen bereikcontrole uitgevoerd en de compiler doet niets om buffer-overrun-fouten te voorkomen.
Typisch gebruik is het cachen van variabelen:
@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
In dit geval “weet” de compiler dat buf het adres is van een array van bytes; hij kan code uitzenden om tijdens runtime snel het adres van buf[x] te berekenen. Wanneer casts worden gebruikt om objecten om te zetten naar Viper-native types, moeten deze aan het begin van de functie worden uitgevoerd in plaats van in kritische timing-lussen, aangezien de cast-bewerking enkele microseconden kan duren. De regels voor casten zijn als volgt:
Cast-operatoren zijn momenteel:
int,bool,uint,ptr,ptr8,ptr16enptr32.Het resultaat van een cast is een native Viper-variabele.
Argumenten voor een cast kunnen een Python-object of een native Viper-variabele zijn.
Als het argument een native Viper-variabele is, dan is de cast een no-op (d.w.z. kost niets tijdens runtime) die alleen het type wijzigt (bijvoorbeeld van
uintnaarptr8) zodat je vervolgens kunt opslaan/laden met deze pointer.Als het argument een Python-object is en de cast
intofuintis, dan moet het Python-object van een integraal type zijn en wordt de waarde van dat integrale object geretourneerd.Het argument voor een bool-cast moet van een integraal type zijn (boolean of integer); wanneer gebruikt als retourtype zal de viper-functie True- of False-objecten retourneren.
Als het argument een Python-object is en de cast
ptr,ptr8,ptr16ofptr32is, dan moet het Python-object ofwel het buffer-protocol hebben (in welk geval een pointer naar het begin van de buffer wordt geretourneerd) of het moet van een integraal type zijn (in welk geval de waarde van dat integrale object wordt geretourneerd).
Schrijven naar een pointer die wijst naar een alleen-lezen object zal leiden tot ongedefinieerd gedrag.
Notitie
De onderstaande codevoorbeelden worden gegeven voor de op STM32 gebaseerde OpenMV Cams, die de stm-module bieden. De beschreven technieken zijn algemeen van toepassing.
De stm-module legt de geheugenadressen van de randapparaatregisters van de MCU bloot. Elke GPIO-poort heeft een output data register (ODR) waarvan de bits één-op-één in kaart worden gebracht op de pinnen van die poort: het schrijven naar het register stuurt die pinnen direct aan, zonder de overhead van een aanroep van een machine.Pin-methode, en het XOR-en van een bit schakelt de bijbehorende pin om. Op de originele OpenMV Cam is de blauwe LED bedraad naar GPIOC pin 2, dus het volgende voorbeeld gebruikt een ptr16-cast om de blauwe LED n keer om te schakelen:
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
Een gedetailleerde technische beschrijving van de drie code emitters is te vinden op Kickstarter, hier Noot 1 en hier Noot 2
Hardware direct benaderen¶
Dit valt in de categorie van meer geavanceerd programmeren en vereist enige kennis van de doel-MCU. Bekijk het voorbeeld van het omschakelen van een uitvoerpin op een OpenMV Cam. De standaardaanpak zou zijn om te schrijven
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Dit brengt de overhead met zich mee van twee aanroepen van de value()-methode van de Pin-instantie. Deze overhead kan worden geëlimineerd door een lees-/schrijfbewerking uit te voeren op de relevante bit van het output data register (ODR) van de GPIO-poort van de chip. Om dit te vergemakkelijken biedt de stm-module een set constanten die de adressen van de relevante registers geven (stm.GPIOC is het basisadres van de GPIOC-poort, stm.GPIO_ODR de offset van het output data register ervan). Zoals hierboven is de blauwe LED op de originele OpenMV Cam GPIOC pin 2, dus een snelle omschakeling ervan kan als volgt worden uitgevoerd:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2