我很早就想把这颗芯片和 LLM 这两个词放在一起,直到 ESP32-P4 出现在我桌上。双核 RISC-V、能跑到 400MHz、还带向量扩展指令,配合外挂的大容量 PSRAM,这套硬件组合天生就是为端侧推理准备的。但能跑和跑得舒服是两回事,我最初在这个板子上跑一个微型 LLM,速度只有 0.61 tok/s,生成一句话要等半天;后来一路调下来稳定到了 4.31 tok/s,差不多 7 倍,整个过程我可以拆成一条清晰的优化链路。
这篇是系列的第 00 篇,也就是总览。我打算把整条调优路线、每步的收益、踩过的坑,以及最终 4.31 tok/s 到底处在什么体验水平,一次性讲清楚。如果你手里也有一块 ESP32-P4,或者正打算在别的 MCU 上尝试本地跑 LLM,这篇可以帮你少走很多弯路。
1. 先把话说清楚:ESP32-P4 跑 LLM 到底意味着什么
1.1 这颗芯片的底牌:不是普通单片机
ESP32-P4 和我之前玩过的 ESP32-S3、C3 完全是两个物种。它是乐鑫目前性能最强的一颗通用 MCU,双核 RISC-V 架构,主频直接拉到 400MHz。更关键的是,它带向量扩展指令(RVV 1.0),也就是说芯片本身具备做矩阵运算加速的硬件基础,这一点对 LLM 推理是决定性的。
内存方面,P4 内部 SRAM 只有不到 1MB,所以必须外挂 PSRAM。我手上这块板子带了 32MB PSRAM,走的是高速并行接口,理论带宽远高于传统 SPI PSRAM。LLM 推理的特点就是权重要反复读,内存带宽直接决定每秒能吐多少个 token。你可以这样理解:CPU 算力决定上限,内存带宽决定实际速度,而 P4 这颗芯片正好在 MCU 里把这两块短板都补上了。
还要注意一个细节:P4 默认不带 WiFi 和蓝牙。这意味着模型文件不能靠边下载边用,必须预先放进 SD 卡或者外部 Flash,跑推理时再加载。你可能会觉得这不方便,但对端侧部署来说反而省心——没有射频干扰,没有协议栈抢占 CPU,整个系统更干净。
1.2 为什么我选了 llama.cpp + GGUF 这条路线
能在 MCU 上做 AI 推理的方案不少,TFLite Micro、ONNX Runtime、ESP-DL 我都试过,但最终选定 llama.cpp + GGUF,原因有三个。
第一,llama.cpp 的核心是 ggml 张量库,它天生就是为了 CPU 推理设计的,内存占用极低,也没有依赖 Python 运行时。它的算子针对不同指令集做过手工优化,RISC-V 向量扩展就是其中一条支持路径。虽然标准版本对 RISC-V 的优化还在持续完善中,但已经能跑,社区也有针对 ESP32-P4 的移植分支。
第二,GGUF 是一种单一文件格式,模型权重、词表、超参数全打包在里面。它最大的价值是支持量化存储,而且量化粒度是按 block 切的。我可以把 33M 参数的模型量化为 Q4_0,文件压到 20MB 左右,正好塞进 PSRAM。TFLite Micro 虽然也能量化,但对 LLM 这种带 KV cache、带采样器、带复杂解码逻辑的场景,它的抽象层级太低了,改起来非常费劲。
第三,llama.cpp 的命令行工具链成熟。我可以在 PC 上完成模型转换、量化、参数验证,板子上只跑一个精简的推理入口,开发调试体验比从零写一个推理器好太多。
这套组合并不新鲜,但在 ESP32-P4 上能成立,核心还是因为板子的内存和指令集撑得住。换个普通 MCU 可能模型只能放 Flash,推理速度会掉到不可用的级别。
2. 从 0.61 到 4.31:七倍性能提升是怎么拆出来的
2.1 基线 0.61 tok/s 是怎么来的
先说基线。很多人一听到 0.61 tok/s 会觉得“这板子是不是不行”,其实不是,这个数字的来源非常值得分析。
我最初跑通时,用的是一个非常简单的思路:模型文件放在 SD 卡上,llama.cpp 的标准构建,没有启用任何针对 P4 的特殊编译参数,线程数用的 1,量化档位用的 Q8_0。跑起来之后,每生成一个 token,程序都要从 SD 卡重新读取一遍相关权重。SD 卡顺序读取大概有 4MB/s,但 LLM 推理是随机访问,实际吞吐远低于这个值。模型 Q8_0 大约 35MB,每个 token 都要扫一遍,算下来 0.61 tok/s 一点不奇怪。
所以基线慢的核心瓶颈是 I/O,而不是 CPU。这个判断很重要,因为它决定了后续优化的方向——先把数据弄到内存里,再考虑计算效率。
我建议每一位想复现的朋友,第一步不要急着改代码,先搭好环境、跑出基线、记录当前速度和你认定的瓶颈。没有基线的调优就是耍流氓,改了好几个变量,最后根本说不清是哪一步起了作用。
2.2 第一刀:编译选项与向量指令
第一个动作是让 ggml 算子真正跑进 RISC-V 向量指令。默认构建可能走的是标量路径,CPU 要一条一条处理数据,矩阵乘法非常吃亏。
我用的是针对 P4 的交叉编译工具链,在编译 llama.cpp 时打开了向量扩展相关的编译参数。不同工具链支持的 march 写法有差异,我这边实际生效的指令集组合包含了向量扩展和位操作扩展。这一步只是编译层面的改动,代码逻辑完全没动,跑分就从 0.61 到了 0.98 tok/s,大约 1.6 倍提升。
这里有个很容易犯的错误:你以为启用了向量编译,但代码实际还是走回退路径。原因通常是内联汇编或 intrinsics 的条件编译没匹配上,或者跑在 cpu 核心上的线程被系统调度到了不支持向量路径的逻辑上。判断方法很简单,看编译日志里有没有打印 RISC-V vector kernels enabled,或者直接对比同一条算子在不同构建下的耗时。
2.3 第二刀:双核并行与线程绑定
P4 是双核,llama.cpp 的并行模型天然支持多线程。我把线程数从 1 调到 2,这一步带来约 1.7 倍提升:0.98 到 1.67 tok/s。
但这里有个反直觉的点:LLM 解码阶段是内存带宽密集型,CPU 常常在等数据,双核并行的收益按理说不会很高。我实测下来提升明显,说明矩阵乘法里的计算开销占比还是不小,向量单元跑起来之后,两个核心刚好能各分一块计算,把内存等待的时间折叠掉了一部分。
线程数调到 2 之后,我没有继续往上加,因为 P4 只有两个物理核心。同时我做了线程亲和性绑定,把推理线程分别固定到两个核上。别小看这一步,在嵌入环境里,系统后台任务可能导致线程在核心间来回迁移,cache 和向量寄存器状态反复重建,性能抖动非常严重。绑定之后跑分稳定了很多。
2.4 第三刀:量化档位与内存带宽
这一步是纯收益很高的改动:把模型从 Q8_0 换成 Q4_0。Q8_0 每个权重占 1 字节,Q4_0 每个权重占 0.5 字节,文件从约 35MB 降到约 19MB。
为什么要关心权重大小?因为在 decode 阶段,每生成一个 token 要把所有权重从头到尾过一遍。权重越小,内存搬运的数据越少,速度越快。这一步我实测从 1.67 到了 2.33 tok/s,约 1.4 倍。
很多人听到“量化”会担心生成质量崩掉。Q4_0 其实是一个保守的选择,每一个 block 里用一个 fp16 的 scale 做缩放,精度损失可控。我用的测试模型本身是故事生成任务,Q4_0 和 Q8_0 输出几乎没有肉眼可见的差别。
如果你想把模型压得更狠,可以考虑 Q2_K 甚至 1.58bit 的 BitNet 系模型,但那些对算子库有额外要求,P4 上不一定能直接跑,这个我后面系列会单独测。对绝大多数人来说,Q4_0 是性价比最高的起点。
2.5 第四刀:prefill/decode 分离与细项调整
到这一步,瓶颈已经从 I/O 转移到了计算与内存访问的平衡。接下来这组改动没有单一的大头,而是几个细节叠加起来:总收益约 1.5 倍,从 2.33 到 3.51 tok/s。
第一个细节是区分 prefill 和 decode 两个阶段的 batch size。prefill 阶段要处理整个 prompt 的所有 token,属于计算密集型,batch 越大向量化越充分;decode 阶段是逐 token 生成,batch 设太大反而增加延迟。我在代码里分别设置了两个参数,实测下来 prefill batch 用 512、decode batch 用 32 左右最舒服。
第二个细节是开启 Flash Attention。它减少了 KV cache 的中间读写次数,对内存带宽吃紧的 MCU 环境很友好。
第三个细节是内存对齐。PSRAM 的 DMA 访问对地址对齐敏感,我把 KV cache 和权重的缓冲区对齐到 64 字节,这一项单独看差不多 10% 的收益,但胜在零成本。
还有个小改动容易被忽略:把推理过程中的调试日志和串口打印关掉。串口输出在 MCU 上是阻塞操作,打印一条日志可能浪费几十毫秒,累计起来非常可观。
2.6 各步提升汇总
我把每一步的实测数据整理成一张表,方便你对照自己调优时的情况。
| 优化步骤 | 关键改动 | 实测 tok/s | 相对上一步 | 累计倍率 |
|---|---|---|---|---|
| 基线 | SD 卡读取,单线程,Q8_0,默认构建 | 0.61 | - | 1.0x |
| 第一步 | 启用向量指令与编译优化 | 0.98 | 1.6x | 1.6x |
| 第二步 | 双核并行 + 线程亲和性 | 1.67 | 1.7x | 2.7x |
| 第三步 | Q8_0 换成 Q4_0 量化 | 2.33 | 1.4x | 3.8x |
| 第四步 | prefill/decode 分离、Flash Attention、内存对齐 | 3.51 | 1.5x | 5.8x |
| 第五步 | 日志关闭、参数微调、反复打磨 | 4.31 | 1.2x | 7.1x |
注意看累计方向:每步都不是白做的,但每步的收益也在递减。头三步解决了“数据根本喂不过来”的问题,后面则是榨干细节。
3. 实操记录:从烧录到跑通再到调优闭环
3.1 环境准备与交叉编译
复现之前,硬件上你需要一块带 32MB PSRAM 的 ESP32-P4 开发板,以及一张 SD 卡用于存放模型。软件方面,我用了 ESP-IDF v5.3 作为基础环境,llama.cpp 则使用社区维护的 esp32 分支。
交叉编译这一步,我踩过最多的坑是工具链和分支版本不匹配。建议按这个顺序来:
# 安装 ESP-IDF,注意设置目标芯片为 esp32p4 idf.py set-target esp32p4 # 拉取 llama.cpp 的 esp32 分支 git clone --branch esp32_p4 https://github.com/your-fork/llama.cpp.git cd llama.cpp # 创建独立构建目录,避免污染 PC 端版本 mkdir build-esp32p4 && cd build-esp32p4 # cmake 关键参数,这里需要根据你用的工具链路径调整 cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/riscv32-esp32p4.toolchain.cmake \ -DCMAKE_BUILD_TYPE=Release \ -DGGML_RISCV=ON \ -DLLAMA_CURL=OFF # 编译可执行文件,之后会生成 llama-bench、llama-cli 等 make -j$(nproc)编译成功后会得到可烧录的固件和一系列命令行工具。注意,交叉编译出来的二进制不能直接在开发机上跑,必须烧进板子。
提示:如果你的分支默认不带 riscv32 的 toolchain 文件,需要从 ESP-IDF 的 riscv32-esp-elf 工具链路径手动指定 CC 和 CXX 环境变量。我这边用的是 riscv32-esp-elf-gcc,版本 13.x。
3.2 模型转换、量化与装载
我选的测试模型是 TinyStories-33M。选它是因为参数量小、生成任务直观,而且学术界对它的行为特性研究很充分,方便验证优化过程没有把模型「调坏」。
模型转换和量化在 PC 上就能完成,不需要占板子资源。我习惯按这条命令链走:
# 1. 把 Hugging Face 格式转成 fp16 的 GGUF python3 convert_hf_to_gguf.py TinyStories-33M \ --outfile tiny33m-f16.gguf # 2. 量化为 Q4_0 ./llama-quantize tiny33m-f16.gguf tiny33m-q4_0.gguf Q4_0 # 3. 看一眼量化后的文件信息,确认大小和 tensor 类型 ./llama-gguf-info tiny33m-q4_0.gguf生成好的 tiny33m-q4_0.gguf 大约 19MB,把它复制到 SD 卡的根目录,SD 卡格式化成 FAT32 就行。
加载方式值得单独说一句。llama.cpp 原生的模型加载默认会把权重读进内存,但如果你把模型路径指向 SD 卡,它可能走 mmap 或流式读取。这块板子上我最终是先把整个文件从 SD 卡读进 PSRAM,再做推理。这样一次加载、多次推理,显存带宽全部留给推理本身。夸张点说,这一步不改,后续所有优化都白搭。
3.3 benchmark 怎么跑才不骗自己
很多人调优失败不是因为优化没效果,而是测速方法不统一,导致判断失真。我固定了一套流程:用 llama-bench,prompt 长度固定 128 token,生成 token 数固定 64,线程数按当前测试场景设置,重复跑 3 次取中位数。
./llama-bench -m /sdcard/tiny33m-q4_0.gguf \ -p 128 -n 64 \ -t 2 -b 32 \ -r 3跑之前还有个习惯:先连续跑两轮预热,让 PSRAM 和缓存都处于稳定状态,再记录正式数据。否则第一轮通常会偏慢,因为 SD 卡到 PSRAM 的搬运、动态内存分配都还没稳定。
另外,每次改动后务必只改一个变量。比如测线程数时保持量化、batch 不动;测量化时保持线程、batch 不动。否则一个提升可能被另一个回退抵消,你会得出完全错误的结论。
3.4 一轮完整的调优现场记录
我贴一段我自己的实验笔记,展示实际调优过程有多琐碎。
| 轮次 | 改了啥 | 观测结果 | 结论 |
|---|---|---|---|
| 1 | 初始基线,SD 卡直接读取 | 0.61 tok/s,明显感觉在“挤牙膏” | 瓶颈在 I/O,先解决数据驻留 |
| 2 | 模型预加载到 PSRAM | 直接到 0.82 tok/s | 数据驻留有效,但计算路径还是标量 |
| 3 | 编译启用向量指令 | 0.98 tok/s | 向量化有效,继续 |
| 4 | 线程 1 改 2 | 1.35 tok/s,但抖动大 | 线程数有效,需要绑定核心 |
| 5 | 线程亲和性绑定 | 1.67 tok/s,稳定输出 | 绑定核心消除调度抖动 |
| 6 | Q8_0 换 Q4_0 | 2.33 tok/s | 量化有效,检查生成质量无明显回退 |
| 7 | prefill/decode batch 分离 | 2.81 tok/s | 分开后 prefill 明显变快 |
| 8 | Flash Attention 开启 | 3.25 tok/s | KV cache 访问减少带来收益 |
| 9 | 内存对齐 64 字节 + 关日志 | 3.51 tok/s | 细节积少成多 |
| 10 | 打磨编译参数和 batch 粒度 | 4.31 tok/s | 达成目标 |
看到没有,第 3 轮到第 10 轮之间,每一步单独看都不惊人,但叠起来就是从 0.98 到 4.31 的跨越。优化工作本质上就是压缩每一环的浪费,没有银弹。
4. 4.31 tok/s 的真实体验与可落地的场景边界
4.1 这个速度到底什么概念
4.31 tok/s 换算一下:生成一个 token 大约 232 毫秒。一个词大概 2 到 3 个 token,所以每个词约半秒。看起来还是慢,但放在 MCU 本地推理的上下文里,它已经进入了「能等人读完」的可用区间。
我用它生成了一段约 50 词的英文小故事,耗时大约 30 秒。这个体验不像云端模型那样秒回,但想想这完全是在一块没有网络、没有操作系统的单片机上完成的,感受完全不同。如果你只需要生成一两句话的回答,比如设备状态提示、传感器数据解读、互动玩具的回应,这个速度完全够用。
对比一下其他路径:如果你试图在 ESP32-S3 上跑类似的 LLM,内存只有 8MB,模型基本塞不下;树莓派 Pico 2 虽然便宜,但主频和向量能力都差不少。P4 是当前 MCU 级别里少数能把 LLM 推理拉到“能对话”门槛的板子。
4.2 内存账本:能跑多大模型
很多人问“P4 能跑多大的模型”,答案是算出来的。以我用 TinyStories-33M 为例,Q4_0 权重约 19MB,KV cache 在 128 token 上下文下约 2MB,推理所需的临时缓冲和运行时占几 MB。所以 32MB PSRAM 勉强能覆盖,16MB 版本会非常紧张。
模型能跑多大,基本由两个因素决定:权重文件大小和 KV cache 占用。权重大小由参数量和量化位数共同决定,KV cache 可以用一个公式粗估:
KV cache 大小 = 2 × 层数 × 注意力头数 × 头维度 × 上下文长度 × 每个元素占用字节数TinyStories-33M 大约 8 层、8 头、每头 64 维,fp16 存储,128 上下文计算出来大约是 2MB。如果换成 0.5B 模型,哪怕量化到 Q4_0,权重就要 250MB 以上,直接超出 PSRAM 极限。所以当前阶段 P4 上能跑的,基本是 30M 到 50M 参数级别的小模型,或者经过极端量化的百M级模型。
这也是我推荐 TinyStories-33M 的原因——它不是最佳模型,但它是优化流程的最佳载体。
4.3 这些场景值得做,那些场景别硬上
基于我跑通后的实际体验,我认为有几个场景是 ESP32-P4 本地 LLM 真正值得落地的。
离线口令或指令理解:嵌入式设备不联网,用一个小模型把自然语言指令解析成结构化动作,比如语音助手的离线指令槽位填充。这类任务 token 量少,延迟要求也低。状态摘要生成:把设备日志、传感器数据喂给模型,生成一段自然语言的状态汇报,这部分不要求实时性,4 tok/s 完全够用。教育或玩具场景:生成故事、成语接龙、猜谜语,模型输出长短可控,用户可以等。
有些场景我不建议硬上。比如本地跑 RAG,需要集成向量数据库和嵌入模型,内存和算力都不够,效果也不会好。再比如长文本对话,KV cache 会迅速膨胀,上下文超 256 token 后内存和速度都会崩。还有实时语音对话,端到端延迟太高,除非把 ASR、LLM、TTS 全部拆成管线并行,否则体验会很差。
4.4 后续可以往哪继续深挖
这次 4.31 tok/s 只是当前这条路线的终点,不是 P4 的天花板。我接下来打算做几件事:一是对比不同量化档位对生成质量的真实影响,不只盯着速度;二是尝试把 ESP-DL 的矩阵乘算子接到 llama.cpp 后端,看有没有额外收益;三是试一下 1.58bit 的 BitNet 类小模型,这种模型权重占用极低,理论上能解锁更大的有效参数量。
另外,llama.cpp 对 RISC-V 的支持还在快速迭代中。我用的分支可能几个月后就被上游合入更好的算子优化,所以关注上游动态比死守本地优化更重要。到时候我还会出一篇对比更新后的版本和当前版本的速度差异。
5. 踩过的坑与排查速查表(含独家心得)
5.1 坑一:模型不驻留 PSRAM,性能直接崩
这是我踩过最深的坑,也是基线 0.61 tok/s 的根源。一开始我图省事,直接让 llama.cpp 从 SD 卡路径加载模型,以为它会自己做好缓存。实际跑起来每个 token 生成都要跨过 SD 卡 I/O,速度直接崩到不可用。
后来我改成启动阶段一次性把整个 GGUF 文件读入 PSRAM 缓冲区,再让模型从内存加载,速度立刻翻倍。这个读文件的动作只在启动时发生一次,推理阶段完全在内存里做。
注意:如果你在代码里看到 llama_model_load 之类的函数,确保传入的路径指向内存缓冲区而不是直接暴露 SD 卡文件接口。更稳妥的做法是先用标准文件 API 把文件完整读出来,再交给 llama 库处理。
5.2 坑二:向量扩展编译对了,代码还是没走进向量路径
第二个坑非常隐蔽。我明明在 CMake 里开了 GGML_RISCV,编译也成功了,但跑分纹丝不动。排查后发现,部分算子因为条件编译的参数判断没匹配,运行时落到了标量回退路径,向量内核根本没有启用。
解决办法是直接查看编译产物里是否包含带向量指令的符号,以及看启动日志里的算子类型打印。我在 llama.cpp 的推理初始化日志里加了一行输出,打印当前 CPU 检测到的指令集和实际启用的内核类型,从此再也没被这个问题坑过。
5.3 坑三:跑分忽高忽低,供电和散热先背锅
有一段时间我测出来的数据波动极大,同一个配置有时 3.8,有时 4.2。排查到最后发现是供电不稳加上芯片温度过高导致的频率抖动。ESP32-P4 在高负载下功耗上升很快,如果电源纹波大,或者芯片散热不好,跑分就会有明显波动。
后来我换了一条粗一点的 USB 供电线,同时把开发板平放,芯片背面贴了块小散热片,跑分立刻稳定下来。做 benchmark 之前先预热两轮,也能消除大部分波动。
5.4 排查速查表
| 症状 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 首 token 特别慢 | 模型启动时从 SD 卡流式加载 | 检查模型加载路径和内存驻留 | 启动阶段整体读入 PSRAM |
| 生成速度和各步优化无关 | 向量内核未启用 | 查看编译日志与运行时日志 | 核对 march 参数和条件编译 |
| 跑分忽高忽低 | 供电纹波大/芯片过热降频 | 观察连续跑分曲线 | 换电源、加散热、预热后再测 |
| 多线程提升很小 | 线程未被正确调度/内存带宽饱和 | 对比单双核跑分和 CPU 占用 | 绑定核心,确认 PSRAM 带宽 |
| 生成质量明显变差 | 量化档位过低或模型不匹配 | 对比 Q8/Q4 的生成样本 | 换回 Q4_0,或换更大模型 |
| 上下文一长就 OOM | KV cache 占用超预算 | 计算 KV cache 大小 | 缩短上下文,或换更小模型 |
5.5 调优顺序的几个个人心得
第一个心得:先把数据搬到内存,再谈指令集和量化。很多人在 I/O 没解决的情况下疯狂调编译参数,收益会被 I/O 瓶颈吃掉,根本看不到效果。第二步才是让 CPU 跑得更快,第三步才是把模型压小。
第二个心得:每次只改一个变量。我见过太多人同时改线程、量化、batch,最后性能变了也说不清是哪步起作用,等于白测。建议准备一个表格,像上面那样记录每一轮的输入、改动、输出,结论一目了然。
第三个心得:别执着于跑分数字,要关注生成质量和稳定性。4.31 tok/s 这个数字好看,但如果一句话里出现乱码,数字就没有意义。每次优化后,我都会跑同一段 prompt,对比生成结果是否退化。在嵌入式 AI 上,“可用”永远优先于“好看”。
第四个心得:保持耐心。从 0.61 到 4.31 不是某一项神操作,而是十几次小改进的叠加。如果你只盯着最终数字,会觉得很神奇;但如果像我一样把每一步拆开,你会发现每一步都有清晰的原因和依据。这也是我写这个系列的核心思路。