Maksimiziranje brzine MicroPythona¶
Ovaj vodič opisuje načine poboljšanja performansi MicroPython koda. Optimizacije koje uključuju druge jezike obrađene su drugdje, naime upotreba modula napisanih u C-u i MicroPython inline asembler.
Proces razvoja koda visokih performansi sastoji se od sljedećih faza koje treba provesti navedenim redoslijedom.
Dizajn za brzinu.
Pisanje i otklanjanje pogrešaka koda.
Koraci optimizacije:
Identificirajte najsporiji dio koda.
Poboljšajte učinkovitost Python koda.
Koristite native emitter koda.
Koristite viper emitter koda.
Koristite optimizacije specifične za hardver.
Dizajn za brzinu¶
Pitanja performansi treba razmotriti od samog početka. To uključuje stvaranje uvida u dijelove koda koji su najkritičniji za performanse i posvećivanje posebne pozornosti njihovu dizajnu. Proces optimizacije počinje kada je kod testiran: ako je dizajn ispravan od samog početka, optimizacija će biti jednostavna i možda zapravo nepotrebna.
Algoritmi¶
Najvažniji aspekt dizajniranja bilo koje rutine za performanse jest osiguravanje da se koristi najbolji algoritam. To je tema za udžbenike, a ne za vodič o MicroPythonu, no spektakularni dobici u performansama ponekad se mogu postići usvajanjem algoritama poznatih po svojoj učinkovitosti.
Alokacija RAM-a¶
Za dizajn učinkovitog MicroPython koda potrebno je razumjeti način na koji interpreter alocira RAM. Kada se objekt stvori ili poraste u veličini (na primjer kada se stavka doda popisu) potrebni RAM se alocira iz bloka poznatog kao heap. To traje značajnu količinu vremena; nadalje, povremeno će pokrenuti proces poznat kao prikupljanje smeća (garbage collection) koji može trajati nekoliko milisekundi.
Posljedično, performanse funkcije ili metode mogu se poboljšati ako se objekt stvori samo jednom i ne dopusti mu se rast u veličini. To podrazumijeva da objekt opstaje tijekom cijelog trajanja svoje upotrebe: tipično će se instancirati u konstruktoru klase i koristiti u raznim metodama.
To je detaljnije obrađeno u odjeljku Upravljanje prikupljanjem smeća u nastavku.
Međuspremnici¶
Primjer navedenog je čest slučaj kada je potreban međuspremnik, poput onog koji se koristi za komunikaciju s uređajem. Tipičan driver stvorit će međuspremnik u konstruktoru i koristiti ga u svojim ulazno/izlaznim metodama koje će se opetovano pozivati.
MicroPython biblioteke obično pružaju podršku za unaprijed alocirane međuspremnike. Na primjer, objekti koji podržavaju stream sučelje (npr. file ili UART) pružaju metodu read() koja alocira novi međuspremnik za pročitane podatke, ali i metodu readinto() za čitanje podataka u postojeći međuspremnik.
Neke korisne klase za stvaranje međuspremnika koji se mogu ponovno koristiti:
Brojevi s pomičnim zarezom¶
Neki MicroPython portovi alociraju brojeve s pomičnim zarezom na heapu. Neki drugi portovi možda nemaju namjenski koprocesor za pomični zarez te aritmetičke operacije na njima izvode „softverski” znatno sporije nego na cijelim brojevima. Tamo gdje su performanse važne, koristite cjelobrojne operacije i ograničite upotrebu pomičnog zareza na dijelove koda gdje performanse nisu od presudne važnosti. Na primjer, zabilježite ADC očitanja kao cjelobrojne vrijednosti u polje u jednom brzom prolazu, i tek ih potom pretvorite u brojeve s pomičnim zarezom za obradu signala.
Polja¶
Razmotrite upotrebu raznih vrsta klasa polja kao alternativu popisima. Modul array podržava razne tipove elemenata, pri čemu su 8-bitni elementi podržani Pythonovim ugrađenim klasama bytes i bytearray. Sve te strukture podataka pohranjuju elemente na susjednim memorijskim lokacijama. Ponovno, kako bi se izbjegla alokacija memorije u kritičnom kodu, ti bi se objekti trebali unaprijed alocirati i proslijediti kao argumenti ili kao vezani objekti.
Memoryview objekti¶
Pri prosljeđivanju isječaka objekata poput instanci bytearray, Python stvara kopiju što uključuje alokaciju veličine proporcionalne veličini isječka. To se može ublažiti upotrebom objekta memoryview. Sam memoryview alocira se na heapu, ali je mali objekt fiksne veličine, neovisno o veličini isječka na koji pokazuje. Isjecanje objekta memoryview stvara novi memoryview, pa se to ne može učiniti u rutini za obradu prekida. Nadalje, sintaksa isječka a:b uzrokuje dodatnu alokaciju instanciranjem objekta 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 se može primijeniti samo na objekte koji podržavaju protokol međuspremnika - to uključuje polja, ali ne i popise. Mala caveat je da dok je memoryview objekt živ, on također drži živim izvorni objekt međuspremnika. Dakle, memoryview nije univerzalni lijek za sve. Na primjer, u gornjem primjeru, ako ste završili s 10K međuspremnikom i trebate samo bajtove 30:2000 iz njega, možda je bolje napraviti isječak i pustiti 10K međuspremnik (neka bude spreman za prikupljanje smeća), umjesto da napravite dugovječni memoryview i držite 10K blokiranim za GC.
Ipak, memoryview je neophodan za napredno upravljanje unaprijed alociranim međuspremnicima. Metoda readinto() o kojoj se raspravljalo gore stavlja podatke na početak međuspremnika i puni cijeli međuspremnik. Što ako trebate staviti podatke u sredinu postojećeg međuspremnika? Samo stvorite memoryview u potrebni odsječak međuspremnika i proslijedite ga metodi readinto().
Stringovi naspram Bytes¶
MicroPython koristi pripajanje stringova (string interning) za uštedu prostora kada postoji više identičnih stringova. Svaki put kada se novi string alocira tijekom izvođenja (na primjer, kada se dva druga stringa spoje), MicroPython provjerava može li se novi string pripojiti radi uštede RAM-a.
Ako imate kod koji izvodi operacije nad stringovima kritične za performanse, razmotrite upotrebu objekata bytes i literala (tj. b"abc"). Time se preskače provjera pripajanja, što može biti nekoliko puta brže od izvođenja istih operacija s string objektima.
Napomena
Najbrže performanse uvijek će se postići potpunim izbjegavanjem stvaranja novih objekata, na primjer pomoću međuspremnika koji se može ponovno koristiti kako je opisano gore.
Identificiranje najsporijeg dijela koda¶
To je proces poznat kao profiliranje i obrađen je u udžbenicima te (za standardni Python) podržan raznim softverskim alatima. Za tip manjih ugrađenih aplikacija koje vjerojatno rade na MicroPython platformama, najsporija funkcija ili metoda obično se može utvrditi razboritom upotrebom grupe funkcija za mjerenje vremena ticks dokumentiranih u time. Vrijeme izvođenja koda može se mjeriti u ms, us ili CPU ciklusima.
Sljedeće omogućuje mjerenje vremena bilo koje funkcije ili metode dodavanjem 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
Poboljšanja MicroPython koda¶
Deklaracija const()¶
MicroPython pruža deklaraciju const(). Ona radi na sličan način kao #define u C-u, u smislu da kada se kod kompilira u bytecode, kompilator zamjenjuje numeričku vrijednost za identifikator. Time se izbjegava pretraživanje rječnika tijekom izvođenja. Argument za const() može biti bilo što što se, u vrijeme kompilacije, evaluira u cijeli broj, npr. 0x100 ili 1 << 8.
Predmemoriranje referenci na objekte¶
Tamo gdje funkcija ili metoda opetovano pristupa objektima, performanse se poboljšavaju predmemoriranjem objekta u lokalnu varijablu:
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
Time se izbjegava potreba za opetovanim pretraživanjem self.ba i obj_display.framebuffer u tijelu metode bar().
Upravljanje prikupljanjem smeća¶
Kada je potrebna alokacija memorije, MicroPython pokušava pronaći blok odgovarajuće veličine na heapu. To može propasti, obično zato što je heap zatrpan objektima na koje kod više ne upućuje. Ako dođe do neuspjeha, proces poznat kao prikupljanje smeća vraća memoriju koju koriste ti suvišni objekti i alokacija se zatim ponovno pokušava - proces koji može trajati nekoliko milisekundi.
Možda postoje koristi u preduhitrenju toga povremenim izdavanjem gc.collect(). Prvo, izvođenje prikupljanja prije nego što je zapravo potrebno je brže - tipično reda veličine 1 ms ako se radi često. Drugo, možete odrediti točku u kodu gdje se to vrijeme koristi umjesto da se duže kašnjenje dogodi na nasumičnim mjestima, eventualno u dijelu kritičnom za brzinu. Konačno, redovito izvođenje prikupljanja može smanjiti fragmentaciju heapa. Ozbiljna fragmentacija može dovesti do nepovratnih neuspjeha alokacije.
Native emitter koda¶
Time se MicroPython kompilator navodi da emitira native CPU opkodove umjesto bytecodea. Pokriva većinu MicroPython funkcionalnosti, pa većina funkcija neće zahtijevati prilagodbu (no vidite u nastavku). Poziva se pomoću dekoratora funkcije:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Postoje određena ograničenja u trenutnoj implementaciji native emittera koda.
Ako se koristi
raise, mora se navesti argument.Pozadinski raspoređivač (vidi
micropython.schedule) ne radi tijekom izvođenja native koda.Na ciljnim platformama s nitima i GIL-om, GIL se ne otpušta tijekom izvođenja native koda.
Kako bi se ublažile posljednje dvije točke, dugotrajne native funkcije trebale bi povremeno pozivati time.sleep(0), što će pokrenuti raspoređivač i odbiti GIL.
Kompromis za poboljšane performanse (otprilike dvostruko brže od bytecodea) je povećanje veličine kompiliranog koda.
Viper emitter koda¶
Optimizacije o kojima se raspravljalo gore uključuju Python kod koji je u skladu sa standardima. Viper emitter koda nije potpuno usklađen. Podržava posebne Viper native tipove podataka u potrazi za performansama. Obrada cijelih brojeva nije usklađena jer koristi strojne riječi: aritmetika na 32-bitnom hardveru izvodi se modulo 2**32.
Poput Native emittera, Viper proizvodi strojne instrukcije, ali se izvode dodatne optimizacije, znatno povećavajući performanse posebno za cjelobrojnu aritmetiku i manipulacije bitovima. Poziva se pomoću dekoratora:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Kao što gornji isječak ilustrira, korisno je koristiti Python type hintove za pomoć Viper optimizatoru. Type hintovi pružaju informacije o tipovima podataka argumenata i povratne vrijednosti; oni su standardna značajka Python jezika formalno definirana ovdje PEP0484. Viper podržava vlastiti skup tipova, naime int, uint (cijeli broj bez predznaka), ptr, ptr8, ptr16 i ptr32. Tipovi ptrX obrađeni su u nastavku. Trenutno tip uint služi jednoj svrsi: kao type hint za povratnu vrijednost funkcije. Ako takva funkcija vrati 0xffffffff, Python će rezultat interpretirati kao 2**32 -1, a ne kao -1.
Uz ograničenja koja nameće native emitter, primjenjuju se sljedeća ograničenja:
Zadane vrijednosti argumenata nisu dopuštene.
Pomični zarez se može koristiti, ali nije optimiziran.
Viper pruža tipove pokazivača za pomoć optimizatoru. Oni obuhvaćaju
ptrPokazivač na objekt.ptr8Pokazuje na bajt.ptr16Pokazuje na 16-bitnu polu-riječ.ptr32Pokazuje na 32-bitnu strojnu riječ.
Koncept pokazivača može biti nepoznat Python programerima. Ima sličnosti s Pythonovim objektom memoryview u tome što pruža izravan pristup podacima pohranjenima u memoriji. Stavkama se pristupa pomoću notacije indeksa, ali isječci nisu podržani: pokazivač može vratiti samo jednu stavku. Njegova je svrha pružiti brzi nasumični pristup podacima pohranjenima na susjednim memorijskim lokacijama - poput podataka pohranjenih u objektima koji podržavaju protokol međuspremnika, te memorijski mapiranih registara periferije u mikrokontroleru. Treba napomenuti da je programiranje pomoću pokazivača opasno: ne provodi se provjera granica i kompilator ne čini ništa kako bi spriječio pogreške prekoračenja međuspremnika.
Tipična upotreba je predmemoriranje varijabli:
@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
U ovom slučaju kompilator „zna” da je buf adresa polja bajtova; može emitirati kod za brzo izračunavanje adrese buf[x] tijekom izvođenja. Tamo gdje se castovi koriste za pretvaranje objekata u Viper native tipove, oni bi se trebali izvoditi na početku funkcije, a ne u vremenski kritičnim petljama, jer operacija casta može trajati nekoliko mikrosekundi. Pravila za castanje su sljedeća:
Operatori za castanje trenutno su:
int,bool,uint,ptr,ptr8,ptr16iptr32.Rezultat casta bit će native Viper varijabla.
Argumenti casta mogu biti Python objekt ili native Viper varijabla.
Ako je argument native Viper varijabla, tada je cast no-op (tj. ne košta ništa tijekom izvođenja) koji samo mijenja tip (npr. iz
uintuptr8) tako da zatim možete pohranjivati/učitavati pomoću tog pokazivača.Ako je argument Python objekt, a cast je
intiliuint, tada Python objekt mora biti cjelobrojnog tipa i vraća se vrijednost tog cjelobrojnog objekta.Argument bool casta mora biti cjelobrojnog tipa (boolean ili cijeli broj); kada se koristi kao povratni tip, viper funkcija će vratiti objekte True ili False.
Ako je argument Python objekt, a cast je
ptr,ptr8,ptr16iliptr32, tada Python objekt mora ili podržavati protokol međuspremnika (u kojem se slučaju vraća pokazivač na početak međuspremnika) ili mora biti cjelobrojnog tipa (u kojem se slučaju vraća vrijednost tog cjelobrojnog objekta).
Pisanje u pokazivač koji pokazuje na objekt samo za čitanje dovest će do nedefiniranog ponašanja.
Napomena
Primjeri koda u nastavku dani su za OpenMV Cam uređaje temeljene na STM32, koji pružaju modul stm. Opisane tehnike primjenjuju se općenito.
Modul stm izlaže memorijske adrese registara periferije MCU-a. Svaki GPIO port ima registar izlaznih podataka (ODR) čiji se bitovi preslikavaju jedan-na-jedan na pinove tog porta: pisanje u registar izravno upravlja tim pinovima, bez dodatnih troškova poziva metode machine.Pin, a XOR-iranje bita prebacuje njegov pin. Na izvornom OpenMV Camu plava LED je spojena na GPIOC pin 2, pa sljedeći primjer koristi ptr16 cast za prebacivanje plave LED n puta:
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
Detaljan tehnički opis triju emittera koda može se pronaći na Kickstarteru ovdje Note 1 i ovdje Note 2
Izravan pristup hardveru¶
Ovo spada u kategoriju naprednijeg programiranja i uključuje nešto znanja o ciljnom MCU-u. Razmotrite primjer prebacivanja izlaznog pina na OpenMV Camu. Standardni pristup bio bi napisati
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
To uključuje dodatne troškove dvaju poziva metode value() instance Pin. Ti se dodatni troškovi mogu eliminirati izvođenjem čitanja/pisanja u relevantni bit registra izlaznih podataka GPIO porta čipa (ODR). Kako bi se to olakšalo, modul stm pruža skup konstanti koje daju adrese relevantnih registara (stm.GPIOC je bazna adresa GPIOC porta, stm.GPIO_ODR je odmak njegova registra izlaznih podataka). Kao i gore, plava LED na izvornom OpenMV Camu je GPIOC pin 2, pa se njezino brzo prebacivanje može izvesti na sljedeći način:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2