news 2026/10/28 18:32:47

ESP32-P4 上跑 LLM:从 0.61 到 4.31 tok/s 的七倍性能优化实录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32-P4 上跑 LLM:从 0.61 到 4.31 tok/s 的七倍性能优化实录

第一次在 ESP32-P4 上把 LLM 跑通的时候,我盯着串口终端看了快半分钟,屏幕上才歪歪扭扭蹦出第一个完整的词。生成速度 0.61 tok/s,这个数字当时给我的感觉就四个字:不可用,纯纯的不可用。但这并不意味着这条路走不通,恰恰相反,它说明我已经站上了一个很有意思的起点。后面我花了三周左右的时间,从量化方式、算子写法、内存布局到双核调度一路调下去,最终把同一个模型的 token 生成速度从 0.61 tok/s 拉到了 4.31 tok/s,差不多 7 倍提升。

这件事值得完整复盘,而且一篇根本装不下全部细节,所以这是系列的第 00 篇,定位是总览和路线图。我会先讲清楚整个优化项目的起因、硬件选型、第一版基线是怎么搭的、7 倍从哪来,以及后面每一篇准备深挖什么。如果你也是想在小硬件上跑 LLM 的人,或者正在被边缘侧推理性能折磨,这篇文章应该能给你一个完整的坐标系。

1. 为什么非要在 MCU 上跑 LLM:项目起点与硬件选型

1.1 边缘设备跑大模型,到底图什么

先回答一个绕不开的问题:都 2025 年了,随便一台手机或者迷你主机都能跑几十亿参数的小模型,为什么还要折腾 MCU?

我自己的理由很简单:有些场景压根不允许带一块完整的 Linux 板子。比如一个装在工业现场的小盒子,要求 5V 供电、被动散热、延时稳定,还得能离线工作。如果你在等一个需要联网翻译的嵌入式设备,网络动不动断一下,体验就会非常像卡在 0.61 tok/s 的那个初版固件。而把 LLM 直接塞进 MCU,意味着推理在本地完成,不依赖云端,也没有网络延迟的问题。

隐私也是一个很实在的考量。在端侧推理,原始数据可以不出设备。对于很多采集现场数据、处理用户语音的硬件产品来说,这比把数据传回服务器再删掉要让人安心得多。

当然,MCU 算力有限,跑不了 7B 甚至 1B 的大模型,能跑的都是压缩到极限的小模型。但"小模型"也是 LLM,也能做文本生成、关键词抽取、意图识别这类很有价值的事情。关键是找到合适的硬件,然后把性能压榨到能用的水平。

1.2 为什么选了 ESP32-P4,而不是树莓派或者手机

选型的时候我其实对比过好几条路线:树莓派 Zero、手机旧芯片、带 NPU 的开发板,还有乐鑫的 ESP32 系列。

树莓派 Zero 在软件生态上有绝对优势,但功耗和体积摆在那里,而且作为完整 Linux 系统,启动要好几秒,实时性也不可控。手机旧芯片就更别提了,光是把屏幕和触摸从启动链里剥掉就够折腾一阵。

ESP32 系列的优势在于:功耗低、启动快、外设丰富、可以在裸机或者 RTOS 上跑,而且社区里搞 AI 推理的人非常多。尤其是 ESP32-P4,它和之前的 S3 完全不是一代产品。S3 着重在 Xtensa 核和向量指令上做文章,而 P4 换成了双核 RISC-V 高性能核心,主频更高,同时引入了面向 AI 计算的向量扩展和矩阵加速逻辑。配合大容量 PSRAM 和高带宽的存储接口,它实际上已经具备了一个"微型 AI 推理盒"的潜质。

对比一圈以后我的判断是:ESP32-P4 是当前 MCU 品类里,把"性能上限"和"功耗底线"平衡得最好的一档。它的浮点能力不强,但 LLM 推理的瓶颈本来就不在浮点算力上,而在内存带宽和矩阵乘法的数据搬运效率,这一点 P4 的设计明显考虑了。

1.3 这套方案适合谁参考

