將 MicroPython 速度最大化

本教學說明如何改善 MicroPython 程式碼的效能。涉及其他語言的最佳化方式則在他處說明,亦即使用以 C 撰寫的模組以及 MicroPython 內嵌組合語言。

開發高效能程式碼的流程包含下列幾個階段,應依列出的順序執行。

  • 為速度而設計。

  • 撰寫程式碼並偵錯。

最佳化步驟:

  • 找出最慢的程式碼區段。

  • 改善 Python 程式碼的效率。

  • 使用原生程式碼產生器(native code emitter)。

  • 使用 Viper 程式碼產生器(viper code emitter)。

  • 使用特定硬體的最佳化方式。

為速度而設計

效能議題應在一開始就納入考量。這牽涉到先釐清哪些程式碼區段對效能最為關鍵,並特別著重於它們的設計。最佳化的流程在程式碼經過測試之後才開始:如果一開始的設計就正確,最佳化將會相當直接,甚至可能根本不需要。

演算法

為效能設計任何常式時,最重要的一點就是確保採用了最佳的演算法。這是教科書的主題,而非 MicroPython 指南所能涵蓋,但採用以高效率著稱的演算法有時可帶來極為驚人的效能提升。

RAM 配置

若要設計高效率的 MicroPython 程式碼,就必須了解直譯器配置 RAM 的方式。當建立物件或物件大小增長時(例如將某項目附加到串列中),便會從一塊稱為堆積(heap)的記憶體配置所需的 RAM。這會耗費可觀的時間;此外,它偶爾還會觸發一種稱為垃圾回收(garbage collection)的程序,而該程序可能需時數毫秒。

因此,如果某個物件只建立一次且不允許其大小增長,函式或方法的效能便可獲得改善。這意味著該物件在其使用期間會持續存在:通常它會在類別的建構函式中具現化,並在各個方法中使用。

下方的 控制垃圾回收 中有更詳細的說明。

緩衝區

上述情況的一個例子,是常見的需要緩衝區(buffer)的情境,例如用於與裝置通訊的緩衝區。典型的驅動程式會在建構函式中建立緩衝區,並在其會被反覆呼叫的 I/O 方法中使用它。

MicroPython 函式庫通常提供對預先配置緩衝區的支援。例如,支援串流介面的物件(如檔案或 UART)會提供 read() 方法,該方法會為讀取的資料配置新的緩衝區,但同時也提供 readinto() 方法,可將資料讀入既有的緩衝區。

一些用於建立可重複使用緩衝區物件的實用類別:

浮點數

某些 MicroPython 移植版本會在堆積上配置浮點數。其他某些移植版本可能缺少專用的浮點協同處理器,而是以「軟體」方式對浮點數執行算術運算,其速度遠低於對整數的運算。在效能很重要的場合,請使用整數運算,並將浮點數的使用限制在效能不那麼關鍵的程式碼區段。例如,先一次快速地將 ADC 讀數以整數值擷取到陣列中,之後才將它們轉換為浮點數以進行訊號處理。

陣列

請考慮使用各種陣列類別作為串列的替代方案。array 模組支援多種元素型別,其中 8 位元元素由 Python 內建的 bytesbytearray 類別支援。這些資料結構全都將元素儲存在連續的記憶體位置中。同樣地,為了避免在關鍵程式碼中發生記憶體配置,這些都應預先配置,並以引數或繫結物件的形式傳入。

Memoryview

在傳遞 bytearray 實例等物件的切片時,Python 會建立一份副本,這牽涉到與切片大小成正比的記憶體配置。這可以使用 memoryview 物件來緩解。memoryview 本身雖配置在堆積上,但它是一個小型、固定大小的物件,與其所指向的切片大小無關。對 memoryview 進行切片會建立一個新的 memoryview,因此這無法在中斷服務常式中進行。此外,切片語法 a:b 會因具現化一個 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 只能套用於支援緩衝協定的物件——這包含陣列,但不包含串列。一個小小的注意事項是,當 memoryview 物件存活時,它也會讓原本的緩衝物件保持存活。因此,memoryview 並非萬靈丹。舉例來說,在上面的例子中,如果你已用完 10K 的緩衝區,而只需要其中第 30 到 2000 個位元組,那麼建立一個切片並讓那塊 10K 緩衝區釋放(準備被垃圾回收)可能會更好,而非建立一個長期存活的 memoryview 並讓那 10K 一直無法被 GC 回收。

