Maximizar a velocidade do MicroPython¶
Este tutorial descreve formas de melhorar o desempenho do código MicroPython. As otimizações que envolvem outras linguagens são abordadas noutros locais, nomeadamente a utilização de módulos escritos em C e o montador inline do MicroPython.
O processo de desenvolvimento de código de alto desempenho compreende as seguintes etapas, que devem ser executadas pela ordem indicada.
Conceber para a velocidade.
Codificar e depurar.
Etapas de otimização:
Identificar a secção de código mais lenta.
Melhorar a eficiência do código Python.
Utilizar o emissor de código nativo.
Utilizar o emissor de código Viper.
Utilizar otimizações específicas do hardware.
Conceber para a velocidade¶
Os problemas de desempenho devem ser considerados desde o início. Isso implica ter uma visão das secções de código mais críticas em termos de desempenho e dedicar-lhes especial atenção no seu desenho. O processo de otimização começa quando o código tiver sido testado: se o desenho for correto desde o início, a otimização será simples e pode mesmo ser desnecessária.
Algoritmos¶
O aspeto mais importante no desenho de qualquer rotina orientada ao desempenho é garantir que o melhor algoritmo é utilizado. Este é um tema para manuais técnicos e não para um guia de MicroPython, mas ganhos de desempenho espetaculares podem por vezes ser alcançados com a adoção de algoritmos reconhecidos pela sua eficiência.
Alocação de RAM¶
Para conceber código MicroPython eficiente, é necessário compreender a forma como o interpretador aloca RAM. Quando um objeto é criado ou aumenta de tamanho (por exemplo, quando um elemento é acrescentado a uma lista), a RAM necessária é alocada a partir de um bloco conhecido como heap. Este processo demora um tempo significativo; além disso, ocasionalmente desencadeia um processo conhecido como recolha de lixo (garbage collection), que pode demorar 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 lhe for permitido crescer em tamanho. Isso implica que o objeto persista durante toda a sua utilização: tipicamente, será instanciado no construtor de uma classe e utilizado em vários métodos.
Isto é abordado com mais detalhe em Controlar a recolha de lixo abaixo.
Buffers¶
Um exemplo do acima exposto é o caso comum em que é necessário um buffer, como o utilizado para comunicação com um dispositivo. Um driver típico criará o buffer no construtor e utilizá-lo-á nos seus métodos de E/S, que serão chamados repetidamente.
As bibliotecas do MicroPython fornecem tipicamente suporte para buffers pré-alocados. Por exemplo, os objetos que suportam a interface de fluxo (por exemplo, ficheiro ou UART) disponibilizam o método read(), que aloca um novo buffer para os dados lidos, mas também um método readinto() para ler dados para um buffer existente.
Algumas classes úteis para criar objetos de buffer reutilizáveis:
Vírgula flutuante¶
Alguns portos do MicroPython alocam números de vírgula flutuante no heap. Outros portos podem não ter um coprocessador dedicado para vírgula flutuante e realizar operações aritméticas sobre eles em «software», com uma velocidade consideravelmente inferior à dos inteiros. Quando o desempenho é importante, utilize operações com inteiros e restrinja o uso de vírgula flutuante às secções de código onde o desempenho não é prioritário. Por exemplo, capture leituras do ADC como valores inteiros numa array numa única passagem rápida e só depois as converta para números de vírgula flutuante para processamento de sinal.
Arrays¶
Considere a utilização dos vários tipos de classes de arrays como alternativa a 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 estas estruturas de dados armazenam os elementos em localizações de memória contíguas. Mais uma vez, para evitar alocação de memória em código crítico, estas devem ser pré-alocadas e passadas como argumentos ou como objetos vinculados.
Memoryviews¶
Quando se passam fatias de objetos como instâncias de bytearray, o Python cria uma cópia que envolve a alocação de memória proporcional ao tamanho da fatia. Isto pode ser atenuado com 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. Fatiando um memoryview cria-se um novo memoryview, pelo que isto não pode ser feito numa 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 suportem o protocolo de buffer — isto inclui arrays mas não listas. Uma pequena ressalva é que enquanto o objeto memoryview estiver ativo, mantém também ativo o objeto de buffer original. Portanto, um memoryview não é uma panaceia universal. Por exemplo, no caso acima, se já terminou com o buffer de 10K e apenas precisa dos bytes 30:2000 do mesmo, pode ser melhor criar uma fatia e deixar o buffer de 10K ser libertado (ficar pronto para recolha de lixo), em vez de criar um memoryview de longa duração e manter 10K bloqueados para o GC.
Não obstante, memoryview é indispensável para a gestão avançada de buffers pré-alocados. O método readinto() discutido acima coloca os dados no início do buffer e preenche-o na totalidade. E se precisar de colocar dados no meio de um buffer existente? Basta criar um memoryview para a secção necessária do buffer e passá-lo ao readinto().
Strings vs Bytes¶
O MicroPython usa interning de strings para poupar espaço quando existem 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 ser internada para poupar RAM.
Se tiver código que realiza operações com strings críticas para o desempenho, considere usar objetos e literais bytes (ou seja, b"abc"). Isto ignora 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 como descrito acima.
Identificar a secção de código mais lenta¶
Este é um processo conhecido como profiling e é abordado em manuais técnicos e (para Python padrão) suportado por várias ferramentas de software. Para o tipo de aplicações embarcadas mais pequenas que provavelmente correrão em plataformas MicroPython, a função ou método mais lento pode geralmente ser determinado através do 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 seguinte código permite temporizar qualquer função ou método 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 ao código MicroPython¶
A declaração const()¶
O MicroPython disponibiliza uma declaração const(). Esta funciona de forma semelhante a #define em C, no sentido em que quando o código é compilado para bytecode, o compilador substitui o valor numérico pelo identificador. Isto evita uma pesquisa em dicionário em tempo de execução. O argumento de const() pode ser qualquer coisa que, em tempo de compilação, avalie para um inteiro, por exemplo 0x100 ou 1 << 8.
Caching de referências a objetos¶
Quando uma função ou método acede repetidamente a objetos, o desempenho melhora ao colocar o objeto em cache numa 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
Isto evita a necessidade de procurar repetidamente self.ba e obj_display.framebuffer no corpo do método bar().
Controlar a recolha de lixo¶
Quando é necessária alocação de memória, o MicroPython tenta localizar um bloco de tamanho adequado no heap. Isto pode falhar, geralmente porque o heap está sobrecarregado com objetos que já não são referenciados pelo código. Se ocorrer uma falha, o processo conhecido como recolha de lixo recupera a memória usada por esses objetos redundantes e a alocação é então tentada novamente — um processo que pode demorar vários milissegundos.
Pode ser vantajoso antecipar isso emitindo periodicamente gc.collect(). Em primeiro lugar, fazer uma recolha antes de ser realmente necessária é mais rápido — tipicamente na ordem de 1 ms se feito frequentemente. Em segundo lugar, pode determinar o ponto no código onde esse tempo é utilizado, em vez de ter um atraso maior a ocorrer em pontos aleatórios, possivelmente numa secção crítica para a velocidade. Por fim, realizar recolhas regularmente pode reduzir a fragmentação do heap. Uma fragmentação severa pode levar a falhas de alocação irrecuperáveis.
O emissor de código nativo¶
Este faz com que o compilador do MicroPython emita opcodes de CPU nativos em vez de bytecode. Cobre a maior parte da funcionalidade do MicroPython, pelo que a maioria das funções não necessitará de adaptação (mas veja abaixo). É invocado através 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
raisefor utilizado, deve ser fornecido um argumento.O agendador em segundo plano (ver
micropython.schedule) não é executado durante a execução de código nativo.Em alvos com threading e o GIL, o GIL não é libertado 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 irá executar o agendador e libertar o GIL.
O compromisso pelo desempenho melhorado (aproximadamente o dobro da velocidade em relação ao 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 o padrão. O emissor de código Viper não é totalmente conforme. Suporta tipos de dados nativos Viper especiais em busca de desempenho. O processamento de inteiros não é conforme porque utiliza palavras de máquina: a aritmética em hardware de 32 bits é realizada módulo 2**32.
Tal como o emissor nativo, o Viper produz instruções de máquina, mas são realizadas otimizações adicionais, aumentando substancialmente o desempenho especialmente para aritmética de inteiros e manipulações de bits. É invocado usando um decorador:
@micropython.viper
def foo(self, arg: int) -> int:
# code
Como o fragmento acima ilustra, é vantajoso usar anotações de tipo Python para auxiliar o otimizador Viper. As anotações de tipo fornecem informação sobre os tipos de dados dos argumentos e do valor de retorno; estas são uma funcionalidade padrão da linguagem Python formalmente definida aqui PEP0484. O Viper suporta o seu próprio conjunto de tipos, nomeadamente int, uint (inteiro sem sinal), ptr, ptr8, ptr16 e ptr32. Os tipos ptrX são discutidos abaixo. Atualmente o tipo uint serve um único propósito: como anotação 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.
Para além das restrições impostas pelo emissor nativo, aplicam-se as seguintes limitações:
Valores de argumentos predefinidos não são permitidos.
A vírgula flutuante pode ser usada mas não é otimizada.
O Viper fornece tipos de ponteiro para auxiliar o otimizador. Estes compreendem
ptrPonteiro para um objeto.ptr8Aponta para um byte.ptr16Aponta para uma meia-palavra de 16 bits.ptr32Aponta para uma palavra de máquina de 32 bits.
O conceito de ponteiro pode ser desconhecido para programadores Python. Tem semelhanças com um objeto memoryview do Python no sentido em que fornece acesso direto a dados armazenados em memória. Os elementos são acedidos usando notação de índice, mas as fatias não são suportadas: um ponteiro só pode retornar um único elemento. O seu propósito é fornecer acesso aleatório rápido a dados armazenados em localizações de memória contíguas — como dados armazenados em objetos que suportam o protocolo de buffer, e registos de periféricos mapeados em memória num microcontrolador. Deve notar-se que programar com ponteiros é arriscado: a verificação de limites não é realizada e o compilador não faz nada para evitar erros de ultrapassagem de buffer.
A utilização típica é colocar 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
Neste caso, o compilador «sabe» que buf é o endereço de uma array de bytes; pode emitir código para calcular rapidamente o endereço de buf[x] em tempo de execução. Quando são usados casts para converter objetos para tipos nativos Viper, estes devem ser realizados no início da função em vez de em ciclos críticos de temporização, pois a operação de cast pode demorar vários microssegundos. As regras de cast são as seguintes:
Os operadores de cast são atualmente:
int,bool,uint,ptr,ptr8,ptr16eptr32.O resultado de um cast será uma variável nativa Viper.
Os argumentos de um cast podem ser um objeto Python ou uma variável nativa Viper.
Se o argumento for uma variável nativa Viper, então o cast é uma operação nula (ou seja, não tem custo em tempo de execução) que apenas altera o tipo (por exemplo, de
uintparaptr8) para que possa então armazenar/carregar usando este ponteiro.Se o argumento for um objeto Python e o cast for
intouuint, então o objeto Python deve ser de tipo inteiro e o valor desse objeto inteiro é retornado.O argumento de um cast para bool deve ser de tipo inteiro (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,ptr16ouptr32, então o objeto Python deve ter o protocolo de buffer (caso em que é retornado um ponteiro para o início do buffer) ou deve ser de tipo inteiro (caso em que é retornado o valor desse objeto inteiro).
Escrever para um ponteiro que aponta para um objeto só de leitura resultará em comportamento indefinido.
Nota
Os exemplos de código abaixo são apresentados para as OpenMV Cams baseadas em STM32, que disponibilizam o módulo stm. As técnicas descritas aplicam-se de forma geral.
O módulo stm expõe os endereços de memória dos registos de periféricos do MCU. Cada porta GPIO tem um registo de dados de saída (ODR) cujos bits mapeiam um-para-um os pinos dessa porta: escrever no registo aciona esses pinos diretamente, sem a sobrecarga de uma chamada ao método machine.Pin, e aplicar XOR a um bit comuta o seu pino. Na OpenMV Cam original, o LED azul está ligado ao pino 2 de GPIOC, pelo que o seguinte exemplo usa um cast ptr16 para comutar 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 Note 1 e aqui Note 2
Aceder diretamente ao hardware¶
Isto entra na categoria de programação mais avançada e envolve algum conhecimento do MCU alvo. Considere o exemplo de comutar um pino de saída numa OpenMV Cam. A abordagem padrão seria escrever
mypin.value(mypin.value() ^ 1) # mypin was instantiated as an output pin
Isto envolve a sobrecarga de duas chamadas ao método value() da instância Pin. Esta sobrecarga pode ser eliminada realizando uma leitura/escrita no bit relevante do registo de dados de saída (ODR) da porta GPIO do chip. Para facilitar isto, o módulo stm fornece um conjunto de constantes com os endereços dos registos relevantes (stm.GPIOC é o endereço base da porta GPIOC, stm.GPIO_ODR o offset do seu registo de dados de saída). Como acima, o LED azul na OpenMV Cam original é o pino 2 de GPIOC, pelo que uma comutação rápida pode ser realizada da seguinte forma:
import machine
import stm
BIT2 = const(1 << 2)
machine.mem16[stm.GPIOC + stm.GPIO_ODR] ^= BIT2