这个项目适合三类人:

  • 嵌入式工程师,想在 MCU 上跑 LLM 或者类 Transformer 模型;
  • 做边缘 AI 产品的人,需要评估小模型在端侧到底能做到什么水平;
  • 以及纯粹好奇"0.61 到 4.31 到底是怎么挤出来的"的调优爱好者。

如果你是第一种,后面整个系列都值得跟;如果你是第三种,这篇总览里的分阶段表格应该就能满足大部分好奇心。

2. 第一版能跑,但只有 0.61 tok/s:基线是怎么搭的,瓶颈出在哪

2.1 第一版架构:逐层读 flash,逐层算

先说一下初始方案。我拿到 ESP32-P4 开发板以后,直接用了社区里已有的 llama.cpp 嵌入式移植分支。模型选的是一个 100M 参数量级的开源小模型,第一版用 int8 量化,整个权重文件打包后是一百多兆字节。

这里要说明一个物理约束:开发板上的 PSRAM 虽然比普通 MCU 大得多,但要一次性装下整个 int8 权重文件仍然不现实。所以第一版的做法非常笨:每一层 transformer 计算之前,先从 flash 里把这一层的权重读进 PSRAM 的一个临时 buffer,算出结果,然后丢掉 buffer,再读下一层。

听起来很笨,但这是绝大多数 MCU 上跑大模型的默认做法,因为内存实在装不下。问题也是很明显的:flash 读取是串行的、慢速的,整个推理周期里,有相当长的时间在等权重从 flash 挪进内存。

第一次编译烧录后,我测到的速度就是 0.61 tok/s。生成一句 30 个字的回答,大概要等快一分钟,属于"你盯着屏幕都能感受到它在挣扎"的状态。

2.2 用 profiler 打点,找到真正的瓶颈

拿到 0.61 这个数字以后,我没有急着改代码,而是先做了 profiling。方法很简单:在推理主循环的各个阶段打时间戳,分别统计:

  • flash/PSRAM 权重读取和搬运耗时;
  • GEMV 矩阵-向量乘法耗时;
  • LayerNorm、激活函数、Softmax 等小算子耗时;
  • 采样和 tokenizer 耗时。

打点结果让我很意外,权重读取/搬运占到了接近 70%,矩阵乘法计算本身只占了 20% 左右,剩下的是各种小算子和采样开销。

这个比例说明一个问题:第一版根本没有在"计算",而是在"等数据"。解码阶段每个 token 都要把所有层跑一遍,每层都要把权重从外部存储读进内存,这个搬运操作是绝对的大头。我还算了一下理论极限:int8 权重大概一百多兆,以 0.61 tok/s 来算,每秒从 flash 读取的权重也只有六十多兆字节。这个速度和 ESP32-P4 的存储接口上限差得很远,说明问题不在接口带宽本身,而在于读取模式太差、搬运和计算完全没有重叠。

到这里我就心里有数了:这不是算力不够的问题,是数据搬运和计算编排的问题。优化空间至少有一个数量级,7 倍的目标完全可以定。

2.3 为什么会是 memory-bound,而不是 compute-bound

在继续之前,我想把"LLM 推理是 memory-bound"这句话解释清楚,这是整个优化的理论基础。

Transformer 的解码阶段,核心操作是矩阵-向量乘法(GEMV),也就是一个权重矩阵 W 乘一个当前的 token 向量 x。注意,batch size 基本等于 1,因为每次只生成一个 token。权重矩阵非常大,而向量只有一列。这种情况下,计算设备需要把 W 的每个元素从内存搬进寄存器,然后做一次乘加。搬运数据的时间远大于做乘加本身的时间。

可以打一个比方:你要从仓库里把一万本书逐本搬到桌上,然后每本书翻一页记下里面的一个数字。哪怕你翻书的速度再快,总时间也主要由搬书决定,翻页的耗时可以忽略不计。GEMV 就是典型的"搬书"场景,所以优化方向必须是:让书变得更轻(量化)、让搬书的动作和翻书的动作重叠(流水线)、让每次搬书尽可能高效(内存对齐和突发读取)。

3. 7 倍性能提升的实际路径:五个阶段逐一拆解

