MicroPythonin nopeuden maksimointi

Tämä opastus kuvaa tapoja parantaa MicroPython-koodin suorituskykyä. Muita kieliä hyödyntäviä optimointeja käsitellään muualla, nimittäin C-kielellä kirjoitettujen moduulien käyttöä ja MicroPythonin inline-assembleria.

Korkean suorituskyvyn koodin kehitysprosessi koostuu seuraavista vaiheista, jotka tulee suorittaa luetellussa järjestyksessä.

  • Suunnittele nopeutta varten.

  • Koodaa ja korjaa virheet.

Optimointivaiheet:

  • Tunnista koodin hitain osa.

  • Paranna Python-koodin tehokkuutta.

  • Käytä natiivikoodin emitteriä.

  • Käytä Viper-koodin emitteriä.

  • Käytä laitteistokohtaisia optimointeja.

Suunnittelu nopeutta varten

Suorituskykyä koskevat seikat tulee ottaa huomioon jo alusta alkaen. Tämä tarkoittaa kannan ottamista koodin suorituskyvyn kannalta kriittisimpiin osiin ja erityishuomion kiinnittämistä niiden suunnitteluun. Optimointiprosessi alkaa, kun koodi on testattu: jos suunnittelu on alusta lähtien oikea, optimointi on suoraviivaista ja saattaa itse asiassa olla tarpeetonta.

Algoritmit

Tärkein näkökohta minkä tahansa rutiinin suunnittelussa suorituskyvyn kannalta on varmistaa, että käytetään parasta algoritmia. Tämä on aihe pikemminkin oppikirjoille kuin MicroPython-oppaalle, mutta toisinaan voidaan saavuttaa näyttäviä suorituskyvyn parannuksia ottamalla käyttöön tehokkuudestaan tunnettuja algoritmeja.

RAM-muistin varaaminen

Tehokkaan MicroPython-koodin suunnittelu edellyttää ymmärrystä siitä, miten tulkki varaa RAM-muistia. Kun olio luodaan tai sen koko kasvaa (esimerkiksi kun listaan lisätään alkio), tarvittava RAM-muisti varataan kekona (heap) tunnetusta lohkosta. Tämä vie merkittävästi aikaa; lisäksi se laukaisee ajoittain roskien keruuna (garbage collection) tunnetun prosessin, joka voi kestää useita millisekunteja.

Tämän seurauksena funktion tai metodin suorituskykyä voidaan parantaa, jos olio luodaan vain kerran eikä sen anneta kasvaa kooltaan. Tämä tarkoittaa, että olio säilyy koko käyttönsä ajan: tyypillisesti se luodaan luokan konstruktorissa ja sitä käytetään eri metodeissa.

Tätä käsitellään tarkemmin alla kohdassa Roskien keruun hallinta.

Puskurit

Esimerkki edellä mainitusta on yleinen tapaus, jossa tarvitaan puskuria, kuten laitteen kanssa kommunikointiin käytettävää. Tyypillinen ajuri luo puskurin konstruktorissa ja käyttää sitä I/O-metodeissaan, joita kutsutaan toistuvasti.

MicroPython-kirjastot tarjoavat tyypillisesti tuen ennalta varatuille puskureille. Esimerkiksi oliot, jotka tukevat stream-rajapintaa (esim. tiedosto tai UART), tarjoavat read()-metodin, joka varaa uuden puskurin luetulle datalle, mutta myös readinto()-metodin datan lukemiseksi olemassa olevaan puskuriin.

Joitakin hyödyllisiä luokkia uudelleenkäytettävien puskuriolioiden luomiseen:

Liukuluvut

