Maximizarea vitezei MicroPython¶
Acest tutorial descrie modalități de îmbunătățire a performanței codului MicroPython. Optimizările care implică alte limbaje sunt tratate în altă parte, și anume utilizarea modulelor scrise în C și asamblorul inline al MicroPython.
Procesul de dezvoltare a codului de înaltă performanță cuprinde următoarele etape, care ar trebui efectuate în ordinea enumerată.
Proiectarea pentru viteză.
Scrierea și depanarea codului.
Pași de optimizare:
Identificarea celei mai lente secțiuni de cod.
Îmbunătățirea eficienței codului Python.
Utilizarea emițătorului de cod nativ.
Utilizarea emițătorului de cod viper.
Utilizarea optimizărilor specifice hardware-ului.
Proiectarea pentru viteză¶
Problemele de performanță ar trebui luate în considerare încă de la început. Acest lucru presupune analizarea secțiunilor de cod care sunt cele mai critice pentru performanță și acordarea unei atenții deosebite proiectării lor. Procesul de optimizare începe după ce codul a fost testat: dacă proiectarea este corectă de la început, optimizarea va fi simplă și ar putea fi chiar inutilă.
Algoritmi¶
Cel mai important aspect al proiectării oricărei rutine pentru performanță este asigurarea utilizării celui mai bun algoritm. Acesta este un subiect pentru manuale, mai degrabă decât pentru un ghid MicroPython, dar uneori se pot obține câștiguri spectaculoase de performanță prin adoptarea unor algoritmi cunoscuți pentru eficiența lor.
Alocarea RAM¶
Pentru a proiecta cod MicroPython eficient este necesar să înțelegem modul în care interpretorul alocă memoria RAM. Atunci când un obiect este creat sau crește în dimensiune (de exemplu, când un element este adăugat la o listă), memoria RAM necesară este alocată dintr-un bloc cunoscut sub numele de heap. Acest lucru durează un timp semnificativ; în plus, va declanșa ocazional un proces cunoscut sub numele de colectare a gunoiului (garbage collection), care poate dura câteva milisecunde.
În consecință, performanța unei funcții sau metode poate fi îmbunătățită dacă un obiect este creat o singură dată și nu i se permite să crească în dimensiune. Acest lucru presupune că obiectul persistă pe toată durata utilizării sale: de obicei va fi instanțiat în constructorul unei clase și utilizat în diverse metode.
Acest aspect este tratat în detaliu la secțiunea Controlarea colectării gunoiului de mai jos.
Tampoane (buffers)¶
Un exemplu al celor de mai sus este cazul frecvent în care este necesar un tampon (buffer), precum cel utilizat pentru comunicarea cu un dispozitiv. Un driver tipic va crea tamponul în constructor și îl va utiliza în metodele sale de I/O, care vor fi apelate în mod repetat.
Bibliotecile MicroPython oferă de obicei suport pentru tampoane prealocate. De exemplu, obiectele care acceptă interfața de tip stream (de ex. fișier sau UART) oferă metoda read() care alocă un nou tampon pentru datele citite, dar și o metodă readinto() pentru a citi datele într-un tampon existent.
Câteva clase utile pentru crearea de obiecte tampon reutilizabile:
Virgulă mobilă¶
Unele porturi MicroPython alocă numerele în virgulă mobilă pe heap. Alte porturi pot să nu dispună de un coprocesor dedicat pentru virgulă mobilă și efectuează operațiile aritmetice asupra acestora în „software”, la o viteză considerabil mai mică decât asupra numerelor întregi. Acolo unde performanța contează, utilizați operații pe numere întregi și restrângeți utilizarea virgulei mobile la secțiunile de cod în care performanța nu este primordială. De exemplu, capturați citirile ADC ca valori întregi într-un array dintr-o singură mișcare rapidă și abia apoi convertiți-le în numere în virgulă mobilă pentru procesarea semnalului.
Array-uri¶
Luați în considerare utilizarea diverselor tipuri de clase de array ca alternativă la liste. Modulul array acceptă diverse tipuri de elemente, cu elemente pe 8 biți susținute de clasele încorporate bytes și bytearray ale Python. Aceste structuri de date stochează toate elementele în locații de memorie contigue. Din nou, pentru a evita alocarea de memorie în codul critic, acestea ar trebui prealocate și transmise ca argumente sau ca obiecte legate.
Memoryview-uri¶
La transmiterea de secțiuni (slices) ale unor obiecte precum instanțele bytearray, Python creează o copie care implică o alocare proporțională cu dimensiunea secțiunii. Acest lucru poate fi atenuat utilizând un obiect memoryview. Obiectul memoryview în sine este alocat pe heap, dar este un obiect mic, de dimensiune fixă, indiferent de dimensiunea secțiunii la care indică. Secționarea unui memoryview creează un nou memoryview, așa că acest lucru nu poate fi făcut într-o rutină de tratare a întreruperilor. În plus, sintaxa de secționare a:b provoacă o alocare suplimentară prin instanțierea unui obiect 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
Un memoryview poate fi aplicat doar obiectelor care acceptă protocolul de tampon (buffer) - acestea includ array-urile, dar nu și listele. O mică precizare este că, atâta timp cât obiectul memoryview este activ, el menține activ și obiectul tampon original. Așadar, un memoryview nu este un panaceu universal. De exemplu, în exemplul de mai sus, dacă ați terminat cu tamponul de 10K și aveți nevoie doar de octeții 30:2000 din el, ar putea fi mai bine să faceți o secțiune și să lăsați tamponul de 10K să fie eliberat (să fie disponibil pentru colectarea gunoiului), în loc să creați un memoryview de lungă durată și să mențineți cei 10K blocați pentru GC.
Cu toate acestea, memoryview este indispensabil pentru gestionarea avansată a tampoanelor prealocate. Metoda readinto() discutată mai sus plasează datele la începutul tamponului și umple întregul tampon. Dar ce faceți dacă trebuie să plasați datele la mijlocul unui tampon existent? Pur și simplu creați un memoryview în secțiunea necesară a tamponului și transmiteți-l către readinto().
Șiruri de caractere vs octeți¶
MicroPython utilizează internarea șirurilor pentru a economisi spațiu atunci când există mai multe șiruri identice. De fiecare dată când un nou șir este alocat în timpul execuției (de exemplu, când două alte șiruri sunt concatenate), MicroPython verifică dacă noul șir poate fi internat pentru a economisi RAM.
Dacă aveți cod care efectuează operații pe șiruri critice pentru performanță, luați în considerare utilizarea obiectelor și literalelor bytes (de exemplu b"abc"). Acest lucru omite verificarea de internare și poate fi de câteva ori mai rapid decât efectuarea acelorași operații cu obiecte de tip șir.
Notă
Cea mai bună performanță va fi întotdeauna obținută evitând complet crearea de noi obiecte, de exemplu cu un tampon reutilizabil descris mai sus.
Identificarea celei mai lente secțiuni de cod¶
Acesta este un proces cunoscut sub numele de profilare și este tratat în manuale, fiind susținut (pentru Python standard) de diverse instrumente software. Pentru tipul de aplicație încorporată mai mică, susceptibilă să ruleze pe platforme MicroPython, cea mai lentă funcție sau metodă poate fi de obicei stabilită prin utilizarea judicioasă a grupului de funcții de cronometrare ticks documentate în time. Timpul de execuție al codului poate fi măsurat în ms, us sau cicluri CPU.
Următorul exemplu permite cronometrarea oricărei funcții sau metode prin adăugarea unui decorator @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
Îmbunătățiri ale codului MicroPython¶
Declarația const()¶
MicroPython oferă o declarație const(). Aceasta funcționează într-un mod similar cu #define din C, în sensul că, atunci când codul este compilat în bytecode, compilatorul substituie identificatorul cu valoarea numerică. Acest lucru evită o căutare în dicționar în timpul execuției. Argumentul pentru const() poate fi orice se evaluează, la momentul compilării, la un număr întreg, de ex. 0x100 sau 1 << 8.
Memorarea în cache a referințelor la obiecte¶
Acolo unde o funcție sau metodă accesează în mod repetat obiecte, performanța este îmbunătățită prin memorarea în cache a obiectului într-o variabilă locală:
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
Acest lucru evită necesitatea de a căuta în mod repetat self.ba și obj_display.framebuffer în corpul metodei bar().
Controlarea colectării gunoiului¶
Atunci când este necesară alocarea de memorie, MicroPython încearcă să localizeze un bloc de dimensiune adecvată pe heap. Acest lucru poate eșua, de obicei pentru că heap-ul este aglomerat cu obiecte care nu mai sunt referențiate de cod. Dacă apare un eșec, procesul cunoscut sub numele de colectare a gunoiului recuperează memoria utilizată de aceste obiecte redundante, iar alocarea este apoi încercată din nou - un proces care poate dura câteva milisecunde.
Pot exista beneficii în prevenirea acestui lucru prin emiterea periodică a gc.collect(). În primul rând, efectuarea unei colectări înainte ca aceasta să fie efectiv necesară este mai rapidă - de obicei de ordinul a 1ms dacă este efectuată frecvent. În al doilea rând, puteți determina punctul din cod în care este utilizat acest timp, în loc să apară o întârziere mai lungă în puncte aleatorii, posibil într-o secțiune critică pentru viteză. În cele din urmă, efectuarea regulată a colectărilor poate reduce fragmentarea heap-ului. Fragmentarea severă poate duce la eșecuri de alocare nerecuperabile.
Emițătorul de cod nativ¶
Acesta determină compilatorul MicroPython să emită opcoduri CPU native, mai degrabă decât bytecode. Acoperă cea mai mare parte a funcționalității MicroPython, așa că majoritatea funcțiilor nu vor necesita nicio adaptare (dar vezi mai jos). Este invocat prin intermediul unui decorator de funcție:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Există anumite limitări în implementarea actuală a emițătorului de cod nativ.
Dacă se utilizează
raise, trebuie furnizat un argument.Planificatorul de fundal (vezi
micropython.schedule) nu este rulat în timpul execuției codului nativ.Pe țintele cu fire de execuție și GIL, GIL-ul nu este eliberat în timpul execuției codului nativ.
Pentru a atenua ultimele două puncte, funcțiile native de lungă durată ar trebui să apeleze periodic time.sleep(0), care va rula planificatorul și va elibera temporar GIL-ul.
Compromisul pentru performanța îmbunătățită (de aproximativ două ori mai rapid decât bytecode-ul) este o creștere a dimensiunii codului compilat.
Emițătorul de cod Viper¶
Optimizările discutate mai sus implică cod Python conform standardelor. Emițătorul de cod Viper nu este pe deplin conform. Acesta acceptă tipuri de date native Viper speciale în căutarea performanței. Procesarea numerelor întregi este neconformă deoarece utilizează cuvinte mașină: aritmetica pe hardware pe 32 de biți este efectuată modulo 2**32.
Asemenea emițătorului nativ, Viper produce instrucțiuni mașină, dar sunt efectuate optimizări suplimentare, crescând substanțial performanța, în special pentru aritmetica cu numere întregi și manipulările de biți. Este invocat utilizând un decorator:
@micropython.viper
def foo(self, arg: int) -> int:
# code
După cum ilustrează fragmentul de mai sus, este benefic să utilizați indicii de tip (type hints) Python pentru a asista optimizatorul Viper. Indiciile de tip oferă informații despre tipurile de date ale argumentelor și ale valorii returnate; acestea sunt o caracteristică standard a limbajului Python, definită formal aici PEP0484. Viper acceptă propriul set de tipuri, și anume int, uint (întreg fără semn), ptr, ptr8, ptr16 și ptr32. Tipurile ptrX sunt discutate mai jos. În prezent, tipul uint servește unui singur scop: ca indiciu de tip pentru o valoare returnată de o funcție. Dacă o astfel de funcție returnează 0xffffffff, Python va interpreta rezultatul ca 2**32 -1 mai degrabă decât ca -1.
Pe lângă restricțiile impuse de emițătorul nativ, se aplică următoarele constrângeri:
Valorile implicite ale argumentelor nu sunt permise.
Virgula mobilă poate fi utilizată, dar nu este optimizată.
Viper oferă tipuri de pointer pentru a asista optimizatorul. Acestea cuprind
ptrPointer către un obiect.ptr8Indică spre un octet.ptr16Indică spre un semicuvânt pe 16 biți.ptr32Indică spre un cuvânt mașină pe 32 de biți.
Conceptul de pointer poate fi necunoscut programatorilor Python. Are asemănări cu un obiect memoryview din Python prin aceea că oferă acces direct la datele stocate în memorie. Elementele sunt accesate folosind notația cu indici, dar secțiunile (slices) nu sunt acceptate: un pointer poate returna doar un singur element. Scopul său este de a oferi acces aleatoriu rapid la datele stocate în locații de memorie contigue - cum ar fi datele stocate în obiecte care acceptă protocolul de tampon (buffer) și registrele de periferice mapate în memorie dintr-un microcontroler. Trebuie remarcat că programarea utilizând pointeri este periculoasă: nu se efectuează verificarea limitelor, iar compilatorul nu face nimic pentru a preveni erorile de depășire a tamponului.
Utilizarea tipică este memorarea în cache a variabilelor:
@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
În acest caz, compilatorul „știe” că buf este adresa unui array de octeți; el poate emite cod pentru a calcula rapid adresa lui buf[x] în timpul execuției. Acolo unde se folosesc conversii (casts) pentru a converti obiecte în tipuri native Viper, acestea ar trebui efectuate la începutul funcției, mai degrabă decât în bucle critice de cronometrare, deoarece operația de conversie poate dura câteva microsecunde. Regulile pentru conversie sunt următoarele:
Operatorii de conversie sunt în prezent:
int,bool,uint,ptr,ptr8,ptr16șiptr32.Rezultatul unei conversii va fi o variabilă nativă Viper.
Argumentele unei conversii pot fi un obiect Python sau o variabilă nativă Viper.
Dacă argumentul este o variabilă nativă Viper, atunci conversia este o operație nulă (no-op) (adică nu costă nimic în timpul execuției) care doar schimbă tipul (de ex. din
uintînptr8), astfel încât să puteți apoi stoca/încărca folosind acest pointer.Dacă argumentul este un obiect Python, iar conversia este
intsauuint, atunci obiectul Python trebuie să fie de tip integral, iar valoarea acelui obiect integral este returnată.Argumentul unei conversii bool trebuie să fie de tip integral (boolean sau întreg); când este utilizat ca tip de retur, funcția viper va returna obiecte True sau False.
Dacă argumentul este un obiect Python, iar conversia este
ptr,ptr8,ptr16sauptr32, atunci obiectul Python trebuie fie să dispună de protocolul de tampon (buffer) (caz în care se returnează un pointer către începutul tamponului), fie să fie de tip integral (caz în care se returnează valoarea acelui obiect integral).
Scrierea într-un pointer care indică spre un obiect doar-citire va duce la comportament nedefinit.
Notă
Exemplele de cod de mai jos sunt date pentru camerele OpenMV Cam bazate pe STM32, care oferă modulul stm. Tehnicile descrise se aplică în general.
Modulul stm expune adresele de memorie ale registrelor de periferice ale MCU-ului. Fiecare port GPIO are un registru de date de ieșire (ODR) ai cărui biți se mapează unu-la-unu la pinii acelui port: scrierea în registru acționează direct acei pini, fără supraîncărcarea unui apel de metodă machine.Pin, iar aplicarea XOR pe un bit comută pinul acestuia. Pe camera OpenMV Cam originală, LED-ul albastru este conectat la pinul 2 al GPIOC, așa că exemplul următor utilizează o conversie ptr16 pentru a comuta LED-ul albastru de n ori:
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
O descriere tehnică detaliată a celor trei emițătoare de cod poate fi găsită pe Kickstarter aici Nota 1 și aici Nota 2
Accesarea directă a hardware-ului¶
Aceasta intră în categoria programării mai avansate și presupune o oarecare cunoaștere a MCU-ului țintă. Luați în considerare exemplul comutării unui pin de ieșire pe o cameră OpenMV Cam. Abordarea standard ar fi să scrieți
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Acest lucru implică supraîncărcarea a două apeluri ale metodei value() a instanței Pin. Această supraîncărcare poate fi eliminată prin efectuarea unei operații de citire/scriere pe bitul relevant al registrului de date de ieșire (ODR) al portului GPIO al cipului. Pentru a facilita acest lucru, modulul stm oferă un set de constante care dau adresele registrelor relevante (stm.GPIOC este adresa de bază a portului GPIOC, stm.GPIO_ODR deplasamentul registrului său de date de ieșire). Ca mai sus, LED-ul albastru de pe camera OpenMV Cam originală este pinul 2 al GPIOC, așa că o comutare rapidă a acestuia poate fi efectuată după cum urmează:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2