1. 项目缘起:为什么要在 MCU 上跑大模型
1.1 一个看似不合理的需求
把 LLM 塞进 MCU,第一次听到这个想法的人大概率会觉得是噱头。毕竟我们习惯了动辄几十 GB 显存、上千 TOPS 算力的推理环境,突然有人告诉你,一颗售价几十块钱、主频几百兆、内存以 KB 计量的微控制器也能跑语言模型,直觉上就会觉得不靠谱。
但真实需求是存在的。工业现场的设备状态问答、离线语音助手的意图理解、教育硬件的本地对话、隐私敏感场景下的文本分类,这些场景要么没有网络,要么不允许把数据传出去,要么成本压到必须用一颗 MCU 搞定。云端 API 在这些场景里不是"贵"的问题,而是"根本用不了"。
ESP32-P4 是我盯了很久的一颗芯片。它和传统 ESP32 系列最大的区别在于:双核 RISC-V 架构,主频拉到 400 MHz,带 PIE 向量指令扩展,片上 SRAM 有 768 KB,还支持外挂 PSRAM。这几个参数凑在一起,让它成为少数"理论上能跑小模型"的 MCU 之一。注意我说的是"理论上",因为从理论到跑通,中间隔着的坑比想象中多得多。
这个系列要复盘的就是这段过程:从最初只能跑到 0.61 tok/s 的"能跑但没法用",一路优化到 4.31 tok/s 的"勉强可用",整整 7 倍的提升。我会把每一层优化为什么做、怎么做、踩了什么坑全部摊开讲。适合正在做边缘 AI 落地的工程师、对 RISC-V 向量指令感兴趣的底层玩家,以及所有想知道"MCU 跑 LLM 到底是不是伪命题"的人。
1.2 先给结论:能跑,但边界要清楚
在展开细节之前,我先把最终的能力边界说清楚,免得有人抱着不切实际的期待往下看。
| 指标 | 实测值 | 说明 |
|---|---|---|
| 模型规模 | 约 260K 参数 | TinyStories 级别的极小模型 |
| 量化方式 | INT8 | 权重和激活都量化 |
| 推理速度 | 4.31 tok/s | 优化后稳定值 |
| 首 token 延迟 | 约 180 ms | 含 prompt 处理 |
| 内存占用 | 约 420 KB | 权重 + 激活 + KV Cache |
| 功耗 | 约 0.9 W | 满载推理时 |
这个速度意味着什么?生成一句 20 个 token 的短回复大概要 4.6 秒。做实时对话肯定不够,但做"按键触发、等几秒出结果"的离线意图识别、关键词抽取、简单问答,是完全够用的。所以定位要准:它不是要替代云端大模型,而是填补"必须离线 + 成本极低 + 任务简单"这个细分空白。
2. 硬件与软件栈的整体设计
2.1 为什么选 ESP32-P4 而不是其他 MCU
市面上能跑神经网络的 MCU 不少,STM32H7 系列、瑞萨 RA8、恩智浦 i.MX RT 系列都有 NPU 或 DSP 加速。我最终选 ESP32-P4,主要基于三个考量。
第一是向量指令。P4 的 PIE(Processor Instruction Extension)提供了一套 128 位 SIMD 指令,虽然比不上 ARM 的 NEON 那么完善,但对于 INT8 矩阵乘这种规整的计算,加速比相当可观。这是后面能实现 7 倍优化的硬件基础。
第二是内存架构。768 KB 片上 SRAM 加上可外挂的 PSRAM,给了模型权重和 KV Cache 足够的摆放空间。很多 MCU 的 SRAM 只有一两百 KB,连权重都放不下,只能靠外部 Flash 边读边算,速度直接崩掉。
第三是工具链成熟度。ESP-IDF 对 RISC-V 的支持已经比较完善,编译器优化选项、链接脚本定制、性能计数器这些底层调试手段都能用上。做这种极限优化,工具链不给力会非常痛苦。
提示:选型时不要只看主频。主频高但内存带宽跟不上,矩阵乘一样跑不快。P4 的 SRAM 访问延迟和 PSRAM 带宽是我实测对比后才确认的关键指标。
2.2 软件栈的分层设计
整个推理栈我分成了四层,从下往上依次是:
- 硬件抽象层:封装 PIE 向量指令的内联汇编,提供 INT8 点积、矩阵乘等原语
- 算子层:基于原语实现 GEMM、Softmax、LayerNorm、GELU 等 Transformer 必需算子
- 模型层:加载量化权重、管理 KV Cache、实现自回归解码循环
- 应用层:tokenizer、prompt 模板、采样策略
这样分层的好处是,优化可以逐层进行,每层的收益能独立测量。比如 PIE 优化只动硬件抽象层,算子层的接口不变;KV Cache 策略调整只动模型层,不影响底层。如果一开始就写成一坨,后面根本没法定位瓶颈在哪。
2.3 模型选型:为什么是 TinyStories
跑在 MCU 上的模型,参数量必须压到极致。我试过几个方向:直接裁剪 GPT-2、用蒸馏出来的小模型、以及专门为小规模训练设计的 TinyStories。
最终选 TinyStories,原因是它的词表小、层数少、注意力头少,而且训练语料是简单的儿童故事,语法结构规整,量化后精度损失可控。具体配置是 4 层 Transformer、隐藏维度 128、4 个注意力头、词表 4096。这个规模下,INT8 量化后权重约 260 KB,塞进 SRAM 绰绰有余。
有人会问为什么不选更小的模型。实测下来,参数量低于 200K 之后,模型基本说不出通顺的句子,做意图识别也会频繁出错,没有实用价值。260K 是我找到的"能干活"的下限。
3. 从 0.61 到 4.31:七倍优化的完整路径
3.1 基线版本:能跑,但慢到怀疑人生
第一版跑通的时候,速度是 0.61 tok/s。这个数字是怎么来的?我用了最朴素的实现:所有权重放在 PSRAM,矩阵乘用三重循环手写,激活函数用查表,没有任何向量化。
跑一个 20 token 的回复要 33 秒。当时我盯着串口输出,一度以为程序卡死了。但它是真的在算,只是每一步都在做无用功。
基线版本的性能剖析结果很清晰:
| 环节 | 耗时占比 | 问题 |
|---|---|---|
| 矩阵乘 | 78% | 标量循环,无向量化 |
| 权重读取 | 12% | PSRAM 随机访问,无预取 |
| 激活函数 | 6% | 查表命中率低 |
| 其他 | 4% | 内存拷贝、循环开销 |
瓶颈一目了然:矩阵乘吃掉了近八成时间。所以优化的主战场就在这里。
3.2 第一层优化:PIE 向量化矩阵乘
PIE 指令集里最有用的是esp.vmulas.s8.xacc这类 INT8 乘累加指令,一次能处理 16 个 INT8 的乘加。我把矩阵乘的内层循环改写成 PIE 内联汇编,一次加载 16 个权重和 16 个激活,做点积。
这里有个关键细节:数据排布。PIE 要求数据按 128 位对齐,如果权重在内存里是散着放的,每次加载都要先做对齐处理,反而更慢。所以我在模型转换阶段就把权重重新排布成 PIE 友好的格式,把同一行的权重连续摆放,保证每次加载都是对齐的。
这一层优化做完,速度从 0.61 提到了 1.8 tok/s,接近 3 倍。但还没到理想状态,因为剖析发现 PSRAM 访问成了新瓶颈。
3.3 第二层优化:权重常驻 SRAM
P4 的 PSRAM 带宽虽然不低,但访问延迟比 SRAM 高一个数量级。矩阵乘是访存密集型操作,每次都要读权重,PSRAM 的延迟直接拖慢了流水线。
我的做法是把最频繁访问的权重搬进 SRAM。具体来说,把前两层的权重常驻 SRAM,后两层留在 PSRAM。为什么这么分?因为剖析显示前两层的激活值复用率最高,权重被反复读取;后两层相对稀疏。
搬移之后,SRAM 占用从 180 KB 涨到 420 KB,但速度提到了 2.9 tok/s。这里要注意,SRAM 不是越大越好,留足空间给 KV Cache 和栈,否则会栈溢出,调试起来非常痛苦。
3.4 第三层优化:KV Cache 与循环展开
到 2.9 tok/s 之后,瓶颈转移到了 KV Cache 的读写和循环控制开销上。
KV Cache 的优化思路是分块存储 + 增量更新。自回归解码时,每生成一个 token 只需要追加一行 KV,不需要重算历史。我把 KV Cache 按注意力头分块,每个头独立管理,避免跨头访问的缓存失效。
循环展开则是针对小矩阵乘的。当矩阵维度是 128 时,内层循环只有 8 次 PIE 迭代,循环控制开销占比很高。我手动展开成 4 路,让编译器有更多指令调度空间。这一层做完,速度到了 3.6 tok/s。
3.5 第四层优化:编译器与内存布局微调
最后一层是"抠细节"。包括:
- 把热点函数用
IRAM_ATTR放进指令 RAM,避免 Flash 取指延迟 - 调整链接脚本,把权重段和栈段分开,减少缓存冲突
- 开启
-O3和-funroll-loops,但关掉-ffast-math(会破坏量化精度) - 用性能计数器定位剩余的 cache miss
这些改动单个看收益都不大,但叠加起来把速度从 3.6 推到了 4.31 tok/s。
注意:编译器优化不是越激进越好。我试过
-Ofast,速度确实快了一点,但量化模型的输出开始出现乱码,因为浮点重排破坏了 INT8 的累加顺序。这种坑只能靠实测发现。
4. 关键实操细节与踩坑记录
4.1 PIE 内联汇编的正确写法
PIE 指令的内联汇编有几个容易出错的地方。首先是累加器初始化,xacc寄存器必须先清零,否则会带上上一次的残留值。我第一次写的时候忘了清零,结果输出全是乱码,查了两天才发现。
其次是对齐约束。PIE 加载指令要求地址 16 字节对齐,如果传入的指针没对齐,会触发异常。我的做法是在权重排布阶段就保证对齐,运行时用断言检查。
// PIE INT8 点积核心片段 static inline int32_t pie_dot_s8(const int8_t *a, const int8_t *b, int len) { int32_t acc = 0; asm volatile( "esp.zero.xacc\n" "loop:\n" "esp.vld.128.ip q0, %1, 16\n" "esp.vld.128.ip q1, %2, 16\n" "esp.vmulas.s8.xacc q0, q1\n" "addi %0, %0, -16\n" "bnez %0, loop\n" "esp.movx.r.xacc %3\n" : "+r"(len), "+r"(a), "+r"(b), "=r"(acc) : : "q0", "q1", "memory" ); return acc; }这段代码看着简单,但每个约束都有讲究。memory告诉编译器内存可能被修改,防止它把加载操作优化掉;q0、q1是 PIE 的向量寄存器,必须显式声明为 clobber。
4.2 量化精度的取舍
INT8 量化最大的风险是激活值溢出。Transformer 里的 GELU 和 Softmax 输出范围差异很大,如果统一用一套 scale,要么权重精度不够,要么激活溢出。
我的方案是分层量化:权重用 per-channel scale,激活用 per-tensor scale,并且在 GELU 之后加一个 clamp,把范围限制在 [-8, 8]。这个 clamp 会损失一点精度,但避免了溢出导致的完全乱码。
实测下来,分层量化 + clamp 的方案,模型输出的困惑度只比 FP32 高了 3%,但内存占用降到了四分之一。这个 trade-off 是值得的。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 输出乱码 | 累加器未清零 / 量化溢出 | 检查 PIE 初始化,加 clamp |
| 速度突然变慢 | 权重被换出 SRAM | 检查 SRAM 占用,调整常驻策略 |
| 程序崩溃 | 栈溢出 / 对齐异常 | 加大栈,检查指针对齐 |
| 首 token 特别慢 | prompt 未做批处理 | 优化 prompt 阶段的矩阵乘 |
| 精度骤降 | 量化 scale 选错 | 重新校准,用真实数据统计 |
4.4 调试手段:性能计数器怎么用
P4 内置了性能计数器,可以统计指令数、cache miss、分支预测失败等。我习惯在优化前后各跑一次,对比数据定位瓶颈。
具体用法是在关键代码段前后读计数器:
uint32_t cyc_start = esp_cpu_get_cycle_count(); // 待测代码 uint32_t cyc_end = esp_cpu_get_cycle_count(); printf("cycles: %u\n", cyc_end - cyc_start);这个手段看起来原始,但比任何 profiler 都直接。尤其是定位"哪一行慢"的时候,二分法加计数器,很快就能锁定。
5. 这套方案能扩展到哪里
5.1 模型层面的扩展空间
260K 参数不是终点。P4 的 768 KB SRAM 理论上能塞下 500K 参数左右的 INT8 模型,前提是把 KV Cache 压到最小。我下一步想试的是稀疏化 + 量化的组合,把不重要的权重剪掉,腾出空间给更大的模型。
另一个方向是多模型切换。把几个专用小模型(意图识别、实体抽取、情感分类)都量化好,运行时按需加载。这样单个模型可以更小更专,整体能力反而更强。
5.2 应用场景的落地建议
如果你打算把这套方案用到实际产品里,我的建议是:
- 任务要窄。不要指望它做开放域对话,聚焦在固定领域的分类、抽取、模板填充上
- 交互要容忍延迟。设计成"按键触发、等待几秒"的模式,而不是实时流式
- 要有降级方案。模型置信度低的时候,回退到规则引擎或提示用户重试
5.3 后续系列的规划
这个总览之后,我计划分几篇展开:PIE 指令的深入用法、量化校准的具体流程、KV Cache 的内存管理、以及完整的性能剖析方法论。每一篇都会带上可复现的代码和实测数据。
我在实际项目里最大的体会是:MCU 跑 LLM 这件事,难点不在"能不能跑",而在"怎么把每一层开销都榨干"。0.61 到 4.31 这 7 倍,没有哪一步是靠某个神奇技巧实现的,全是把瓶颈一个个找出来、一个个解决掉。这个过程很枯燥,但每解决一个瓶颈,看到速度往上跳一截的时候,那种成就感是实打实的。如果你也在做类似的边缘推理优化,欢迎按这个思路自己走一遍,踩过的坑我都写在上面了,能帮你省不少时间。