Jotkin MicroPython-portit varaavat liukuluvut keosta. Joistakin muista porteista saattaa puuttua oma liukulukuyksikkö, ja ne suorittavat liukulukuaritmetiikan ”ohjelmistollisesti” huomattavasti hitaammin kuin kokonaisluvuilla. Kun suorituskyky on tärkeää, käytä kokonaislukuoperaatioita ja rajoita liukulukujen käyttö koodin osiin, joissa suorituskyky ei ole ratkaisevaa. Kerää esimerkiksi ADC-lukemat kokonaislukuarvoina taulukkoon yhdellä nopealla kerralla, ja muunna ne vasta sen jälkeen liukuluvuiksi signaalinkäsittelyä varten.

Taulukot

Harkitse erilaisten taulukkoluokkien käyttöä listojen vaihtoehtona. array-moduuli tukee erilaisia alkiotyyppejä, ja 8-bittisiä alkioita tukevat Pythonin sisäänrakennetut bytes- ja bytearray-luokat. Kaikki nämä tietorakenteet tallentavat alkiot peräkkäisiin muistipaikkoihin. Jälleen kerran muistinvarauksen välttämiseksi kriittisessä koodissa nämä tulee varata ennalta ja välittää argumentteina tai sidottuina olioina.

Memoryview-näkymät

Kun välitetään viipaleita olioista, kuten bytearray-instansseista, Python luo kopion, mikä edellyttää viipaleen kokoon suhteutetun muistin varaamista. Tätä voidaan lieventää käyttämällä memoryview-oliota. memoryview itse varataan keosta, mutta se on pieni, kiinteäkokoinen olio riippumatta siitä, kuinka suureen viipaleeseen se osoittaa. memoryview-näkymän viipalointi luo uuden memoryview-näkymän, joten tätä ei voida tehdä keskeytyspalvelurutiinissa. Lisäksi viipalointisyntaksi a:b aiheuttaa lisävarauksen luomalla slice(a, b)-olion.

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-näkymää voidaan soveltaa vain olioihin, jotka tukevat puskuriprotokollaa - tämä sisältää taulukot mutta ei listoja. Pieni varaus on, että niin kauan kuin memoryview-olio on elossa, se pitää myös alkuperäisen puskuriolion elossa. Memoryview ei siis ole yleispätevä ihmelääke. Esimerkiksi yllä olevassa esimerkissä, jos olet valmis 10K:n puskurin kanssa ja tarvitset siitä vain tavut 30:2000, voi olla parempi tehdä viipale ja antaa 10K:n puskurin vapautua (olla valmiina roskien keruuseen), sen sijaan että tekisit pitkäikäisen memoryview’n ja pitäisit 10K:ta varattuna GC:ltä.

Siitä huolimatta memoryview on korvaamaton kehittyneessä ennalta varattujen puskureiden hallinnassa. Edellä käsitelty readinto()-metodi sijoittaa datan puskurin alkuun ja täyttää koko puskurin. Entä jos sinun täytyy sijoittaa data olemassa olevan puskurin keskelle? Luo vain memoryview puskurin tarvittavaan osaan ja välitä se readinto()-metodille.

Merkkijonot vs. tavut

MicroPython käyttää merkkijonojen internöintiä (string interning) säästääkseen tilaa, kun samanlaisia merkkijonoja on useita. Aina kun uusi merkkijono varataan suorituksen aikana (esimerkiksi kun kaksi muuta merkkijonoa yhdistetään), MicroPython tarkistaa, voidaanko uusi merkkijono internöidä RAM-muistin säästämiseksi.

Jos sinulla on koodia, joka suorittaa suorituskyvyn kannalta kriittisiä merkkijono-operaatioita, harkitse bytes-olioiden ja -literaalien käyttöä (esim. b"abc"). Tämä ohittaa internöintitarkistuksen ja voi olla useita kertoja nopeampi kuin samojen operaatioiden suorittaminen merkkijono-olioilla.

Muista

Nopein suorituskyky saavutetaan aina välttämällä uusien olioiden luomista kokonaan, esimerkiksi käyttämällä uudelleenkäytettävää puskuria edellä kuvatulla tavalla.

