MicroPython hızını en üst düzeye çıkarmak

Bu öğretici, MicroPython kodunun performansını artırmanın yollarını açıklar. Diğer dilleri içeren optimizasyonlar başka yerlerde ele alınmıştır; özellikle C ile yazılmış modüllerin kullanımı ve MicroPython satır içi assembler.

Yüksek performanslı kod geliştirme süreci, listelenen sırayla gerçekleştirilmesi gereken aşağıdaki aşamalardan oluşur.

  • Hız için tasarlayın.

  • Kodlayın ve hata ayıklayın.

Optimizasyon adımları:

  • Kodun en yavaş bölümünü belirleyin.

  • Python kodunun verimliliğini artırın.

  • Yerel (native) kod yayıcısını kullanın.

  • Viper kod yayıcısını kullanın.

  • Donanıma özgü optimizasyonları kullanın.

Hız için tasarlama

Performans sorunları en baştan dikkate alınmalıdır. Bu, kodun performans açısından en kritik bölümleri hakkında bir görüş oluşturmayı ve bunların tasarımına özel önem vermeyi içerir. Optimizasyon süreci kod test edildiğinde başlar: tasarım en baştan doğruysa optimizasyon basit olacak ve hatta aslında gereksiz olabilir.

Algoritmalar

Herhangi bir rutini performans için tasarlamanın en önemli yönü, en iyi algoritmanın kullanılmasını sağlamaktır. Bu, bir MicroPython kılavuzundan ziyade ders kitaplarına ait bir konudur, ancak verimlilikleriyle bilinen algoritmalar benimsenerek bazen göz alıcı performans kazanımları elde edilebilir.

RAM tahsisi

Verimli MicroPython kodu tasarlamak için, yorumlayıcının RAM tahsis etme şeklini anlamak gerekir. Bir nesne oluşturulduğunda veya boyutu büyüdüğünde (örneğin bir öğenin listeye eklenmesi durumunda) gerekli RAM, yığın (heap) olarak bilinen bir bloktan tahsis edilir. Bu önemli miktarda zaman alır; ayrıca zaman zaman birkaç milisaniye sürebilen çöp toplama (garbage collection) olarak bilinen bir süreci tetikler.

Sonuç olarak, bir nesne yalnızca bir kez oluşturulursa ve boyutunun büyümesine izin verilmezse bir fonksiyonun veya metodun performansı artırılabilir. Bu, nesnenin kullanımı süresince var olduğu anlamına gelir: genellikle bir sınıf yapıcısında örneklenir ve çeşitli metotlarda kullanılır.

Bu, aşağıdaki Çöp toplamayı denetleme bölümünde daha ayrıntılı olarak ele alınmıştır.

Arabellekler

Yukarıdakine bir örnek, bir cihazla iletişimde kullanılan gibi bir arabelleğin gerekli olduğu yaygın durumdur. Tipik bir sürücü, arabelleği yapıcıda oluşturur ve tekrar tekrar çağrılacak G/Ç metotlarında kullanır.

MicroPython kütüphaneleri genellikle önceden tahsis edilmiş arabellekler için destek sağlar. Örneğin, akış arabirimini destekleyen nesneler (örn. dosya veya UART), okunan veriler için yeni bir arabellek tahsis eden read() metodunu, ayrıca verileri mevcut bir arabelleğe okumak için bir readinto() metodunu sağlar.

Yeniden kullanılabilir arabellek nesneleri oluşturmak için bazı yararlı sınıflar:

Kayan nokta

Bazı MicroPython portları kayan nokta sayılarını yığın (heap) üzerinde tahsis eder. Diğer bazı portlarda özel bir kayan nokta yardımcı işlemcisi bulunmayabilir ve aritmetik işlemleri “yazılımda”, tam sayılara göre oldukça daha düşük hızda gerçekleştirir. Performansın önemli olduğu yerlerde, tam sayı işlemlerini kullanın ve kayan nokta kullanımını performansın çok önemli olmadığı kod bölümleriyle sınırlayın. Örneğin, ADC okumalarını hızlı bir şekilde tek seferde bir diziye tam sayı değerleri olarak yakalayın ve ancak ondan sonra sinyal işleme için bunları kayan nokta sayılarına dönüştürün.

Diziler

Listelere bir alternatif olarak çeşitli dizi sınıfı türlerinin kullanımını değerlendirin. array modülü, 8 bitlik öğelerin Python’un yerleşik bytes ve bytearray sınıfları tarafından desteklendiği çeşitli öğe türlerini destekler. Bu veri yapılarının tümü öğeleri bitişik bellek konumlarında saklar. Kritik kodda bellek tahsisinden kaçınmak için, bunlar yeniden önceden tahsis edilmeli ve argüman olarak ya da bağlı nesneler olarak iletilmelidir.