优化不是一步到位的,我把它拆成了五个阶段。每个阶段都相对独立,且复用性很强。下表是总览,后面会逐一解释每个阶段背后的思路。

阶段关键改动实测 tok/s相对上一阶段提升
基线llama.cpp 移植分支、int8、逐层串行读取0.61-
阶段 1切换到 Q4_K_M 量化,权重体积减半1.23约 2.0 倍
阶段 2用 P4 向量指令重写 GEMV 内核1.78约 1.45 倍
阶段 3双核流水线:一个核预取权重,一个核计算2.90约 1.63 倍
阶段 4PSRAM 访问对齐、缓存策略、编译选项优化3.97约 1.37 倍
阶段 5LayerNorm/激活函数等小算子融合进主循环4.31约 1.09 倍

五个阶段叠乘下来正好接近 7 倍,但这不是简单的乘法关系,有些阶段的收益会和后面阶段相互影响。下面拆开讲。

3.1 阶段 1:量化减负,为什么白捡的是最干净的两倍

第一个动作是换量化。初版用的是 int8,权重一百多兆。我把模型转成 Q4_K_M 格式之后,权重体积直接降到六七十兆,读取量几乎减半。

为什么说这个收益最干净?因为 memory-bound 场景下的耗时和读取数据量近似线性相关。权重小一半,每次搬运的数据就小一半,速度自然接近两倍。实测从 0.61 到 1.23,基本符合预期。

当然,实际提升没有正好 2 倍,原因有几个:量化格式本身带一些元数据,非权重部分的体积没有按比例缩减;反量化在计算时需要额外处理;以及 flash 搬运的固定开销仍然存在。但方向非常明确:先把模型压到最小,再来谈算得快。

这里要特别提一句 Q4_K_M 而不是更激进的 Q4_0,是我做完精度对比后的选择。Q4_0 能再小一点,但生成的文本明显开始出现重复词和语义漂移。Q4_K_M 对关键层和 attention 部分保留了更高精度,体量只多了几个百分点,但生成质量稳很多。在做完这个切换后,我也顺手把 embedding 表和 lm_head 单独留成了高精度格式,因为这些部分的误差会被放大到所有输出 token 上。这个细节后面系列里会专门展开。

3.2 阶段 2:重写 GEMV,把标量循环改成向量指令

量化做完以后,计算部分的占比相对升高了,这时候再优化算子就很有价值。

我最初用的 GGML 内核是通用版本,在 ESP32-P4 上用纯 C 标量循环实现。由于 GEMV 每次要读取连续权重和单列向量做点积,通用实现里有大量分支判断和低效的内存访问模式。我把矩阵的存储顺序改成更适合逐行点积的布局,然后针对 P4 的向量扩展指令重写了内层循环。

重写后一次循环能处理原来好几轮标量循环才能完成的数据,读取也更连续。这一轮实测只提升了约 1.45 倍,没有阶段 1 那么夸张,但它为后面的流水线打下了基础:如果 GEMV 算子还是一团乱麻,双核并行时的计算耗时就没法和预取耗时做好的时间搭配。

这一轮最核心的收获不是那一行 vector 指令本身,而是我彻底理解了 GEMV 的每次读取都是可以预测的:权重是顺序的,向量是固定的,这种模式天然适合提前预取和突发传输。知道这一点之后,后面阶段的优化方向就非常清楚了。

3.3 阶段 3:双核流水线,把等待变成计算

P4 有两个高性能 RISC-V 核心,第一反应自然是用两个核一起算。但实测很快给我泼了冷水:两个核同时做 GEMV,内存总线瞬间饱和,两边互相抢带宽,总耗时几乎没有减少。

正确的做法是流水线,而不是并行计算。我的方案是双缓冲加生产者-消费者模型:

  • 核心 A 负责从 flash/PSRAM 读取下一层的权重,填进一个 ready buffer;
  • 核心 B 负责用当前层的权重做 GEMV 和其他算子;
  • 两层之间通过事件标志切换 buffer,避免一方在等另一方。