Koodin hitaimman osan tunnistaminen

Tämä on prosessi, joka tunnetaan profilointina ja jota käsitellään oppikirjoissa ja (standardi-Pythonin osalta) tuetaan erilaisilla ohjelmistotyökaluilla. MicroPython-alustoilla todennäköisesti ajettavan pienemmän sulautetun sovelluksen tyypin osalta hitain funktio tai metodi voidaan yleensä määrittää käyttämällä harkiten time-moduulissa dokumentoitua ajanmittauksen ticks-funktioryhmää. Koodin suoritusaika voidaan mitata millisekunteina, mikrosekunteina tai CPU-jaksoina.

Seuraava mahdollistaa minkä tahansa funktion tai metodin ajan mittaamisen lisäämällä @timed_function-koristeen:

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

MicroPython-koodin parannukset

const()-määrittely

MicroPython tarjoaa const()-määrittelyn. Tämä toimii samalla tavalla kuin #define C-kielessä siinä mielessä, että kun koodi käännetään tavukoodiksi, kääntäjä korvaa tunnisteen numeerisella arvolla. Tämä välttää sanakirjahaun suorituksen aikana. const()-funktion argumentti voi olla mikä tahansa, joka käännöshetkellä evaluoituu kokonaisluvuksi, esim. 0x100 tai 1 << 8.

Olioviittausten välimuistitus

Kun funktio tai metodi käyttää olioita toistuvasti, suorituskykyä parannetaan välimuistittamalla olio paikalliseen muuttujaan:

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ä välttää tarpeen etsiä toistuvasti self.ba ja obj_display.framebuffer metodin bar() rungossa.

Roskien keruun hallinta

Kun muistinvaraus tarvitaan, MicroPython yrittää paikantaa riittävän kokoisen lohkon keosta. Tämä voi epäonnistua, yleensä siksi, että keko on täynnä olioita, joihin koodi ei enää viittaa. Jos epäonnistuminen tapahtuu, roskien keruuna tunnettu prosessi vapauttaa näiden tarpeettomien olioiden käyttämän muistin, ja varausta yritetään sitten uudelleen - prosessi, joka voi kestää useita millisekunteja.

Tätä voi olla hyödyllistä ennakoida antamalla gc.collect() säännöllisesti. Ensinnäkin keruun tekeminen ennen kuin sitä todella tarvitaan on nopeampaa - tyypillisesti noin 1ms, jos se tehdään usein. Toiseksi voit määrittää koodin kohdan, jossa tämä aika käytetään, sen sijaan että pidempi viive tapahtuisi satunnaisissa kohdissa, mahdollisesti nopeuden kannalta kriittisessä osassa. Lopuksi keruiden suorittaminen säännöllisesti voi vähentää keon pirstoutumista. Vakava pirstoutuminen voi johtaa palautumattomiin varauksen epäonnistumisiin.

Natiivikoodin emitteri

Tämä saa MicroPython-kääntäjän tuottamaan natiiveja CPU-käskykoodeja tavukoodin sijaan. Se kattaa suurimman osan MicroPythonin toiminnallisuudesta, joten useimmat funktiot eivät vaadi mukautusta (mutta katso alla). Se otetaan käyttöön funktiokoristeen avulla:

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

Natiivikoodin emitterin nykyisessä toteutuksessa on tiettyjä rajoituksia.

  • Jos käytetään raise-lausetta, argumentti on annettava.

  • Taustaskeduleria (katso micropython.schedule) ei ajeta natiivikoodin suorituksen aikana.

  • Kohteissa, joissa on säikeistys ja GIL, GIL:iä ei vapauteta natiivikoodin suorituksen aikana.

Kahden viimeisen seikan lieventämiseksi pitkään suoritettavien natiivifunktioiden tulisi kutsua time.sleep(0)-funktiota säännöllisesti, mikä ajaa skedulerin ja vapauttaa hetkellisesti GIL:in.

