28.9M 参数模型跑进 8 美元 ESP32:PLE 如何把大表放进 Flash
一、这不是把聊天模型塞进单片机
HN 今天的高分项目 slvDev/esp32-ai 把一个 28.9M 参数的 TinyStories 语言模型跑在 ESP32-S3 上:开发板约 8 美元,端到端约 9.5 tok/s,模型文件 14.9MB,整条推理链不依赖服务器。仓库在 2026-07-23 建立,截至 2026-07-26 已有 1,074 stars、116 forks,MIT 许可。
这里最值得讨论的不是“8 美元硬件也能跑大模型”这种标题,而是参数被拆成了三种访问模式:每个 token 都要随机访问的核心放进 SRAM;每个 token 顺序扫描的输出 head 放进 PSRAM;按 token 只取少量行的 25M 参数表留在 flash。这个思路来自 Google Gemma 的 Per-Layer Embeddings(PLE),但项目作者把它压到了微控制器的内存层级上。
我没有完整 clone 仓库:当前网络直连 github.com 超时,所以改用 GitHub API 的 raw Accept 头读取 README、RESULTS.md、固件说明和源文件,并对 4 个 Python 文件做了 compile() 校验。下文数字以仓库文档和代码为准,不冒充本机 ESP32 实测。
二、先把 28.9M 参数拆开看
仓库的 RESULTS.md 给了配置:词表 32,768,核心维度 96,6 层,4 个 attention head,PLE 维度 128。28.9M 参数分成三部分:
| 区域 | 规模 | 访问方式 | 对应硬件 |
|---|---|---|---|
| dense core | 约 559K 参数 | 每个 token 都随机访问 | 512KB 内部 SRAM(项目按 320KB 可用预算规划) |
| output head | 约 2.43M 参数,约 2.53MB int8 | 顺序扫描 | 8MB PSRAM |
| PLE lookup table | 25M 参数 | 每层取当前 token 的一行 | 16MB flash |
firmware/esp32_llm/partitions.csv 把 model 分区放在 0x110000,大小为 0xEE0000,即 15,597,568 bytes;应用分区为 1MB。最终 4-bit 模型文件 14,912,332 bytes,剩余 685,236 bytes。模型能否部署,首先是 flash layout 问题,其次才是算子问题。
每个 token 并不需要把 25M 表全部搬进 SRAM。项目说明每层大约读一行,6 行合计约 450B。RESULTS.md 的 ESP32-S3 N16R8 带宽测试记录为:内部 SRAM 顺序读 240MB/s、PSRAM 顺序读 60.7MB/s、flash 512B 随机读 20.3μs,6 行表的单 token 成本约 0.12ms。
三、PLE 的关键不是“把权重放到慢内存”
PLE 不只是把普通 embedding 放到 flash:它为每个 token、每一层准备一份可查表向量,运行到该层时再注入。仓库 src/model.py 的简化路径如下:
# x: 当前层的 hidden state;ple_table: 当前 token 的逐层查表结果
g = F.gelu(self.ple_gate(x))
x = x + self.ple_norm(self.ple_proj(g * ple))
# 模型侧把上下文投影和 flash table 合并
ple = (context_ple + table * (cfg.ple_dim ** 0.5)) * (2 ** -0.5)
这里有两个关键点。第一,项目把 PLE 表和 token embedding 产生的 context 分支相加,不是查表后直接加到 hidden state。第二,ple_notable 对照保留 adapter、去掉 table;在词表 4,096 的控制实验里,它比 baseline 差 0.017 nats,说明收益来自 flash table,而不只是多了一套 projection。
Google 官方对 Gemma 3n 的描述也采用类似的内存分工:E2B 总参数超过 5B,但通过参数跳过和 PLE caching,effective memory load 可降到 1.91B;PLE 数据可以单独生成、缓存到较快存储,再在各层运行时加入。ESP32 项目没有照搬 Gemma 规模,而是借用了这个访问模式。
四、项目到底验证了什么
1. 质量对照
在词表 32,768、核心规模匹配的两组 seed 实验中,baseline perplexity 为 12.58,PLE 为 11.41,报告 +0.098 nats、约 9.3% ppl 改善,seed 噪声约 ±0.006。fatembed 为 11.94,因此每层注入比底部宽 embedding 多约 0.046 nats。
这个结果不能理解成 28.9M 模型拥有接近 28.9M dense 模型的能力。训练数据是 TinyStories,作者明确说它主要生成短故事,不回答事实问题、不写代码、没有通用世界知识。PLE 改善的是受限核心下的语言建模质量,不是把小模型变成通用助手。
2. 4-bit 是否把收益量化掉
仓库还对大权重做 group-wise symmetric int4 PTQ,table 也在量化范围内。两个 seed 中,PLE 相对 baseline 的 fp32 优势为 +0.101/+0.095 nats,shipping format 后为 +0.127/+0.115,保留约 121%-126%。量化后两边都变差,但表不是最脆弱的部分。
3. 真机吞吐的瓶颈
固件说明的路径是:启动时把 output head 从 int4 解包成 int8 放入 PSRAM,activation 也按 token 量化,再让两个 LX7 核心并行计算 output rows。200 token 记录从 0.57 tok/s(最初 portable port)到 4.61-4.77 tok/s、双核 6.22 tok/s,最终 int8 head 约 9.5 tok/s;纯计算口径 9.72 tok/s,即 102.9ms/token。
当前 profile 是 output head 57.6ms、attention 25.6ms、PLE 路径 8.5ms、FFN 6.9ms、输入 4.4ms。结果很反直觉:PLE 表本身不是主瓶颈,真正占时间的是每 token 都要扫描的 output head,且它已经受 PSRAM 带宽约束。项目报告的 58 tok/s 只是带宽 ceiling,不是观察到的端到端吞吐。
五、如果要自己复现,先从 host golden 开始
我把复现顺序整理成三层:先导出模型,再验证 C runtime 与 PyTorch 的 logits 一致,最后才刷板。固件 README 给出的核心命令如下;串口设备名按自己的系统替换:
cd src
uv run python export.py
cd ..
cc -O3 -o /tmp/esp32-llm-verify firmware/host_verify/verify.c -lm
/tmp/esp32-llm-verify firmware/model/model.bin firmware/model/golden.txt
arduino-cli compile \
--fqbn 'esp32:esp32:esp32s3:UploadSpeed=921600,USBMode=hwcdc,CDCOnBoot=cdc,UploadMode=default,CPUFreq=240,FlashMode=qio,FlashSize=16M,PartitionScheme=custom,PSRAM=opi,DebugLevel=info' \
--build-property compiler.optimization_flags=-O3 \
--build-path /tmp/esp32-llm-build \
firmware/esp32_llm
固件 README 还记录了一个重要边界:C runtime 在 host 上对 32,768 个 logits 的最大绝对误差为 0.00001,先证明导出和 C 实现一致,再谈芯片性能。model payload 单独写入 0x110000 分区,之后只改固件时不必重复刷 14.9MB 权重。
如果只是看内存布局,不必先训练。GitHub API 读取的仓库结构已有 src/model.py、src/budget.py、src/quantize.py、src/export.py、firmware/host_verify/verify.c 和 firmware/esp32_llm/esp32_llm.ino;我对 4 个 Python 文件做了本地编译检查,均为 OK。
六、这条路线适合什么,不适合什么
| 场景 | 判断 | 原因 |
|---|---|---|
| 离线短文本生成、设备演示、传感器旁边的固定语料模型 | 适合 | 无网络、14.9MB、约 9.5 tok/s,成本低 |
| 需要事实问答、代码生成、长对话的边缘助手 | 不适合 | TinyStories 训练域决定了能力上限 |
| 追求最低延迟的批量推理 | 不适合 | output head 每 token 顺序扫描,PSRAM 是硬约束 |
| 研究存储层级与模型结构的组合 | 值得研究 | PLE 把“参数量”和“高速内存占用”拆成了两个变量 |
这也是这个项目比“在单片机上跑一个模型”的演示更有价值的地方:它把部署预算写成访问模式。端侧推理里,参数总量、每 token 触碰的字节数、随机读延迟、顺序带宽和可用 SRAM 不是同一个指标,混在一起比较容易误判。
七、目前的局限与待验证项
- 通用能力不足(不足):目前只在 TinyStories 域证明连贯生成,问答、代码、算术和多轮对话没有结果。
- SIMD 收益待验证(待验证):当前 int8 dot 还是标量实现,作者估计 SIMD 单独最多再带来约 15%,但还没有公平的端到端对照。
- flash 读取有边界(坑点):6 行约 450B、0.12ms 是当前配置的测量;扩大表宽或层数后,随机读延迟不一定仍可忽略。
- 硬件复现还在调研(还在调研):文档使用 ESP32-S3 N16R8、16MB flash、8MB PSRAM 和 Arduino ESP32 core 3.3.10,其他板型不能直接套用。
- 模型文件尚未刷板(局限):本文验证了 API 下载的源文件、静态 Python 编译和文档数字,没有在本机硬件重新跑 14.9MB artifact,因此不声称完成独立真机复现。
八、结论
这个项目的结论可以压缩成一句话:小设备不必把所有参数塞进最快的内存,但要让参数访问模式匹配存储层。PLE 把按 token 取用的参数放到 flash,再用小 dense core 完成计算;代价是 output head 顺序扫描,收益是 28.9M 存储规模能落进 16MB flash。
它不是“8 美元替代云端大模型”,而是一份端侧工程样本:有同核心对照、4-bit 结果、host golden、分区表和真机 profile,也把速度 ceiling 与实际吞吐分开。对做 IoT、离线设备或嵌入式推理的人来说,这个边界比一句 SOTA 更值得复用。
九、参考链接
- 项目仓库:https://github.com/slvDev/esp32-ai(README、RESULTS.md、固件与训练代码)
- Google Gemma 3n 模型说明:https://ai.google.dev/gemma/docs/gemma-3n
- Google Gemma 3n 开发者指南:https://developers.googleblog.com/en/introducing-gemma-3n-developer-guide
- Hacker News 讨论:https://news.ycombinator.com/item?id=49050512(201 分、47 条评论,评论字段按文本长度交叉阅读)
- TinyStories 论文:https://arxiv.org/abs/2305.07759
浙公网安备 33010602011771号