最大化MicroPython速度¶
本教程介绍了改进MicroPython代码性能的方法。涉及其他语言的优化在其他地方介绍,即使用C语言编写的模块和MicroPython内联汇编程序。
开发高性能的代码包括以下阶段,这些阶段应按列出的顺序执行。
速度设计。
代码和调试。
优化步骤:
识别代码的最慢段。
改进Python代码的效率。
使用本地代码发射器。
使用Viper代码发射器。
使用特定于硬件的优化。
速度设计¶
应该从开始就考虑性能问题。这涉及到对性能至关重要的代码部分,应特别关注代码的设计。优化过程从检测代码开始:若设计从开始就没有差错,那优化就很轻松了,实际上可能没有优化的必要。
算法¶
设计性能程序的最重要的部分就是确保使用最佳算法。这应是教科书上的议题而非出现在MicroPython指南中。但是有时可通过使用以效率著称的算法来实现可观的性能收益。
RAM分配¶
设计高效的MicroPython代码,则有必要理解解释器分配RAM的方式。当创建某一对象或该对象大小增长时(例如将一个项附加到列表),RAM即从名为堆的块中分配出来。这一过程需耗费很长时间,而且有时会触发垃圾收集的过程,此过程将耗时数毫秒。
因此,若对象仅允许创建一次且其大小不可增长,则函数或方法的性能得以改进。这意味着对象在其使用期间持续存在:通常对象在类构造函数中实例化,并在各种方法中使用。
更多详细信息,请参见下面的 Controlling garbage collection。
缓冲区¶
上述示例是需要缓冲区的常见情况,例如用于与设备通信的缓冲区。典型的驱动器将在构造函数中创建缓冲区,并在其I/O方法中使用,该方法将重复调用。
MicroPython库通常为预分配的缓冲区提供支持。例如,支持流接口(例如:文件或UART)的对象提供为读取数据分配新的缓冲区的 read()
方法,以及将数据读取入现存缓冲区的 readinto()
方法。
浮点数¶
某些MicroPython移植版本在堆上分配浮点数。其他移植版本可能缺少专用的浮点协处理器,且在”软件”上以低于在整数上的速度对它们执行算术运算。性能事关重要的情况下,使用整数运算;性能无关紧要的情况下,限制浮点数用于代码的部分。例如,将ADC读数作为整数值捕捉到数组中,然后将其转换为浮点数进行信号处理。
数组¶
考虑使用各种类型的数组来替代列表。 array
模块支持不同的元素类型,其中8位元素由 Python的内置 bytes
和 bytearray
类支持。这些数据结构将项储存在连续内存位置中。为避免在临界区代码中分配内存,内存应进行预分配并作为参数或限定性对象传递。
在传递诸如 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缓冲区(垃圾收集准备就绪),而不是做一个长时间的内存视图,并保持10K阻塞的GC。
尽管如此, memoryview
对于高级预分配缓冲区管理而言必不可少。上述 readinto()
方法将数据放在缓冲区的开始处,并填充整个缓冲区。 若您需要将数据放进现有缓冲区中,应如何操作? 只需在缓冲区的所需部分创建一个memoryview,并将其传递给 readinto()
。
识别代码的最慢段¶
此过程也称为profiling,教科书中对其进行了介绍,此过程由不同的软件工具支持(对于标准Python而言)。对于可能在MicroPython平台上运行的较小型嵌入式应用程序,最慢的函数或方法通常可通过正确使用 time
中记录的时序 ticks
函数来建立。代码执行时长可用毫秒、微秒和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()
可能对预防有帮助。首先,在真正需要回收之前进行回收速度会更快,若经常回收,则耗时约1毫秒。其次,您可在代码中确定此时间的使用点,而非在随机点上发生较长的延迟,可能在速度临界区。最后,经常进行回收可减少堆中的碎片化。严重的碎片化会导致无法修复的分配故障。
本地密码发射器¶
这使得MicroPython编译器发送本地CPU操作码,而非字节码。它涵盖了MicroPython的大部分功能,所以大部分功能无需适应(见下文)。它是通过一个函数装饰器调用的:
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code
本地代码发送器的当前实现仍然存在一些局限性。
不支持上下文管理器(
with
语句)。不支持生成器。
若使用
raise
,则必须应用一个参数。
性能提高的代价(速度约为字节码的两倍)是编译代码大小的增加。
Viper代码发送器¶
上面讨论的优化包含符合标准的Python代码。 Viper代码发射器并不完全兼容。为实现高性能,它支持特殊的Viper本地数据类型。整数处理并不兼容,因其使用机器字:32位硬件上的运算是以模2**32执行的。
与本地发送器相似,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。
除了本地发送器施加的限制之外,以下限制也适用:
不许可默认参数值。
浮点数可能被使用但未优化。
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对象须为整数类型,且返回该整数对象的值。布尔转换的参数须为整数类型(布尔值或整数);当用作返回类型时,Viper函数将返回True或False对象。
若参数为Python对象,转换为
ptr
、ptr
、ptr16
或ptr32
,则Python对象必须具有缓冲区协议(在此情况下,返回指向缓冲区开始的指针)或为整数类型(在此情况下,返回整数对象的值)。
写入指向只读对象的指针将导致未定义的行为。
以下示例说明了使用 ptr16
转换来切换引脚X1 n
次:
BIT0 = const(1)
@micropython.viper
def toggle_n(n: int):
odr = ptr16(stm.GPIOA + stm.GPIO_ODR)
for _ in range(n):
odr[0] ^= BIT0
直接访问硬件¶
备注
本节给出了Pyboard的代码示例。 不过,此处介绍的技术也可能适用于其他MicroPython移植版本。
这属于更高级的编程范畴,涉及目标MCU的一些知识。考虑切换Pyboard上的输出引脚的例子。标准方法是写入
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
这涉及到两次调用 Pin
instance的 value()
方法。通过对芯片的GPIO端口输出数据寄存器(odr)的相关位执行读/写操作,可消除此开销。为实现这一点, stm
模块提供了一组提供相关寄存器地址的常量。引脚 P4
(CPU引脚 A14
)的快速切换(对应绿色LED)可按如下方式执行:
import machine
import stm
BIT14 = const(1 << 14)
machine.mem16[stm.GPIOA + stm.GPIO_ODR] ^= BIT14