Creación de un LLM en el dispositivo con entrada en tiempo real usando ESP32S3 y 16MB de Flash
Proponerse ejecutar un modelo de lenguaje de 28.9 millones de parámetros en una placa ESP32-S3 de 8 dólares rápidamente lleva a un callejón sin salida. Un modelo que funciona de maravilla en un video de demostración se bloquea instantáneamente al entrar en contacto con los periféricos de hardware caseros. Con apenas 512KB de SRAM y la necesidad de flashear una partición de 15MB cada vez que se cambia una sola oración, el desarrollo se vuelve frustrante. Para superar estas limitaciones y construir un dispositivo que funcione con entradas de teclado reales, es necesario rediseñar por completo el almacenamiento en caché de memoria y los búferes de E/S desde cero.
Inyección de prompts por serie sin necesidad de reflashear constantemente
Querer cambiar una sola oración de prueba y tener que reflashear la placa es una pérdida de tiempo. Al vincular las interrupciones UART asíncronas y los semáforos de FreeRTOS, se elimina la necesidad de reiniciar la placa. El modelo comprende inmediatamente las oraciones a medida que se escriben desde el monitor serie o el teclado.
- Asigna los pines GPIO 16/17 al puerto UART_NUM_2, establece el umbral del FIFO de hardware en 120 bytes y habilita la interrupción de recepción.
- Crea un búfer circular de 2048 bytes en la SRAM interna. Esto evita desbordamientos y fallos de datos mientras se ejecuta la inferencia.
- Incorpora un analizador de streaming que detecte los caracteres de salto de línea, y utiliza una biblioteca de codificador BTK1 de 43,056 bytes para convertir directamente el texto de entrada en una matriz de ID de tokens.
- Configura un doble semáforo xPromptSemaphore. Cuando la tarea de recepción termina de escribir los tokens y llama a xSemaphoreGive, la tarea de inferencia en espera obtiene el bloqueo de memoria mediante xSemaphoreTake y comienza los cálculos.
Esta configuración permite reducir en más del 80% el tiempo dedicado a modificar y probar prompts.
`
+------------------+ +-------------------+ +--------------------+
| 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) |
+------------------+ +-------------------+ +--------------------+
`
Alcanzando 14 tokens por segundo mediante la división de embeddings por capas y caché en SRAM
Utilizando la estructura de Embeddings por Capa (PLE, por sus siglas en inglés) propuesta por los investigadores de Google Gemma 3n, es posible dividir y alojar un modelo de 14.9MB reducido a 4 bits. La tabla de embeddings, que alcanza los 25 millones de parámetros, se almacena en la memoria flash SPI de 16MB, mientras que los pesos del Output Head (3.1 millones de parámetros) y la caché KV se colocan en la PSRAM de 8MB. El núcleo de cómputo denso (Dense Compute Core) de 559K parámetros de uso más frecuente y el búfer Hot Activation se ubican directamente en la SRAM de 512KB.
Aumentar la velocidad a más de 14 tokens por segundo es más complicado de lo esperado.
- Añade el atributo
__attribute__((noinline)) directamente a la función de operación matvec_i8_range. Si el compilador realiza la inlining por su cuenta, se producen fallos de caché en la I-RAM y el tiempo de cálculo empeora drásticamente, pasando de 94.9ms a 155.2ms.
- Divide las operaciones ple_model_proj y qkv entre los dos núcleos Xtensa LX7 del ESP32-S3. La velocidad adecuada solo se consigue poniendo a trabajar ambos núcleos simultáneamente.
- Amplía el búfer de prefetch para cargar previamente los datos de los embeddings de las primeras capas en el espacio libre de SRAM, rompiendo así el cuello de botella de la memoria flash.
Una simple adaptación a C arroja una velocidad desesperante de 0.57 tokens por segundo. Sin embargo, al completar el almacenamiento en fase INT8 y el prefetch en SRAM, se superan los 14.0 tokens por segundo.
| Etapa de optimización |
Latencia por token |
Tokens generados por segundo |
Tecnologías clave aplicadas |
| Puerto puro en C (Baseline) |
1,757.2 ms |
0.57 tok/s |
Monocúcleo, cálculo FP32 |
| PSRAM Head y optimización escalar |
193.9 ms |
4.61 tok/s |
Asignación de Output Head en PSRAM |
| Aplicación de FP32 en doble núcleo |
139.4 ms |
6.22 tok/s |
Cálculo dividido por capas en doble núcleo |
| INT8 Staging + Optimización SRAM |
94.9 ms |
9.88 tok/s |
Cuantización INT8, aplicación de noinline |
| Ampliación del búfer de prefetch SRAM |
~71.4 ms |
14.0+ tok/s |
Prefetch de capas iniciales en SRAM |
Control de suspensión ligera para mantener el consumo en 210mA
Mantener los dos núcleos funcionando continuamente a un reloj de 240MHz dispara el consumo de corriente hasta los 210mA (777mW). Dejando de lado que la placa se calienta, la batería se agota en un abrir y cerrar de ojos. Mediante el comando esp_pm_configure, es necesario reducir la frecuencia del reloj a 40MHz y activar la suspensión ligera automática cuando no hay inferencias.
- En la estructura esp_pm_config_esp32s3_t, establece max_freq_mhz en 240, min_freq_mhz en 40 y habilita light_sleep_enable en true.
- Ejecuta
esp_sleep_pd_config(ESP_PD_DOMAIN_VDDSDIO, ESP_PD_OPTION_ON). Es necesario mantener la alimentación de la memoria flash SPI y la RAM incluso al entrar en estado de suspensión para evitar la pérdida de datos.
- Utiliza uart_set_wakeup_threshold y esp_sleep_enable_uart_wakeup para configurar una interrupción de recuperación que despierte a la CPU de la suspensión en el momento exacto en que entra una señal serie.
Si se realizan cálculos continuos con una batería de polímero de litio de 3.7V y 1000mAh, esta dura apenas 4.7 horas. Sin embargo, en el modo de espera por suspensión ligera, la corriente cae drásticamente a 0.24mA (0.88mW). En un entorno de uso real que intercala tiempos de espera, la corriente promedio se mantiene en un nivel de 15 a 30mA, lo que multiplica la duración de la batería por más de 3.