Maximizando a velocidade do MicroPython

Este tutorial descreve maneiras de melhorar o desempenho do código MicroPython. Otimizações que envolvem outras linguagens são abordadas em outros lugares, especificamente o uso de módulos escritos em C e o assembler inline do MicroPython.

O processo de desenvolvimento de código de alto desempenho compreende os seguintes estágios, que devem ser executados na ordem listada.

  • Projetar para velocidade.

  • Codificar e depurar.

Etapas de otimização:

  • Identificar a seção mais lenta do código.

  • Melhorar a eficiência do código Python.

  • Usar o emissor de código nativo (native).

  • Usar o emissor de código viper.

  • Usar otimizações específicas de hardware.

Projetando para velocidade

As questões de desempenho devem ser consideradas desde o início. Isso envolve ter uma visão das seções de código mais críticas para o desempenho e dedicar atenção especial ao projeto delas. O processo de otimização começa quando o código foi testado: se o projeto estiver correto desde o início, a otimização será simples e pode até ser desnecessária.

Algoritmos

O aspecto mais importante ao projetar qualquer rotina visando o desempenho é garantir que o melhor algoritmo seja empregado. Esse é um tópico para livros didáticos, e não para um guia do MicroPython, mas às vezes é possível obter ganhos espetaculares de desempenho ao adotar algoritmos conhecidos por sua eficiência.

Alocação de RAM

Para projetar código MicroPython eficiente, é necessário compreender a forma como o interpretador aloca a RAM. Quando um objeto é criado ou cresce em tamanho (por exemplo, quando um item é anexado a uma lista), a RAM necessária é alocada a partir de um bloco conhecido como heap. Isso leva um tempo significativo; além disso, ocasionalmente dispara um processo conhecido como coleta de lixo (garbage collection), que pode levar vários milissegundos.

Consequentemente, o desempenho de uma função ou método pode ser melhorado se um objeto for criado apenas uma vez e não tiver permissão para crescer em tamanho. Isso implica que o objeto persiste durante toda a sua utilização: normalmente ele será instanciado no construtor de uma classe e utilizado em vários métodos.

Isso é abordado em mais detalhes em Controlando a coleta de lixo abaixo.

Buffers

Um exemplo do que foi exposto acima é o caso comum em que um buffer é necessário, como um usado para comunicação com um dispositivo. Um driver típico criará o buffer no construtor e o utilizará em seus métodos de E/S, que serão chamados repetidamente.

As bibliotecas do MicroPython normalmente oferecem suporte a buffers pré-alocados. Por exemplo, objetos que suportam a interface de stream (por exemplo, arquivo ou UART) fornecem o método read(), que aloca um novo buffer para os dados lidos, mas também um método readinto() para ler dados em um buffer existente.

Algumas classes úteis para criar objetos de buffer reutilizáveis:

Ponto flutuante

Alguns ports do MicroPython alocam números de ponto flutuante no heap. Alguns outros ports podem não ter um coprocessador dedicado de ponto flutuante e realizam operações aritméticas sobre eles em “software”, a uma velocidade consideravelmente menor do que com inteiros. Onde o desempenho é importante, use operações com inteiros e restrinja o uso de ponto flutuante às seções do código onde o desempenho não é primordial. Por exemplo, capture leituras do ADC como valores inteiros em um array de uma só vez, de forma rápida, e somente depois converta-os em números de ponto flutuante para o processamento do sinal.

Arrays

Considere o uso dos vários tipos de classes de array como alternativa às listas. O módulo array suporta vários tipos de elementos, com elementos de 8 bits suportados pelas classes integradas do Python bytes e bytearray. Todas essas estruturas de dados armazenam elementos em locais de memória contíguos. Mais uma vez, para evitar alocação de memória em código crítico, eles devem ser pré-alocados e passados como argumentos ou como objetos vinculados.

Memoryviews

Ao passar fatias (slices) de objetos como instâncias de bytearray, o Python cria uma cópia, o que envolve alocação de um tamanho proporcional ao tamanho da fatia. Isso pode ser atenuado usando um objeto memoryview. O próprio memoryview é alocado no heap, mas é um objeto pequeno e de tamanho fixo, independentemente do tamanho da fatia para a qual aponta. Fatiar um memoryview cria um novo memoryview, portanto isso não pode ser feito em uma rotina de serviço de interrupção. Além disso, a sintaxe de fatia a:b causa alocação adicional ao instanciar um 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

Um memoryview só pode ser aplicado a objetos que suportam o protocolo de buffer - isso inclui arrays, mas não listas. Uma pequena ressalva é que, enquanto o objeto memoryview estiver ativo, ele também mantém vivo o objeto de buffer original. Portanto, um memoryview não é uma panaceia universal. Por exemplo, no caso acima, se você terminou de usar o buffer de 10K e precisa apenas dos bytes 30:2000 dele, pode ser melhor fazer uma fatia e deixar o buffer de 10K ir embora (ficar disponível para coleta de lixo), em vez de criar um memoryview de longa duração e manter 10K bloqueados para o GC.

