Maximierung der MicroPython-Geschwindigkeit

Dieses Tutorial beschreibt Möglichkeiten, die Leistung von MicroPython-Code zu verbessern. Optimierungen, die andere Sprachen betreffen, werden an anderer Stelle behandelt, namentlich der Einsatz von in C geschriebenen Modulen und der Inline-Assembler von MicroPython.

Der Prozess der Entwicklung von Hochleistungscode umfasst die folgenden Phasen, die in der aufgeführten Reihenfolge durchgeführt werden sollten.

  • Auf Geschwindigkeit ausgelegt entwerfen.

  • Codieren und debuggen.

Optimierungsschritte:

  • Den langsamsten Codeabschnitt identifizieren.

  • Die Effizienz des Python-Codes verbessern.

  • Den nativen Code-Emitter verwenden.

  • Den Viper-Code-Emitter verwenden.

  • Hardwarespezifische Optimierungen verwenden.

Entwurf auf Geschwindigkeit

Leistungsaspekte sollten von Anfang an berücksichtigt werden. Dazu gehört, sich einen Überblick über die leistungskritischsten Codeabschnitte zu verschaffen und deren Entwurf besondere Aufmerksamkeit zu widmen. Der Optimierungsprozess beginnt, wenn der Code getestet wurde: Ist der Entwurf von Anfang an korrekt, gestaltet sich die Optimierung unkompliziert und kann sogar überflüssig sein.

Algorithmen

Der wichtigste Aspekt beim Entwurf einer leistungsorientierten Routine besteht darin, sicherzustellen, dass der beste Algorithmus eingesetzt wird. Dies ist eher ein Thema für Lehrbücher als für einen MicroPython-Leitfaden, doch durch die Verwendung von Algorithmen, die für ihre Effizienz bekannt sind, lassen sich mitunter spektakuläre Leistungssteigerungen erzielen.

RAM-Zuweisung

Um effizienten MicroPython-Code zu entwerfen, ist ein Verständnis der Art und Weise erforderlich, wie der Interpreter RAM zuweist. Wenn ein Objekt erstellt wird oder an Größe zunimmt (zum Beispiel, wenn ein Element an eine Liste angehängt wird), wird der benötigte RAM aus einem als Heap bekannten Block zugewiesen. Dies nimmt eine beträchtliche Zeit in Anspruch; zudem löst es gelegentlich einen Prozess namens Garbage Collection aus, der mehrere Millisekunden dauern kann.

Folglich kann die Leistung einer Funktion oder Methode verbessert werden, wenn ein Objekt nur einmal erstellt wird und nicht an Größe zunehmen darf. Dies impliziert, dass das Objekt für die Dauer seiner Verwendung bestehen bleibt: Typischerweise wird es in einem Klassenkonstruktor instanziiert und in verschiedenen Methoden verwendet.

Dies wird weiter unten unter Steuerung der Garbage Collection näher behandelt.

Puffer

Ein Beispiel für das oben Gesagte ist der häufige Fall, dass ein Puffer benötigt wird, etwa für die Kommunikation mit einem Gerät. Ein typischer Treiber erstellt den Puffer im Konstruktor und verwendet ihn in seinen E/A-Methoden, die wiederholt aufgerufen werden.

Die MicroPython-Bibliotheken bieten typischerweise Unterstützung für vorab zugewiesene Puffer. Beispielsweise stellen Objekte, die die Stream-Schnittstelle unterstützen (z. B. Datei oder UART), eine read()-Methode bereit, die einen neuen Puffer für die gelesenen Daten zuweist, aber auch eine readinto()-Methode, um Daten in einen vorhandenen Puffer einzulesen.

Einige nützliche Klassen zum Erstellen wiederverwendbarer Pufferobjekte:

Gleitkommazahlen

