On-Device-LLM mit ESP32S3 und 16MB Flash für Echtzeiteingaben erstellen
Wer beschließt, ein Sprachmodell mit 28,9 Millionen Parametern auf einem 8-Dollar-ESP32-S3-Board laufen zu lassen, steht schnell vor einer Wand. Modelle, die im Demo-Video reibungslos funktionieren, blockieren sofort, sobald sie auf selbst gebaute Hardware-Peripheriegeräte treffen. Bei nur 512 KB SRAM und der Notwendigkeit, für jede kleine Satzänderung eine 15-MB-Partition neu zu flashen, ist Frustration bei der Entwicklung vorgrammiert. Um diese Einschränkungen zu überwinden und ein Gerät zu bauen, das tatsächlich auf Tastenfeldeingaben reagiert, müssen Speicher-Caching und E/A-Puffer von Grund auf neu konzipiert werden.
Prompts über Serial injizieren, ohne jedes Mal neu zu flashen
Das Board für jeden Test eines einzelnen Satzes neu zu flashen, ist reine Zeitverschwendung. Durch die Kopplung von asynchronen UART-Interrupts und FreeRTOS-Semaphoren ist ein Neustart des Boards nicht erforderlich. Das Modell versteht Sätze sofort, sobald sie über den seriellen Monitor oder ein Tastenfeld eingegeben werden.
- Weisen Sie die GPIO-Pins 16/17 dem Port UART_NUM_2 zu, setzen Sie die Hardware-FIFO-Schwelle auf 120 Bytes und öffnen Sie den Empfangsinterrupt.
- Richten Sie einen 2048-Byte-Ringpuffer im internen SRAM ein. Dies verhindert Datenüberläufe und Systemabstürze während laufender Inferenzberechnungen.
- Fügen Sie einen Streaming-Parser hinzu, der Zeilenumbruchzeichen erkennt, und konvertieren Sie den Eingabetext mit einer 43.056 Bytes großen BTK1-Encoder-Bibliothek direkt in ein Token-ID-Array.
- Richten Sie das doppelte Semaphore
xPromptSemaphore ein. Sobald der Empfangs-Task das Schreiben der Token abgeschlossen hat und xSemaphoreGive aufruft, erhält der wartende Inferenz-Task über xSemaphoreTake die Speichersperre und startet die Berechnung.
Mit dieser Konfiguration lässt sich der Zeitaufwand für Prompt-Änderungen und Tests um über 80% reduzieren.
`
+------------------+ +-------------------+ +--------------------+
| 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) |
+------------------+ +-------------------+ +--------------------+
`
Per-Layer Embedding Sharding und SRAM Caching für eine Geschwindigkeit von 14 Token
Die von den Forschern von Google Gemma 3n vorgeschlagene Per-Layer-Embeddings-Architektur (PLE) ermöglicht es, ein auf 4 Bit reduziertes 14,9-MB-Modell aufzuteilen und zu laden. Die Embedding-Tabelle mit 25 Millionen Parametern wird im 16-MB-SPI-Flash gespeichert, während die Output-Head-Gewichte (3,1 Millionen Parameter) und der KV-Cache im 8-MB-PSRAM abgelegt werden. Lediglich der am häufigsten genutzte Dense Compute Core mit 559.000 Parametern und der Hot-Activation-Puffer werden im 512-KB-SRAM untergebracht.
Die Steigerung der Geschwindigkeit auf über 14 Token pro Sekunde ist anspruchsvoller als gedacht:
- Fügen Sie der Berechnungsfunktion
matvec_i8_range unbedingt das Attribut __attribute__((noinline)) hinzu. Wenn der Compiler eigenständig eine Inlining-Optimierung durchführt, kommt es zu I-RAM-Cache-Misses, wodurch sich die Berechnungszeit von 94,9 ms auf 155.2 ms verschlechtert.
- Teilen Sie die Operationen
ple_model_proj und qkv auf die Dual-Xtensa-LX7-Kerne des ESP32-S3 auf. Die gleichzeitige Nutzung beider Kerne ist für die Zielgeschwindigkeit unerlässlich.
- Erweitern Sie den Prefetch-Puffer, um die Embedding-Daten der frühen Layer vorab in den freien SRAM-Bereich zu laden und so den Flash-Speicher-Engpass zu beseitigen.
Bei einer reinen Portierung nach C ergibt sich eine enttäuschende Geschwindigkeit von nur 0,57 Token pro Sekunde. Mit INT8-Staging und SRAM-Prefetching werden jedoch über 14,0 Token pro Sekunde erreicht.
| Optimierungsstufe |
Berechnungsverzögerung pro Token |
Erzeugte Token pro Sekunde |
Hauptsächlich angewandte Technologien |
| Reine C-Portierung (Baseline) |
1.757,2 ms |
0,57 tok/s |
Einzelner Kern, FP32-Berechnung |
| PSRAM Head & Skalar-Optimierung |
193,9 ms |
4,61 tok/s |
Platzierung des Output Head im PSRAM |
| Dual-Core FP32-Anwendung |
139,4 ms |
6,22 tok/s |
Aufteilung der Layer-Berechnung auf Dual-Core |
| INT8 Staging + SRAM-Optimierung |
94,9 ms |
9,88 tok/s |
INT8-Quantisierung, Anwendung von noinline |
| Erweiterung des SRAM-Prefetch-Puffers |
~71.4 ms |
14.0+ tok/s |
SRAM-Prefetching für frühe Layer |
Steuerung des Light Sleep zur Reduzierung des Stromverbrauchs auf 210 mA
Wenn die Dual-Kerne kontinuierlich mit einem Takt von 240 MHz betrieben werden, steigt der Stromverbrauch auf bis zu 210 mA (777 mW). Abgesehen davon, dass das Board heiß wird, entlädt sich der Akku in kürzester Zeit. Mit dem Befehl esp_pm_configure muss der Takt bei Inaktivität auf 40 MHz gesenkt und der automatische Light-Sleep-Modus aktiviert werden.
- Setzen Sie in der Struktur
esp_pm_config_esp32s3_t den Wert max_freq_mhz auf 240, min_freq_mhz auf 40 und aktivieren Sie light_sleep_enable (true).
- Führen Sie
esp_sleep_pd_config(ESP_PD_DOMAIN_VDDSDIO, ESP_PD_OPTION_ON) aus. Die Stromversorgung für SPI-Flash und RAM muss auch im Sleep-Modus aufrecht erhalten werden, um Datenverluste zu vermeiden.
- Verwenden Sie
uart_set_wakeup_threshold und esp_sleep_enable_uart_wakeup, um einen Wiederherstellungsinterrupt einzurichten, der die CPU beim Eintreffen serieller Signale sofort aus dem Sleep-Modus aufweckt.
Bei kontinuierlicher Berechnung mit einem 3,7V 1000mAh Lithium-Polymer-Akku hält das System nur 4,7 Stunden. Im Light-Sleep-Bereitschaftsmodus sinkt der Stromverbrauch jedoch drastisch auf 0,24 mA (0,88 mW). In realen Einsatzszenarien mit gemischten Wartezeiten bleibt der durchschnittliche Stromverbrauch auf einem Niveau von 15 bis 30 mA, wodurch sich die Batterielaufzeit um das Dreifache oder mehr verlängert.