儘管如此,memoryview 對於進階的預先配置緩衝區管理而言不可或缺。上面討論過的 readinto() 方法會將資料放在緩衝區的開頭,並填滿整個緩衝區。那如果你需要把資料放到既有緩衝區的中間呢?只要對緩衝區中所需的區段建立一個 memoryview,並將它傳給 readinto() 即可。

Strings 與 Bytes

MicroPython 使用 字串駐留(string interning) 來在出現多個相同字串時節省空間。每當在執行期配置一個新字串時(例如將另外兩個字串串接起來時),MicroPython 都會檢查該新字串是否可以被駐留以節省 RAM。

如果你的程式碼執行對效能要求很高的字串運算,那麼請考慮使用 bytes 物件與字面值(即 b"abc")。這會略過駐留檢查,可能比對字串物件執行相同運算快上數倍。

備註

完全避免建立新物件永遠能達到最快的效能,例如使用上述的可重複使用 緩衝區

找出最慢的程式碼區段

這是一個稱為效能剖析(profiling)的程序,教科書中有所涵蓋,而且(對標準 Python 而言)有各種軟體工具支援。對於可能在 MicroPython 平台上執行的這類較小型嵌入式應用而言,通常只要明智地運用 time 中所記載的計時 ticks 系列函式,就能找出最慢的函式或方法。程式碼的執行時間可以用 ms、us 或 CPU 週期來量測。

下列做法可透過加上 @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

MicroPython 程式碼改善

const() 宣告

MicroPython 提供 const() 宣告。它的運作方式類似於 C 中的 #define,也就是當程式碼編譯為位元組碼時,編譯器會以該數值取代識別字。這避免了在執行期進行字典查詢。const() 的引數可以是任何在編譯期會求值為整數的東西,例如 0x1001 << 8

快取物件參考

當函式或方法反覆存取物件時,將該物件快取到一個區域變數中可改善效能:

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

這避免了在方法 bar() 主體中反覆查詢 self.baobj_display.framebuffer

控制垃圾回收

當需要配置記憶體時,MicroPython 會嘗試在堆積上找到一塊大小足夠的區塊。這可能會失敗,通常是因為堆積中堆滿了不再被程式碼參考的物件。一旦發生失敗,便會由稱為垃圾回收的程序回收這些多餘物件所占用的記憶體,接著再次嘗試配置——這個程序可能需時數毫秒。

藉由定期發出 gc.collect() 來搶先處理這件事可能會有好處。首先,在真正需要之前就先進行回收會比較快——如果經常進行,通常約在 1ms 的數量級。其次,你可以決定在程式碼中的哪個點耗用這段時間,而非讓較長的延遲在隨機的時點發生,有可能正好落在速度關鍵的區段。最後,定期進行回收可以減少堆積中的碎裂化。嚴重的碎裂化可能導致無法復原的配置失敗。

原生程式碼產生器

這會使 MicroPython 編譯器產生原生的 CPU 運算碼,而非位元組碼。它涵蓋了 MicroPython 的大部分功能,因此大多數函式都不需要調整(但請見下文)。它透過函式裝飾器來啟用:

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

在原生程式碼產生器目前的實作中存在某些限制。

  • 如果使用了 raise,則必須提供一個引數。

  • 在執行原生程式碼期間,背景排程器(請見 micropython.schedule)不會執行。

  • 在具有執行緒與 GIL 的目標上,執行原生程式碼期間不會釋放 GIL。

為了減輕後兩點的影響,長時間執行的原生函式應定期呼叫 time.sleep(0),這會執行排程器並讓出 GIL。

效能提升(大約是位元組碼的兩倍快)所付出的代價是已編譯程式碼大小的增加。

Viper 程式碼產生器

上述討論的最佳化都涉及符合標準的 Python 程式碼。Viper 程式碼產生器則並不完全符合標準。它為了追求效能而支援特殊的 Viper 原生資料型別。整數處理並不符合標準,因為它使用機器字組(machine word):在 32 位元硬體上的算術運算是以 2**32 取模執行的。

與原生產生器一樣,Viper 也會產生機器指令,但還會進行進一步的最佳化,大幅提升效能,尤其是對整數算術與位元操作而言。它透過一個裝飾器來啟用:

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

如上面的程式片段所示,使用 Python 型別提示(type hints)來協助 Viper 最佳化器是有益的。型別提示提供關於引數與回傳值資料型別的資訊;這是一項標準的 Python 語言特性,正式定義於此 PEP0484。Viper 支援它自己的一組型別,亦即 intuint(無號整數)、ptrptr8ptr16ptr32ptrX 型別於下文討論。目前 uint 型別只有單一用途:作為函式回傳值的型別提示。如果這樣的函式回傳 0xffffffff,Python 會將結果解讀為 2**32 -1,而非 -1。

