將 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 內建的 bytes 與 bytearray 類別支援。這些資料結構全都將元素儲存在連續的記憶體位置中。同樣地,為了避免在關鍵程式碼中發生記憶體配置,這些都應預先配置,並以引數或繫結物件的形式傳入。
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() 的引數可以是任何在編譯期會求值為整數的東西,例如 0x100 或 1 << 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.ba 與 obj_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 支援它自己的一組型別,亦即 int、uint(無號整數)、ptr、ptr8、ptr16 與 ptr32。ptrX 型別於下文討論。目前 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 原生型別時,這些轉型應在函式開頭進行,而非在時序關鍵的迴圈中進行,因為轉型操作可能需時數微秒。轉型的規則如下:
目前的轉型運算子為:
int、bool、uint、ptr、ptr8、ptr16與ptr32。轉型的結果會是一個原生的 Viper 變數。
轉型的引數可以是 Python 物件或原生的 Viper 變數。
如果引數是原生的 Viper 變數,那麼轉型就是一個無操作(即在執行期不耗費任何成本),它只是改變型別(例如從
uint改為ptr8),以便你接著能使用這個指標進行儲存/載入。如果引數是 Python 物件且轉型為
int或uint,那麼該 Python 物件必須是整數型別,並會回傳該整數物件的值。bool 轉型的引數必須是整數型別(布林值或整數);當用作回傳型別時,viper 函式會回傳 True 或 False 物件。
如果引數是 Python 物件且轉型為
ptr、ptr8、ptr16或ptr32,那麼該 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
直接存取硬體¶
這屬於較進階的程式設計範疇,並牽涉到對目標 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