Memoryview’ler

bytearray örnekleri gibi nesnelerin dilimlerini iletirken, Python dilimin boyutuyla orantılı bir boyutta tahsis içeren bir kopya oluşturur. Bu, bir memoryview nesnesi kullanılarak hafifletilebilir. memoryview kendisi yığın üzerinde tahsis edilir, ancak işaret ettiği dilimin boyutundan bağımsız olarak küçük, sabit boyutlu bir nesnedir. Bir memoryview dilimlemek yeni bir memoryview oluşturur, bu nedenle bu bir kesme hizmet rutininde yapılamaz. Ayrıca, a:b dilim sözdizimi bir slice(a, b) nesnesi örnekleyerek ek tahsise neden olur.

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

Bir memoryview yalnızca arabellek protokolünü destekleyen nesnelere uygulanabilir - bu, dizileri içerir ancak listeleri içermez. Küçük bir uyarı, memoryview nesnesi canlıyken orijinal arabellek nesnesini de canlı tutmasıdır. Yani, bir memoryview evrensel bir çare değildir. Örneğin, yukarıdaki örnekte, 10K’lık arabellekle işiniz bittiyse ve yalnızca ondan 30:2000 baytlarına ihtiyacınız varsa, uzun ömürlü bir memoryview oluşturup 10K’yı GC için bloke tutmak yerine bir dilim oluşturmak ve 10K’lık arabelleği serbest bırakmak (çöp toplamaya hazır olmak) daha iyi olabilir.

Yine de, memoryview gelişmiş önceden tahsis edilmiş arabellek yönetimi için vazgeçilmezdir. Yukarıda tartışılan readinto() metodu verileri arabelleğin başına koyar ve tüm arabelleği doldurur. Verileri mevcut bir arabelleğin ortasına koymanız gerekirse ne olur? Arabelleğin gereken bölümüne bir memoryview oluşturun ve onu readinto() metoduna iletin.

String’ler ve Bytes karşılaştırması

MicroPython, birden çok özdeş string olduğunda yer kazanmak için string interning kullanır. Çalışma zamanında her yeni string tahsis edildiğinde (örneğin, iki diğer string birleştirildiğinde), MicroPython RAM tasarrufu için yeni string’in internlenebilir olup olmadığını kontrol eder.

Performans açısından kritik string işlemleri gerçekleştiren bir kodunuz varsa, bytes nesnelerini ve sabit değerlerini (yani b"abc") kullanmayı düşünün. Bu, interning kontrolünü atlar ve aynı işlemleri string nesneleriyle gerçekleştirmekten birkaç kat daha hızlı olabilir.

Not

En hızlı performans, örneğin yukarıda açıklanan bir arabellek ile, yeni nesne oluşturmaktan tamamen kaçınılarak her zaman elde edilir.

Kodun en yavaş bölümünü belirleme

Bu, profil çıkarma (profiling) olarak bilinen bir süreçtir ve ders kitaplarında ele alınır ve (standart Python için) çeşitli yazılım araçları tarafından desteklenir. MicroPython platformlarında çalışması muhtemel daha küçük gömülü uygulama türleri için, en yavaş fonksiyon veya metot genellikle time içinde belgelenen zamanlama ticks fonksiyon grubunun akıllıca kullanımıyla belirlenebilir. Kod yürütme süresi ms, us veya CPU döngüleri cinsinden ölçülebilir.

Aşağıdaki, bir @timed_function dekoratörü eklenerek herhangi bir fonksiyonun veya metodun zamanlanmasını sağlar:

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 kod iyileştirmeleri

const() bildirimi

MicroPython bir const() bildirimi sağlar. Bu, C’deki #define ile benzer şekilde çalışır; öyle ki kod bayt koduna derlendiğinde derleyici tanımlayıcı yerine sayısal değeri koyar. Bu, çalışma zamanında bir sözlük aramasından kaçınır. const() argümanı, derleme zamanında bir tam sayıya değerlenen herhangi bir şey olabilir, örn. 0x100 veya 1 << 8.

Nesne referanslarını önbelleğe alma

Bir fonksiyon veya metot nesnelere tekrar tekrar eriştiğinde, nesne yerel bir değişkende önbelleğe alınarak performans artırılır:

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

Bu, bar() metodunun gövdesinde self.ba ve obj_display.framebuffer öğelerini tekrar tekrar arama ihtiyacını ortadan kaldırır.

Çöp toplamayı denetleme

Bellek tahsisi gerektiğinde, MicroPython yığın üzerinde yeterli boyutta bir blok bulmaya çalışır. Bu başarısız olabilir; genellikle yığın artık kod tarafından referans verilmeyen nesnelerle dolu olduğu için. Bir başarısızlık meydana gelirse, çöp toplama olarak bilinen süreç bu gereksiz nesneler tarafından kullanılan belleği geri kazanır ve tahsis daha sonra yeniden denenir - bu süreç birkaç milisaniye sürebilir.