这个设计把之前 70% 的等待时间拆出来和计算时间重叠了。核心 B 还在算当前层的时候,核心 A 已经在预取下一层了。到了稳态以后,每层的耗时几乎等于 max(读取时间, 计算时间),而不是读取时间加计算时间。实测从 1.78 到 2.90,这 1.63 倍基本就是从"浪费掉的等待时间"里捞回来的。

这里有一个非常容易踩的坑:预取不能一次性把整层权重都搬进 buffer,因为 PSRAM 空间本身很紧张。我的方案是按层内 block 粒度来预取:这一层的矩阵被切成多个片段,核心 A 不断填满一个固定大小的环形缓冲,核心 B 从里面消费。粒度太粗会导致切换频繁,粒度太细则同步开销变大。最后调下来,我选了单个 block 大小在 4KB 到 8KB 之间,性能最稳。

3.4 阶段 4:内存对齐和缓存策略,把搬书姿势练到最优

流水线跑通以后,2.90 tok/s 看似不错,但我总觉得还有余力。profiling 显示,核心 B 在等核心 A 补数据的时间仍然存在,说明预取还不是完全平滑。问题出在内存访问模式上。

ESP32-P4 的 PSRAM 走的是高速接口,但如果你访问的起始地址没有对齐,或者读取长度不是突发传输的整数倍,实际带宽会明显打折。我的第一版 buffer 分配没有刻意做对齐,导致每次 DMA 传输都可能多花不少周期。

这一阶段的改动包括:所有 buffer 地址按 32 字节对齐分配;flash 读取长度统一调整为突发传输的倍数;把 P4 的缓存命中策略调成更适合顺序读的模式。另外我把 embedding 表和 lm_head 这两块访问频率极高的权重直接常驻在 PSRAM 的常驻区,因为它们体积不大但每次 token 都要用,从 flash 流式读它们太亏了。

这些改动单独看都不大,加起来却到了 3.97 tok/s,提升 1.37 倍。核心原因很简单:带宽一直就在那里,之前的读取姿势太差,没有把接口的每个时钟周期都用满。

3.5 阶段 5:小算子融合,最后 8% 的细节价值

最后一个阶段是我一开始不太看好的:把 LayerNorm、激活函数、Softmax 这些小算子融合进 GEMV 的主循环。

在原始实现里,每个算子都会从内存读一遍输入张量,写一遍输出张量,然后下一个算子再读一遍。Transformer 的中间张量虽然不大,但 MCU 上的内存带宽本来就不宽裕,这些中间读写都在白耗时间。

融合以后,LayerNorm 的参数直接喂给向量计算单元,GELU 激活函数在寄存器里就地完成,不落回内存;Softmax 和之前一步的乘加操作合并处理。中间张量的创建和销毁大幅减少。这一阶段把 3.97 推到了 4.31,相对提升只有 9%,但累计到这一步,4.31 tok/s 已经让日常的短文本生成变得可以忍受了。

4. 调优过程中踩过的四个坑:现象、定位与解决

4.1 DMA 搬运偶发校验失败,罪魁祸首是地址没对齐

第一次跑通流水线的时候,系统并不稳定,大概几十次推理会出现一次莫名其妙的输出错乱。排查了很久,最后发现是预取 buffer 的地址没有对齐,DMA 在传输时跨了边界,导致部分数据被截断或覆盖。解决方式很简单:在分配 buffer 时强制按 32 字节对齐,并且将单次传输长度设成突发传输的整数倍。这个问题之后再也没有出现过。

4.2 量化切到 Q4_K_M 后,偶尔整段输出重复

量化切到 Q4_K_M 以后,速度是上去了,但有时候生成文本会出现"死循环式重复",比如一整段都在重复同一个词。这个现象一开始我以为是模型本身的问题,后来把 embedding 层和 lm_head 单独提出来用高精度格式保存,重复现象立刻消失了。

原因其实很好理解:embedding 表的每一个向量都会影响所有后续 token 的语义方向,lm_head 的误差会直接作用在最终采样的概率分布上。这两处对量化误差最敏感,必须保留更高精度。这个教训对任何 LLM 端侧部署都有参考意义。

4.3 双核互等导致死锁,卡在"上一个 token 正常,这一个卡死"