Einige MicroPython-Ports weisen Gleitkommazahlen auf dem Heap zu. Bei manchen anderen Ports fehlt möglicherweise ein dedizierter Gleitkomma-Koprozessor, sodass arithmetische Operationen mit ihnen „in Software“ und mit erheblich geringerer Geschwindigkeit als bei Ganzzahlen ausgeführt werden. Wo Leistung wichtig ist, verwenden Sie Ganzzahloperationen und beschränken Sie den Einsatz von Gleitkommazahlen auf Codeabschnitte, in denen Leistung nicht entscheidend ist. Erfassen Sie beispielsweise ADC-Messwerte in einem schnellen Durchgang als Ganzzahlwerte in einem Array und wandeln Sie sie erst danach für die Signalverarbeitung in Gleitkommazahlen um.

Arrays

Erwägen Sie den Einsatz der verschiedenen Array-Klassentypen als Alternative zu Listen. Das array-Modul unterstützt verschiedene Elementtypen, wobei 8-Bit-Elemente von Pythons eingebauten Klassen bytes und bytearray unterstützt werden. Diese Datenstrukturen speichern Elemente allesamt in zusammenhängenden Speicherbereichen. Auch hier sollten diese, um eine Speicherzuweisung in kritischem Code zu vermeiden, vorab zugewiesen und als Argumente oder als gebundene Objekte übergeben werden.

Memoryviews

Beim Übergeben von Slices von Objekten wie bytearray-Instanzen erstellt Python eine Kopie, was eine Zuweisung in einer Größe proportional zur Größe des Slice mit sich bringt. Dies lässt sich mit einem memoryview-Objekt abmildern. Der memoryview selbst wird auf dem Heap zugewiesen, ist aber ein kleines Objekt fester Größe, unabhängig von der Größe des Slice, auf das er verweist. Das Slicen eines memoryview erzeugt einen neuen memoryview, sodass dies nicht in einer Interrupt-Service-Routine erfolgen kann. Außerdem verursacht die Slice-Syntax a:b durch das Instanziieren eines slice(a, b)-Objekts eine weitere Zuweisung.

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

Ein memoryview kann nur auf Objekte angewendet werden, die das Buffer-Protokoll unterstützen – dazu gehören Arrays, aber keine Listen. Ein kleiner Vorbehalt ist, dass ein memoryview-Objekt, solange es aktiv ist, auch das ursprüngliche Pufferobjekt am Leben hält. Ein memoryview ist also kein Allheilmittel. Wenn Sie zum Beispiel im obigen Beispiel mit dem 10K-Puffer fertig sind und nur die Bytes 30:2000 daraus benötigen, kann es besser sein, ein Slice zu erstellen und den 10K-Puffer freizugeben (für die Garbage Collection bereit zu machen), anstatt ein langlebiges memoryview zu erzeugen und 10K für die GC blockiert zu halten.

Nichtsdestotrotz ist memoryview für die fortgeschrittene Verwaltung vorab zugewiesener Puffer unverzichtbar. Die oben erörterte readinto()-Methode legt Daten am Anfang des Puffers ab und füllt den gesamten Puffer. Was, wenn Sie Daten in der Mitte eines vorhandenen Puffers ablegen müssen? Erstellen Sie einfach einen memoryview auf den benötigten Abschnitt des Puffers und übergeben Sie ihn an readinto().

Strings vs. Bytes

MicroPython verwendet String-Interning, um Speicherplatz zu sparen, wenn mehrere identische Strings vorhanden sind. Jedes Mal, wenn zur Laufzeit ein neuer String zugewiesen wird (zum Beispiel, wenn zwei andere Strings verkettet werden), prüft MicroPython, ob der neue String interniert werden kann, um RAM zu sparen.

Wenn Sie Code haben, der leistungskritische String-Operationen durchführt, sollten Sie die Verwendung von bytes-Objekten und -Literalen (d. h. b"abc") in Betracht ziehen. Dies überspringt die Interning-Prüfung und kann um ein Mehrfaches schneller sein als dieselben Operationen mit String-Objekten.

Bemerkung

