MicroPython性能调优

本文主要介绍了MicroPython代码性能优化的方法,包括语言以及编译等方面的内容。MicroPython开发高性能代码一般遵循以下阶段:

  • 设计时就要考虑性能
  • 优化代码及性能调试
  • 性能调优的步骤:
    • 确定代码中最慢的部分
    • 提高Python代码执行效率
    • 使用本机代码发射器
    • 使用viper代码发射器
    • 针对硬件进行优化

在设计时就考虑性能问题

性能问题应该一开始就应该考虑,特别是要关注最关键的代码部分的设计。当进行代码测试时,性能优化就开始了,如果设计得当,优化就会很简单,甚至是不必要的。

算法

程序性能设计时最重要的是要确保采用最佳算法,这是教科书的观点而不是MicroPython手册主题,但有时采用有效率的算法能实现性能的大幅提升。

内存分配

为了设计高效的MicroPython代码,有必要了解一下解释器的内存分配方式。当创建对象或增大对象大小时(例如,将条目添加到列表),解释器会从内存堆块中分配必要的内存,这个过程需要大量的时间,有时还会触发垃圾收集机制,可能需要几毫秒的时间。因此,减少对象的创建并控制其变化可以提高函数或方法的性能,同时也意味着对象在其使用期间会持续存在。通常,对象在类的构造函数中创建,并在各种方法中使用。

缓冲区

上面提到的内存分配是需要缓冲区的常见情况,就像用于与设备通信的缓冲区,驱动程序会在构造函数中创建缓冲区,并在调用I/O方法时反复使用它。

MicroPython库提供了对预分配缓冲区的支持,例如,为支持流接口(文件或UART)的对象提供读取时分配新缓冲区的方法read(),还提供了将数据读入现有缓冲区的方法readinto()。

浮点数

一些MicroPython端口在堆上分配浮点数,而另一些端口可能缺乏专用的浮点协处理器,不得不在“软件”中以比整数低得多的速度执行算术运算。如果性能很重要,请使用整数运算,并将浮点的使用限制在性能不重要的代码部分。例如,将ADC读数作为整数值快速捕获到数组中,然后将其转换为浮点数进行信号处理。

数组

为减少内存分配,可以考虑使用各种类型的数组类作为列表的替代方案。array模块支持各种元素类型,其中Python内置bytes和bytearray类支持8位元素。这些数据结构都将元素存储在连续的内存位置。为了避免在关键代码中分配内存,应该预先分配内存,并将其作为参数或绑定对象传递。

当传递对象切片(例如bytearray实例)时,Python 会创建一个副本,其中涉及到与切片大小成比例的内存分配,可以使用memoryview进行改进。memoryview本身是在堆上分配的,但它是一个小的、固定大小的对象,无论它指向的切片的大小如何。对memoryview切片会创建一个新的memoryview,因此不能在中断服务例程中进行此操作。此外,切片语法a:b会因为实例化对象slice(a, b)引起进一步的内存分配。

ba = bytearray(10000)  # 大数组
func(ba[30:2000])      # 传递副本, 新分配 ~2K 
mv = memoryview(ba)    # 分配一个小对象
func(mv[30:2000])      # 传递内存指针

memoryview只能用于支持缓冲区协议的对象(包括数组但不包括列表)。需要注意的是,当memoryview对象处于活动状态时,它也会使原始缓冲区对象保持活动状态。所以,memoryview并不是万能的灵丹妙药。例如,在上面的示例中,如果使用了10K缓冲区,且只需要其中的30:2000字节,最好还是进行切片,释放这10K缓冲区(为垃圾回收做好准备),而不是使用一个长期存在的memoryview阻止GC回收这10K的缓冲区。

尽管如此,memoryview对于高级预分配缓冲区管理是必不可少的。上面讨论的readinto()方法是将数据放在缓冲区的开头并填充整个缓冲区。如果需要将数据放入现有缓冲区的中间,则只需要在缓冲区的所需部分创建一个内存视图并将其传递给readinto()即可。

识别代码最慢的部分