Mesmo assim, o memoryview é indispensável para o gerenciamento avançado de buffers pré-alocados. O método readinto() discutido acima coloca os dados no início do buffer e preenche todo o buffer. E se você precisar colocar dados no meio de um buffer existente? Basta criar um memoryview na seção necessária do buffer e passá-lo para readinto().

Strings vs Bytes

O MicroPython usa interning de strings para economizar espaço quando há múltiplas strings idênticas. Cada vez que uma nova string é alocada em tempo de execução (por exemplo, quando duas outras strings são concatenadas), o MicroPython verifica se a nova string pode passar pelo interning para economizar RAM.

Se você tiver código que realiza operações de string críticas para o desempenho, considere usar objetos bytes e literais (ou seja, b"abc"). Isso pula a verificação de interning e pode ser várias vezes mais rápido do que realizar as mesmas operações com objetos string.

Nota

O melhor desempenho será sempre alcançado evitando completamente a criação de novos objetos, por exemplo, com um buffer reutilizável conforme descrito acima.

Identificando a seção mais lenta do código

Esse é um processo conhecido como profiling e é abordado em livros didáticos e (para o Python padrão) suportado por várias ferramentas de software. Para o tipo de aplicação embarcada menor, que provavelmente estará rodando em plataformas MicroPython, a função ou método mais lento geralmente pode ser determinado pelo uso criterioso do grupo de funções de temporização ticks documentado em time. O tempo de execução do código pode ser medido em ms, us ou ciclos de CPU.

O exemplo a seguir permite que qualquer função ou método seja cronometrado adicionando um 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

Melhorias no código MicroPython

A declaração const()

O MicroPython fornece uma declaração const(). Ela funciona de maneira semelhante ao #define em C, no sentido de que, quando o código é compilado para bytecode, o compilador substitui o identificador pelo valor numérico. Isso evita uma busca em dicionário em tempo de execução. O argumento de const() pode ser qualquer coisa que, em tempo de compilação, seja avaliado como um inteiro, por exemplo 0x100 ou 1 << 8.

Cacheando referências de objetos

Quando uma função ou método acessa repetidamente objetos, o desempenho é melhorado ao armazenar o objeto em cache em uma variável 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

Isso evita a necessidade de buscar repetidamente self.ba e obj_display.framebuffer no corpo do método bar().

Controlando a coleta de lixo

Quando a alocação de memória é necessária, o MicroPython tenta localizar um bloco de tamanho adequado no heap. Isso pode falhar, geralmente porque o heap está cheio de objetos que não são mais referenciados pelo código. Se ocorrer uma falha, o processo conhecido como coleta de lixo recupera a memória usada por esses objetos redundantes e a alocação é então tentada novamente - um processo que pode levar vários milissegundos.

Pode haver benefícios em antecipar isso emitindo periodicamente gc.collect(). Em primeiro lugar, fazer uma coleta antes de ela ser realmente necessária é mais rápido - normalmente da ordem de 1ms se feito com frequência. Em segundo lugar, você pode determinar o ponto no código onde esse tempo é gasto, em vez de ter um atraso maior ocorrendo em pontos aleatórios, possivelmente em uma seção crítica de velocidade. Por fim, realizar coletas regularmente pode reduzir a fragmentação do heap. Uma fragmentação severa pode levar a falhas de alocação não recuperáveis.

O emissor de código nativo (Native)

Isso faz com que o compilador do MicroPython emita opcodes nativos da CPU em vez de bytecode. Ele cobre a maior parte da funcionalidade do MicroPython, portanto a maioria das funções não exigirá adaptação (mas veja abaixo). É invocado por meio de um decorador de função:

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

Existem certas limitações na implementação atual do emissor de código nativo.

  • Se raise for usado, um argumento deve ser fornecido.

  • O agendador em segundo plano (veja micropython.schedule) não é executado durante a execução de código nativo.

  • Em alvos com threading e o GIL, o GIL não é liberado durante a execução de código nativo.

Para mitigar os dois últimos pontos, funções nativas de longa duração devem chamar time.sleep(0) periodicamente, o que executará o agendador e fará o GIL alternar.

O compromisso pelo desempenho melhorado (aproximadamente duas vezes mais rápido que o bytecode) é um aumento no tamanho do código compilado.

O emissor de código Viper

As otimizações discutidas acima envolvem código Python em conformidade com os padrões. O emissor de código Viper não é totalmente compatível. Ele suporta tipos de dados nativos especiais do Viper em busca de desempenho. O processamento de inteiros não é compatível porque usa palavras de máquina: a aritmética em hardware de 32 bits é realizada módulo 2**32.

Como o emissor Native, o Viper produz instruções de máquina, mas otimizações adicionais são realizadas, aumentando substancialmente o desempenho, especialmente para aritmética de inteiros e manipulações de bits. Ele é invocado usando um decorador:

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

