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배 이상 늘어납니다.