slvDev/esp32-ai · 학습 Python 3.12 · PyTorch · 펌웨어 C · Arduino ESP32 · 데이터셋 TinyStories · 칩 ESP32-S3 N16R8 · 상태 2026-07-21 종단 검증 완료 · 30 커밋 · shallow clone 소스 직접 분석)
스마트폰의 두뇌인 SoC조차 버거워하는 LLM을, 이 프로젝트는 냉장고 온도 센서에나 들어갈 법한 $8짜리 칩 ESP32-S3 위에서 돌린다. 모델은 TinyStories라는, 3~4세 아이도 이해할 수준의 짧은 영어 동화 데이터로 학습됐다. 그래서 이 모델은 질문에 답하거나 지식을 말하거나 코드를 쓰지는 못한다. 대신 "옛날 옛적에…"로 시작하는 짧고 일관성 있는 이야기를 스스로 지어낸다.
중요한 건 모델이 무슨 말을 하느냐가 아니라, 어떻게 그 큰 덩치가 그 작은 칩에 들어갔느냐다. ESP32-S3의 빠른 내부 메모리(SRAM)는 고작 512KB. 보통은 모델 전체가 여기에 들어와야 하니 지금까지 이런 칩에 올린 모델은 26만 파라미터가 한계였다. 이 프로젝트는 그 벽을 "모델을 빠른 메모리에 다 넣지 않는다"는 발상으로 넘어, 2,890만 파라미터를 앉혔다.
보통의 방식은 작업에 필요한 모든 책을 좁은 책상(SRAM) 위에 다 올려두는 것과 같다. 책상이 작으면 올릴 수 있는 책도 적다 — 그래서 모델이 작아야 했다.
esp32-ai는 거대한 서가(flash)에 대부분의 책을 꽂아 두고, 지금 이 순간 필요한 몇 페이지(약 450바이트)만 책상으로 가져온다. 서가는 느리지만, 한 번에 몇 페이지만 꺼내면 되니 사실상 공짜다. 덕분에 책상 크기는 그대로인데 다룰 수 있는 책(모델)은 100배로 커진다.
"작은 칩에 큰 모델"이라는 주제는 늘 인기지만, 이 레포가 트렌딩에 오른 건 단순한 데모가 아니라 진짜 과학이기 때문이다. 네 가지 결이 겹친다.
더 빠른 칩을 사거나 모델을 무조건 줄이는 대신, 메모리의 성질이 저마다 다르다는 점을 이용했다. 언어모델 파라미터의 대부분은 계산에 쓰이는 게 아니라 단순히 찾아보는 표(embedding table)다. 그 표는 토큰마다 딱 몇 줄만 읽으면 되니, 굳이 빠른 메모리에 둘 필요가 없다. "느린 flash에 둬도 되는 것"과 "빠른 SRAM에 꼭 둬야 하는 것"을 가른 것이 이 프로젝트의 전부다.
주장을 검증하는 방식이 놀랍도록 성실하다. PLE가 baseline보다 나은지 2개 시드로 재현했고, 4-bit로 압축해도 이득이 살아남는지 따로 측정했으며, C로 짠 추론기가 PyTorch 원본과 3만 2768개 logit 전부에서 최대오차 0.00001로 일치함을 하드웨어에 올리기 전에 확인했다. 심지어 저자는 초기 파라미터 계산 버그로 숫자를 부풀렸던 이력과 그걸 고친 결과를 일부러 저장소에 남겨 뒀다.
클라우드도, GPU도, 인터넷 연결도 없이 몇 달러짜리 부품이 스스로 문장을 만든다는 그림은 완전한 오프라인·프라이버시 보존 AI의 원형을 보여준다. 데이터가 칩 밖으로 한 바이트도 나가지 않는다. 이 서사가 개발자 커뮤니티(특히 임베디드·IoT 진영)를 자극했다.
저자는 Andrej Karpathy의 llama2.c(순수 C로 작은 LLM을 돌리는 프로젝트)가 "작은 언어모델을 직접 학습해 C로 돌릴 수 있다"는 믿음을 준 계기였다고 밝힌다. 무거운 프레임워크 없이 바닥부터 직접 짠다는 이 문화적 계보가, 같은 감성을 공유하는 개발자들에게 매력적으로 다가온다. (단, 저자는 코드·방법이 llama2.c에서 파생된 건 아니라고 명확히 선을 긋는다 — 비교 맥락일 뿐이다.)
저자는 "28.9M 파라미터"가 무엇을 뜻하고 무엇을 뜻하지 않는지까지 못박는다. 이건 저장된(stored) 파라미터가 2,890만이라는 뜻이지, 능력이 100배라는 뜻이 아니다. 여전히 TinyStories 도메인 모델이라 세상 지식·산수·다단계 추론은 못 한다. "메모리 계층 분할로 상주시킨 파라미터 수"라고 부르지, 절대 "능력 배수"로 광고하지 말라고 스스로 적어 둔다. 이 절제가 오히려 신뢰를 높인다.
| 구분 | 전형적 온디바이스 LLM | esp32-ai (PLE) |
|---|---|---|
| 모델 크기 한계 | 빠른 메모리(SRAM) 용량에 갇힘 | 느린 flash 용량까지 확장 (약 110배) |
| 어디에 모델을 두나 | 전부 빠른 메모리에 상주 | 계산 코어만 SRAM, 표는 flash에 |
| 토큰당 flash 접근 | 거의 안 함(다 RAM에 있음) | 표에서 6줄(~450B)만 읽음 (~0.12ms) |
| 연결성 | 보통 클라우드 보조 | 완전 오프라인, 칩 밖으로 0바이트 |
| 검증 태도 | 데모 위주 | 2시드·4bit·host-golden 1e-5 매칭 |
이 저장소는 완전히 다른 두 파이프라인이 한 몸으로 붙어 있다. 하나는 데스크톱에서 모델을 학습·양자화·검증하는 Python(PyTorch) 세계이고, 다른 하나는 그 결과물을 칩 위에서 실제로 돌리는 C(Arduino) 세계다. 둘을 잇는 다리가 export.py가 뽑는 납작한 바이너리(model.bin)와, 호스트와 칩에서 같은 코드가 도는 단일 헤더 llm.h다.
# name = "esp32-llm" · requires-python = ">=3.12"
numpy >=2.5.1 # 토큰 bin(memmap) 처리
requests >=2.34.2 # TinyStories 데이터 다운로드
tokenizers >=0.23.1 # BPE 토크나이저 학습
torch >=2.13.0 # 모델 학습/양자화/golden 생성
tqdm >=4.69.0 # 진행 표시
| 영역 | 선택 | 왜 이렇게 했나 |
|---|---|---|
| 학습 프레임워크 | PyTorch 2.13 | 맥(MPS)·CUDA·CPU 자동 감지. 작은 모델이라 노트북에서도 학습 가능 |
| 패키지 매니저 | uv | uv run python ... 한 줄로 재현. 빠르고 lock 파일(uv.lock)로 고정 |
| 데이터셋 | TinyStories | 작은 모델도 일관된 글을 배울 만큼 단순한 합성 동화 (~300MB 슬라이스) |
| 토크나이저 | BPE (vocab 4096 / 32768) | 모든 실험이 같은 토크나이저 공유 — cross-entropy 비교가 성립하려면 필수 |
| 양자화 | 그룹별 대칭 int4 PTQ | GGUF-Q4 스타일. group-128 ragged + fp16 scale로 16MB flash에 맞춤 |
| 추론(칩) | 순수 C 단일 헤더 llm.h | 동적 라이브러리·프레임워크 0. 호스트 검증과 칩 실행이 같은 코드 |
| 펌웨어 빌드 | Arduino ESP32 core 3.3.10 | arduino-cli로 컴파일·업로드, esptool로 모델 파티션 flash |
이 프로젝트의 모든 것은 파라미터를 세 종류로 나누는 것에서 출발한다. 기준은 "얼마나 빠른가"가 아니라 "어떻게 접근되는가(access pattern)"다. 이 3단 분류가 칩의 3단 메모리(SRAM·PSRAM·flash)에 그대로 대응된다.
왼쪽이 하드웨어, 오른쪽이 모델이다. 셋을 짝지은 게 설계의 전부다. 여기서 제일 반직관적인 결정은 출력 head를 'core'에서 뺀 것이다. head는 매 토큰마다 전부 읽히지만 처음부터 끝까지 순차로 훑을 뿐이라, 용량이 아니라 대역폭만 잡아먹는다. 이걸 core에 넣어버리면 큰 어휘(vocab)가 감당 불가로 보이지만, 사실은 "느릴 뿐"이다. 이 구분이 큰 어휘 → 값싼 표라는 전체 전략을 가능하게 한다.
PLE는 구글이 Gemma 3n / Gemma 4에서 쓴 기법이다. 보통 트랜스포머는 토큰을 입력에서 한 번만 임베딩하지만, PLE는 레이어마다 그 토큰 전용의 추가 정보를 주입한다. 그 "레이어별 추가 정보"가 바로 flash에 사는 거대한 표(ple_table)에서 온다. 토큰이 정해지면 각 레이어에 뿌릴 조각을 표에서 꺼내(랜덤 읽기 몇 줄), 레이어 내부에서 곱셈으로 게이팅한다.
// src/model.py — PLE 입력 조립 (설명용으로 단순화)
ple = ple_model_proj(x) * (d_model ** -0.5) # ① 맥락 인지: 토큰 임베딩을 레이어별로 투영
ple = ple_proj_norm(ple.view(B,T, n_layers, ple_dim))
table = ple_table(idx) * (ple_dim ** 0.5) # ② flash 표에서 토큰별 행을 꺼냄
ple = (ple + table) * (2 ** -0.5) # ③ 둘을 합쳐 각 레이어로 배분
// 각 레이어(Block) 안에서 게이팅 — 곱셈이라 서로를 억제할 수 있음
g = gelu(ple_gate(x))
x = x + ple_norm(ple_proj(g * ple_layer)) # 단순 편향이 아니라 '조건부' 주입
나머지 모델 본체는 요즘 LLM의 표준 부품으로 짜였다: 디코더-온리 트랜스포머, 정규화는 RMSNorm, 위치 인코딩은 RoPE(split-half), FFN은 SwiGLU, 입력·출력 임베딩은 묶음(tied). 배포 설정은 어휘 32768 · d_model 96 · 6개 레이어 · ple_dim 128이고, 계산 코어는 559K로 512KB SRAM에 들어가도록 크기를 맞췄다.
같은 구조인데 어휘 4096에서는 PLE 이득이 +0.025 nats로 미미하다. 그런데 어휘를 32768로 키우면 +0.098 nats(약 4배)로 뛴다. 큰 어휘는 표를 거대하면서 동시에 값싸게 만들기 때문이다(행은 많아지지만 토큰당 읽는 건 여전히 몇 줄). 즉 "표를 flash에 두는" 전제가 유리하게 작동하는 바로 그 구간에 배포 설정이 산다. 물리 제약과 알고리즘 이득의 방향이 같은 곳을 가리키는, 보기 드문 정합이다.
파일 수는 적지만(핵심 소스 ~1,800줄) 역할 분담이 또렷하다. src/는 파이토치 학습·양자화·export, firmware/는 C 추론과 온칩 배포, experiments/는 밤새 돌리는 실험 큐, data/는 데이터 준비다. "학습 → 양자화 → export → 호스트 검증 → 칩 flash"라는 파이프라인이 폴더 순서에 그대로 담겨 있다.
처음 읽는다면 RESULTS.md(무엇을·왜 증명했나) → src/budget.py(3단 회계) → src/model.py(모델과 5개 arm) → firmware/common/llm.h(칩 추론) → RESULTS.md 온칩 섹션(성능 여정) 순서를 추천한다. model.py의 상단 주석은 다섯 실험 arm(baseline·ple·ple_notable·fatembed·bigcore)이 각각 무엇을 증명하려 존재하는지를 교재처럼 설명해 두어, 이 파일 하나만 읽어도 실험 설계 사고를 통째로 배울 수 있다.
가장 값진 교훈. 파라미터를 접근 패턴(dense-every-token / sequential-scan / sparse-lookup)으로 나누고, 각각을 맞는 메모리 층에 배치하는 사고방식이다. budget.py는 이 회계를 코드로 보여주고, "출력 head를 core에서 빼는" 한 줄의 결정이 어떻게 큰 어휘를 가능케 하는지 드러낸다. 임베디드가 아니어도, "무엇을 어디에 두는가"가 모델 설계의 일부라는 관점을 얻는다.
논문·블로그로만 접하던 Gemma의 PLE를, model.py의 60줄 남짓으로 직접 읽을 수 있다. "레이어별 표 lookup + 게이팅"이 실제 텐서 연산으로 어떻게 조립되는지, embed_scale(sqrt(ple_dim))처럼 문서에 안 적힌 결정적 디테일이 어디서 곱해지는지까지 주석으로 짚어 준다.
"PLE가 좋다"를 어떻게 공정하게 증명하는가. 이 레포는 모든 arm의 계산 코어 크기를 이진 탐색으로 똑같이 맞추고(안 그러면 그냥 FFN 큰 놈이 이김), 2개 시드로 노이즈 대비 효과 크기를 재고, ple_notable(표 없는 PLE 배관)·fatembed(같은 표 예산을 입력에 몰아줌)·bigcore(표 예산을 코어 확장에)라는 대조군으로 "이득의 출처가 정확히 표"임을 격리한다. 실험을 설계하는 사람이라면 반드시 볼 예제다.
quantize.py는 그룹별 대칭 int4 PTQ를 구현하고, 거대한 표까지 포함해 전부 양자화한 뒤 품질을 다시 잰다. 배우는 것: 왜 작은 dense 모델은 4비트에 취약하고(모든 가중치가 중요), 중복 많은 큰 표는 4비트에 강한가. "flash 예산을 건 그 부분이 하필 가장 압축에 강하더라"는 결과가, 왜 QAT 없이도 되는지를 설명한다.
firmware/common/llm.h(385줄)는 동적 아키텍처 없이 모델 헤더에서 차원을 읽어 동작하는 단일 헤더 추론기다. 같은 코드를 호스트에서 컴파일해 PyTorch golden과 대조(1e-5 일치)한 뒤, 글자 하나 안 바꾸고 ESP32로 컴파일한다. "칩에 올리기 전에 정확성을 완전히 못박는" 방법론의 정석이다.
RESULTS.md의 온칩 섹션은 0.57 → 9.5 tok/s까지의 최적화 여정을 단계별 수치로 기록한다. head를 PSRAM에 int8로 미리 펼치고, 활성값도 int8로 양자화해 토큰당 언패킹을 없애고, 두 코어(LX7)에 출력 행을 나눈다. 결국 head가 계산이 아니라 PSRAM 대역폭에 묶인다는 걸 프로파일로 증명하고, "여기서 더 빨라지려면 SIMD가 아니라 읽는 바이트 자체를 줄여야 한다"는 결론에 도달한다. 실전 프로파일링 사고의 표본이다.
budget.py와 초기 추정은 대역폭만으로 ~58 tok/s가 가능하다고 봤지만, 실제로는 int4 언패킹과 수백만 번의 스칼라 float 연산이 대역폭보다 먼저 병목이 됐다. 실측 종단 속도는 ~9.5 tok/s다. 문서도 "58은 대역폭 천장일 뿐"이라고 반복해 못박는다. 벤치마크 숫자를 인용할 땐 이론 천장과 실측을 반드시 구분하자.
| 항목 | 사양 / 비고 |
|---|---|
| CPU | Xtensa LX7 듀얼코어 @ 240MHz (출력 head를 두 코어로 분할) |
| 내부 SRAM | 512KB — 가장 귀한 자원. 계산 코어(559K, 4bit 273KB)가 여기 상주 |
| PSRAM(외부 램) | 8MB — head를 int8로 스테이징, 스크래치·KV 캐시. 순차 60.7MB/s |
| flash | 16MB — 25M 표를 memory-mapped(XIP)로 상주. 랜덤 20.3us/512B |
| 화면(선택) | ST7789 2.0" 240x320 (GMT020-02-7P) — 없으면 시리얼 출력만 |
| 가격 | 약 $8 |
# Name, Type, SubType, Offset, Size
factory, app, factory, 0x10000, 0x100000 # 619KB 애플리케이션 (1MB 파티션)
model, data, 0x40, 0x110000, 0xEE0000 # 14.9MB 모델 (15.6MB 커스텀 파티션)
# 모델 payload는 새로 export 할 때만 다시 flash. 펌웨어만 고치면 재업로드 불필요.
| 필요 | 버전 / 비고 |
|---|---|
| Python | 3.12 (.python-version) · uv 패키지 매니저 권장 |
| PyTorch | 2.13+ — MPS(맥)·CUDA·CPU 자동 감지, 작은 모델이라 노트북도 가능 |
| 디스크 | TinyStories 슬라이스 ~300MB + 런 산출물(runs/*.json) |
| 펌웨어 툴 | Arduino ESP32 core 3.3.10 · arduino-cli · esptool · C 컴파일러(호스트 검증용) |
| 구현 단계 | 종단 속도 | 모델 스텝 |
|---|---|---|
| 첫 포터블 포트 | 0.57 tok/s | 1,757 ms |
| PSRAM head + 스칼라 정리 | 4.6–4.8 tok/s | 194 ms |
| 듀얼코어 정확 head | 5.7–6.2 tok/s | 139 ms |
| int8 head + int8 활성값(현재) | ~9.5 tok/s | 103 ms |
현재 런타임 프로파일(토큰당, 듀얼코어 wall): head 57.6 · attn 25.6 · PLE 8.5 · FFN 6.9 · 입력 4.4 ms. head가 토큰당 int8 가중치 2.43MB를 읽어 60.7MB/s에서 ~40ms 대역폭 바닥에 묶인다. 여기서 더 빨라지려면 SIMD가 아니라 읽는 바이트를 줄이는 방향(int4 head + SIMD 언패킹)이나 더 작은 head(모델 변경)가 필요하다.
보드 없이 시작. uv run python src/budget.py --bits 4를 돌려, core/stream/table 각 층이 몇 KB인지, SRAM에 맞는지, 토큰당 표 읽기가 몇 ms인지를 출력으로 확인하라. 어휘·d_model·ple_dim을 바꾸며 "무엇이 SRAM을 넘치게 하는가"를 감으로 익히는 게 목표다.
uv run python data/prepare.py로 TinyStories 300MB를 받아 BPE를 학습하고 uint16 토큰 bin을 만든다. 왜 모든 실험이 같은 토크나이저를 공유해야 cross-entropy 비교가 성립하는지, vocab 4096과 32768이 bin 크기·이후 PLE 이득에 어떻게 영향을 주는지 prepare.py 주석과 함께 읽어라.
src/train.py --arm baseline과 --arm ple을 같은 target-core로 각각 학습해, PLE가 정말 낮은 val loss(=낮은 perplexity)를 내는지 재현하라. 노트북(MPS/CPU)이면 스텝 수를 줄여 감만 봐도 된다. make_model()이 FFN 크기를 이진 탐색해 코어를 맞추는 부분을 꼭 확인 — 이게 공정 비교의 핵심이다.
src/quantize.py로 학습 모델을 그룹별 int4로 양자화→역양자화한 뒤 val loss를 다시 재라. baseline과 ple가 각각 얼마나 열화되는지 비교해, "표가 4비트에 더 강하다(→ PLE 이득이 유지된다)"는 결론을 스스로 확인한다. 그런 다음 export.py로 model.bin과 golden을 뽑고, 호스트에서 verify.c를 컴파일해 C가 PyTorch와 1e-5로 일치하는지 대조하라.
보드가 있다면 최종 단계. arduino-cli compile(ESP32 core 3.3.10, -O3)로 펌웨어를 빌드하고, esptool write_flash 0x110000 model.bin으로 모델 파티션을 굽는다. 시리얼 모니터에서 "Once upon a time…"이 초당 ~9.5 토큰으로 이어지는지 확인하라. 부팅 진단(V=32768 D=96 L=6 H=4 F=66 P=128, head int8 2.53MB, PSRAM 여유 ~5100KB)이 문서와 일치하는지도 함께 본다.
| 주차 | 주제 | 할 것 |
|---|---|---|
| 1주차 | 트랜스포머 기초 | model.py 정독 → RMSNorm·RoPE(split-half)·SwiGLU·tied embedding이 각각 무엇인지 이해 → sample.py로 작은 모델을 학습·생성해 감 잡기 |
| 2주차 | 메모리 계층 + PLE | budget.py로 3단 회계 → model.py의 PLE arm 60줄 정독 → Gemma PLE 원리(레이어별 표 lookup + 게이팅)와 "어휘가 클수록 이득" 논리 정리 |
| 3주차 | 양자화 + C 추론 | quantize.py의 그룹별 int4 PTQ → export.py 바이너리 포맷 → llm.h로 "호스트=칩 동일 코드"와 golden 대조(1e-5) 흐름 파악 |
| 4주차 | 온칩 배포·최적화 | ESP32 툴체인(arduino-cli·esptool)·파티션 → bandwidth_bench로 대역폭 실측 → RESULTS.md 온칩 섹션으로 int8 head·듀얼코어·대역폭 병목 최적화 여정 재현 |
임베디드가 낯설어도 1~3주차는 순수 소프트웨어라 노트북만으로 끝난다. 이 레포는 "작은 트랜스포머를 바닥부터 학습하고 → 양자화하고 → C로 이식하고 → 정확성을 못박는 전 과정"의 압축 교재다. 마이크로컨트롤러라는 소재를 걷어내도, LLM 추론 파이프라인을 처음부터 끝까지 손으로 짜 보고 싶은 사람에게 값지다. 보드(과제 5)는 여유가 될 때 얹으면 된다.
| 용어 | 뜻 |
|---|---|
| ESP32-S3 | Wi-Fi 내장 듀얼코어(LX7) 마이크로컨트롤러. 512KB SRAM·8MB PSRAM·16MB flash, 약 $8 |
| PLE (Per-Layer Embeddings) | 토큰별로 레이어마다 전용 벡터를 주입하는 기법(Gemma). 표 lookup만 하므로 느린 flash에 둬도 값이 쌈 |
| TinyStories | 작은 모델도 일관된 글을 배우도록 만든 단순 합성 동화 데이터셋 (Eldan & Li, MS Research) |
| core / stream / table | 파라미터 3층. core=매토큰 계산(SRAM), stream=출력 head 순차스캔(PSRAM), table=토큰당 몇 줄 lookup(flash) |
| SRAM / PSRAM / flash | 칩의 3단 메모리. 빠르고 작음 / 중간 / 느리고 큼. 각각 core·head·표를 맡음 |
| 양자화 · int4 / int8 | float32 가중치를 4/8비트 정수로 줄여 용량·대역폭 절약. 표는 4비트에 특히 강함 |
| PTQ | Post-Training Quantization — 학습 후 양자화. QAT(양자화 인지 재학습) 없이도 이득 유지 |
| perplexity (ppl) / nats | 언어모델 품질 지표. 낮을수록 좋음. nats는 자연로그 기준 손실 단위(값이 클수록 개선폭 큼) |
| RoPE / SwiGLU / RMSNorm | 현대 LLM 표준 부품 — 회전 위치 인코딩 / 게이트형 FFN / 경량 정규화 |
| tied embedding | 입력 임베딩과 출력 head가 같은 가중치를 공유(묶음)해 파라미터 절약 |
| memory-mapped / XIP | flash를 RAM처럼 주소로 직접 읽기. 표를 통째로 옮기지 않고 필요한 행만 그 자리에서 읽음 |
| ablation (대조 실험) | 요소를 하나씩 빼거나 바꿔(ple_notable·fatembed·bigcore) 이득의 진짜 출처를 격리하는 실험 설계 |
| tok/s | 초당 생성 토큰 수. 이 프로젝트 종단 실측 ~9.5, 대역폭 이론 천장 ~58 |
| duel-core / LX7 | ESP32-S3의 두 Xtensa LX7 코어. 출력 head 계산을 둘로 나눠 처리 |
src/model.py(모델·5 arm) · src/budget.py(3단 회계) · src/quantize.py(int4 PTQ) · firmware/common/llm.h(포터블 C 추론) · RESULTS.md(전체 방법·실측)firmware/esp32_llm/README.md (arduino-cli·esptool·파티션·부팅 진단)