Massimizzare la velocità di MicroPython¶
Questo tutorial descrive i modi per migliorare le prestazioni del codice MicroPython. Le ottimizzazioni che coinvolgono altri linguaggi sono trattate altrove, ovvero l’uso di moduli scritti in C e l’assembler inline di MicroPython.
Il processo di sviluppo di codice ad alte prestazioni comprende le seguenti fasi, che dovrebbero essere eseguite nell’ordine elencato.
Progettare per la velocità.
Scrivere il codice ed eseguire il debug.
Fasi di ottimizzazione:
Identificare la sezione di codice più lenta.
Migliorare l’efficienza del codice Python.
Usare il native code emitter.
Usare il viper code emitter.
Usare ottimizzazioni specifiche per l’hardware.
Progettare per la velocità¶
Le questioni relative alle prestazioni dovrebbero essere considerate fin dall’inizio. Ciò comporta una valutazione delle sezioni di codice più critiche per le prestazioni e una particolare attenzione alla loro progettazione. Il processo di ottimizzazione inizia quando il codice è stato testato: se la progettazione è corretta fin dall’inizio, l’ottimizzazione sarà semplice e potrebbe addirittura risultare superflua.
Algoritmi¶
L’aspetto più importante nella progettazione di qualsiasi routine orientata alle prestazioni è garantire l’impiego dell’algoritmo migliore. Questo è un argomento da manuale piuttosto che da guida a MicroPython, ma a volte si possono ottenere guadagni prestazionali spettacolari adottando algoritmi noti per la loro efficienza.
Allocazione della RAM¶
Per progettare codice MicroPython efficiente è necessario comprendere il modo in cui l’interprete alloca la RAM. Quando un oggetto viene creato o aumenta di dimensione (ad esempio quando un elemento viene aggiunto a una lista) la RAM necessaria viene allocata da un blocco noto come heap. Questo richiede una quantità di tempo significativa; inoltre, occasionalmente innescherà un processo noto come garbage collection che può richiedere diversi millisecondi.
Di conseguenza le prestazioni di una funzione o di un metodo possono essere migliorate se un oggetto viene creato una sola volta e non gli si consente di aumentare di dimensione. Ciò implica che l’oggetto persista per tutta la durata del suo utilizzo: tipicamente verrà istanziato nel costruttore di una classe e usato in vari metodi.
Questo è trattato in maggior dettaglio in Controllare la garbage collection più avanti.
Buffer¶
Un esempio di quanto sopra è il caso comune in cui è richiesto un buffer, come quello usato per la comunicazione con un dispositivo. Un driver tipico creerà il buffer nel costruttore e lo userà nei suoi metodi di I/O, che verranno chiamati ripetutamente.
Le librerie di MicroPython forniscono tipicamente supporto per buffer pre-allocati. Ad esempio, gli oggetti che supportano l’interfaccia stream (es. file o UART) forniscono un metodo read() che alloca un nuovo buffer per i dati letti, ma anche un metodo readinto() per leggere i dati in un buffer esistente.
Alcune classi utili per creare oggetti buffer riutilizzabili:
Virgola mobile¶
Alcune port di MicroPython allocano i numeri in virgola mobile sull’heap. Altre port possono mancare di un coprocessore dedicato alla virgola mobile ed eseguire operazioni aritmetiche su di essi via «software» a una velocità considerevolmente inferiore rispetto agli interi. Dove le prestazioni sono importanti, usa operazioni su interi e limita l’uso della virgola mobile alle sezioni di codice in cui le prestazioni non sono fondamentali. Ad esempio, acquisisci le letture dell’ADC come valori interi in un array in un’unica operazione rapida e solo successivamente convertili in numeri in virgola mobile per l’elaborazione del segnale.
Array¶
Considera l’uso dei vari tipi di classi array come alternativa alle liste. Il modulo array supporta diversi tipi di elementi, con elementi a 8 bit supportati dalle classi integrate di Python bytes e bytearray. Queste strutture dati memorizzano tutte gli elementi in posizioni di memoria contigue. Ancora una volta, per evitare allocazioni di memoria nel codice critico, queste dovrebbero essere pre-allocate e passate come argomenti o come oggetti vincolati.
Memoryview¶
Quando si passano slice di oggetti come istanze di bytearray, Python crea una copia che comporta l’allocazione di una dimensione proporzionale alla dimensione della slice. Questo può essere mitigato usando un oggetto memoryview. La memoryview stessa è allocata sull’heap, ma è un oggetto piccolo e di dimensione fissa, indipendentemente dalla dimensione della slice a cui punta. Lo slicing di una memoryview crea una nuova memoryview, quindi questo non può essere fatto in una routine di servizio di interrupt. Inoltre, la sintassi di slice a:b causa un’ulteriore allocazione istanziando un oggetto 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
Una memoryview può essere applicata solo a oggetti che supportano il buffer protocol; ciò include gli array ma non le liste. Un piccolo avvertimento è che, finché un oggetto memoryview è vivo, mantiene vivo anche l’oggetto buffer originale. Quindi una memoryview non è una panacea universale. Ad esempio, nell’esempio precedente, se hai finito con il buffer da 10K e ti servono solo i byte 30:2000 da esso, potrebbe essere meglio fare una slice e lasciar andare il buffer da 10K (renderlo pronto per la garbage collection), invece di creare una memoryview di lunga durata e tenere 10K bloccati per il GC.
Ciononostante, la memoryview è indispensabile per la gestione avanzata dei buffer pre-allocati. Il metodo readinto() discusso sopra colloca i dati all’inizio del buffer e riempie l’intero buffer. E se avessi bisogno di mettere i dati nel mezzo di un buffer esistente? Basta creare una memoryview nella sezione necessaria del buffer e passarla a readinto().
Stringhe vs Byte¶
MicroPython usa lo string interning per risparmiare spazio quando ci sono più stringhe identiche. Ogni volta che una nuova stringa viene allocata a runtime (ad esempio, quando due altre stringhe vengono concatenate), MicroPython verifica se la nuova stringa può essere internata per risparmiare RAM.
Se hai del codice che esegue operazioni su stringhe critiche per le prestazioni, considera l’uso di oggetti bytes e letterali (cioè b"abc"). Questo salta il controllo di interning e può essere diverse volte più veloce rispetto all’esecuzione delle stesse operazioni con oggetti stringa.
Nota
Le prestazioni più elevate si otterranno sempre evitando del tutto la creazione di nuovi oggetti, ad esempio con un buffer riutilizzabile come descritto sopra.
Identificare la sezione di codice più lenta¶
Questo è un processo noto come profiling ed è trattato nei manuali e (per il Python standard) supportato da vari strumenti software. Per il tipo di applicazione embedded più piccola che è probabile venga eseguita su piattaforme MicroPython, la funzione o il metodo più lento può solitamente essere individuato con un uso giudizioso del gruppo di funzioni di temporizzazione ticks documentato in time. Il tempo di esecuzione del codice può essere misurato in ms, us o cicli di CPU.
Il seguente permette di misurare il tempo di qualsiasi funzione o metodo aggiungendo un decoratore @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
Miglioramenti al codice MicroPython¶
La dichiarazione const()¶
MicroPython fornisce una dichiarazione const(). Funziona in modo simile a #define in C, nel senso che quando il codice viene compilato in bytecode il compilatore sostituisce il valore numerico all’identificatore. Questo evita una ricerca nel dizionario a runtime. L’argomento di const() può essere qualsiasi cosa che, al momento della compilazione, si valuti come un intero, ad esempio 0x100 o 1 << 8.
Memorizzazione nella cache dei riferimenti agli oggetti¶
Quando una funzione o un metodo accede ripetutamente a oggetti, le prestazioni migliorano memorizzando l’oggetto nella cache di una variabile locale:
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
Questo evita la necessità di cercare ripetutamente self.ba e obj_display.framebuffer nel corpo del metodo bar().
Controllare la garbage collection¶
Quando è richiesta un’allocazione di memoria, MicroPython tenta di individuare un blocco di dimensione adeguata sull’heap. Questo può fallire, di solito perché l’heap è ingombro di oggetti che non sono più referenziati dal codice. Se si verifica un fallimento, il processo noto come garbage collection recupera la memoria usata da questi oggetti ridondanti e l’allocazione viene quindi ritentata, un processo che può richiedere diversi millisecondi.
Possono esserci dei vantaggi nel prevenire ciò emettendo periodicamente gc.collect(). In primo luogo, eseguire una collection prima che sia effettivamente necessaria è più rapido, tipicamente nell’ordine di 1ms se fatto frequentemente. In secondo luogo, puoi determinare il punto del codice in cui viene speso questo tempo, invece di avere un ritardo più lungo che si verifica in punti casuali, possibilmente in una sezione critica per la velocità. Infine, eseguire le collection regolarmente può ridurre la frammentazione dell’heap. Una frammentazione grave può portare a fallimenti di allocazione non recuperabili.
Il Native code emitter¶
Questo fa sì che il compilatore di MicroPython emetta opcode nativi della CPU anziché bytecode. Copre la maggior parte delle funzionalità di MicroPython, quindi la maggior parte delle funzioni non richiederà alcun adattamento (ma vedi sotto). Viene invocato per mezzo di un decoratore di funzione:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Ci sono alcune limitazioni nell’implementazione attuale del native code emitter.
Se viene usato
raise, è necessario fornire un argomento.Lo scheduler in background (vedi
micropython.schedule) non viene eseguito durante l’esecuzione di codice nativo.Sui target con threading e GIL, il GIL non viene rilasciato durante l’esecuzione di codice nativo.
Per mitigare questi ultimi due punti, le funzioni native a lunga esecuzione dovrebbero chiamare periodicamente time.sleep(0), che eseguirà lo scheduler e farà rimbalzare il GIL.
Il compromesso per le migliori prestazioni (all’incirca due volte più veloce del bytecode) è un aumento della dimensione del codice compilato.
Il Viper code emitter¶
Le ottimizzazioni discusse sopra coinvolgono codice Python conforme agli standard. Il Viper code emitter non è pienamente conforme. Supporta tipi di dati nativi speciali di Viper alla ricerca delle prestazioni. L’elaborazione degli interi non è conforme perché usa parole macchina: l’aritmetica su hardware a 32 bit viene eseguita modulo 2**32.
Come il Native emitter, Viper produce istruzioni macchina, ma vengono eseguite ulteriori ottimizzazioni che aumentano sostanzialmente le prestazioni, specialmente per l’aritmetica intera e le manipolazioni di bit. Viene invocato usando un decoratore:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Come illustra il frammento precedente, è utile usare i type hint di Python per assistere l’ottimizzatore di Viper. I type hint forniscono informazioni sui tipi di dati degli argomenti e del valore di ritorno; questi sono una funzionalità standard del linguaggio Python definita formalmente qui PEP0484. Viper supporta il proprio insieme di tipi, ovvero int, uint (intero senza segno), ptr, ptr8, ptr16 e ptr32. I tipi ptrX sono discussi più avanti. Attualmente il tipo uint serve a un unico scopo: come type hint per il valore di ritorno di una funzione. Se una tale funzione restituisce 0xffffffff, Python interpreterà il risultato come 2**32 -1 anziché come -1.
Oltre alle restrizioni imposte dal native emitter, si applicano i seguenti vincoli:
I valori di default degli argomenti non sono permessi.
La virgola mobile può essere usata ma non è ottimizzata.
Viper fornisce tipi puntatore per assistere l’ottimizzatore. Questi comprendono
ptrPuntatore a un oggetto.ptr8Punta a un byte.ptr16Punta a una mezza parola a 16 bit.ptr32Punta a una parola macchina a 32 bit.
Il concetto di puntatore può essere poco familiare ai programmatori Python. Presenta somiglianze con un oggetto memoryview di Python in quanto fornisce accesso diretto ai dati memorizzati in memoria. Gli elementi vengono acceduti usando la notazione con indice, ma le slice non sono supportate: un puntatore può restituire un solo elemento. Il suo scopo è fornire un accesso casuale rapido ai dati memorizzati in posizioni di memoria contigue, come i dati memorizzati in oggetti che supportano il buffer protocol e i registri di periferica mappati in memoria in un microcontrollore. Va notato che la programmazione tramite puntatori è rischiosa: non viene eseguito il controllo dei limiti e il compilatore non fa nulla per prevenire errori di buffer overrun.
L’uso tipico è memorizzare nella cache le variabili:
@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 questo caso il compilatore «sa» che buf è l’indirizzo di un array di byte; può emettere codice per calcolare rapidamente l’indirizzo di buf[x] a runtime. Quando si usano cast per convertire oggetti in tipi nativi di Viper, questi dovrebbero essere eseguiti all’inizio della funzione anziché in loop critici per il timing, poiché l’operazione di cast può richiedere diversi microsecondi. Le regole per il casting sono le seguenti:
Gli operatori di cast sono attualmente:
int,bool,uint,ptr,ptr8,ptr16eptr32.Il risultato di un cast sarà una variabile nativa di Viper.
Gli argomenti di un cast possono essere un oggetto Python o una variabile nativa di Viper.
Se l’argomento è una variabile nativa di Viper, allora il cast è un no-op (cioè non costa nulla a runtime) che si limita a cambiare il tipo (ad esempio da
uintaptr8) in modo da poter poi memorizzare/caricare usando questo puntatore.Se l’argomento è un oggetto Python e il cast è
intouint, allora l’oggetto Python deve essere di tipo integrale e viene restituito il valore di quell’oggetto integrale.L’argomento di un cast bool deve essere di tipo integrale (booleano o intero); quando usato come tipo di ritorno, la funzione viper restituirà oggetti True o False.
Se l’argomento è un oggetto Python e il cast è
ptr,ptr8,ptr16optr32, allora l’oggetto Python deve avere il buffer protocol (nel qual caso viene restituito un puntatore all’inizio del buffer) oppure deve essere di tipo integrale (nel qual caso viene restituito il valore di quell’oggetto integrale).
Scrivere su un puntatore che punta a un oggetto di sola lettura porterà a un comportamento indefinito.
Nota
Gli esempi di codice seguenti sono forniti per le OpenMV Cam basate su STM32, che forniscono il modulo stm. Le tecniche descritte si applicano in generale.
Il modulo stm espone gli indirizzi di memoria dei registri di periferica dell’MCU. Ogni porta GPIO ha un output data register (ODR) i cui bit corrispondono uno a uno ai pin di quella porta: scrivere il registro pilota direttamente quei pin, senza l’overhead di una chiamata a un metodo di machine.Pin, e fare lo XOR di un bit commuta il relativo pin. Sulla OpenMV Cam originale il LED blu è collegato al pin 2 di GPIOC, quindi il seguente esempio usa un cast ptr16 per commutare il LED blu n volte:
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
Una descrizione tecnica dettagliata dei tre code emitter può essere trovata su Kickstarter qui Nota 1 e qui Nota 2
Accedere direttamente all’hardware¶
Questo rientra nella categoria della programmazione più avanzata e richiede una certa conoscenza dell’MCU target. Considera l’esempio della commutazione di un pin di output su una OpenMV Cam. L’approccio standard sarebbe scrivere
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Questo comporta l’overhead di due chiamate al metodo value() dell’istanza di Pin. Questo overhead può essere eliminato eseguendo una lettura/scrittura sul bit pertinente dell’output data register (ODR) della porta GPIO del chip. Per facilitare ciò, il modulo stm fornisce un insieme di costanti che indicano gli indirizzi dei registri pertinenti (stm.GPIOC è l’indirizzo base della porta GPIOC, stm.GPIO_ODR l’offset del suo output data register). Come sopra, il LED blu sulla OpenMV Cam originale è il pin 2 di GPIOC, quindi una sua commutazione rapida può essere eseguita come segue:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2