除了原生產生器所施加的限制之外,還適用下列約束:

  • 不允許使用預設引數值。

  • 可以使用浮點數,但不會被最佳化。

Viper 提供指標型別以協助最佳化器。這些包括

  • ptr 指向一個物件。

  • ptr8 指向一個位元組。

  • ptr16 指向一個 16 位元半字組(half-word)。

  • ptr32 指向一個 32 位元機器字組。

指標的概念對 Python 程式設計師而言可能很陌生。它與 Python 的 memoryview 物件有相似之處,因為它提供對儲存在記憶體中資料的直接存取。項目使用下標表示法存取,但不支援切片:指標只能回傳單一項目。它的用途是提供對儲存在連續記憶體位置中資料的快速隨機存取——例如儲存在支援緩衝協定的物件中的資料,以及微控制器中記憶體映射的周邊裝置暫存器。應當留意的是,使用指標來編寫程式是危險的:不會執行邊界檢查,編譯器也不會採取任何措施來防止緩衝區溢位錯誤。

典型的用法是用來快取變數:

@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

在這個例子中,編譯器「知道」buf 是一個位元組陣列的位址;它可以產生程式碼,在執行期快速計算出 buf[x] 的位址。當使用轉型(cast)將物件轉換為 Viper 原生型別時,這些轉型應在函式開頭進行,而非在時序關鍵的迴圈中進行,因為轉型操作可能需時數微秒。轉型的規則如下:

  • 目前的轉型運算子為:intbooluintptrptr8ptr16ptr32

  • 轉型的結果會是一個原生的 Viper 變數。

  • 轉型的引數可以是 Python 物件或原生的 Viper 變數。

  • 如果引數是原生的 Viper 變數,那麼轉型就是一個無操作(即在執行期不耗費任何成本),它只是改變型別(例如從 uint 改為 ptr8),以便你接著能使用這個指標進行儲存/載入。

  • 如果引數是 Python 物件且轉型為 intuint,那麼該 Python 物件必須是整數型別,並會回傳該整數物件的值。

  • bool 轉型的引數必須是整數型別(布林值或整數);當用作回傳型別時,viper 函式會回傳 True 或 False 物件。

  • 如果引數是 Python 物件且轉型為 ptrptr8ptr16ptr32,那麼該 Python 物件必須具備緩衝協定(在此情況下會回傳指向緩衝區開頭的指標),或者必須是整數型別(在此情況下會回傳該整數物件的值)。

對指向唯讀物件的指標進行寫入會導致未定義行為。

備註

下方的程式碼範例是針對基於 STM32 的 OpenMV Cam 所提供,這類相機提供 stm 模組。所描述的技巧普遍適用。

stm 模組公開了 MCU 周邊裝置暫存器的記憶體位址。每個 GPIO 埠都有一個輸出資料暫存器(ODR),其位元與該埠的接腳一對一對應:寫入該暫存器會直接驅動那些接腳,而無須 machine.Pin 方法呼叫的額外開銷,而對某個位元進行 XOR 運算則會切換其對應接腳。在最初的 OpenMV Cam 上,藍色 LED 接到 GPIOC 接腳 2,因此下列範例使用 ptr16 轉型來將藍色 LED 切換 n 次:

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

關於這三種程式碼產生器的詳細技術說明,可在 Kickstarter 上找到,分別於此 Note 1 與此 Note 2

直接存取硬體

這屬於較進階的程式設計範疇,並牽涉到對目標 MCU 的一些了解。考慮在 OpenMV Cam 上切換一支輸出接腳的例子。標準的做法會是這樣寫

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

這牽涉到對 Pin 實例的 value() 方法進行兩次呼叫的額外開銷。藉由對晶片 GPIO 埠輸出資料暫存器(ODR)的相關位元執行讀取/寫入,便可消除這項開銷。為了便於此事,stm 模組提供了一組常數,給出相關暫存器的位址(stm.GPIOC 是 GPIOC 埠的基底位址,stm.GPIO_ODR 則是其輸出資料暫存器的偏移量)。如上所述,最初的 OpenMV Cam 上的藍色 LED 是 GPIOC 接腳 2,因此可以如下方式對它進行快速切換:

import machine
import stm

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