Maximizar la velocidad de MicroPython

Este tutorial describe formas de mejorar el rendimiento del código MicroPython. Las optimizaciones que implican otros lenguajes se tratan en otra parte, en concreto el uso de módulos escritos en C y el ensamblador en línea de MicroPython.

El proceso de desarrollo de código de alto rendimiento consta de las siguientes etapas, que deben realizarse en el orden indicado.

  • Diseñar para la velocidad.

  • Codificar y depurar.

Pasos de optimización:

  • Identificar la sección de código más lenta.

  • Mejorar la eficiencia del código Python.

  • Usar el emisor de código nativo.

  • Usar el emisor de código viper.

  • Usar optimizaciones específicas del hardware.

Diseñar para la velocidad

Los problemas de rendimiento deben tenerse en cuenta desde el principio. Esto implica analizar las secciones de código más críticas para el rendimiento y dedicar especial atención a su diseño. El proceso de optimización comienza cuando el código ha sido probado: si el diseño es correcto desde el principio, la optimización será sencilla y puede que incluso sea innecesaria.

Algoritmos

El aspecto más importante al diseñar cualquier rutina con miras al rendimiento es asegurarse de emplear el mejor algoritmo. Este es un tema más propio de los libros de texto que de una guía de MicroPython, pero en ocasiones se pueden lograr mejoras espectaculares de rendimiento adoptando algoritmos conocidos por su eficiencia.

Asignación de RAM

Para diseñar código MicroPython eficiente es necesario comprender la forma en que el intérprete asigna RAM. Cuando se crea un objeto o este aumenta de tamaño (por ejemplo, cuando se añade un elemento a una lista), la RAM necesaria se asigna desde un bloque conocido como heap. Esto requiere una cantidad de tiempo considerable; además, en ocasiones desencadenará un proceso conocido como recolección de basura que puede tardar varios milisegundos.

En consecuencia, el rendimiento de una función o método puede mejorarse si un objeto se crea una sola vez y no se le permite aumentar de tamaño. Esto implica que el objeto persiste durante todo su uso: normalmente se instanciará en el constructor de una clase y se utilizará en varios métodos.

Esto se trata con más detalle en Controlar la recolección de basura más abajo.

Búferes

Un ejemplo de lo anterior es el caso común en el que se requiere un búfer, como uno usado para la comunicación con un dispositivo. Un controlador típico creará el búfer en el constructor y lo utilizará en sus métodos de E/S, que se llamarán repetidamente.

Las bibliotecas de MicroPython suelen proporcionar soporte para búferes preasignados. Por ejemplo, los objetos que admiten la interfaz de flujo (p. ej., archivo o UART) proporcionan un método read() que asigna un nuevo búfer para los datos leídos, pero también un método readinto() para leer datos en un búfer existente.

Algunas clases útiles para crear objetos búfer reutilizables:

Coma flotante

Algunos ports de MicroPython asignan los números de coma flotante en el heap. Otros ports pueden carecer de un coprocesador dedicado de coma flotante y realizar operaciones aritméticas sobre ellos por «software» a una velocidad considerablemente menor que con enteros. Cuando el rendimiento es importante, utilice operaciones con enteros y restrinja el uso de la coma flotante a las secciones del código donde el rendimiento no sea primordial. Por ejemplo, capture las lecturas del ADC como valores enteros en un array de una sola vez rápida, y solo entonces conviértalos a números de coma flotante para el procesamiento de señales.

Arrays

Considere el uso de los distintos tipos de clases de array como alternativa a las listas. El módulo array admite varios tipos de elementos, con elementos de 8 bits soportados por las clases integradas de Python bytes y bytearray. Todas estas estructuras de datos almacenan los elementos en posiciones de memoria contiguas. Una vez más, para evitar la asignación de memoria en el código crítico, estos deben preasignarse y pasarse como argumentos o como objetos enlazados.

Memoryviews

