Criando um LLM embarcado com entradas em tempo real usando ESP32-S3 e Flash de 16MB
Decidir rodar um modelo de linguagem de 28,9 milhões de parâmetros em uma placa ESP32-S3 de 8 dólares rapidamente traz uma sensação de impasse. O modelo que roda perfeitamente em vídeos de demonstração simplesmente trava assim que encontra os periféricos de hardware que você criou. Com apenas 512KB de SRAM e a necessidade de regravar uma partição de 15MB a cada alteração de frase, o desenvolvimento rapidamente se torna frustrante. Para superar essas restrições e construir um dispositivo que funcione recebendo entradas de teclado reais, você precisa projetar o cache de memória e os buffers de E/S de forma totalmente diferente desde o início.
Injetando prompts via serial sem precisar reflashear a cada vez
Perder tempo gravando a placa novamente só para mudar uma frase de teste é um desperdício. Ao vincular interrupções UART assíncronas e semáforos FreeRTOS, você elimina a necessidade de reiniciar a placa. Cada frase digitada no monitor serial ou no teclado é compreendida instantaneamente pelo modelo.
- Atribua os pinos GPIO 16/17 à porta UART_NUM_2, defina o limite do FIFO de hardware como 120 bytes e habilite a interrupção de recepção.
- Crie um buffer circular de 2048 bytes na SRAM interna. Isso evita estouros de dados (buffer overflow) enquanto a computação de inferência está emandamento.
- Conecte um parser de streaming que detecta caracteres de nova linha e converta diretamente o texto de entrada em uma matriz de IDs de token usando a biblioteca de tokenizador BTK1 de 43.056 bytes.
- Configure o semáforo duplo xPromptSemaphore. Quando a tarefa de recepção terminar de gravar os tokens e chamar xSemaphoreGive, a tarefa de inferência em espera obtém o bloqueio de memória via xSemaphoreTake e inicia o cálculo.
Essa configuração reduz o tempo gasto na modificação e teste de prompts em mais de 80%.
`
+------------------+ +-------------------+ +--------------------+
| External UART | ---> | HW FIFO Buffer | ---> | SW Ring Buffer |
| (Keypad/Monitor) | | (120 Bytes) | | (2048 Bytes) |
+------------------+ +-------------------+ +--------------------+
|
v
+------------------+ +-------------------+ +--------------------+
| LLM Forward Pass | <--- | xPromptSemaphore | <--- | BTK1 Tokenizer |
| (Inference Task) | | (Binary Lock) | | (43,056 B Library) |
+------------------+ +-------------------+ +--------------------+
`
Alcançando 14 tokens por segundo com divisão de embeddings por camada e cache na SRAM
Utilizando a estrutura Per-Layer Embeddings (PLE) proposta pelos pesquisadores do Google Gemma 3n, podemos dividir e acomodar um modelo de 14,9MB reduzido para 4 bits. A tabela de embeddings, que alcança 25 milhões de parâmetros, fica na flash SPI de 16MB, enquanto os pesos do Output Head (3,1 milhões de parâmetros) e o cache KV ficam na PSRAM de 8MB. Apenas o Dense Compute Core de 559K parâmetros mais utilizado e o buffer Hot Activation são armazenados na SRAM de 512KB.
Aumentar a velocidade para mais de 14 tokens por segundo é mais complicado do que parece.
- Adicione o atributo
__attribute__((noinline)) diretamente à função de cálculo matvec_i8_range. Se o compilador realizar a in-lining por conta própria, ocorrerão falhas de cache I-RAM e o tempo de computação despencará de 94,9 ms para 155.2 ms.
- Divida as operações ple_model_proj e qkv entre os núcleos Dual Xtensa LX7 do ESP32-S3. A velocidade desejada só é alcançada operando ambos os núcleos simultaneamente.
- Amplie o buffer de pré-carregamento (prefetch), que carrega os dados de embedding das camadas iniciais antecipadamente no espaço livre da SRAM, para eliminar o gargalo da memória flash.
Ao apenas fazer a portabilidade pura em linguagem C, obtemos uma velocidade desanimadora de 0,57 tokens por segundo. No entanto, após concluir o staging INT8 e o prefetch na SRAM, ultrapassamos 14,0 tokens por segundo.
| Estágio de Otimização |
Latência de cálculo por token |
Tokens gerados por segundo |
Principais tecnologias aplicadas |
| Portabilidade pura em C (Baseline) |
1.757,2 ms |
0,57 tok/s |
Núcleo único, computação FP32 |
| PSRAM Head & Otimização Escalar |
193,9 ms |
4,61 tok/s |
Alocação do Output Head na PSRAM |
| Aplicação Dual-Core FP32 |
139,4 ms |
6,22 tok/s |
Operação de divisão de camadas em dual-core |
| INT8 Staging + Otimização SRAM |
94,9 ms |
9,88 tok/s |
Quantização INT8, aplicação de noinline |
| Expansão do Buffer de Prefetch SRAM |
~71.4 ms |
14,0+ tok/s |
Prefetch de camadas iniciais na SRAM |
Controlando a corrente de 210mA com Light Sleep
Manter os dois núcleos rodando continuamente com um clock de 240MHz faz o consumo de corrente disparar para 210mA (777mW). Além de aquecer a placa, isso esgota a bateria rapidamente. Você precisa usar o comando esp_pm_configure para reduzir o clock para 40MHz e ativar o light sleep automático quando não houver inferência em execução.
- Na estrutura esp_pm_config_esp32s3_t, defina max_freq_mhz como 240, min_freq_mhz como 40 e ative light_sleep_enable como true.
- Execute
esp_sleep_pd_config(ESP_PD_DOMAIN_VDDSDIO, ESP_PD_OPTION_ON). Mesmo entrando no estado de sleep, a alimentação da flash SPI e da RAM deve ser mantida para evitar a perda de dados.
- Utilize uart_set_wakeup_threshold e esp_sleep_enable_uart_wakeup para configurar uma interrupção de recuperação que desperta a CPU do sleep assim que um sinal serial é recebido.
Se você continuar executando inferências continuamente com uma bateria de polímero de lítio de 3,7V e 1000mAh, ela durará apenas 4,7 horas. No entanto, no modo de espera em light sleep, a corrente cai drasticamente para 0,24mA (0,88mW). Em ambientes de uso real com tempos de espera intercalados, a corrente média é mantida na faixa de 15 a 30mA, triplicando ou mais a autonomia da bateria.