Vastineena parantuneesta suorituskyvystä (suunnilleen kaksi kertaa nopeampi kuin tavukoodi) on käännetyn koodin koon kasvu.

Viper-koodin emitteri

Edellä käsitellyt optimoinnit koskevat standardien mukaista Python-koodia. Viper-koodin emitteri ei ole täysin standardien mukainen. Se tukee erityisiä Viperin natiiveja tietotyyppejä suorituskyvyn tavoittelemiseksi. Kokonaislukujen käsittely on epästandardia, koska se käyttää konesanoja: aritmetiikka 32-bittisellä laitteistolla suoritetaan modulo 2**32.

Natiiviemitterin tavoin Viper tuottaa konekäskyjä, mutta lisäksi suoritetaan optimointeja, jotka kasvattavat suorituskykyä huomattavasti erityisesti kokonaislukuaritmetiikassa ja bittien käsittelyssä. Se otetaan käyttöön koristeen avulla:

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

Kuten yllä oleva katkelma havainnollistaa, on hyödyllistä käyttää Pythonin tyyppivihjeitä Viper-optimoijan avuksi. Tyyppivihjeet antavat tietoa argumenttien ja paluuarvon tietotyypeistä; nämä ovat Python-kielen standardiominaisuus, joka on muodollisesti määritelty täällä PEP0484. Viper tukee omaa tyyppijoukkoaan, nimittäin int, uint (etumerkitön kokonaisluku), ptr, ptr8, ptr16 ja ptr32. ptrX-tyyppejä käsitellään alla. Tällä hetkellä uint-tyypillä on yksi tarkoitus: tyyppivihjeenä funktion paluuarvolle. Jos tällainen funktio palauttaa 0xffffffff, Python tulkitsee tuloksen arvoksi 2**32 -1 eikä -1.

Natiiviemitterin asettamien rajoitusten lisäksi sovelletaan seuraavia rajoituksia:

  • Argumenttien oletusarvoja ei sallita.

  • Liukulukuja voidaan käyttää, mutta niitä ei optimoida.

Viper tarjoaa osoitintyyppejä optimoijan avuksi. Näihin kuuluvat

  • ptr Osoitin olioon.

  • ptr8 Osoittaa tavuun.

  • ptr16 Osoittaa 16-bittiseen puolisanaan.

  • ptr32 Osoittaa 32-bittiseen konesanaan.

Osoittimen käsite saattaa olla Python-ohjelmoijille vieras. Sillä on yhtäläisyyksiä Pythonin memoryview-olion kanssa siinä, että se tarjoaa suoran pääsyn muistiin tallennettuun dataan. Alkioihin päästään käyttäen alaindeksinotaatiota, mutta viipaleita ei tueta: osoitin voi palauttaa vain yhden alkion. Sen tarkoitus on tarjota nopea satunnaispääsy peräkkäisiin muistipaikkoihin tallennettuun dataan - kuten puskuriprotokollaa tukeviin olioihin tallennettuun dataan ja mikro-ohjaimen muistikartoitettuihin oheislaiterekistereihin. On huomattava, että osoittimilla ohjelmointi on vaarallista: rajatarkistusta ei suoriteta, eikä kääntäjä tee mitään puskurin ylivuotovirheiden estämiseksi.

Tyypillinen käyttötapa on muuttujien välimuistitus:

@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