Al pasar porciones (slices) de objetos como las instancias de bytearray, Python crea una copia que implica la asignación de un tamaño proporcional al tamaño de la porción. Esto puede mitigarse usando un objeto memoryview. El propio memoryview se asigna en el heap, pero es un objeto pequeño y de tamaño fijo, independientemente del tamaño de la porción a la que apunta. Hacer una porción de un memoryview crea un nuevo memoryview, por lo que esto no puede hacerse dentro de una rutina de servicio de interrupción. Además, la sintaxis de porción a:b provoca una asignación adicional al instanciar un objeto 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

Un memoryview solo puede aplicarse a objetos que admiten el protocolo de búfer, lo que incluye los arrays pero no las listas. Una pequeña advertencia es que mientras un objeto memoryview está vivo, también mantiene vivo el objeto búfer original. Por tanto, un memoryview no es una panacea universal. Por ejemplo, en el caso anterior, si ya terminó con el búfer de 10K y solo necesita esos bytes 30:2000 de él, puede ser mejor hacer una porción y dejar que el búfer de 10K se libere (quede listo para la recolección de basura), en lugar de crear un memoryview de larga duración y mantener 10K bloqueados para el GC.

No obstante, memoryview es indispensable para la gestión avanzada de búferes preasignados. El método readinto() tratado anteriormente coloca los datos al principio del búfer y rellena el búfer entero. ¿Y si necesita colocar datos en medio de un búfer existente? Simplemente cree un memoryview sobre la sección necesaria del búfer y pásela a readinto().

Cadenas frente a Bytes

MicroPython utiliza interning de cadenas para ahorrar espacio cuando hay varias cadenas idénticas. Cada vez que se asigna una nueva cadena en tiempo de ejecución (por ejemplo, cuando se concatenan otras dos cadenas), MicroPython comprueba si la nueva cadena puede internarse para ahorrar RAM.

Si tiene código que realiza operaciones con cadenas críticas para el rendimiento, considere usar objetos y literales bytes (es decir, b"abc"). Esto omite la comprobación de interning y puede ser varias veces más rápido que realizar las mismas operaciones con objetos de cadena.

Nota

El mejor rendimiento se logrará siempre evitando por completo la creación de nuevos objetos, por ejemplo con un búfer reutilizable como se describió anteriormente.

Identificar la sección de código más lenta

Este es un proceso conocido como profiling (perfilado) y se trata en los libros de texto y (para Python estándar) está respaldado por diversas herramientas de software. Para el tipo de aplicaciones embebidas más pequeñas que probablemente se ejecuten en plataformas MicroPython, la función o método más lento suele poder determinarse mediante el uso juicioso del grupo de funciones de temporización ticks documentado en time. El tiempo de ejecución del código puede medirse en ms, us o ciclos de CPU.

Lo siguiente permite cronometrar cualquier función o método añadiendo un decorador @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

Mejoras del código MicroPython

La declaración const()

MicroPython proporciona una declaración const(). Funciona de manera similar a #define en C, en el sentido de que cuando el código se compila a bytecode el compilador sustituye el identificador por el valor numérico. Esto evita una búsqueda en diccionario en tiempo de ejecución. El argumento de const() puede ser cualquier cosa que, en tiempo de compilación, se evalúe como un entero, p. ej. 0x100 o 1 << 8.

Almacenar en caché las referencias a objetos

Cuando una función o método accede repetidamente a objetos, el rendimiento mejora si se almacena el objeto en caché en una variable local:

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

Esto evita la necesidad de buscar repetidamente self.ba y obj_display.framebuffer en el cuerpo del método bar().

Controlar la recolección de basura

Cuando se requiere asignación de memoria, MicroPython intenta localizar un bloque de tamaño adecuado en el heap. Esto puede fallar, normalmente porque el heap está saturado de objetos que ya no están referenciados por el código. Si se produce un fallo, el proceso conocido como recolección de basura recupera la memoria utilizada por estos objetos redundantes y se vuelve a intentar la asignación, un proceso que puede tardar varios milisegundos.