双核流水线最头疼的问题就是同步。有一版代码出现了非常诡异的现象:前一个 token 输出完全正常,下一个 token 就永久卡住,没有任何报错。查了很久才发现是环形缓冲的事件标志逻辑写错了,两个核在某些时刻会互相等待对方释放同一个信号,形成经典死锁。解决办法是给同步逻辑加上超时计数,并且把事件顺序统一成"生产者先更新写索引,消费者再更新读索引",从设计上消除互等。

4.4 供电不足导致频率降档,tok/s 从 4 掉到 2

有一段时间我用电池给开发板供电,发现实测速度上下浮动很厉害,最差的时候只有 2.4 tok/s 左右。开始我以为是软件问题,后来示波器一量,P4 满载运行时电压跌落明显,芯片自动降频保护了。换用稳压电源后速度立刻回到正常水平。这提醒我:在 MCU 上做性能优化,硬件供电同样是需要关注的一环,尤其是双核满载 + flash 持续读取的场景,瞬时功耗比想象中大得多。

5. 后续系列文章怎么规划:一张路线图

这次复盘的细节量非常大,一篇总览不可能全部装下。我按照自己调优的顺序,把后续系列文章做了如下规划:

5.1 01 篇:环境搭建与第一版基准复现

这一篇会讲清楚开发环境、工具链版本、如何把社区移植分支跑起来,以及如何复现 0.61 tok/s 的初始基线。如果你有一块 ESP32-P4 开发板,跟着这一篇就能搭好实验环境,为后续优化打下基础。

5.2 02 篇:量化路线全景对比

int8、Q4_0、Q4_K_M 等不同量化格式在 ESP32-P4 上的速度和生成质量对比,包括 embedding 层精度保留的具体操作,以及如何在项目里自动选择每层的最优量化方式。

5.3 03 篇:GEMV 算子底层实现详解

从标量循环到向量指令的完整重写过程,包括矩阵存储布局、循环展开、向量寄存器的使用方式,以及不同参数下 GEMV 内核的性能差异。这一篇偏底层,但对想做深度优化的人来说最有价值。

5.4 04 篇:双核流水线实现细节

环形缓冲的大小选择、事件标志的同步逻辑、双核任务怎么分工,以及如何在正确的粒度上切分权重数据,让预取和计算尽量接近无缝衔接。

5.5 05 篇:端侧 LLM 的效果评估与微调思路

硬件优化到最后,还是要回到"模型生成的东西能不能用"这个问题上。这一篇会聊如何在端侧评估小模型的文本质量、如何选择更适合硬件的小模型,以及是否需要做轻量微调来补偿量化带来的精度损失。

写在最后的一点经验

项目走到这一步,我个人最大的感受是:MCU 上跑 LLM 的瓶颈从来不是"算力",而是"搬运"。只要把数据读取和计算编排成流水线,把权重压到最小,速度的提升是实打实的。

如果让我只留一条建议给你:拿到任何板子,先算清楚模型量化后的体积和目标 tok/s,然后估算所需的内存带宽,对比一下硬件的接口上限。只要带宽还没到上限,就说明还有优化的空间;如果已经接近上限,那就得考虑换更小的模型或者更激进的量化了。

这个系列还会继续更新,我会把每一层的实现细节和踩坑记录都写出来。希望这篇总览能帮你建立一个完整的视角,少走我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 18:40:49

单图像三维重建实战:神经3D网络渲染器与可微渲染全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 10:13:53

Windows GDI AlphaBlend 预乘Alpha与DPI适配实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 21:53:51

TPS259483AYWPR与STM32F405RG工业电源协同设计实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 3:52:54

基于深度学习的人脸识别考勤系统:选型、实现与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 9:54:18

openrig是什么?从铝型材到模拟驾驶舱的模块化搭建指南

搜“openrig”这个词的人,多半是看到了某个视频或帖子,然后被一个光秃秃的英文单词卡住了。也不怪大家疑惑——这词本身没有自带说明文档,它既不是某个商业产品,也不是某个官方项目名,而是开源硬件社区里对“一套按自己…

作者头像 李华
网站建设 2026/10/25 13:31:03

Java+MySQL构建高并发在线评测系统(OJ)实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华