ESP32S3と16MBフラッシュでリアルタイム入力を受け取るオンデバイスLLMの作成
8ドルのESP32-S3ボードで2,890万パラメータの言語モデルを動かそうと決意すると、すぐに途方に暮れることになります。デモ映像ではスムーズに動くモデルが、自分が作ったハードウェア周辺機器と組み合わさると途端にフリーズしてしまうからです。SRAMはわずか512KBしかなく、文章を一つ変えるたびに15MBのパーティションを書き直さなければならないと、開発中についイライラしてしまいます。この制約を突破して実際にキーパッド入力を受け付けて動作するデバイスを作るには、メモリキャッシュと入出力バッファを最初から設計し直す必要があります。
毎回リフレッシュせずにシリアルでプロンプトを注入する
テスト文を一つ変えるたびにボードを焼き直すようなやり方は、時間の無駄です。非同期UART割り込みとFreeRTOSセマフォを組み合わせれば、ボードを再起動する必要はありません。シリアルモニターやキーパッドで文章を入力するそばから、モデルが即座に理解します。
- GPIO 16/17ピンをUART_NUM_2ポートに割り当て、ハードウェアFIFOの閾値を120バイトに設定して受信割り込みを有効にします。
- 内蔵SRAMに2048バイトのリングバッファを作成します。推論演算の実行中にデータがあふれてクラッシュするのを防ぎます。
- 改行文字を検出するストリーミングパーサーを追加し、43,056バイトのBTK1エンコーダーライブラリを使用して入力テキストを直接トークンID配列に変換します。
- xPromptSemaphoreのバイナリセマフォを設定します。受信タスクがトークンの書き込みを完了してxSemaphoreGiveを呼び出すと、待機中の推論タスクがxSemaphoreTakeでメモリロックを取得して演算を開始します。
この構成を整えておけば、プロンプトの修正とテストにかかる時間を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) |
+------------------+ +-------------------+ +--------------------+
`
階層別埋め込み分散とSRAMキャッシュで14トークンの速度を出す
Google Gemma 3nの研究陣が提案したPer-Layer Embeddings (PLE)構造を使用すると、4ビットに圧縮した14.9MBのモデルを分割して配置できます。2,500万パラメータに達する埋め込みテーブルは16MBのSPIフラッシュに置き、Output Headの重み(310万パラメータ)とKVキャッシュは8MBのPSRAMに配置します。最も頻繁に使用される559KパラメータのDense Compute CoreとHot Activationバッファのみを512KBのSRAMに埋め込みます。
速度を秒間14トークン以上に引き上げる作業は、想像以上に手強いものです。
- matvec_i8_range演算関数に
__attribute__((noinline))属性をしっかりと付与します。コンパイラが勝手にインライン化を進めてしまうと、I-RAMのキャッシュミスが発生し、演算時間が94.9msから155.2msにかえって悪化してしまいます。
- ESP32-S3のDual Xtensa LX7コアにple_model_proj演算とqkv演算を分散させます。デュアルコアを同時に稼働させないと、本来の速度が出ません。
- 初期レイヤーの埋め込みデータをSRAMの空き領域に事前に読み込んでおくプリフェッチバッファを拡張し、フラッシュメモリのボトルネックを解消します。
C言語でただ移植しただけでは、秒間0.57トークンという絶望的な速度になります。しかし、INT8ステージングとSRAMプリフェッチまで完了させると、秒間14.0トークンを超えてきます。
| 最適化段階 |
トークンあたりの演算遅延時間 |
秒間トークン生成数 |
主な適用技術 |
| C純粋移植 (Baseline) |
1,757.2 ms |
0.57 tok/s |
シングルコア、FP32演算 |
| PSRAM Head & スカラー最適化 |
193.9 ms |
4.61 tok/s |
Output Head PSRAM配置 |
| デュアルコアFP32適用 |
139.4 ms |
6.22 tok/s |
デュアルコアレイヤー分割演算 |
| INT8 Staging + SRAM最適化 |
94.9 ms |
9.88 tok/s |
INT8量子化、noinline適用 |
| SRAMプリフェッチバッファ拡張 |
~71.4 ms |
14.0+ tok/s |
初期レイヤーSRAMプリフェッチ |
210mAの電流を抑えるライトスリープ制御
240MHzのクロックでデュアルコアを稼働させ続けると、消費電流が210mA (777mW)まで跳ね上がります。ボードが熱くなることは言うまでもなく、バッテリーが瞬く間に消耗してしまいます。esp_pm_configureコマンドを使用して、推論が行われていないときはクロックを40MHzに下げ、自動ライトスリープをかける必要があります。
- esp_pm_config_esp32s3_t構造体でmax_freq_mhzを240、min_freq_mhzを40に設定し、light_sleep_enableをtrueに有効化します。
esp_sleep_pd_config(ESP_PD_DOMAIN_VDDSDIO, ESP_PD_OPTION_ON)を実行します。スリープ状態に入ってもSPIフラッシュとRAMの電源は維持し、データが消えないようにする必要があります。
- uart_set_wakeup_thresholdとesp_sleep_enable_uart_wakeupを使用して、シリアル信号が入力された瞬間にCPUがスリープから復帰するように復帰割り込みを設定します。
3.7V 1000mAhのリチウムポリマーバッテリーで演算を続けさせると、4.7時間しか持ちません。しかし、ライトスリープ待機モードでは電流が0.24mA (0.88mW)まで急減します。待機時間が混ざり合う実際の使用環境では、平均電流が15〜30mAのレベルに維持され、バッテリーの駆動時間が3倍以上に延びます。