Puede haber beneficios en anticiparse a esto emitiendo periódicamente gc.collect(). En primer lugar, hacer una recolección antes de que sea realmente necesaria es más rápido, normalmente del orden de 1 ms si se hace con frecuencia. En segundo lugar, puede determinar el punto del código donde se emplea este tiempo en lugar de que ocurra un retraso más largo en puntos aleatorios, posiblemente en una sección crítica para la velocidad. Por último, realizar recolecciones de forma regular puede reducir la fragmentación del heap. Una fragmentación grave puede provocar fallos de asignación irrecuperables.

El emisor de código nativo

Esto hace que el compilador de MicroPython emita opcodes nativos de la CPU en lugar de bytecode. Cubre la mayor parte de la funcionalidad de MicroPython, por lo que la mayoría de las funciones no requerirán ninguna adaptación (pero vea más abajo). Se invoca mediante un decorador de función:

@micropython.native
def foo(self, arg):
    buf = self.linebuf # Cached object
    # code

Existen ciertas limitaciones en la implementación actual del emisor de código nativo.

  • Si se usa raise debe suministrarse un argumento.

  • El planificador en segundo plano (vea micropython.schedule) no se ejecuta durante la ejecución de código nativo.

  • En destinos con hilos y el GIL, el GIL no se libera durante la ejecución de código nativo.

Para mitigar los dos últimos puntos, las funciones nativas de larga duración deben llamar periódicamente a time.sleep(0), lo que ejecutará el planificador y liberará temporalmente el GIL.

La contrapartida por el rendimiento mejorado (aproximadamente el doble de rápido que el bytecode) es un aumento del tamaño del código compilado.

El emisor de código Viper

Las optimizaciones tratadas anteriormente implican código Python conforme a los estándares. El emisor de código Viper no es totalmente conforme. Admite tipos de datos nativos especiales de Viper en aras del rendimiento. El procesamiento de enteros no es conforme porque utiliza palabras de máquina: la aritmética en hardware de 32 bits se realiza módulo 2**32.

Al igual que el emisor Nativo, Viper produce instrucciones de máquina, pero se realizan optimizaciones adicionales que aumentan sustancialmente el rendimiento, especialmente para la aritmética de enteros y las manipulaciones de bits. Se invoca mediante un decorador:

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

Como ilustra el fragmento anterior, es beneficioso usar anotaciones de tipo (type hints) de Python para ayudar al optimizador de Viper. Las anotaciones de tipo proporcionan información sobre los tipos de datos de los argumentos y del valor de retorno; son una característica estándar del lenguaje Python definida formalmente aquí PEP0484. Viper admite su propio conjunto de tipos, a saber int, uint (entero sin signo), ptr, ptr8, ptr16 y ptr32. Los tipos ptrX se tratan más abajo. Actualmente el tipo uint cumple un único propósito: como anotación de tipo para el valor de retorno de una función. Si una función de este tipo devuelve 0xffffffff, Python interpretará el resultado como 2**32 -1 en lugar de como -1.

Además de las restricciones impuestas por el emisor nativo, se aplican las siguientes limitaciones:

  • No se permiten valores de argumento por defecto.

  • Puede usarse coma flotante, pero no se optimiza.

Viper proporciona tipos de puntero para ayudar al optimizador. Estos comprenden

  • ptr Puntero a un objeto.

  • ptr8 Apunta a un byte.

  • ptr16 Apunta a una media palabra de 16 bits.

  • ptr32 Apunta a una palabra de máquina de 32 bits.

