最大化 MicroPython 运行速度¶
本教程介绍提升 MicroPython 代码性能的多种方法。涉及其他语言的优化在别处讨论,即使用 C 编写的模块以及 MicroPython 内联汇编器。
开发高性能代码的过程包含以下几个阶段,应当按照所列顺序执行。
面向速度进行设计。
编写并调试代码。
优化步骤:
找出运行最慢的代码段。
提升 Python 代码的效率。
使用 native 代码发射器(native code emitter)。
使用 viper 代码发射器(viper code emitter)。
使用针对特定硬件的优化。
面向速度进行设计¶
性能问题应当在一开始就加以考虑。这包括判断出代码中对性能最为关键的部分,并对其设计给予特别关注。优化过程在代码经过测试后开始:如果一开始设计正确,那么优化将十分简单,甚至可能根本无需进行。
算法¶
为性能设计任何例程时,最重要的一点就是确保采用了最佳算法。这是教科书的主题,而非 MicroPython 指南所能涵盖,但有时通过采用以高效著称的算法,可以获得惊人的性能提升。
RAM 分配¶
要设计高效的 MicroPython 代码,就有必要理解解释器分配 RAM 的方式。当创建一个对象或其体积增长时(例如向列表追加元素时),所需的 RAM 会从一块称为堆(heap)的区域中分配。这会耗费相当多的时间;此外它有时还会触发一个称为垃圾回收(garbage collection)的过程,可能需要数毫秒。
因此,如果一个对象只创建一次且不允许其体积增长,那么函数或方法的性能就能得到提升。这意味着该对象在其使用期间持续存在:通常它会在类的构造函数中实例化,并在各个方法中被使用。
这一点在下文 控制垃圾回收 中有更详细的论述。
缓冲区¶
上述情况的一个例子就是需要缓冲区的常见场景,例如用于与某个设备通信的缓冲区。典型的驱动程序会在构造函数中创建该缓冲区,并在其会被反复调用的 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() 即可。
字符串与字节¶
MicroPython 使用 字符串驻留 在存在多个相同字符串时节省空间。每次在运行时分配新字符串时(例如,当两个其他字符串被拼接时),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。其次,你可以确定代码中花费这段时间的位置,而不是让较长的延迟在随机的位置上发生(可能正好在对速度要求很高的代码段中)。最后,定期执行回收可以减少堆中的碎片化。严重的碎片化可能导致无法恢复的分配失败。
Native 代码发射器¶
这会使 MicroPython 编译器发射原生 CPU 操作码而非字节码。它涵盖了 MicroPython 的大部分功能,因此大多数函数无需改动(但请见下文)。它通过函数装饰器来调用:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
在 native 代码发射器的当前实现中存在某些限制。
如果使用了
raise,则必须提供一个参数。在 native 代码执行期间,后台调度器(见
micropython.schedule)不会运行。在带有线程和 GIL 的目标平台上,native 代码执行期间不会释放 GIL。
为缓解后两点,长时间运行的 native 函数应定期调用 time.sleep(0),这将运行调度器并让出 GIL。
为获得性能提升(大约比字节码快一倍)所付出的代价是编译后代码体积的增大。
Viper 代码发射器¶
上面讨论的优化涉及符合标准的 Python 代码。Viper 代码发射器并不完全符合标准。它为追求性能而支持特殊的 Viper 原生数据类型。整数处理不符合标准,因为它使用机器字:在 32 位硬件上的算术运算是按 2**32 取模进行的。
与 Native 发射器一样,Viper 也生成机器指令,但它还执行进一步的优化,大幅提升性能,尤其是对于整数算术和位操作。它通过装饰器来调用:
@micropython.viper
def foo(self, arg: int) -> int:
# code
正如上面的片段所示,使用 Python 类型提示来辅助 Viper 优化器是有益的。类型提示提供了关于参数和返回值数据类型的信息;这是一项标准的 Python 语言特性,在此处正式定义:PEP0484。Viper 支持它自己的一套类型,即 int、uint(无符号整数)、ptr、ptr8、ptr16 和 ptr32。ptrX 类型将在下文讨论。目前 uint 类型只有一个用途:作为函数返回值的类型提示。如果这样的函数返回 0xffffffff,Python 会将结果解释为 2**32 -1 而不是 -1。
除了 native 发射器所施加的限制之外,还适用以下约束:
不允许使用默认参数值。
可以使用浮点运算,但不会对其进行优化。
Viper 提供指针类型来辅助优化器。它们包括
ptr指向一个对象的指针。ptr8指向一个字节。ptr16指向一个 16 位半字。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] 的地址。在使用强制转换将对象转换为 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 方法调用的开销,对某一位进行异或运算则会翻转其对应的引脚。在初代 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