Créer un LLM on-device avec saisie en temps réel sur ESP32-S3 et flash de 16 Mo
Tenter de faire tourner un modèle de langage de 28,9 millions de paramètres sur une carte ESP32-S3 à 8 dollars mène rapidement à une impasse. Un modèle qui fonctionne à merveille dans une vidéo de démonstration se bloque instantanément dès qu'on le confronte aux périphériques matériels de notre propre conception. Avec seulement 512 Ko de SRAM, et l'obligation de reflasher une partition de 15 Mo à chaque modification de phrase, le développement devient vite exaspérant. Pour surmonter ces contraintes et construire un appareil capable de fonctionner avec de véritables saisies au clavier, il faut repenser entièrement la mise en cache de la mémoire et les tampons d'E/S.
Injecter des prompts via le port série sans reflasher à chaque fois
Perdre son temps à reflasher la carte juste pour modifier une phrase de test est une impasse. En combinant les interruptions UART asynchrones et les sémaphores FreeRTOS, il n'est plus nécessaire de redémarrer la carte. Chaque phrase saisie via le moniteur série ou le clavier est immédiatement comprise par le modèle.
- Attribuez les broches GPIO 16/17 au port UART_NUM_2, définissez le seuil FIFO matériel à 120 octets et activez l'interruption de réception.
- Créez un buffer circulaire de 2048 octets dans la SRAM intégrée pour éviter tout dépassement de données pendant l'inférence.
- Intégrez un parseur de streaming capable de détecter les caractères de saut de ligne, et convertissez directement le texte d'entrée en un tableau d'ID de tokens à l'aide d'une bibliothèque d'encodeur BTK1 de 43 056 octets.
- Mettez en place un double sémaphore xPromptSemaphore. Une fois que la tâche de réception a terminé d'écrire les tokens et appelle xSemaphoreGive, la tâche d'inférence en attente obtient le verrouillage de la mémoire via xSemaphoreTake et lance le calcul.
Cette configuration permet de réduire de plus de 80 % le temps consacré à la modification et au test des 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) |
+------------------+ +-------------------+ +--------------------+
`
Atteindre 14 tokens par seconde grâce au découpage des embeddings par couche et au cache SRAM
En adoptant l'architecture Per-Layer Embeddings (PLE) proposée par les chercheurs de Google Gemma 3n, il est possible de fractionner et d'embarquer un modèle de 14,9 Mo réduit à 4 bits. La table d'embeddings, qui atteint 25 millions de paramètres, est placée dans la mémoire flash SPI de 16 Mo, tandis que les poids de la tête de sortie (Output Head - 3,1 millions de paramètres) et le cache KV résident dans la PSRAM de 8 Mo. Seuls le cœur de calcul dense (Dense Compute Core) de 559 Ko le plus sollicité et le buffer Hot Activation sont stockés dans la SRAM de 512 Ko.
Faire grimper la vitesse au-delà de 14 tokens par seconde s'avère plus ardu que prévu.
- Ajoutez l'attribut
__attribute__((noinline)) à la fonction de calcul matvec_i8_range. Si le compilateur procède à un inlining automatique, des défauts de cache I-RAM surviennent et font chuter drastiquement le temps de calcul, qui passe de 94,9 ms à 155.2 ms.
- Répartissez les calculs ple_model_proj et qkv entre les deux cœurs Xtensa LX7 de l'ESP32-S3. L'exploitation simultanée des cœurs du mulco-processeur est indispensable pour atteindre les performances escomptées.
- Agrandissez le buffer de préchargement (prefetch buffer) pour charger à l'avance les données d'embeddings des premières couches dans l'espace SRAM disponible, levant ainsi le goulet d'étranglement de la mémoire flash.
Un simple portage en langage C se solde par une vitesse désespérante de 0,57 token par seconde. En revanche, combiné au staging INT8 et au préchargement SRAM, le système dépasse les 14,0 tokens par seconde.
| Étape d'optimisation |
Latence de calcul par token |
Tokens générés par seconde |
Principales technologies appliquées |
| Portage C pur (Baseline) |
1 757,2 ms |
0,57 tok/s |
Monocœur, calcul FP32 |
| PSRAM Head & Optimisation scalaire |
193,9 ms |
4,61 tok/s |
Placement de l'Output Head en PSRAM |
| Application FP32 multicœur |
139,4 ms |
6,22 tok/s |
Calcul et division par couche multicœur |
| Staging INT8 + Optimisation SRAM |
94,9 ms |
9,88 tok/s |
Quantification INT8, application de noinline |
| Extension du buffer de préchargement SRAM |
~71,4 ms |
14,0+ tok/s |
Préchargement SRAM des premières couches |
Maîtriser la consommation à 210 mA grâce au contrôle de veille légère (Light Sleep)
Faire tourner les deux cœurs en continu à une fréquence d'horloge de 240 MHz fait grimper la consommation de courant jusqu'à 210 mA (777 mW). Outre la surchauffe de la carte, la batterie se vide en un clin d'œil. La commande esp_pm_configure permet d'abaisser la fréquence à 40 MHz en l'absence d'inférence et d'activer la veille légère automatique.
- Dans la structure esp_pm_config_esp32s3_t, définissez max_freq_mhz sur 240 et min_freq_mhz sur 40, puis activez light_sleep_enable à true.
- Exécutez
esp_sleep_pd_config(ESP_PD_DOMAIN_VDDSDIO, ESP_PD_OPTION_ON). Même en état de veille, l'alimentation de la mémoire flash SPI et de la RAM doit être maintenue pour éviter toute perte de données.
- Utilisez uart_set_wakeup_threshold et esp_sleep_enable_uart_wakeup pour configurer une interruption de réveil permettant au CPU de sortir de sa veille dès l'arrivée d'un signal sur le port série.
Une batterie lithium-polymère de 3,7 V et 1000 mAh ne tiendra que 4,7 heures en cas d'inférence ininterrompue. En revanche, en mode veille légère, la consommation chute brutalement à 0,24 mA (0,88 mW). Dans un scénario d'utilisation réelle alternant avec des temps d'attente, le courant moyen se maintient entre 15 et 30 mA, prolongeant l'autonomie de la batterie par un facteur supérieur à trois.