Maximalizace rychlosti MicroPythonu¶
Tento návod popisuje způsoby, jak zlepšit výkon kódu v MicroPythonu. Optimalizace zahrnující jiné jazyky jsou popsány jinde, konkrétně použití modulů napsaných v C a inline assembleru MicroPythonu.
Proces vývoje vysoce výkonného kódu se skládá z následujících fází, které by měly být provedeny v uvedeném pořadí.
Navrhujte s ohledem na rychlost.
Napište kód a odlaďte jej.
Optimalizační kroky:
Identifikujte nejpomalejší část kódu.
Zvyšte efektivitu kódu v Pythonu.
Použijte nativní generátor kódu (native code emitter).
Použijte generátor kódu Viper (viper code emitter).
Použijte optimalizace specifické pro hardware.
Návrh s ohledem na rychlost¶
O výkonnostních otázkách je třeba uvažovat již na začátku. To zahrnuje zvážení částí kódu, které jsou nejkritičtější z hlediska výkonu, a věnování zvláštní pozornosti jejich návrhu. Proces optimalizace začíná po otestování kódu: pokud je návrh od počátku správný, bude optimalizace přímočará a může být ve skutečnosti zbytečná.
Algoritmy¶
Nejdůležitějším aspektem návrhu jakékoli rutiny z hlediska výkonu je zajištění použití nejlepšího algoritmu. Toto je téma spíše pro učebnice než pro průvodce MicroPythonem, ale použitím algoritmů známých svou efektivitou lze někdy dosáhnout pozoruhodného nárůstu výkonu.
Alokace RAM¶
Pro návrh efektivního kódu v MicroPythonu je nutné rozumět tomu, jak interpret alokuje RAM. Když je vytvořen objekt nebo když roste jeho velikost (například když je položka přidána do seznamu), potřebná RAM se alokuje z bloku zvaného halda (heap). To zabere značné množství času; navíc to občas spustí proces zvaný garbage collection, který může trvat několik milisekund.
V důsledku toho lze výkon funkce nebo metody zlepšit, pokud je objekt vytvořen pouze jednou a není mu dovoleno růst. To znamená, že objekt přetrvává po dobu svého používání: typicky bude instanciován v konstruktoru třídy a používán v různých metodách.
Tomuto tématu se podrobněji věnuje níže uvedená sekce Řízení garbage collection.
Buffery¶
Příkladem výše uvedeného je běžný případ, kdy je potřeba buffer, například buffer používaný pro komunikaci se zařízením. Typický ovladač vytvoří buffer v konstruktoru a používá jej ve svých vstupně-výstupních metodách, které jsou opakovaně volány.
Knihovny MicroPythonu obvykle poskytují podporu pro předem alokované buffery. Například objekty podporující rozhraní stream (např. soubor nebo UART) poskytují metodu read(), která alokuje nový buffer pro načtená data, ale také metodu readinto() pro načtení dat do existujícího bufferu.
Některé užitečné třídy pro vytváření opakovaně použitelných objektů typu buffer:
Čísla s plovoucí desetinnou čárkou¶
Některé porty MicroPythonu alokují čísla s plovoucí desetinnou čárkou na haldě. Jiným portům může chybět dedikovaný koprocesor pro plovoucí desetinnou čárku a aritmetické operace s nimi provádějí „softwarově“ podstatně nižší rychlostí než s celými čísly. Tam, kde je důležitý výkon, používejte celočíselné operace a omezte použití plovoucí desetinné čárky na části kódu, kde výkon není prvořadý. Například zachyťte hodnoty z ADC jako celočíselné hodnoty do pole v jednom rychlém kroku a teprve poté je převeďte na čísla s plovoucí desetinnou čárkou pro zpracování signálu.
Pole¶
Zvažte použití různých typů tříd pro pole jako alternativu k seznamům. Modul array podporuje různé typy prvků, přičemž 8bitové prvky jsou podporovány vestavěnými třídami Pythonu bytes a bytearray. Všechny tyto datové struktury ukládají prvky na souvislých místech v paměti. Opět platí, že aby se předešlo alokaci paměti v kritickém kódu, měly by být tyto struktury předem alokovány a předávány jako argumenty nebo jako vázané objekty.
Memoryview¶
Při předávání řezů (slices) objektů, jako jsou instance bytearray, vytváří Python kopii, což zahrnuje alokaci o velikosti úměrné velikosti řezu. Tomu lze předejít použitím objektu memoryview. Samotný memoryview je alokován na haldě, ale je to malý objekt pevné velikosti bez ohledu na velikost řezu, na který ukazuje. Vytvořením řezu z memoryview vznikne nový memoryview, takže toto nelze provádět v obslužné rutině přerušení. Navíc syntaxe řezu a:b způsobuje další alokaci instanciováním objektu 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 lze aplikovat pouze na objekty podporující protokol buffer – to zahrnuje pole, nikoli však seznamy. Drobnou výhradou je, že dokud je objekt memoryview aktivní, udržuje naživu i původní objekt bufferu. Takže memoryview není univerzální všelék. Například ve výše uvedeném příkladu, pokud jste s 10K bufferem hotovi a potřebujete z něj jen bajty 30:2000, může být lepší vytvořit řez a nechat 10K buffer uvolnit (připravit jej pro garbage collection), místo abyste vytvořili dlouho žijící memoryview a blokovali 10K paměti před GC.
Přesto je memoryview nepostradatelný pro pokročilou správu předem alokovaných bufferů. Výše popsaná metoda readinto() umisťuje data na začátek bufferu a zaplňuje celý buffer. Co když potřebujete umístit data doprostřed existujícího bufferu? Stačí vytvořit memoryview do potřebné části bufferu a předat jej metodě readinto().
Řetězce vs. bajty¶
MicroPython používá internování řetězců k úspoře místa, když existuje více identických řetězců. Pokaždé, když je za běhu alokován nový řetězec (například při spojení dvou jiných řetězců), MicroPython zkontroluje, zda lze nový řetězec internovat kvůli úspoře RAM.
Pokud máte kód provádějící operace s řetězci kritické pro výkon, zvažte použití objektů bytes a literálů (tj. b"abc"). Tím se přeskočí kontrola internování a může to být několikanásobně rychlejší než provádění stejných operací s řetězcovými objekty.
Poznámka
Nejrychlejšího výkonu vždy dosáhnete úplným vyhnutím se vytváření nových objektů, například pomocí opakovaně použitelného bufferu, jak je popsáno výše.
Identifikace nejpomalejší části kódu¶
Toto je proces známý jako profilování (profiling), který je popsán v učebnicích a (pro standardní Python) podporován různými softwarovými nástroji. U menších vestavěných aplikací, které pravděpodobně poběží na platformách MicroPythonu, lze nejpomalejší funkci nebo metodu obvykle určit uvážlivým použitím skupiny časovacích funkcí ticks zdokumentovaných v modulu time. Dobu provádění kódu lze měřit v ms, us nebo cyklech CPU.
Následující kód umožňuje měřit čas libovolné funkce nebo metody přidáním dekorátoru @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
Vylepšení kódu v MicroPythonu¶
Deklarace const()¶
MicroPython poskytuje deklaraci const(). Funguje podobně jako #define v jazyce C v tom, že když je kód kompilován do bytecode, kompilátor nahradí identifikátor číselnou hodnotou. Tím se předejde vyhledávání ve slovníku za běhu. Argumentem const() může být cokoli, co se v době kompilace vyhodnotí jako celé číslo, např. 0x100 nebo 1 << 8.
Ukládání odkazů na objekty do mezipaměti¶
Tam, kde funkce nebo metoda opakovaně přistupuje k objektům, lze výkon zlepšit uložením objektu do lokální proměnné:
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
Tím se předejde nutnosti opakovaně vyhledávat self.ba a obj_display.framebuffer v těle metody bar().
Řízení garbage collection¶
Když je požadována alokace paměti, MicroPython se pokusí najít na haldě dostatečně velký blok. To může selhat, obvykle proto, že je halda zaplněná objekty, na které kód již neodkazuje. Pokud dojde k selhání, proces zvaný garbage collection uvolní paměť používanou těmito nadbytečnými objekty a alokace se poté zkusí znovu – tento proces může trvat několik milisekund.
Může být prospěšné tomuto předcházet periodickým voláním gc.collect(). Zaprvé, provedení sběru dříve, než je skutečně nutný, je rychlejší – typicky řádově 1 ms, pokud se provádí často. Zadruhé můžete určit místo v kódu, kde je tento čas spotřebován, namísto toho, aby k delší prodlevě docházelo v náhodných okamžicích, případně v části kritické pro rychlost. A konečně, pravidelné provádění sběrů může snížit fragmentaci haldy. Vážná fragmentace může vést k nezotavitelným selháním alokace.
Nativní generátor kódu (native code emitter)¶
Tento dekorátor způsobí, že kompilátor MicroPythonu vygeneruje nativní opcode CPU namísto bytecode. Pokrývá většinu funkcionality MicroPythonu, takže většina funkcí nebude vyžadovat žádné úpravy (viz však níže). Vyvolává se pomocí dekorátoru funkce:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
V současné implementaci nativního generátoru kódu existují určitá omezení.
Pokud je použito
raise, musí být dodán argument.Plánovač na pozadí (viz
micropython.schedule) se během provádění nativního kódu nespouští.Na cílech s vlákny a GIL není GIL během provádění nativního kódu uvolněn.
Ke zmírnění posledních dvou bodů by dlouho běžící nativní funkce měly periodicky volat time.sleep(0), což spustí plánovač a uvolní GIL.
Kompromisem za zlepšený výkon (zhruba dvakrát rychlejší než bytecode) je nárůst velikosti zkompilovaného kódu.
Generátor kódu Viper (Viper code emitter)¶
Optimalizace popsané výše zahrnují kód v Pythonu odpovídající standardům. Generátor kódu Viper není plně kompatibilní. Pro dosažení výkonu podporuje speciální nativní datové typy Viper. Zpracování celých čísel není kompatibilní, protože používá strojová slova: aritmetika na 32bitovém hardwaru se provádí modulo 2**32.
Stejně jako nativní generátor produkuje Viper strojové instrukce, ale provádí další optimalizace, které podstatně zvyšují výkon, zejména pro celočíselnou aritmetiku a bitové manipulace. Vyvolává se pomocí dekorátoru:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Jak ukazuje výše uvedený úryvek, je výhodné použít typové anotace (type hints) Pythonu, které pomáhají optimalizátoru Viper. Typové anotace poskytují informace o datových typech argumentů a návratové hodnoty; jedná se o standardní funkci jazyka Python formálně definovanou zde PEP0484. Viper podporuje vlastní sadu typů, konkrétně int, uint (celé číslo bez znaménka), ptr, ptr8, ptr16 a ptr32. Typy ptrX jsou popsány níže. V současnosti slouží typ uint jedinému účelu: jako typová anotace návratové hodnoty funkce. Pokud taková funkce vrátí 0xffffffff, Python interpretuje výsledek jako 2**32 -1 namísto jako -1.
Kromě omezení uložených nativním generátorem platí následující omezení:
Výchozí hodnoty argumentů nejsou povoleny.
Plovoucí desetinnou čárku lze použít, ale není optimalizována.
Viper poskytuje typy ukazatelů (pointer types) na pomoc optimalizátoru. Patří mezi ně
ptrUkazatel na objekt.ptr8Ukazuje na bajt.ptr16Ukazuje na 16bitové půlslovo.ptr32Ukazuje na 32bitové strojové slovo.
Koncept ukazatele může být programátorům v Pythonu neznámý. Má podobnosti s objektem memoryview v Pythonu v tom, že poskytuje přímý přístup k datům uloženým v paměti. K položkám se přistupuje pomocí indexové notace, ale řezy nejsou podporovány: ukazatel může vrátit pouze jednu položku. Jeho účelem je poskytnout rychlý náhodný přístup k datům uloženým na souvislých místech v paměti – jako jsou data uložená v objektech podporujících protokol buffer a paměťově mapované registry periferií v mikrokontroléru. Je třeba poznamenat, že programování pomocí ukazatelů je riskantní: neprovádí se kontrola mezí a kompilátor nedělá nic pro zabránění chybám přetečení bufferu.
Typickým použitím je ukládání proměnných do mezipaměti:
@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
V tomto případě kompilátor „ví“, že buf je adresa pole bajtů; může vygenerovat kód pro rychlý výpočet adresy buf[x] za běhu. Tam, kde se k převodu objektů na nativní typy Viper používají přetypování (casts), měla by se provádět na začátku funkce, nikoli v časově kritických smyčkách, protože operace přetypování může trvat několik mikrosekund. Pravidla pro přetypování jsou následující:
Operátory přetypování jsou v současnosti:
int,bool,uint,ptr,ptr8,ptr16aptr32.Výsledkem přetypování bude nativní proměnná Viper.
Argumenty přetypování mohou být objekt Pythonu nebo nativní proměnná Viper.
Pokud je argumentem nativní proměnná Viper, pak je přetypování operací bez efektu (tj. za běhu nic nestojí), která pouze změní typ (např. z
uintnaptr8), takže pak můžete ukládat/načítat pomocí tohoto ukazatele.Pokud je argumentem objekt Pythonu a přetypování je
intnebouint, pak musí být objekt Pythonu celočíselného typu a vrátí se hodnota tohoto celočíselného objektu.Argument přetypování na bool musí být celočíselného typu (boolean nebo integer); při použití jako návratový typ vrátí funkce viper objekt True nebo False.
Pokud je argumentem objekt Pythonu a přetypování je
ptr,ptr8,ptr16neboptr32, pak musí mít objekt Pythonu buď protokol buffer (v takovém případě se vrátí ukazatel na začátek bufferu), nebo musí být celočíselného typu (v takovém případě se vrátí hodnota tohoto celočíselného objektu).
Zápis do ukazatele, který ukazuje na objekt jen pro čtení, povede k nedefinovanému chování.
Poznámka
Níže uvedené příklady kódu jsou určeny pro kamery OpenMV Cam založené na STM32, které poskytují modul stm. Popsané techniky platí obecně.
Modul stm zpřístupňuje paměťové adresy registrů periferií MCU. Každý GPIO port má registr výstupních dat (output data register, ODR), jehož bity se mapují jedna ku jedné na piny daného portu: zápis do registru řídí tyto piny přímo, bez režie volání metody machine.Pin, a operace XOR nad bitem přepne jeho pin. Na původní OpenMV Cam je modrá LED připojena k pinu 2 portu GPIOC, takže následující příklad používá přetypování ptr16 k přepnutí modré LED n-krát:
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
Podrobný technický popis tří generátorů kódu lze nalézt na Kickstarteru zde Poznámka 1 a zde Poznámka 2
Přímý přístup k hardwaru¶
Toto spadá do kategorie pokročilejšího programování a vyžaduje určité znalosti cílového MCU. Uvažte příklad přepínání výstupního pinu na OpenMV Cam. Standardní přístup by byl napsat
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
To zahrnuje režii dvou volání metody value() instance Pin. Tuto režii lze odstranit provedením čtení/zápisu na příslušný bit registru výstupních dat (ODR) GPIO portu čipu. K usnadnění tohoto poskytuje modul stm sadu konstant udávajících adresy příslušných registrů (stm.GPIOC je bázová adresa portu GPIOC, stm.GPIO_ODR offset jeho registru výstupních dat). Jak bylo uvedeno výše, modrá LED na původní OpenMV Cam je pin 2 portu GPIOC, takže její rychlé přepnutí lze provést následovně:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2