Periyodik olarak gc.collect() çağrısı yaparak bunu önceden gerçekleştirmenin yararları olabilir. Birincisi, gerçekten gerekli olmadan önce bir toplama yapmak daha hızlıdır - sık yapılırsa tipik olarak 1ms civarındadır. İkincisi, rastgele noktalarda, muhtemelen hız açısından kritik bir bölümde daha uzun bir gecikme oluşmasındansa, bu zamanın kullanıldığı kod noktasını siz belirleyebilirsiniz. Son olarak, düzenli olarak toplama yapmak yığındaki parçalanmayı azaltabilir. Şiddetli parçalanma kurtarılamaz tahsis başarısızlıklarına yol açabilir.

Yerel (Native) kod yayıcısı

Bu, MicroPython derleyicisinin bayt kodu yerine yerel CPU işlem kodları (opcode) yaymasına neden olur. MicroPython işlevselliğinin büyük kısmını kapsar, bu nedenle çoğu fonksiyon herhangi bir uyarlama gerektirmez (ancak aşağıya bakın). Bir fonksiyon dekoratörü aracılığıyla çağrılır:

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

Yerel kod yayıcısının mevcut uygulamasında bazı sınırlamalar vardır.

  • raise kullanılırsa bir argüman sağlanmalıdır.

  • Arka plan zamanlayıcısı (bkz. micropython.schedule) yerel kodun yürütülmesi sırasında çalıştırılmaz.

  • İş parçacıkları (threading) ve GIL bulunan hedeflerde, yerel kodun yürütülmesi sırasında GIL serbest bırakılmaz.

Son iki noktayı hafifletmek için, uzun süre çalışan yerel fonksiyonlar periyodik olarak time.sleep(0) çağırmalıdır; bu, zamanlayıcıyı çalıştırır ve GIL’i serbest bırakıp geri alır.

İyileştirilen performansın (kabaca bayt kodunun iki katı kadar hızlı) bedeli, derlenmiş kod boyutundaki artıştır.

Viper kod yayıcısı

Yukarıda tartışılan optimizasyonlar standartlara uyumlu Python kodunu içerir. Viper kod yayıcısı tam olarak uyumlu değildir. Performans peşinde özel Viper yerel veri türlerini destekler. Tam sayı işleme uyumlu değildir çünkü makine sözcüklerini kullanır: 32 bit donanımda aritmetik modulo 2**32 olarak gerçekleştirilir.

Yerel yayıcı gibi Viper de makine talimatları üretir, ancak özellikle tam sayı aritmetiği ve bit işlemleri için performansı önemli ölçüde artıran daha fazla optimizasyon gerçekleştirilir. Bir dekoratör kullanılarak çağrılır:

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

Yukarıdaki parçanın gösterdiği gibi, Viper optimize edicisine yardımcı olmak için Python tür ipuçlarını kullanmak yararlıdır. Tür ipuçları, argümanların ve dönüş değerinin veri türleri hakkında bilgi sağlar; bunlar burada PEP0484 resmî olarak tanımlanmış standart bir Python dili özelliğidir. Viper, kendi tür kümesini destekler; yani int, uint (işaretsiz tam sayı), ptr, ptr8, ptr16 ve ptr32. ptrX türleri aşağıda tartışılmaktadır. Şu anda uint türü tek bir amaca hizmet eder: bir fonksiyon dönüş değeri için tür ipucu olarak. Böyle bir fonksiyon 0xffffffff döndürürse Python sonucu -1 yerine 2**32 -1 olarak yorumlar.

Yerel yayıcı tarafından getirilen kısıtlamalara ek olarak aşağıdaki kısıtlamalar geçerlidir:

  • Varsayılan argüman değerlerine izin verilmez.

  • Kayan nokta kullanılabilir ancak optimize edilmez.

Viper, optimize ediciye yardımcı olmak için işaretçi (pointer) türleri sağlar. Bunlar şunları içerir:

  • ptr Bir nesneye işaretçi.

  • ptr8 Bir bayta işaret eder.

  • ptr16 16 bitlik bir yarım sözcüğe işaret eder.

  • ptr32 32 bitlik bir makine sözcüğüne işaret eder.

İşaretçi kavramı Python programcılarına yabancı gelebilir. Bellekte saklanan verilere doğrudan erişim sağlaması bakımından bir Python memoryview nesnesine benzerlikleri vardır. Öğelere alt simge gösterimi kullanılarak erişilir, ancak dilimler desteklenmez: bir işaretçi yalnızca tek bir öğe döndürebilir. Amacı, bitişik bellek konumlarında saklanan verilere - örneğin arabellek protokolünü destekleyen nesnelerde saklanan veriler ve bir mikrodenetleyicideki belleğe eşlenmiş çevre birimi yazmaçları gibi - hızlı rastgele erişim sağlamaktır. İşaretçiler kullanılarak programlama yapmanın tehlikeli olduğu unutulmamalıdır: sınır denetimi gerçekleştirilmez ve derleyici arabellek taşması hatalarını önlemek için hiçbir şey yapmaz.