这是一个分析的过程,在很多教科书中都有介绍,而且有各种工具软件的支持(标准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
        # 使用两个局部变量引用对象

避免了在bar()方法中反复调用self.ba和obj_display.framebufferbar()而需要的重复查找。

控制垃圾收集

当需要内存分配时,MicroPython会尝试在堆上找到足够大小的块,这可能会失败,通常是因为堆中堆满了代码不再引用的对象。如果发生故障,称为垃圾收集的过程将回收这些冗余对象使用的内存,然后再次尝试分配 - 这个过程可能需要几毫秒。

通过定期发布来预防这种情况可能会有好处gc.collect()。首先在实际需要之前进行收集会更快 - 如果频繁执行,通常约为 1 毫秒。其次,您可以确定代码中使用该时间的点,而不是在随机点(可能在速度关键部分)发生较长的延迟。最后定期执行收集可以减少堆中的碎片。严重的碎片可能导致不可恢复的分配失败。

本地代码发射器

PS:“代码发射器”在代码编译过程中负责将经过处理的代码生成为最终可以执行的代码,是连接源代码与目标代码的重要桥梁。

本地代码发射器会让MicroPython编译器生成本机CPU操作码而不是字节码。它涵盖了MicroPython的大部分功能,因此大多数功能不需要调整(但见下文)。它通过函数装饰器进行调用:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # 缓存对象
    # 代码

本地代码发射器的当前实现存在一定的限制。

  • 不支持上下文管理器(with声明)。
  • 不支持生成器。
  • 如果使用raise,则必须提供一个参数。

要权衡提高性能的利弊,使用本地代码发射器将导致编译代码的增大(大约是字节码的两倍)。

Viper代码发射器

上面的优化属于标准编译的Python代码,Viper代码发射器不完全兼容,为了追求性能它支持特殊的Viper原生数据类型,不兼容整数处理,因为它使用机器字:32位硬件上的运算是以模2^32执行的。

与本机发射器一样,Viper生成机器指令,但执行了进一步的优化,显着提高了性能,尤其是整数算术和位操作。它也是使用装饰器进行调用:

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

正如上面的代码所示,使用Python类型提示有助于Viper优化器,类型提示提供有关参数和返回值的数据类型的信息。这些是在PEP0484中正式定义的标准 Python 语言功能。Viper支持自己的一组类型,即int, uint(无符号整数)ptrptr8ptr16ptr32。后面会讨论ptrX这些类型。目前该uint类型只有一个用途:作为函数返回值的类型提示。如果这样的函数返回,0xffffffff Python 会将结果解释为-2^32而不是-1。

除了本机发射器施加的限制之外,还适用以下限制:

  • 不允许使用默认参数值。
  • 可以使用浮点,但未进行优化。

Viper 提供了指针类型来协助优化器。包括:

  • ptr指向对象的指针。
  • ptr8指向一个字节。
  • ptr16指向 16 位半字。
  • ptr32指向 32 位机器字。

对于Python程序员来说,指针的概念可能比较陌生,它跟Pythonmemoryview对象有相似之处,因为它提供对存储在内存中的数据的直接访问。使用下标表示法访问元素,但不支持切片,指针只能返回单个元素。其目的是提供对存储在连续内存位置中的数据的快速随机访问,例如存储在支持缓冲区协议的对象中的数据以及微控制器中的内存映射外设寄存器。应该注意的是,使用指针进行编程是危险的:因为不会执行边界检查,并且编译器不会采取任何措施来防止缓冲区溢出错误。

典型用法是缓存变量:

@micropython.viper
def foo(self, arg: int) -> int:
    buf = ptr8(self.linebuf) # self.linebuf为字节或字节对象数组
    for x in range(20, 30):
        bar = buf[x] # 通过指针访问数据条目
        # 发射后的代码

在这种情况下,编译器“知道”buf是字节数组的地址,它可以生成在运行时快速计算地址buf[x]的代码。如果使用强制转换将对象转换为Viper本机代码,则应在函数开始时执行这些操作,而不是在关键的计时循环中执行,因为强制转换操作可能需要几微秒。强制转换规则如下:

  • 目前,强制转换运算符有:intbooluintptrptr8ptr16ptr32
  • 转换的结果将是本机Viper变量。
  • 强制转换的参数可以是Python对象或本地Viper变量。
  • 如果参数是本地Viper变量,则强制转换是无操作(即在运行时不花费任何成本),仅更改类型(例如从uint到ptr8),以便可以使用该指针存储/加载。
  • 如果参数是Python对象并且强制转换为intor uint,则Python对象必须是整型,并且返回该整型对象的值。
  • bool类型转换的参数必须是整型(布尔值或整数);当用作返回类型时,viper 函数将返回True或False对象。
  • 如果参数是Python对象并且强制转换为ptrprt16ptr32,则该 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 被实例化为输出引脚

Pin这涉及两次调用实例value()方法的开销,通过对芯片的GPIO端口输出数据寄存器 (odr) 的相关位执行读/写操作可以避免这些开销,为此,stm 模块提供了一组常量来提供相关寄存器的地址,可以快速切换P4引脚(CPU A14引脚),对应于色LED,可以按如下方式执行:

import machine
import stm

BIT14 = const(1 << 14)
machine.mem16[stm.GPIOA + stm.GPIO_ODR] ^= BIT14

你可能感兴趣的:(硬件,micropython,python,单片机,物联网)