Tässä tapauksessa kääntäjä ”tietää”, että buf on tavutaulukon osoite; se voi tuottaa koodia, joka laskee nopeasti buf[x]:n osoitteen suorituksen aikana. Kun olioiden muuntamiseen Viperin natiiveiksi tyypeiksi käytetään tyyppimuunnoksia (cast), nämä tulisi suorittaa funktion alussa eikä ajoituksen kannalta kriittisissä silmukoissa, koska muunnosoperaatio voi kestää useita mikrosekunteja. Muuntamisen säännöt ovat seuraavat:

  • Muunnosoperaattorit ovat tällä hetkellä: int, bool, uint, ptr, ptr8, ptr16 ja ptr32.

  • Muunnoksen tulos on natiivi Viper-muuttuja.

  • Muunnoksen argumentti voi olla Python-olio tai natiivi Viper-muuttuja.

  • Jos argumentti on natiivi Viper-muuttuja, muunnos on no-op (eli ei maksa mitään suorituksen aikana), joka vain muuttaa tyyppiä (esim. uint-tyypistä ptr8-tyyppiin), jotta voit sitten tallentaa/ladata käyttäen tätä osoitinta.

  • Jos argumentti on Python-olio ja muunnos on int tai uint, niin Python-olion on oltava kokonaislukutyyppinen, ja kyseisen kokonaislukuolion arvo palautetaan.

  • Bool-muunnoksen argumentin on oltava kokonaislukutyyppinen (totuusarvo tai kokonaisluku); kun sitä käytetään paluutyyppinä, viper-funktio palauttaa True- tai False-olioita.

  • Jos argumentti on Python-olio ja muunnos on ptr, ptr8, ptr16 tai ptr32, niin Python-oliolla on joko oltava puskuriprotokolla (jolloin palautetaan osoitin puskurin alkuun) tai sen on oltava kokonaislukutyyppinen (jolloin kyseisen kokonaislukuolion arvo palautetaan).

Kirjoittaminen osoittimeen, joka osoittaa vain luettavissa olevaan olioon, johtaa määrittelemättömään toimintaan.

Muista

Alla olevat koodiesimerkit on annettu STM32-pohjaisille OpenMV Cameille, jotka tarjoavat stm-moduulin. Kuvatut tekniikat pätevät yleisesti.

stm-moduuli paljastaa MCU:n oheislaiterekistereiden muistiosoitteet. Jokaisella GPIO-portilla on lähtödatarekisteri (ODR), jonka bitit kuvautuvat yksi yhteen kyseisen portin nastoihin: rekisteriin kirjoittaminen ohjaa näitä nastoja suoraan ilman machine.Pin-metodikutsun ylimääräistä kuormaa, ja bitin XOR-operaatio vaihtaa sen nastan tilan. Alkuperäisessä OpenMV Camissa sininen LED on kytketty GPIOC-portin nastaan 2, joten seuraava esimerkki käyttää ptr16-muunnosta sinisen LEDin tilan vaihtamiseen n kertaa:

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

Yksityiskohtainen tekninen kuvaus kolmesta koodiemitteristä löytyy Kickstarterista täältä Note 1 ja täältä Note 2

Laitteiston suora käyttö

Tämä kuuluu kehittyneemmän ohjelmoinnin luokkaan ja edellyttää jonkin verran tietoa kohde-MCU:sta. Tarkastellaan esimerkkiä lähtönastan tilan vaihtamisesta OpenMV Camissa. Tavanomainen lähestymistapa olisi kirjoittaa

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

Tähän liittyy kahden kutsun ylimääräinen kuorma Pin-instanssin value()-metodiin. Tämä ylimääräinen kuorma voidaan poistaa suorittamalla luku/kirjoitus sirun GPIO-portin lähtödatarekisterin (ODR) asiaankuuluvaan bittiin. Tämän helpottamiseksi stm-moduuli tarjoaa joukon vakioita, jotka antavat asiaankuuluvien rekistereiden osoitteet (stm.GPIOC on GPIOC-portin perusosoite, stm.GPIO_ODR sen lähtödatarekisterin siirtymä). Kuten edellä, sininen LED alkuperäisessä OpenMV Camissa on GPIOC-portin nasta 2, joten sen tilan nopea vaihtaminen voidaan suorittaa seuraavasti:

import machine
import stm

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