El concepto de puntero puede resultar poco familiar para los programadores de Python. Tiene similitudes con un objeto memoryview de Python en que proporciona acceso directo a los datos almacenados en memoria. Los elementos se acceden mediante notación de subíndice, pero las porciones no están admitidas: un puntero solo puede devolver un único elemento. Su propósito es proporcionar acceso aleatorio rápido a los datos almacenados en posiciones de memoria contiguas, como los datos almacenados en objetos que admiten el protocolo de búfer y los registros de periféricos mapeados en memoria de un microcontrolador. Cabe señalar que programar usando punteros es peligroso: no se realiza comprobación de límites y el compilador no hace nada para evitar errores de desbordamiento de búfer.

El uso típico es almacenar variables en caché:

@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

En este caso el compilador «sabe» que buf es la dirección de un array de bytes; puede emitir código para calcular rápidamente la dirección de buf[x] en tiempo de ejecución. Cuando se usan casts para convertir objetos a tipos nativos de Viper, estos deben realizarse al inicio de la función en lugar de en bucles con temporización crítica, ya que la operación de cast puede tardar varios microsegundos. Las reglas para hacer casts son las siguientes:

  • Los operadores de cast son actualmente: int, bool, uint, ptr, ptr8, ptr16 y ptr32.

  • El resultado de un cast será una variable nativa de Viper.

  • Los argumentos de un cast pueden ser un objeto Python o una variable nativa de Viper.

  • Si el argumento es una variable nativa de Viper, entonces el cast es una no-op (es decir, no cuesta nada en tiempo de ejecución) que simplemente cambia el tipo (p. ej. de uint a ptr8) para que luego pueda almacenar/cargar usando este puntero.

  • Si el argumento es un objeto Python y el cast es int o uint, entonces el objeto Python debe ser de tipo integral y se devuelve el valor de ese objeto integral.

  • El argumento de un cast a bool debe ser de tipo integral (booleano o entero); cuando se usa como tipo de retorno, la función viper devolverá objetos True o False.

  • Si el argumento es un objeto Python y el cast es ptr, ptr8, ptr16 o ptr32, entonces el objeto Python debe tener el protocolo de búfer (en cuyo caso se devuelve un puntero al inicio del búfer) o bien debe ser de tipo integral (en cuyo caso se devuelve el valor de ese objeto integral).

Escribir en un puntero que apunta a un objeto de solo lectura conducirá a un comportamiento indefinido.

Nota

Los ejemplos de código siguientes se dan para las OpenMV Cam basadas en STM32, que proporcionan el módulo stm. Las técnicas descritas se aplican de forma general.

El módulo stm expone las direcciones de memoria de los registros de periféricos del MCU. Cada puerto GPIO tiene un registro de datos de salida (ODR) cuyos bits se corresponden uno a uno con los pines de ese puerto: escribir el registro controla esos pines directamente, sin la sobrecarga de una llamada al método machine.Pin, y aplicar XOR a un bit conmuta su pin. En la OpenMV Cam original el LED azul está conectado al pin 2 de GPIOC, por lo que el siguiente ejemplo usa un cast ptr16 para conmutar el LED azul n veces:

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

Puede encontrarse una descripción técnica detallada de los tres emisores de código en Kickstarter aquí Nota 1 y aquí Nota 2

Acceder al hardware directamente

Esto entra en la categoría de programación más avanzada e implica cierto conocimiento del MCU de destino. Considere el ejemplo de conmutar un pin de salida en una OpenMV Cam. El enfoque estándar sería escribir

mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin

Esto implica la sobrecarga de dos llamadas al método value() de la instancia de Pin. Esta sobrecarga puede eliminarse realizando una lectura/escritura sobre el bit correspondiente del registro de datos de salida (ODR) del puerto GPIO del chip. Para facilitar esto, el módulo stm proporciona un conjunto de constantes que dan las direcciones de los registros pertinentes (stm.GPIOC es la dirección base del puerto GPIOC, stm.GPIO_ODR el desplazamiento de su registro de datos de salida). Como antes, el LED azul de la OpenMV Cam original es el pin 2 de GPIOC, por lo que una conmutación rápida del mismo puede realizarse de la siguiente manera:

import machine
import stm

BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2