Maximiser la vitesse de MicroPython¶
Ce tutoriel décrit des moyens d’améliorer les performances du code MicroPython. Les optimisations faisant appel à d’autres langages sont traitées ailleurs, à savoir l’utilisation de modules écrits en C et l’assembleur en ligne de MicroPython.
Le processus de développement de code à hautes performances comprend les étapes suivantes, qui doivent être réalisées dans l’ordre indiqué.
Concevoir pour la vitesse.
Coder et déboguer.
Étapes d’optimisation :
Identifier la section de code la plus lente.
Améliorer l’efficacité du code Python.
Utiliser l’émetteur de code natif.
Utiliser l’émetteur de code viper.
Utiliser des optimisations spécifiques au matériel.
Concevoir pour la vitesse¶
Les problèmes de performances doivent être pris en compte dès le départ. Cela implique d’avoir une vision des sections de code les plus critiques pour les performances et de consacrer une attention particulière à leur conception. Le processus d’optimisation commence une fois le code testé : si la conception est correcte dès le départ, l’optimisation sera simple et pourra même s’avérer inutile.
Algorithmes¶
L’aspect le plus important de la conception d’une routine pour les performances consiste à s’assurer que le meilleur algorithme est employé. C’est un sujet qui relève davantage des manuels que d’un guide MicroPython, mais des gains de performances spectaculaires peuvent parfois être obtenus en adoptant des algorithmes connus pour leur efficacité.
Allocation de RAM¶
Pour concevoir un code MicroPython efficace, il est nécessaire de comprendre la façon dont l’interpréteur alloue la RAM. Lorsqu’un objet est créé ou grandit en taille (par exemple lorsqu’un élément est ajouté à une liste), la RAM nécessaire est allouée à partir d’un bloc appelé le tas (heap). Cela prend un temps non négligeable ; de plus, cela déclenchera à l’occasion un processus appelé ramasse-miettes (garbage collection) qui peut prendre plusieurs millisecondes.
Par conséquent, les performances d’une fonction ou d’une méthode peuvent être améliorées si un objet n’est créé qu’une seule fois et n’est pas autorisé à grandir en taille. Cela implique que l’objet persiste pendant toute la durée de son utilisation : il sera généralement instancié dans un constructeur de classe et utilisé dans diverses méthodes.
Ce point est traité plus en détail dans Contrôler le ramasse-miettes ci-dessous.
Tampons¶
Un exemple de ce qui précède est le cas courant où un tampon est nécessaire, comme celui utilisé pour la communication avec un périphérique. Un pilote typique créera le tampon dans le constructeur et l’utilisera dans ses méthodes d’E/S, qui seront appelées de manière répétée.
Les bibliothèques MicroPython fournissent généralement une prise en charge des tampons pré-alloués. Par exemple, les objets qui prennent en charge l’interface de flux (par exemple, un fichier ou une UART) fournissent une méthode read() qui alloue un nouveau tampon pour les données lues, mais aussi une méthode readinto() pour lire les données dans un tampon existant.
Quelques classes utiles pour créer des objets tampons réutilisables :
Virgule flottante¶
Certains portages de MicroPython allouent les nombres à virgule flottante sur le tas. D’autres portages peuvent ne pas disposer de coprocesseur dédié à la virgule flottante et effectuer les opérations arithmétiques sur ces nombres « par logiciel » à une vitesse considérablement inférieure à celle des entiers. Lorsque les performances sont importantes, utilisez des opérations sur les entiers et limitez l’utilisation de la virgule flottante aux sections du code où les performances ne sont pas primordiales. Par exemple, capturez les lectures de l’ADC sous forme de valeurs entières dans un tableau en une seule passe rapide, puis convertissez-les seulement ensuite en nombres à virgule flottante pour le traitement du signal.
Tableaux¶
Envisagez l’utilisation des divers types de classes de tableaux comme alternative aux listes. Le module array prend en charge divers types d’éléments, les éléments de 8 bits étant pris en charge par les classes bytes et bytearray intégrées à Python. Ces structures de données stockent toutes leurs éléments dans des emplacements mémoire contigus. Là encore, pour éviter l’allocation de mémoire dans le code critique, ceux-ci devraient être pré-alloués et passés en arguments ou en tant qu’objets liés.
Memoryviews¶
Lors du passage de tranches d’objets tels que des instances de bytearray, Python crée une copie qui implique une allocation de taille proportionnelle à la taille de la tranche. Cela peut être atténué à l’aide d’un objet memoryview. La memoryview elle-même est allouée sur le tas, mais c’est un petit objet de taille fixe, indépendamment de la taille de la tranche vers laquelle elle pointe. Découper une memoryview en tranche crée une nouvelle memoryview, ce qui ne peut donc pas être fait dans une routine de service d’interruption. De plus, la syntaxe de tranche a:b provoque une allocation supplémentaire en instanciant un objet 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
Une memoryview ne peut être appliquée qu’à des objets prenant en charge le protocole de tampon (buffer) - cela inclut les tableaux mais pas les listes. Une petite réserve est que, tant que l’objet memoryview est actif, il maintient également en vie l’objet tampon d’origine. Une memoryview n’est donc pas une panacée universelle. Par exemple, dans le cas ci-dessus, si vous en avez terminé avec le tampon de 10K et que vous n’avez besoin que des octets 30:2000 de celui-ci, il peut être préférable de faire une tranche et de laisser partir le tampon de 10K (le rendre disponible pour le ramasse-miettes), plutôt que de créer une memoryview à longue durée de vie et de garder 10K bloqués pour le GC.
Néanmoins, la memoryview est indispensable pour la gestion avancée de tampons pré-alloués. La méthode readinto() évoquée ci-dessus place les données au début du tampon et remplit l’intégralité du tampon. Que faire si vous devez placer des données au milieu d’un tampon existant ? Créez simplement une memoryview vers la section nécessaire du tampon et passez-la à readinto().
Chaînes (Strings) contre octets (Bytes)¶
MicroPython utilise l”internalisation des chaînes (string interning) pour économiser de l’espace lorsqu’il y a plusieurs chaînes identiques. Chaque fois qu’une nouvelle chaîne est allouée à l’exécution (par exemple, lorsque deux autres chaînes sont concaténées), MicroPython vérifie si la nouvelle chaîne peut être internalisée pour économiser de la RAM.
Si vous avez du code qui effectue des opérations sur les chaînes critiques pour les performances, envisagez d’utiliser des objets bytes et des littéraux (c.-à-d. b"abc"). Cela contourne la vérification d’internalisation et peut être plusieurs fois plus rapide que d’effectuer les mêmes opérations avec des objets chaînes.
Note
Les meilleures performances seront toujours obtenues en évitant entièrement la création de nouveaux objets, par exemple avec un tampon réutilisable tel que décrit ci-dessus.
Identifier la section de code la plus lente¶
Il s’agit d’un processus appelé profilage (profiling), traité dans les manuels et (pour le Python standard) pris en charge par divers outils logiciels. Pour le type d’applications embarquées plus petites susceptibles de s’exécuter sur les plateformes MicroPython, la fonction ou la méthode la plus lente peut généralement être établie par un usage judicieux du groupe de fonctions de chronométrage ticks documenté dans time. Le temps d’exécution du code peut être mesuré en ms, en us ou en cycles CPU.
Le code suivant permet de chronométrer n’importe quelle fonction ou méthode en ajoutant un décorateur @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
Améliorations du code MicroPython¶
La déclaration const()¶
MicroPython fournit une déclaration const(). Elle fonctionne de manière similaire à #define en C, en ce sens que lorsque le code est compilé en bytecode, le compilateur substitue la valeur numérique à l’identifiant. Cela évite une recherche dans un dictionnaire à l’exécution. L’argument de const() peut être tout ce qui, à la compilation, s’évalue en un entier, par exemple 0x100 ou 1 << 8.
Mise en cache des références d’objets¶
Lorsqu’une fonction ou une méthode accède de manière répétée à des objets, les performances sont améliorées en mettant l’objet en cache dans une variable 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
Cela évite d’avoir à rechercher de manière répétée self.ba et obj_display.framebuffer dans le corps de la méthode bar().
Contrôler le ramasse-miettes¶
Lorsqu’une allocation de mémoire est requise, MicroPython tente de localiser un bloc de taille adéquate sur le tas. Cela peut échouer, généralement parce que le tas est encombré d’objets qui ne sont plus référencés par le code. En cas d’échec, le processus appelé ramasse-miettes récupère la mémoire utilisée par ces objets redondants, puis l’allocation est de nouveau tentée - un processus qui peut prendre plusieurs millisecondes.
Il peut y avoir des avantages à anticiper cela en exécutant périodiquement gc.collect(). Premièrement, effectuer une collecte avant qu’elle ne soit réellement nécessaire est plus rapide - typiquement de l’ordre de 1 ms si elle est faite fréquemment. Deuxièmement, vous pouvez déterminer le point du code où ce temps est consommé, plutôt que de subir un délai plus long à des moments aléatoires, possiblement dans une section critique pour la vitesse. Enfin, effectuer des collectes régulièrement peut réduire la fragmentation du tas. Une fragmentation sévère peut conduire à des échecs d’allocation non récupérables.
L’émetteur de code natif¶
Cela amène le compilateur MicroPython à émettre des opcodes CPU natifs plutôt que du bytecode. Il couvre l’essentiel des fonctionnalités de MicroPython, de sorte que la plupart des fonctions ne nécessiteront aucune adaptation (mais voir ci-dessous). Il s’invoque au moyen d’un décorateur de fonction :
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
Il existe certaines limitations dans l’implémentation actuelle de l’émetteur de code natif.
Si
raiseest utilisé, un argument doit être fourni.L’ordonnanceur d’arrière-plan (voir
micropython.schedule) n’est pas exécuté pendant l’exécution du code natif.Sur les cibles dotées du multithreading et du GIL, le GIL n’est pas libéré pendant l’exécution du code natif.
Pour atténuer ces deux derniers points, les fonctions natives de longue durée devraient appeler périodiquement time.sleep(0), ce qui exécutera l’ordonnanceur et fera relâcher le GIL.
Le compromis pour ces performances améliorées (environ deux fois plus rapides que le bytecode) est une augmentation de la taille du code compilé.
L’émetteur de code Viper¶
Les optimisations évoquées ci-dessus impliquent du code Python conforme aux standards. L’émetteur de code Viper n’est pas entièrement conforme. Il prend en charge des types de données natifs Viper spéciaux dans la recherche de performances. Le traitement des entiers n’est pas conforme car il utilise des mots machine : l’arithmétique sur du matériel 32 bits est effectuée modulo 2**32.
Comme l’émetteur natif, Viper produit des instructions machine, mais des optimisations supplémentaires sont effectuées, augmentant considérablement les performances, en particulier pour l’arithmétique sur les entiers et les manipulations de bits. Il s’invoque à l’aide d’un décorateur :
@micropython.viper
def foo(self, arg: int) -> int:
# code
Comme l’illustre le fragment ci-dessus, il est avantageux d’utiliser les annotations de type (type hints) de Python pour aider l’optimiseur Viper. Les annotations de type fournissent des informations sur les types de données des arguments et de la valeur de retour ; il s’agit d’une fonctionnalité standard du langage Python définie formellement ici PEP0484. Viper prend en charge son propre ensemble de types, à savoir int, uint (entier non signé), ptr, ptr8, ptr16 et ptr32. Les types ptrX sont abordés ci-dessous. Actuellement, le type uint ne sert qu’à un seul usage : comme annotation de type pour une valeur de retour de fonction. Si une telle fonction renvoie 0xffffffff, Python interprétera le résultat comme 2**32 -1 plutôt que comme -1.
En plus des restrictions imposées par l’émetteur natif, les contraintes suivantes s’appliquent :
Les valeurs d’arguments par défaut ne sont pas autorisées.
La virgule flottante peut être utilisée mais n’est pas optimisée.
Viper fournit des types pointeur pour aider l’optimiseur. Ceux-ci comprennent
ptrPointeur vers un objet.ptr8Pointe vers un octet.ptr16Pointe vers un demi-mot de 16 bits.ptr32Pointe vers un mot machine de 32 bits.
Le concept de pointeur peut être peu familier aux programmeurs Python. Il présente des similitudes avec un objet memoryview de Python, en ce sens qu’il fournit un accès direct aux données stockées en mémoire. Les éléments sont accédés à l’aide de la notation indicée, mais les tranches ne sont pas prises en charge : un pointeur ne peut renvoyer qu’un seul élément. Son but est de fournir un accès aléatoire rapide aux données stockées dans des emplacements mémoire contigus - comme les données stockées dans des objets qui prennent en charge le protocole de tampon, et les registres de périphériques mappés en mémoire d’un microcontrôleur. Il convient de noter que la programmation à l’aide de pointeurs est dangereuse : aucune vérification des limites n’est effectuée et le compilateur ne fait rien pour empêcher les erreurs de dépassement de tampon (buffer overrun).
L’usage typique consiste à mettre en cache des variables :
@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
Dans ce cas, le compilateur « sait » que buf est l’adresse d’un tableau d’octets ; il peut émettre du code pour calculer rapidement l’adresse de buf[x] à l’exécution. Lorsque des transtypages (casts) sont utilisés pour convertir des objets en types natifs Viper, ceux-ci devraient être effectués au début de la fonction plutôt que dans les boucles critiques en temps, car l’opération de transtypage peut prendre plusieurs microsecondes. Les règles de transtypage sont les suivantes :
Les opérateurs de transtypage sont actuellement :
int,bool,uint,ptr,ptr8,ptr16etptr32.Le résultat d’un transtypage sera une variable native Viper.
Les arguments d’un transtypage peuvent être un objet Python ou une variable native Viper.
Si l’argument est une variable native Viper, alors le transtypage est une opération nulle (c.-à-d. qu’il ne coûte rien à l’exécution) qui change simplement le type (par exemple de
uintàptr8) afin que vous puissiez ensuite stocker/charger à l’aide de ce pointeur.Si l’argument est un objet Python et que le transtypage est
intouuint, alors l’objet Python doit être de type intégral et la valeur de cet objet intégral est renvoyée.L’argument d’un transtypage bool doit être de type intégral (booléen ou entier) ; lorsqu’il est utilisé comme type de retour, la fonction viper renverra des objets True ou False.
Si l’argument est un objet Python et que le transtypage est
ptr,ptr8,ptr16ouptr32, alors l’objet Python doit soit prendre en charge le protocole de tampon (auquel cas un pointeur vers le début du tampon est renvoyé), soit être de type intégral (auquel cas la valeur de cet objet intégral est renvoyée).
Écrire dans un pointeur qui pointe vers un objet en lecture seule conduira à un comportement indéfini.
Note
Les exemples de code ci-dessous sont donnés pour les OpenMV Cam à base de STM32, qui fournissent le module stm. Les techniques décrites s’appliquent de manière générale.
Le module stm expose les adresses mémoire des registres de périphériques du MCU. Chaque port GPIO possède un registre de données de sortie (ODR) dont les bits correspondent un à un aux broches de ce port : écrire dans le registre pilote ces broches directement, sans la surcharge d’un appel de méthode machine.Pin, et faire un XOR sur un bit bascule sa broche. Sur l’OpenMV Cam d’origine, la LED bleue est câblée à la broche 2 de GPIOC, de sorte que l’exemple suivant utilise un transtypage ptr16 pour faire basculer la LED bleue n fois :
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
Une description technique détaillée des trois émetteurs de code se trouve sur Kickstarter ici Note 1 et ici Note 2
Accéder directement au matériel¶
Cela relève de la catégorie de la programmation plus avancée et nécessite une certaine connaissance du MCU cible. Considérez l’exemple du basculement d’une broche de sortie sur une OpenMV Cam. L’approche standard consisterait à écrire
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Cela implique la surcharge de deux appels à la méthode value() de l’instance Pin. Cette surcharge peut être éliminée en effectuant une lecture/écriture sur le bit pertinent du registre de données de sortie (ODR) du port GPIO de la puce. Pour faciliter cela, le module stm fournit un ensemble de constantes donnant les adresses des registres pertinents (stm.GPIOC est l’adresse de base du port GPIOC, stm.GPIO_ODR le décalage de son registre de données de sortie). Comme ci-dessus, la LED bleue sur l’OpenMV Cam d’origine est la broche 2 de GPIOC, de sorte qu’un basculement rapide de celle-ci peut être effectué comme suit :
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2