Embedded 101: MicroPython Performance Optimization — Steps & Practical Cases from Slow to Fast
This article details MicroPython performance optimizations such as memory tuning, native/viper compilation, mpy-cross and RP2040 hardware optimizations.
【Preliminary Note】The original hardware example in this article was written based on the RP2040. The actual hardware used in this hands-on demonstration features the W55RP20 as the main controller chip. The circuit logic and UF2 flashing operation principles are universally applicable, with only the main controller model differing. The original chip model mentioned in the circuit descriptions below is provided for reference purposes only.
1. Maximize the running speed of MicroPython
time module (e. g., time. ticks_us ()) for microsecond-level timing to facilitate bottleneck locating;@ symbol to add extra features (such as timing) to a function, so as to improve code reusability;native emitter compiles Python into native machine code (fast);viper emitter is more aggressive and supports operating hardware registers (speed close to C);mpy-cross on a computer to pre-. py files into. mpy bytecode, reducing the compile time on the device.const to declare constants, precompile bytecode, etc. ;2. Optimization Steps
utime. ticks_us () can obtain microsecond-level system timestamps (1 microsecond = 1/1000 millisecond = 1/1000000 second). utime. ticks_diff () is used to calculate the difference between two timestamps. It is more suitable for embedded scenarios than the ordinary time module, as embedded development often requires high-precision timing at the millisecond/microsecond level.. py scripts into. mpy bytecode files on a personal computer (PC), then upload the. mpy files to a microcontroller for execution. Compared with dynamically compiling. py files on the device, pre-compiled. mpy bytecode has three advantages: it reduces the compile time on the device (especially when running a script for the first time, preventing the device from wasting computing power on compiling);. mpy files have a smaller size, saving flash space on the microcontroller;Bytecode loads and executes slightly faster than the original. py files.utime module to measure the execution time of each function and locate the most time-consuming "performance bottleneck".@micropython. native decorator to compile the function into native machine code, which will significantly improve execution speed with almost no code modifications required.@micropython. viper decorator, which enables the code to run at a speed close to that of C, but note that it comes with syntax restrictions (e. g., complex Python data structures cannot be used).. py files into. mpy bytecode files, then upload them to the device. This step does not require modifying the code, only changes the file loading method, and can significantly reduce the compilation overhead on the device.3. Optimization Methods
3.1 Identify the Slowest Code
utime. The code execution time can be measured in ms (milliseconds), us (microseconds), or CPU cycles.@timed_function decorator to time any function or method:import time
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# 用@timed_function装饰器修饰测试函数
@timed_function
def slow_function():
for i in range(10000):
pass
return "Done"slow_function()3.2 Specific Measures for Performance optimization
3.2.1 Performance Bottlenecks and Core Fundamental Concepts of MicroPython
3.2.1.1 Core Basic Concepts
3.2.1.1.1 Variables and Constants
# 变量,运行时存储在字典中
max_count = 10000
for i in range(max_count):
# 每次循环都要查字典找max_count的值
pass const () declaration (similar to C's #define), which directly replaces the identifier with the corresponding literal value when compiling bytecode, completely eliminating runtime dictionary lookup overhead.from micropython import const
# 常量,编译时直接替换为10000
MAX_COUNT = const(10000)
for i in range(MAX_COUNT):
# 循环中直接使用10000,无需查字典
pass 3.2.1.1.2 Three Memory Partitions
| 内存分区 | 比喻(仓库) | 存储内容 | 特点(性能相关) |
| 静态区 | 长期存储的固定货架(永久) | 全局变量、static变量、程序代码、常量 | 无分配 / 回收开销,生命周期和程序一致 |
| 栈 | 临时取货的小货架(快速) | 局部变量、函数参数、函数调用的返回地址 | 分配 / 释放极快(硬件级),但空间极小(几 KB) |
| 堆 | 临时堆放的杂项区域(混乱) | 动态创建的对象(列表、字典、字节数组、类实例) | 分配 / 回收有显著开销,易产生碎片 |
3.2.1.1.3 Reference and Copy
# 在堆上创建字节数组对象,a存储的是对象的地址(门牌号)
a = bytearray(10)
# b是a的引用,指向同一个对象,无新分配
b = a
# 操作b会改变a的内容,因为是同一个对象
b[0] = 1
# 输出:1
print(a[0]) # 大字节数组(堆上10KB)
a = bytearray(10000)
# 切片操作,创建新的字节数组(堆上~2KB),属于深拷贝
b = a[30:2000] # 大字节数组
a = bytearray(10000)
# 创建内存视图(仅分配小对象,几十字节)
mv = memoryview(a)
# 切片内存视图,无新分配,仅传递地址
b = mv[30:2000]
# 操作b会改变a的内容,因为是同一个数据
b[0] = 1
print(a[30]) GC): MicroPython automatically cleans up objects in the heap that are no longer referenced, and this process will block the program. We can manually call gc. collect () to control the timing of cleanup to avoid triggering it in performance-critical sections.machine. Pin), directly reading and writing the register addresses of the chip to eliminate the overhead of method calls (suitable for high-frequency hardware operations).. py scripts into. mpy bytecode on a computer, so as to reduce the compile overhead on the device and speed up script loading.3.2.1.2 Performance Bottlenecks of MicroPython
3.2.2 Memory and Object Optimization
3.2.2.1 Pre-allocated Memory and Fixed Object Size
append, adding new Attribute–Value Pairs to a dictionary). Especially for buffers (such as buffers for serial communication), they shall be pre-allocated and to reuse.readinto () instead of read ()(read () allocates a new buffer each time, while readinto () reads data into an existing buffer).from machine import UART, Pin
# 1. 预分配缓冲区(只创建一次,避免动态分配)
buf = bytearray(64) # 预分配64字节的缓冲区
# 初始化UART
uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1))
# 2. 使用readinto()读入预分配的缓冲区(无新分配)
while True:
if uart.any():
# 数据读入buf,返回读取的字节数
n = uart.readinto(buf)
# 对比:uart.read(64) 会每次创建新的字节对象,触发堆分配
print("recv data:", buf[:n])3.2.2.2 Use arrays instead of lists + memoryview to avoid data copying
array module (or bytearray) stores contiguous data of primitive types, delivering higher performance after pre-allocation; meanwhile, slicing operations (such as ba[30:2000]) will create data copies and trigger heap allocation; using memoryview enables direct passing of memory pointers with no copy overhead.import array
import time
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
# 1. 用array替代列表(存储整数,连续内存)
# 预分配1000个int型元素的数组
arr = array.array('i', [0]*1000)
# 2. 用memoryview避免切片拷贝
# 大字节数组
ba = bytearray(10000)
# 直接切片:会创建副本,触发~2K的堆分配
@timed_function
def func(data):
pass
# 测试切片拷贝(耗时且占内存)
func(ba[30:2000])
# 使用memoryview:只分配小对象,无数据拷贝
mv = memoryview(ba)
# 传递的是内存指针,无分配
func(mv[30:2000]) 3.2.2.3 Cache Object Reference
self. ba, obj_display. framebuffer) into local variables to avoid attribute lookups on every access — attribute lookups involve dictionary operations, which are time-consuming and may trigger memory allocation.import time
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
class Foo:
def __init__(self):
self.ba = bytearray(100)
@timed_function
def bar(self):
ba_ref = self.ba
for i in range(100):
ba_ref[i] = i % 256
class Foo_compare:
def __init__(self):
self.ba = bytearray(100)
@timed_function
def bar(self):
for i in range(100):
self.ba[i] = i % 256
# 测试
f = Foo()
f.bar()
f_c = Foo()
f_c.bar()3.2.2.4 Manual Garbage Collection Control
gc. collect () regularly to manually trigger GC, so as to prevent GC from being triggered randomly in performance-critical code segments (manual GC allows for timing control, and the time cost of frequent minor GCs is far lower than that of a single major GC).import gc
import time
# 启用GC(默认启用,可手动关闭/开启)
gc.enable()
# 性能关键循环前,手动触发GC
# 提前清理内存,耗时约1ms
gc.collect()
# 性能关键代码段
start = time.ticks_us()
for i in range(10000):
pass
end = time.ticks_us()
print(f"耗时:{utime.ticks_diff(end, start)/1000}ms")
# 定期在非关键段触发GC
# gc.collect()3.2.3 Code Execution Efficiency Optimization
3.2.3.1 Declare constants using const ()
const () replaces identifiers with numerical values (completed at compile time), avoiding runtime dictionary lookups, and delivers particularly significant optimization effects for constants used in loops.from micropython import const
import time
# 声明常量(编译时替换为数值)
MAX_COUNT = const(100000)
# 二进制常量也支持
PIN_BIT = const(1 << 2)
MAX_COUNT_NOT_USE_CONST = 100000
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
@timed_function
def use_const():
total = 0
for i in range(MAX_COUNT):
total += i
return total
# 对比:不用const(),每次访问都会查字典,耗时更长
@timed_function
def no_const():
global MAX_COUNT_NOT_USE_CONST
total = 0
for i in range(MAX_COUNT_NOT_USE_CONST):
total += i
return total
result1 = use_const()
result2 = no_const()const 's real advantage only becomes obvious in pre-compiled bytecode.3.2.3.2 mpy-cross compile bytecode
mpy-cross on a computer to pre- compile. py scripts into. mpy bytecode, then upload it to the device.pip command to install the mpy-cross tool:MicroPython py files into mpy files:mpy file is smaller. Then just use the mpremote tool to transfer it to the device side.3.2.3.3 Using a Code Emitter
3.2.3.3.1 native code emitter
@micropython.native
def foo(self, arg):
buf = self.linebuf # Cached object
# code3.2.3.3.2 Viper Code Transmitter
@micropython.viper
def foo(self, arg: int) -> int:
# codeimport time
import micropython
def timed_function(name):
def decorator(f):
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(name, delta/1000))
return result
return new_func
return decorator
# 普通Python函数:100万次累加(手动指定函数名'normal_add_loop')
@timed_function('normal_add_loop')
def normal_add_loop(n: int) -> int:
total = 0
for i in range(n):
total += i * 2 + 5
return total
# Viper优化函数:相同计算量(手动指定函数名'viper_add_loop')
@timed_function('viper_add_loop')
@micropython.viper
def viper_add_loop(n: int) -> int:
total = 0
for i in range(n):
total += i * 2 + 5
return total
# 调用测试(100万次运算,正常计时输出)
normal_add_loop(1000000)
viper_add_loop(1000000)i*2+ 5 accumulation), but normal_add_loop is purely interpreted for execution, while viper_add_loop is directly executed as machine code. The terminal running results are as follows:where the time consumption of normal_add_loop is dozens of times that of viper_add_loop.a: int = 10 as a default value, otherwise an error will be thrown.import micropython
# 错误示例(REPL运行会报错:Viper does not support default arguments)
# @micropython.viper
# def viper_default(a: int = 10) -> int:
# total = 0
# for i in range(a):
# total += i
# return total
# 正确示例(无默认参数,100万次运算,计时)
@micropython.viper
@timed_function
def viper_no_default(a: int) -> int:
total = 0
for i in range(a):
total += i
return total
# 调用测试
viper_no_default(1000000)import micropython
def timed_function(name):
def decorator(f):
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(name, delta/1000))
return result
return new_func
return decorator
# 普通函数:10万次浮点乘法累加
@timed_function('normal_float_calc')
def normal_float_calc(n: int) -> float:
total = 0.0
for i in range(n):
total += float(i) * 3.14159
return total
# Viper函数:相同的10万次浮点运算(无优化)
@timed_function('viper_float_calc')
@micropython.viper
def viper_float_calc(n: int):
total = 0.0
for i in range(n):
total += float(i) * 3.14159
return total
# 调用测试(计时结果几乎一致)
normal_float_calc(100000)
viper_float_calc(100000)ptr8/ptr16/ptr32) are used to directly access contiguous memory (such as bytearray) without boundary checking, and support single-element access via subscripts (slicing is not supported). A key optimization technique is: to place the operation of converting an object to a pointer at the beginning of the function rather than inside a loop, as the conversion takes several microseconds, which will be amplified in large loops.import micropython
def timed_function(name):
def decorator(f):
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(name, delta/1000))
return result
return new_func
return decorator
# 准备1万长度的bytearray(大数组)
ba = bytearray(10000)
for i in range(10000):
ba[i] = i % 256
# 普通函数:遍历10万bytearray,累加值
@timed_function('normal_bytearray_access')
def normal_bytearray_access(ba: bytearray) -> int:
total = 0
for i in range(10000):
total += ba[i]
return total
# Viper函数:ptr8指针访问,累加值(转换放在开头)
@timed_function('viper_ptr8_access')
@micropython.viper
def viper_ptr8_access(ba) -> int:
buf = ptr8(ba)
total = 0
for i in range(10000):
total += buf[i]
return total
# 调用测试(指针访问速度远超普通访问)
normal_bytearray_access(ba)
viper_ptr8_access(ba)viper_ptr8_access takes only one-thirtieth of the time of normal_bytearray_access in a regular function requires steps such as Python's boundary check and object attribute lookup; the ba[i]in a regular function requires steps such as Python's boundary check and object attribute lookup; the buf[i]in Viper directly calculates the memory address and accesses the byte, with no additional overhead.import micropython
def timed_function(name):
def decorator(f):
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(name, delta/1000))
return result
return new_func
return decorator
# 准备1万长度的bytearray(大数组)
ba = bytearray(10000)
for i in range(10000):
ba[i] = i % 256
# Viper函数:循环内重复转换ptr8(低效)
@timed_function('viper_bad_convert')
@micropython.viper
def viper_bad_convert(ba) -> int:
total = 0
for i in range(10000):
buf = ptr8(ba)
total += buf[i]
return total
# Viper函数:开头一次性转换ptr8(高效)
@timed_function('viper_good_convert')
@micropython.viper
def viper_good_convert(ba) -> int:
buf = ptr8(ba)
total = 0
for i in range(10000):
total += buf[i]
return total
# 调用测试(低效版耗时远高于高效版)
viper_bad_convert(ba)
viper_good_convert(ba)viper_bad_convert performs a type conversion on ptr8 (ba) in each of 1 million loops (each conversion takes a few microseconds, resulting in significant cumulative time);viper_good_convert performs the conversion only once at the start, eliminating redundant overhead.3.2.4 Operation and hardware Optimization
3.2.4.1 Replace Floating-Point Operations with Integer Operations
from machine import ADC, Pin
import array
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
# 1. 纯整数运算:预分配数组+ADC读数(无浮点)
# 预分配100个int型元素的数组(连续内存,无动态分配)
# 纯整数运算:读取16位整数ADC值
@timed_function
def adc_read_integer():
adc_data = array.array('i', [0]*100)
adc = ADC(Pin(26))
for i in range(100):
adc_data[i] = adc.read_u16()
return adc_data
# 2. 包含浮点运算:整数读数+浮点转换(电压计算)
# 第一步:整数读数(和上面一致)
# 第二步:浮点运算转换为电压(读数/65535*3.3)
@timed_function
def adc_read_float():
adc_data = array.array('i', [0]*100)
adc = ADC(Pin(26))
for i in range(100):
adc_data[i] = adc.read_u16()
voltage_data = [x/65535*3.3 for x in adc_data]
return voltage_data
# 执行测试,对比耗时
print("=== ADC读数性能对比 ===")
integer_data = adc_read_integer()
float_data = adc_read_float()
# 打印前5个电压值(验证功能)
print("\n前5个电压值:", float_data[:5])3.2.4.2 Direct Register Read/Write
from machine import Pin, mem32
import time
from micropython import const
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
# --------------------------
# 配置SIO寄存器(树莓派Pico专属)
# --------------------------
# SIO模块基地址(RP2040固定)
# SIO GPIO核心寄存器(正确地址,修正之前的错误)
SIO_BASE = const(0xD0000000)
# 一次性写入所有GPIO输出值
GPIO_OUT = SIO_BASE + 0x010
# 原子置位GPIO
GPIO_OUT_SET = SIO_BASE + 0x014
# 原子清零GPIO
GPIO_OUT_CLR = SIO_BASE + 0x018
# 原子翻转GPIO
GPIO_OUT_XOR = SIO_BASE + 0x01C
# 原子设置GPIO为输出模式# GPIO25(板载LED)的位掩码(bit25对应GPIO25)
GPIO_OE_SET = SIO_BASE + 0x024
# 初始化:将GPIO25设为输出模式(仅执行一次,原子操作)
PIN25_MASK = const(1 << 25)
mem32[GPIO_OE_SET] = PIN25_MASK
# 初始化Pin对象(仅执行一次)
led_pin = Pin(25, Pin.OUT)
# 方式1:普通machine.Pin操作GPIO25(硬件抽象层,有开销)
@timed_function
def led_pin_loop(loop_count):
for _ in range(loop_count):
led_pin.value(1)
led_pin.value(0)
# 方式2:SIO寄存器操作GPIO25(无抽象层,极致高效)
@timed_function
def led_sio_set_clr_loop(loop_count):
set_reg = GPIO_OUT_SET
clr_reg = GPIO_OUT_CLR
mask = PIN25_MASK
for _ in range(loop_count):
mem32[set_reg] = mask
mem32[clr_reg] = mask
# 测试运行速度
loop_count = 1000
led_pin_loop(loop_count)
led_sio_set_clr_loop(loop_count)GPIO_OUT_SET, GPIO_OUT_CLR, and PIN25_MASK are cached into local variables, which reduces the overhead of global variable lookup and makes the performance advantage of SIO more prominent.