Die schnellste Leistung wird stets erreicht, indem das Erstellen neuer Objekte gänzlich vermieden wird, zum Beispiel mit einem wiederverwendbaren Puffer wie oben beschrieben.

Identifizierung des langsamsten Codeabschnitts

Dies ist ein als Profiling bekannter Prozess, der in Lehrbüchern behandelt und (für Standard-Python) durch verschiedene Softwarewerkzeuge unterstützt wird. Bei den kleineren eingebetteten Anwendungen, die wahrscheinlich auf MicroPython-Plattformen laufen, lässt sich die langsamste Funktion oder Methode in der Regel durch den umsichtigen Einsatz der in time dokumentierten Zeitmess-ticks-Funktionsgruppe ermitteln. Die Codeausführungszeit kann in ms, us oder CPU-Zyklen gemessen werden.

Das Folgende ermöglicht es, jede Funktion oder Methode durch Hinzufügen eines @timed_function-Dekorators zeitlich zu messen:

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

Verbesserungen am MicroPython-Code

Die const()-Deklaration

MicroPython stellt eine const()-Deklaration bereit. Diese funktioniert ähnlich wie #define in C: Wenn der Code in Bytecode kompiliert wird, ersetzt der Compiler den Bezeichner durch den numerischen Wert. Dies vermeidet eine Dictionary-Suche zur Laufzeit. Das Argument für const() kann alles sein, was zur Kompilierzeit zu einer Ganzzahl ausgewertet wird, z. B. 0x100 oder 1 << 8.

Zwischenspeichern von Objektreferenzen

Wenn eine Funktion oder Methode wiederholt auf Objekte zugreift, wird die Leistung verbessert, indem das Objekt in einer lokalen Variablen zwischengespeichert wird:

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

Dies vermeidet die Notwendigkeit, self.ba und obj_display.framebuffer im Rumpf der Methode bar() wiederholt nachzuschlagen.

Steuerung der Garbage Collection

Wenn eine Speicherzuweisung erforderlich ist, versucht MicroPython, einen ausreichend großen Block auf dem Heap zu finden. Dies kann fehlschlagen, in der Regel weil der Heap mit Objekten überfüllt ist, die vom Code nicht mehr referenziert werden. Tritt ein Fehler auf, gibt der als Garbage Collection bekannte Prozess den von diesen überflüssigen Objekten belegten Speicher frei, und die Zuweisung wird anschließend erneut versucht – ein Vorgang, der mehrere Millisekunden dauern kann.

Es kann von Vorteil sein, dem durch periodisches Ausführen von gc.collect() zuvorzukommen. Erstens ist es schneller, eine Collection durchzuführen, bevor sie tatsächlich benötigt wird – bei häufiger Ausführung typischerweise in der Größenordnung von 1 ms. Zweitens können Sie die Codestelle bestimmen, an der diese Zeit aufgewendet wird, anstatt dass eine längere Verzögerung an zufälligen Stellen auftritt, möglicherweise in einem geschwindigkeitskritischen Abschnitt. Schließlich kann regelmäßiges Durchführen von Collections die Fragmentierung im Heap verringern. Starke Fragmentierung kann zu nicht behebbaren Zuweisungsfehlern führen.

Der native Code-Emitter

Dies veranlasst den MicroPython-Compiler, native CPU-Opcodes anstelle von Bytecode auszugeben. Er deckt den Großteil der MicroPython-Funktionalität ab, sodass die meisten Funktionen keine Anpassung erfordern (siehe jedoch unten). Er wird mittels eines Funktionsdekorators aufgerufen:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # Cached object
    # code

In der aktuellen Implementierung des nativen Code-Emitters bestehen gewisse Einschränkungen.

  • Wenn raise verwendet wird, muss ein Argument angegeben werden.

  • Der Hintergrund-Scheduler (siehe micropython.schedule) wird während der Ausführung von nativem Code nicht ausgeführt.

  • Auf Zielsystemen mit Threading und dem GIL wird der GIL während der Ausführung von nativem Code nicht freigegeben.