Tipik kullanım değişkenleri önbelleğe almaktır:

@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

Bu örnekte derleyici, buf öğesinin bir bayt dizisinin adresi olduğunu “bilir”; çalışma zamanında buf[x] adresini hızla hesaplamak için kod yayabilir. Nesneleri Viper yerel türlerine dönüştürmek için tip dönüşümleri (cast) kullanıldığında, dönüşüm işlemi birkaç mikrosaniye sürebileceğinden bunlar zamanlama açısından kritik döngülerde değil, fonksiyonun başında gerçekleştirilmelidir. Tip dönüşümü kuralları şu şekildedir:

  • Tip dönüşüm operatörleri şu anda şunlardır: int, bool, uint, ptr, ptr8, ptr16 ve ptr32.

  • Bir tip dönüşümünün sonucu yerel bir Viper değişkeni olacaktır.

  • Bir tip dönüşümüne verilen argümanlar bir Python nesnesi veya bir yerel Viper değişkeni olabilir.

  • Argüman bir yerel Viper değişkeniyse, tip dönüşümü yalnızca türü değiştiren (örn. uint türünden ptr8 türüne) bir işlemsizdir (yani çalışma zamanında hiçbir maliyeti yoktur), böylece bu işaretçiyi kullanarak depolama/yükleme yapabilirsiniz.

  • Argüman bir Python nesnesiyse ve tip dönüşümü int veya uint ise, Python nesnesi tam sayı türünde olmalıdır ve o tam sayı nesnesinin değeri döndürülür.

  • Bir bool tip dönüşümüne verilen argüman tam sayı türünde (boolean veya tam sayı) olmalıdır; bir dönüş türü olarak kullanıldığında viper fonksiyonu True veya False nesneleri döndürür.

  • Argüman bir Python nesnesiyse ve tip dönüşümü ptr, ptr8, ptr16 veya ptr32 ise, Python nesnesi ya arabellek protokolüne sahip olmalıdır (bu durumda arabelleğin başlangıcına bir işaretçi döndürülür) ya da tam sayı türünde olmalıdır (bu durumda o tam sayı nesnesinin değeri döndürülür).

Salt okunur bir nesneye işaret eden bir işaretçiye yazmak tanımsız davranışa yol açar.

Not

Aşağıdaki kod örnekleri, stm modülünü sağlayan STM32 tabanlı OpenMV Cam’ler için verilmiştir. Açıklanan teknikler genel olarak geçerlidir.

stm modülü, MCU’nun çevre birimi yazmaçlarının bellek adreslerini açığa çıkarır. Her GPIO bağlantı noktasının, bitleri o bağlantı noktasının pinlerine bire bir eşlenen bir çıkış veri yazmacı (ODR) vardır: yazmaca yazmak, bir machine.Pin metodu çağrısının ek yükü olmadan o pinleri doğrudan sürer ve bir biti XOR’lamak pinini değiştirir. Orijinal OpenMV Cam’de mavi LED GPIOC pin 2’ye bağlıdır, bu nedenle aşağıdaki örnek mavi LED’i n kez değiştirmek için bir ptr16 tip dönüşümü kullanır:

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

Üç kod yayıcısının ayrıntılı bir teknik açıklaması Kickstarter’da burada Not 1 ve burada Not 2 bulunabilir

Donanıma doğrudan erişme

Bu, daha ileri düzey programlama kategorisine girer ve hedef MCU hakkında bir miktar bilgi gerektirir. Bir OpenMV Cam’de bir çıkış pinini değiştirme örneğini düşünün. Standart yaklaşım şunu yazmak olurdu:

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

Bu, Pin örneğinin value() metoduna iki çağrının ek yükünü içerir. Bu ek yük, yongaların GPIO bağlantı noktası çıkış veri yazmacının (ODR) ilgili bitine bir okuma/yazma gerçekleştirilerek ortadan kaldırılabilir. Bunu kolaylaştırmak için stm modülü, ilgili yazmaçların adreslerini veren bir dizi sabit sağlar (stm.GPIOC GPIOC bağlantı noktasının taban adresidir, stm.GPIO_ODR ise çıkış veri yazmacının ofsetidir). Yukarıdaki gibi, orijinal OpenMV Cam’deki mavi LED GPIOC pin 2’dir, bu nedenle hızlı bir değişimi şu şekilde gerçekleştirilebilir:

import machine
import stm

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