ESP32 这块芯片被无数人玩过,但很少有人敢想象在上面跑大语言模型。ESP32-P4 发布之后,情况有了点新变化:双核 RISC-V、更高的主频、更大的寻址空间,让我萌生了一个念头——把 LLM 的推理搬上去,看看这块板子的极限到底在哪。
最终的成果不算惊艳,但足够有意思:从最开始的 0.61 tok/s,一路优化到 4.31 tok/s,整整提升了 7 倍。这个数字放在桌面 GPU 面前微不足道,但在嵌入式 MCU 上,能稳定输出 4 个 token 每秒,意味着实时语音交互、智能家居本地决策、离线辅助写作这类场景真的能在单片机级别落地了。
这次我准备把这个过程完整复盘一遍。作为一个系列的总览,我不会只甩几张性能对比图,而是会把硬件选型、模型选取、初始瓶颈分析、每一步优化的思路和实操代码、踩过的坑全部梳理清楚。无论你是想评估 ESP32-P4 能不能跑 LLM,还是已经上手却卡在性能上,这篇文章应该都能给你一份可参考的路径。
1. 项目整体设计与底层逻辑拆解
1.1 为什么选 ESP32-P4:把它当迷你主机而不是单片机
在真正动手之前,先要明确一个认知:ESP32-P4 不是传统意义上那种几十块钱的 WiFi MCU。它是一颗面向边缘 AIoT 应用的处理器,双核 RISC-V 处理器,主频可以跑到 400MHz 级别,而且硬件上集成了向量扩展指令支持,这对矩阵乘法来说是质的改进。更重要的是,它的系统架构允许挂载较大容量的 PSRAM,这意味着一个量化后的几百 MB 级别模型有地方可以放。
官方资料里没有刻意宣传“AI 推理”,但从硬件底子来看,P4 就是乐鑫特意为端侧轻量级 AI 准备的。我拿它当一台没有 Linux 的迷你主机来用,完全不加载 RTOS 的图形化组件,直接写一个裸机级别的推理循环。这样做的原因很简单:LLM 推理的瓶颈在于内存带宽和计算密度,而不是任务切换,操作系统的调度开销在这种交互式场景下只会拖后腿。
1.2 模型选型:不是越大越强,而是“刚好塞得下”
想在 ESP32-P4 上跑 LLM,第一步就要承认物理限制。桌面显卡能跑 7B、13B 模型,P4 不行,能放进去的只有那些参数量在几百万到一两亿规模的小模型。我最后选用的是一个经过 INT4 量化的迷你中文模型,量化后体积大约百 MB 级别,可以完整塞进外置 PSRAM。
选择它的理由有三点:
- 参数量适中,保证单次前向推理的矩阵维度不会大得离谱。
- 词表大小控制在几千以内,embedding 层和输出层不会吃掉太多内存。
- 中文支持较好,实际对话体验比英文小模型更适合后续应用开发。
这个选择看起来很直接,但后来回想,这是整个项目最关键的决策。如果当时头铁去跑 1B 模型,后续所有优化都无从谈起。在资源受限的场景里,先想清“要解决什么问题”,再决定“用什么模型”,顺序不能反。
1.3 性能目标设定:0.61 tok/s 让人无法接受
最初的实现很粗糙,我用了一个现成的 GGUF 加载库,直接跑那套标准的 tokenizer 和 transformer 前向代码。结果很残酷:每秒只出 0.6 个 token。我当时在串口终端里敲了一句话,等了接近半分钟才开始有反馈,那体验基本属于“打印 Helloworld 级别的等待”。
于是我把目标定在 4 字以上每秒。这个性能的门槛在于:如果一句话二十个字,那么三到五秒内能开始返回结果,用户的等待焦虑会急剧降低。而 0.6 tok/s 意味着二十个字等半分钟,基本崩了。
要达成这个目标,不能靠单一手段,需要同时从编译、内存、算子和并行架构四个维度去压榨。这也构成了整个优化复盘的主路线。
2. 初始状态与性能瓶颈精确定位
2.1 第一版实现:能跑,但完全没考虑性能
我先说一下第一版是怎么搭起来的,方便你在自己的板子上复现整个流程。基础框架是这样的:
- 开发环境:ESP-IDF 最新稳定版,启用 PSRAM 支持,时钟配置为最高频率。
- 推理实现:从 GitHub 拉了一个针对 RISC-V 的 GGML 后端,做了最小移植,让它能编译到 P4 上。
- 模型加载:直接从 TF 卡读出 GGUF 文件,解析里面的 tokenizer 和模型权重,放到 PSRAM 里。
- 输出:串口打印,每打印一个 token 就记一个时间戳。
这样做的好处是“先跑通,再优化”,坏处是每一步都留有巨大的浪费。我打开芯片自带的性能计数器,发现 CPU 大部分时间都是在等 PSRAM 的数据,真正干计算的时间可能不到 20%。这就像一个仓库到工厂之间的传送带太窄,工人再多也只能等着。
2.2 内存带宽是第一瓶颈:PSRAM 读写的吞金兽属性
用逻辑分析仪抓取了一下读写时序,发现每次矩阵乘法都要把权重从 PSRAM 搬到寄存器,然后算完再搬回去。P4 的 PSRAM 带宽有限,虽然理论频率看着还行,但实际随机访问延迟很高。对于 LLM 推理,权重数据是顺序读取的,理论上可以用突发传输优化,但默认的 GGML 实现根本没利用这一点。
我统计了前向推理中各类操作的耗时占比:
- 矩阵乘法:约 72%
- 激活函数(GELU、Softmax):约 12%
- 位置编码和 LayerNorm:约 8%
- 其他(采样、分词):约 8%
矩阵乘法占了接近四分之三。但这里有个反直觉的事实:矩阵乘法的算力占用并不高,真正的痛点是在读取权重时没有充分利用 PSRAM 的带宽。因此,优化矩阵乘法的本质是优化数据搬运路径,而不是去抠乘法指令本身。
2.3 量化粒度过粗:一个隐藏的低效
最初模型用的是 GGUF 里比较常见的 Q4_K 量化方案,这种量化方式在通用硬件上没问题。但在没有原生 INT4 乘加指令的 MCU 上,反量化操作(dequantize)会带来额外开销。也就是说,在读取权重时不仅要搬字节,还要做解码运算,这部分成本被很多人忽略。
我试着把算子改成先批量反量化然后直接以 FP32 做矩阵乘法,发现性能反而没有提升,因为临时缓冲区的开销更大了。后来采用了一种半固定方案:对于大矩阵,让反量化后的数据直接进入矩阵乘累加器,中间不落地。这个改动在后续优化中贡献了不小的收益。
3. 优化实操:从 0.61 到 4.31 的每一个关键动作
3.1 编译选项优化:别让编译器拖后腿
很多做嵌入式的人拿到代码第一反应是直接编译烧录,编译器默认优化级别是 O2 甚至 Os,这根本不适合计算密集任务。我第一步就把优化级别开到了 O3,并显式启用了硬件浮点单元和 RISC-V 向量扩展。
关键编译选项如下:
idf.py set-target esp32p4 idf.py menuconfig # 在 Compiler options 中设置: # Optimization Level: -O3 # Use RISC-V Vector Extensions: Enabled # Use FPU: Enabled (单精度)顺手调整了缓存配置,把栈和关键数据段放到内部 SRAM 中,PSRAM 只放模型权重和中间缓冲区。内部 SRAM 的访问速度远高于 PSRAM,把解码循环里的临时变量塞在 SRAM 里能减少不少延迟。
这一步做完,性能直接从 0.61 提升到 0.82 tok/s。提升不算多,但验证了一个判断:后端代码本身还有很大优化空间。
3.2 内存池与静态分配:消灭动态内存碎片的诅咒
第一版代码里用了大量malloc和free来创建中间张量。在 PC 上这不是问题,但在 MCU 上,频繁动态分配会导致堆碎片化,而 PSRAM 的堆分配速度比 SRAM 慢得多。更麻烦的是,解码到中间层时,张量形状不断变化,每次分配都可能触发一次 PSRAM 的低效写回。
我把整个推理阶段所有会用到的中间张量提成了静态内存池。具体做法是在初始化阶段一次性分配好最大可能尺寸的缓冲区,然后根据实际 shape 复用。用到的核心数据结构类似这样:
typedef struct { uint8_t *data; size_t capacity; size_t current_size; uint32_t owner_layer; } mem_pool_tok_t; static mem_pool_arena_t g_arena = {0}; void *arena_alloc(size_t size) { // 对齐到 16 字节,不足则从预分配区域切分 }这个改动的效果非常直接:解码速度从 0.82 跳到 1.20 tok/s。因为它消除了每一次 GPU 式张量创建过程中 PSRAM 上的随机写操作,把路径变成线性顺序写。
经验是:在 MCU 上做 LLM 推理,内存池不是可选项,而是必选项。哪怕你的模型只有几十 MB,如果中间张量反复分配,照样会让性能折半。
3.3 GEMV 算子重写:让矩阵乘在 RISC-V 上飞起来
LLM 的推理主要是矩阵-向量乘(GEMV),而不是矩阵-矩阵乘(GEMM)。因为解码过程是逐个 token 顺序生成,每次只有一个 token 的隐藏向量和权重矩阵做乘法。传统 BLAS 库针对 GEMM 做了很多缓存优化,但在解码场景里用不上。
我花了很长时间重写 GEMV 算子,核心思路有三条:
- 利用向量扩展:RISC-V 向量指令可以将连续内存加载到向量寄存器,并一次执行多次乘加。针对权重矩阵按行存储的特点,把行切分成若干块,每个块加载到向量寄存器中计算。
- 数据预取:提前把下一批权重地址预取到缓存,隐藏内存延迟。
- 循环展开:把内部乘加循环展开为 4 组并行计算,减少循环分支开销。
下面是核心代码结构(简化版):
void gemv_quantized_s8(const int8_t *weights, const float *input, float *output, int rows, int cols) { for (int r = 0; r < rows; r += 4) { float acc0 = 0.f, acc1 = 0.f, acc2 = 0.f, acc3 = 0.f; for (int c = 0; c < cols; c += 16) { // 加载 input 向量到寄存器 // 加载 weights[r..r+3][c..c+15] 块 // 执行乘累加 } output[r] = acc0; output[r+1] = acc1; output[r+2] = acc2; output[r+3] = acc3; } }这个版本加上去,性能从 1.20 直接到 2.45 tok/s,几乎翻倍。GEMV 算子每次读取的都是权重矩阵的一行,如果能够把这些行内的连续数据充分利用向量指令处理,收益非常可观。
3.4 双核并行:一半给 prompt 预处理,一半给解码
P4 有两个核。大多数人会想着一个核跑推理、一个核跑通信,但我发现推理过程中 prompt 处理和解码生成是两个完全不同的阶段。Prompt 阶段是并行的矩阵乘法,解码阶段是串行的 GEMV。
我的做法是把两个核做成流水线:核心 0 负责计算当前 token 的 logits 和采样,核心 1 提前准备下一个 token 需要的输入 embedding,并做半部分的 LayerNorm 和激活函数。因为 LLM 解码在时序上有天然的先后依赖,但 token 内部不同层之间并没有严格的前后关联,所以可以提前计算一部分。
实际实现稍微复杂,我用了 ESP-IDF 的queue做两个核之间的令牌传递。核心 0 完成后把中间结果放到队列,核心 1 取出后继续处理。这个优化把 2.45 提高到 3.01 tok/s。
双核并行之所以只提升了 20% 而不是 100%,是因为两个核共享同一段 PSRAM 带宽,并行计算时内存总线又成了新的瓶颈。
3.5 动态量化与权重重排:把内存访问变成顺序流
初始量化后的权重存储布局是“按张量顺序存放”,但解码时我们大概率取某一块权重做 GEMV。如果这些权重在内存中分散排列,每次取数都遇到随机访问。这就像图书馆里书没有按编号摆放,找起书来特别慢。
我写了一个离线转换脚本,把模型的权重张量重新排列,序列化为“块式”布局:每个块的宽度正好是 16 的倍数,高度是 4 的倍数,这样和 GEMV 算子里循环 tile 一一对应。同时在量化时采用动态缩放:每个 32 元素块使用一个独立的浮点 scale 和 zero point,这样在计算时局部反量化,不需要加载整张权重表。
这一步做完,内存访问从随机变成了近乎顺序读取,PSRAM 的带宽利用率大幅提升,性能提高到 3.76 tok/s。
3.6 采样层与 Tokenizer 细节:压榨最后一个毫秒
当模型计算速度上来之后,我开始盯采样和 tokenizer 的开销。原来用的是比较通用的采样算法,每次解码时都要计算完整的概率分布并做随机数采样。对于一个词表只有几千个 token 的小模型来说,这部分开销居然占到了接近 10%。
优化有两个方向:
- 采样时使用 top-k 截断,并且只在概率排名前列的 token 上做归一化,避免对整个词表做 softmax 的全量计算。
- Tokenizer 的字符串解码采用增量式处理,每次只解析新字节而不是重新分析整个缓存序列。
这两点改动虽不显眼,却把性能从 3.76 拉到了 4.05 tok/s。
最后,我把所有中间步骤的打印信息关闭,使用 DMA 方式把输出数据写到串口 FIFO,避免 CPU 等待串口空闲。最终稳定在 4.31 tok/s。
4. 优化效果对比与各阶段成本分析
4.1 各阶段性能数据汇总
完整的优化过程可以整理成一张表格,方便你对照自己的进度:
| 优化阶段 | 核心改动 | 输出速度(tok/s) | 相对初始提升 |
|---|---|---|---|
| 初始版本 | 默认编译 + 动态张量分配 | 0.61 | — |
| 编译与内存布局 | O3 + 向量扩展 + 内存池 | 1.20 | 1.97x |
| GEMV 算子重写 | 向量化 + 循环展开 + 预取 | 2.45 | 4.02x |
| 双核流水线 | prompt 与解码并行 | 3.01 | 4.93x |
| 权重重排 + 动态量化 | 块式布局 + 局部反量化 | 3.76 | 6.16x |
| 采样与输出优化 | top-k 截断 + DMA 串口 | 4.31 | 7.07x |
注意,这六个阶段是叠加关系,不是独立效果。如果你只做 GEMV 重写而不做内存池和权重重排,效果会大打折扣。
4.2 性能提升背后的“天花板级”瓶颈
从 0.61 到 4.31,看似增长巨大,但最后到 4.31 之后再做实验,无论怎么调整都无法超过 4.4。我用性能计数器持续抓取数据,发现瓶颈几乎完全集中在 PSRAM 带宽上。具体来说,CPU 执行一次 GEMV 需要读取的字节数大约为权重矩阵总字节数,而 PSRAM 的理论带宽大致固定。在固定频率和位宽下,每秒钟能搬运的数据量有上限,这个上限决定了 tok/s 的物理天花板。
如果你想在这个基础上继续提速,要么增加 PSRAM 带宽(比如切换更宽的总线模式),要么把部分权重放到内部 SRAM 中。但内部 SRAM 容量有限,放不下整个模型。还可以使用 Flash 上执行读取缓存(如映射到 XIP)来配合压缩权重,但这又会引入新的 flash 随机读问题。总体来说,4.31 tok/s已经是一个相当扎实的终点。
4.3 用 4.31 tok/s 能做出什么产品
很多人可能觉得,每秒 4 个字还不得把人急死?确实,和云端大模型比差太远,但在特定的嵌入式场景里,这个速度已经有了实用价值。
- 离线语音指令:用户说完一句话,本地直接解析意图,控制智能家居设备。一个指令就几个 token,反应时间不到一秒,完全可用。
- 车载/户外环境下的本地写作助手:比如写摘要、生成固定模板文本。虽然整体等待时间略长,但数据隐私性高,不需要网络连接。
- MCU 大模型教学和玩法验证:对于学习端侧 AI 的人来说,能在单片机上看到真正的基础模型推理过程,比自己纯看公式直观得多。
功耗上我也测过:跑 LLM 时整板平均功耗约 5W 左右。如果是电池供电,不适合连续推理,但可以做成“休眠 + 唤醒识别 + 连续生成一段文字”的间歇式方案。
5. 常见问题与排查技巧实录
5.1 模型加载时莫名崩溃:堆栈溢出和 PSRAM 对齐
早期我遇到一个诡异的问题:模型加载到一半就重启。排查了很久,最终在引导日志里发现是一级栈溢出。原因是 PSRAM 上的慢速访问导致某些库函数在递归调用时栈增长过快,而我把所有栈空间都分配到了内部 SRAM,且预留得不够。
解决办法比较土:在主任务中把栈空间增加到 64KB,并检查所有函数里的大型局部数组是否太大。更重要的教训是,不要在解码循环里使用大数组作为临时缓冲区,应该统一使用内存池。
此外,PSRAM 的 DMA 要求地址对齐到 16 字节。如果模型权重没有对齐,DMA 传输会失败或者性能骤降。我的解决方法是:在加载 GGU(GGML 权重)时,对每个张量首地址手动做一次均衡偏向对齐。
size_t offset = align_up(offset, 16);5.2 向量指令编译通过但执行时结果错误
RISC-V 向量扩展有许多不同的实现细节。GCC 编译出的向量指令可能在你的 P4 芯片上不被支持,或者支持的方式不同。我最初直接在 -march 参数里加 v 扩展,程序不报错,但计算结果是错的。
解决方法是:先查目标芯片支持的向量长度和向量扩展版本,然后通过内联汇编显式使用vsetvli以及向量寄存器操作,而不是依赖编译器的自动向量化。更稳妥的方法是用 SPM(单指令多数据)风格的内置函数,并写一个小的自检例程验证gemv结果与普通实现一致。
我当时写了一个批量随机输入的测试,如果误差超过 1e-3,就回退到标量实现。
5.3 优化后性能反而变慢了?很可能是数据对齐或缓存冲突
我在尝试把权重从 PSRAM 搬到 SRAM 后再计算时,性能居然不升反降。后来发现是缓存冲突。PSRAM 和 SRAM 的地址部分重叠导致 cache line 的替换过于频繁,GEMV 算子拿到的方式反而是每次都要访问未命中的内存。
这种情况只能用portDISABLE_INTERRUPTS()来做一次全局关键段测试,后来还是放弃了强行 cache 控制,选用了更简单粗暴的权重重排:让 GEMV 算子按顺序以 64 字节块读取,确保每个块都在缓存行边界内对齐。
5.4 板子在高温下性能下降:热降频和电磁干扰
连续跑 30 分钟后,发现 tok/s 稳定下降 10%。查了芯片温度寄存器,发现已经接近 85 度,频率降到 300MHz。这是硬件热保护,不是软件问题。解决办法是加了散热片,并把 PSRAM 频率从一个激进档位降到稳定档位。性能只降了 2%,但稳定性显著提高。
如果你也准备长时间跑推理,请一定在程序里加一个温度监测,温度超过 75 度时主动降低 batch size 或者进入休眠冷却。
写在最后的个人体会
这个项目做完之后,我对“边缘 AI”这个词有了更深的理解。以前总听人说 MCU 上跑模型是噱头,但经历完从 0.61 到 4.31 的完整过程,我确信只要选对模型、吃透硬件的存储带宽特性、优化好数据搬运逻辑,嵌入式 LLM 是能落地出实用产品的。
如果你想复现这条优化路线,最重要的是不要照搬某一个具体 hack。真正通用的方法是:先 profile,看瓶颈在内存还是计算,然后针对瓶颈做结构性改动,而不是盲目加一堆编译参数。每一个有效的优化手段背后,都是对“为什么慢”这个问题的精准回答。
关于模型选型,我想补充一点:不要一味追求参数量大。在资源受限的设备上,一个稍小但量化和部署做得好的模型,实际体验会远好于一个勉强塞进去而且推理极慢的“大模型”。跑通只是第一步,跑得稳才是目的。
后续我会把几个关键模块拆开单独写:比如手写 RISC-V 向量 GEMV 的完整思路、PSRAM 带宽压榨的详细实验记录、以及如何为小模型做定制 tokenizer。如果你也在折腾 ESP32-P4 或者类似的高性能 MCU,希望这篇文章能节省你不少试错时间。