Um die letzten beiden Punkte abzumildern, sollten lange laufende native Funktionen periodisch time.sleep(0) aufrufen, was den Scheduler ausführt und den GIL kurz freigibt.

Der Kompromiss für die verbesserte Leistung (ungefähr doppelt so schnell wie Bytecode) ist eine Zunahme der kompilierten Codegröße.

Der Viper-Code-Emitter

Die oben erörterten Optimierungen betreffen standardkonformen Python-Code. Der Viper-Code-Emitter ist nicht vollständig konform. Er unterstützt spezielle native Viper-Datentypen im Streben nach Leistung. Die Ganzzahlverarbeitung ist nicht konform, da sie Maschinenwörter verwendet: Arithmetik auf 32-Bit-Hardware wird modulo 2**32 ausgeführt.

Wie der native Emitter erzeugt Viper Maschinenbefehle, doch es werden weitere Optimierungen durchgeführt, die die Leistung erheblich steigern, insbesondere bei Ganzzahlarithmetik und Bitmanipulationen. Er wird mit einem Dekorator aufgerufen:

@micropython.viper
def foo(self, arg: int) -> int:
    # code

Wie das obige Fragment veranschaulicht, ist es vorteilhaft, Python-Typ-Hinweise zu verwenden, um den Viper-Optimierer zu unterstützen. Typ-Hinweise liefern Informationen über die Datentypen von Argumenten und des Rückgabewerts; dies ist ein Standard-Sprachmerkmal von Python, das formal hier definiert ist: PEP0484. Viper unterstützt seinen eigenen Satz von Typen, nämlich int, uint (vorzeichenlose Ganzzahl), ptr, ptr8, ptr16 und ptr32. Die ptrX-Typen werden weiter unten erörtert. Derzeit erfüllt der Typ uint einen einzigen Zweck: als Typ-Hinweis für den Rückgabewert einer Funktion. Wenn eine solche Funktion 0xffffffff zurückgibt, interpretiert Python das Ergebnis als 2**32 -1 anstatt als -1.

Zusätzlich zu den vom nativen Emitter auferlegten Beschränkungen gelten die folgenden Einschränkungen:

  • Standardargumentwerte sind nicht zulässig.

  • Gleitkommazahlen dürfen verwendet werden, werden aber nicht optimiert.

Viper stellt Zeigertypen zur Unterstützung des Optimierers bereit. Diese umfassen

  • ptr Zeiger auf ein Objekt.

  • ptr8 Zeigt auf ein Byte.

  • ptr16 Zeigt auf ein 16-Bit-Halbwort.

  • ptr32 Zeigt auf ein 32-Bit-Maschinenwort.

Das Konzept eines Zeigers ist Python-Programmierern möglicherweise unbekannt. Es weist Ähnlichkeiten mit einem Python-memoryview-Objekt auf, da es direkten Zugriff auf im Speicher abgelegte Daten bietet. Auf Elemente wird mittels Indexnotation zugegriffen, Slices werden jedoch nicht unterstützt: Ein Zeiger kann nur ein einzelnes Element zurückgeben. Sein Zweck besteht darin, schnellen wahlfreien Zugriff auf Daten zu ermöglichen, die in zusammenhängenden Speicherbereichen abgelegt sind – wie etwa Daten in Objekten, die das Buffer-Protokoll unterstützen, sowie speicherabgebildete Peripherieregister in einem Mikrocontroller. Es ist zu beachten, dass das Programmieren mit Zeigern riskant ist: Es findet keine Bereichsprüfung statt, und der Compiler unternimmt nichts, um Pufferüberlauf-Fehler zu verhindern.

Eine typische Verwendung besteht darin, Variablen zwischenzuspeichern:

@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 diesem Fall „weiß“ der Compiler, dass buf die Adresse eines Byte-Arrays ist; er kann Code ausgeben, um die Adresse von buf[x] zur Laufzeit schnell zu berechnen. Wo Casts verwendet werden, um Objekte in native Viper-Typen umzuwandeln, sollten diese am Anfang der Funktion und nicht in zeitkritischen Schleifen durchgeführt werden, da die Cast-Operation mehrere Mikrosekunden dauern kann. Die Regeln für das Casting lauten wie folgt:

  • Cast-Operatoren sind derzeit: int, bool, uint, ptr, ptr8, ptr16 und ptr32.

  • Das Ergebnis eines Casts ist eine native Viper-Variable.

  • Argumente für einen Cast können ein Python-Objekt oder eine native Viper-Variable sein.

  • Ist das Argument eine native Viper-Variable, so ist der Cast eine No-op (d. h. er kostet zur Laufzeit nichts), die lediglich den Typ ändert (z. B. von uint zu ptr8), sodass Sie anschließend mit diesem Zeiger speichern/laden können.

  • Ist das Argument ein Python-Objekt und der Cast int oder uint, so muss das Python-Objekt vom Ganzzahltyp sein, und der Wert dieses Ganzzahlobjekts wird zurückgegeben.

  • Das Argument für einen bool-Cast muss vom Ganzzahltyp sein (boolesch oder Ganzzahl); wird er als Rückgabetyp verwendet, gibt die Viper-Funktion True- oder False-Objekte zurück.

  • Ist das Argument ein Python-Objekt und der Cast ptr, ptr8, ptr16 oder ptr32, so muss das Python-Objekt entweder das Buffer-Protokoll besitzen (in diesem Fall wird ein Zeiger auf den Anfang des Puffers zurückgegeben) oder vom Ganzzahltyp sein (in diesem Fall wird der Wert dieses Ganzzahlobjekts zurückgegeben).

Das Schreiben in einen Zeiger, der auf ein schreibgeschütztes Objekt verweist, führt zu undefiniertem Verhalten.

Bemerkung

Die folgenden Codebeispiele sind für die STM32-basierten OpenMV Cams angegeben, die das stm-Modul bereitstellen. Die beschriebenen Techniken gelten allgemein.

Das stm-Modul legt die Speicheradressen der Peripherieregister des MCU offen. Jeder GPIO-Port verfügt über ein Ausgangsdatenregister (ODR), dessen Bits eins zu eins auf die Pins dieses Ports abgebildet sind: Das Schreiben in das Register steuert diese Pins direkt an, ohne den Mehraufwand eines machine.Pin-Methodenaufrufs, und das XOR-Verknüpfen eines Bits schaltet seinen Pin um. Bei der ursprünglichen OpenMV Cam ist die blaue LED an Pin 2 von GPIOC angeschlossen, sodass das folgende Beispiel einen ptr16-Cast verwendet, um die blaue LED n-mal umzuschalten:

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

Eine detaillierte technische Beschreibung der drei Code-Emitter findet sich auf Kickstarter hier Note 1 und hier Note 2

Direkter Zugriff auf Hardware

Dies fällt in die Kategorie der fortgeschritteneren Programmierung und erfordert gewisse Kenntnisse des Ziel-MCU. Betrachten Sie das Beispiel des Umschaltens eines Ausgangspins auf einer OpenMV Cam. Der Standardansatz wäre, Folgendes zu schreiben

mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin

Dies bringt den Mehraufwand von zwei Aufrufen der value()-Methode der Pin-Instanz mit sich. Dieser Mehraufwand kann eliminiert werden, indem ein Lese-/Schreibzugriff auf das relevante Bit des Ausgangsdatenregisters (ODR) des GPIO-Ports des Chips durchgeführt wird. Um dies zu erleichtern, stellt das stm-Modul einen Satz von Konstanten bereit, die die Adressen der relevanten Register angeben (stm.GPIOC ist die Basisadresse des GPIOC-Ports, stm.GPIO_ODR der Offset seines Ausgangsdatenregisters). Wie oben ist die blaue LED auf der ursprünglichen OpenMV Cam GPIOC Pin 2, sodass ein schnelles Umschalten wie folgt durchgeführt werden kann:

import machine
import stm

BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2