Como o fragmento acima ilustra, é benéfico usar dicas de tipo (type hints) do Python para auxiliar o otimizador Viper. As dicas de tipo fornecem informações sobre os tipos de dados dos argumentos e do valor de retorno; esses são um recurso padrão da linguagem Python, definido formalmente aqui PEP0484. O Viper suporta seu próprio conjunto de tipos, a saber int, uint (inteiro sem sinal), ptr, ptr8, ptr16 e ptr32. Os tipos ptrX são discutidos abaixo. Atualmente, o tipo uint serve a um único propósito: como dica de tipo para o valor de retorno de uma função. Se tal função retornar 0xffffffff, o Python interpretará o resultado como 2**32 -1 em vez de -1.

Além das restrições impostas pelo emissor nativo, as seguintes restrições se aplicam:

  • Valores padrão de argumentos não são permitidos.

  • Ponto flutuante pode ser usado, mas não é otimizado.

O Viper fornece tipos de ponteiro para auxiliar o otimizador. Eles incluem

  • ptr Ponteiro para um objeto.

  • ptr8 Aponta para um byte.

  • ptr16 Aponta para uma meia-palavra de 16 bits.

  • ptr32 Aponta para uma palavra de máquina de 32 bits.

O conceito de ponteiro pode ser desconhecido para programadores Python. Ele tem semelhanças com um objeto memoryview do Python, no sentido de que fornece acesso direto a dados armazenados na memória. Os itens são acessados usando notação de subscrito, mas fatias não são suportadas: um ponteiro só pode retornar um único item. Seu propósito é fornecer acesso aleatório rápido a dados armazenados em locais de memória contíguos - como dados armazenados em objetos que suportam o protocolo de buffer e registradores de periféricos mapeados em memória em um microcontrolador. Deve-se observar que programar usando ponteiros é perigoso: a verificação de limites não é realizada e o compilador não faz nada para evitar erros de estouro de buffer (buffer overrun).

O uso típico é para armazenar variáveis em cache:

@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

Nesse caso, o compilador “sabe” que buf é o endereço de um array de bytes; ele pode emitir código para calcular rapidamente o endereço de buf[x] em tempo de execução. Onde casts são usados para converter objetos em tipos nativos do Viper, eles devem ser realizados no início da função, em vez de em loops de temporização crítica, pois a operação de cast pode levar vários microssegundos. As regras para casting são as seguintes:

  • Os operadores de cast são atualmente: int, bool, uint, ptr, ptr8, ptr16 e ptr32.

  • O resultado de um cast será uma variável nativa do Viper.

  • Os argumentos de um cast podem ser um objeto Python ou uma variável nativa do Viper.

  • Se o argumento for uma variável nativa do Viper, então o cast é um no-op (ou seja, não custa nada em tempo de execução) que apenas altera o tipo (por exemplo, de uint para ptr8) para que você possa então armazenar/carregar usando esse ponteiro.

  • Se o argumento for um objeto Python e o cast for int ou uint, então o objeto Python deve ser de tipo integral e o valor desse objeto integral é retornado.

  • O argumento de um cast bool deve ser de tipo integral (booleano ou inteiro); quando usado como tipo de retorno, a função viper retornará objetos True ou False.

  • Se o argumento for um objeto Python e o cast for ptr, ptr8, ptr16 ou ptr32, então o objeto Python deve ou ter o protocolo de buffer (caso em que um ponteiro para o início do buffer é retornado) ou ser de tipo integral (caso em que o valor desse objeto integral é retornado).

Escrever em um ponteiro que aponta para um objeto somente leitura levará a comportamento indefinido.

Nota

Os exemplos de código abaixo são fornecidos para as OpenMV Cams baseadas em STM32, que fornecem o módulo stm. As técnicas descritas se aplicam de forma geral.

O módulo stm expõe os endereços de memória dos registradores de periféricos do MCU. Cada porta GPIO possui um registrador de dados de saída (ODR) cujos bits mapeiam um a um para os pinos daquela porta: escrever no registrador aciona esses pinos diretamente, sem a sobrecarga de uma chamada de método de machine.Pin, e fazer XOR de um bit alterna seu pino. Na OpenMV Cam original, o LED azul está conectado ao pino 2 de GPIOC, portanto o exemplo a seguir usa um cast ptr16 para alternar o LED azul n vezes:

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

Uma descrição técnica detalhada dos três emissores de código pode ser encontrada no Kickstarter aqui Nota 1 e aqui Nota 2

Acessando o hardware diretamente

Isso se enquadra na categoria de programação mais avançada e envolve algum conhecimento do MCU alvo. Considere o exemplo de alternar um pino de saída em uma OpenMV Cam. A abordagem padrão seria escrever

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

Isso envolve a sobrecarga de duas chamadas ao método value() da instância de Pin. Essa sobrecarga pode ser eliminada realizando uma leitura/escrita no bit relevante do registrador de dados de saída (ODR) da porta GPIO do chip. Para facilitar isso, o módulo stm fornece um conjunto de constantes que dão os endereços dos registradores relevantes (stm.GPIOC é o endereço base da porta GPIOC, stm.GPIO_ODR o offset de seu registrador de dados de saída). Como acima, o LED azul na OpenMV Cam original é o pino 2 de GPIOC, portanto uma alternância rápida dele pode ser realizada da seguinte forma:

import machine
import stm

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