☰
在ESP32-P4上跑LLM:从0.61到4.31 tok/s的7倍优化实战
2026/10/7 7:37:34 网站建设 项目流程

1. 为什么要在 MCU 上跑 LLM:一个反直觉的工程选择

第一次跟朋友聊起这个项目,对方的第一反应基本都是同一句话:“你是不是闲的?” 这个反应很正常。ESP32-P4 是一颗面向嵌入式视觉和 HMI 场景的 MCU,RISC-V 双核,主频 400MHz,加上一堆外设和 MIPI 接口,定位是“能跑 UI、能接摄像头”的实时控制器。而 LLM 这个词,大家脑子里默认绑定的硬件是 GPU 集群、至少几十 GB 显存、动辄几百瓦功耗。把这两个东西放在一起,直觉上就是错配。

但实际做下来,这件事的价值恰恰不在“跑得动大模型”本身,而在于它逼着你去理解一个平时被算力掩盖掉的问题:推理的瓶颈到底在哪一层。当你有 A100 的时候,你不需要关心 KV Cache 的内存布局,不需要关心权重是不是按 cache line 对齐,不需要关心一次矩阵乘法的循环顺序会不会导致频繁的 cache miss。这些细节全被算力红利吃掉了。而在 ESP32-P4 上,这些细节就是全部——0.61 tok/s 和 4.31 tok/s 之间的差距,不是靠换硬件换来的,是靠一层一层把这些被忽略的东西抠出来的。

这个系列总览想做的事情,是把整个优化链路完整摊开。不是那种“我调了个参数就快了”的爽文,而是每一步为什么这么改、改之前测出来是什么数、改之后涨了多少、哪些改动其实是无效的。我踩过的坑包括但不限于:以为瓶颈在算力结果发现卡在内存带宽、优化了半天发现是量化格式选错了、以及最经典的——某个“优化”让速度涨了 15% 但输出质量直接崩了。

适合读这个系列的人有三类。第一类是嵌入式工程师,手上有 ESP32-P4 或者类似的 RISC-V MCU,想搞清楚这颗芯片在 AI 负载下的真实能力边界。第二类是做端侧推理的算法工程师,平时在 ARM 或者 x86 上做量化部署,想看看换到更受限的架构上哪些经验还能用、哪些直接失效。第三类是纯粹对“极限优化”这件事感兴趣的开发者,喜欢看一个系统从能跑到跑得快中间到底发生了什么。如果你属于第三类,那这个系列应该会让你看得比较过瘾,因为中间有大量“测出来和预期完全相反”的时刻。

先把结论性的数字放在这里,方便你判断要不要继续往下读:基线版本 0.61 tok/s,最终版本 4.31 tok/s,整体约 7 倍。这个 7 倍不是单一手段带来的,是量化格式、内存布局、算子实现、KV Cache 管理、编译选项这几块叠加的结果,每一块的贡献从 1.2 倍到 2 倍不等。后面会逐块拆。

2. ESP32-P4 这颗芯片到底给了什么牌

2.1 算力账:400MHz 双核 RISC-V 能干什么

ESP32-P4 的 CPU 部分是双核 RISC-V,主频 400MHz,带 FPU,支持单精度浮点。先算一笔最朴素的账:400MHz 意味着每秒 4 亿个时钟周期,双核理论上 8 亿。如果每个周期能完成一次乘加(MAC),那峰值就是 800M MAC/s,也就是 1.6 GFLOPS 左右。这个数字放在 GPU 面前不值一提,但放在 MCU 里已经算不错了。

问题在于,通用 RISC-V 核每个周期根本做不到一次 MAC。一次 FP32 乘加,取指、译码、取操作数、执行、写回,流水线排下来,实际吞吐可能只有峰值的几分之一。而且 LLM 推理里大量的操作是矩阵向量乘,访存密集,CPU 大部分时间在等数据而不是在算。所以真实可用的算力,比纸面峰值还要打不少折扣。

这就是为什么 P4 上的 PIE 协处理器变得关键。

2.2 PIE:被低估的向量加速单元

PIE 是 ESP32-P4 里的向量扩展单元,全称是 Processor Instruction Extension 之类的意思(乐鑫的文档里对它的描述比较克制)。它提供了一批 SIMD 风格的指令,能在一个周期里对多个数据做并行运算。对于 LLM 推理里最常见的矩阵乘、点积、激活函数这些操作,PIE 能带来的加速是实打实的。

但 PIE 不是免费的午餐。它的指令集有自己的约束:数据需要按特定方式对齐,寄存器数量有限,某些操作的数据类型支持不完整。我在项目里遇到的一个典型问题是,PIE 对 INT8 的支持比对 FP32 好得多,这直接影响了量化格式的选择——后面会详细讲这个决策链。

提示:如果你打算在 P4 上做任何计算密集型的工作,先把 PIE 的指令手册过一遍。不是让你背指令,而是搞清楚哪些操作有硬件加速、哪些只能走通用核。这个认知会直接决定你的算法该怎么写。

2.3 内存:真正的瓶颈所在

P4 的片上 SRAM 是几百 KB 级别,具体数字看具体型号和配置。外部可以挂 PSRAM,容量能到几十 MB,但带宽和延迟跟片上 SRAM 差了一个数量级。LLM 推理需要频繁访问权重和 KV Cache,如果这些东西放在 PSRAM 里,每次访问都要走外部总线,延迟直接吃掉所有计算收益。

我一开始的基线版本就是把所有权重放在 PSRAM 里,结果就是 CPU 大部分时间在等内存。后来把最热的那部分权重(比如 attention 里的 QKV 投影矩阵)搬到片上 SRAM,速度立刻上了一个台阶。这个改动的本质是:在 MCU 上,内存层级的重要性远大于算力。你有一块快的内存,比有一个快的核更重要。

3. 从 0.61 到 4.31:优化链路的整体拆解

3.1 基线版本长什么样

基线版本是一个“能跑就行”的实现。模型选的是一个小型 Transformer,参数量在几 M 级别,量化到 INT8。推理代码是直接照着 PyTorch 的实现翻译成 C 的,矩阵乘法就是三重循环,没有用 PIE,没有做内存对齐,KV Cache 用最简单的数组实现,每次生成新 token 都重新分配。

这个版本跑出来 0.61 tok/s。说实话,第一次看到这个数字的时候我是有点意外的——比预期慢,但没慢到离谱。说明基本逻辑是对的,只是每一层都有优化空间。

3.2 七倍是怎么攒出来的

整个优化过程可以分成几个阶段,每个阶段的贡献大致如下:

优化阶段主要改动速度提升累计 tok/s
基线纯 C 实现,无优化-0.61
量化格式调整INT8 权重 + PIE 友好布局约 1.8x1.10
内存布局优化权重搬片上 SRAM,对齐 cache line约 1.5x1.65
算子重写矩阵乘用 PIE 指令重写约 1.6x2.64
KV Cache 管理预分配 + 环形缓冲约 1.3x3.43
编译与调度编译选项 + 循环展开 + 双核分工约 1.25x4.31

这张表是事后整理的,实际过程中顺序有交叉,有些改动是同时做的,所以单独归因不一定完全准确。但大致的量级关系是对的:没有哪一项是银弹,七倍是攒出来的。

3.3 哪些改动其实没用

这个部分可能比“什么有用”更有价值。我试过但基本没效果的改动包括:

  • 把 FP32 换成 FP16:P4 的 FPU 对 FP16 没有额外加速,反而因为转换开销更慢了。
  • 手动做循环展开:编译器在 -O2 下已经做得不错了,手动展开收益很小。
  • 把模型切得更小:参数量减半,速度只涨了不到 20%,说明瓶颈不在计算量而在访存。
  • 用 DMA 搬数据:对于小块的、频繁的访问,DMA 的启动开销比直接访问还大。

这些失败尝试的共同点是:它们都在优化“计算”,但真正的瓶颈在“访存”。这个认知是后面所有有效优化的前提。

4. 量化格式的选择:为什么 INT8 是唯一解

4.1 FP32、FP16、INT8 在 P4 上的实测对比

先看数据:

格式模型大小单 token 延迟输出质量备注
FP32基准基准最好内存放不下,需要频繁换入换出
FP16约 50%比 FP32 慢接近 FP32FPU 无加速,转换有开销
INT8约 25%最快可接受PIE 有原生支持

FP16 比 FP32 慢这个结果一开始让我很困惑。后来想明白了:P4 的 FPU 是单精度的,FP16 运算需要先转成 FP32 再算再转回去,多出来的转换指令把省下的内存带宽又吃回去了。而 INT8 不一样,PIE 有专门的 INT8 乘加指令,一个周期能处理多个 INT8 数据,这是真正的硬件加速。

4.2 量化粒度:per-tensor 还是 per-channel

确定了 INT8 之后,下一个问题是量化粒度。Per-tensor 是整个权重矩阵共用一个 scale,per-channel 是每一行或每一列有自己的 scale。Per-channel 精度更好,但推理时需要额外的 scale 乘法,在 P4 上这个开销不可忽略。

我的选择是:权重用 per-channel,激活用 per-tensor。理由是权重的 scale 可以在编译期确定并预乘到权重里,推理时不需要额外操作;而激活的 scale 是动态的,per-tensor 实现更简单,精度损失在可接受范围内。

4.3 量化校准集的坑

量化校准集的选择比想象中重要。我一开始用了一段通用的英文文本做校准,结果模型在中文输入上表现明显变差。后来换成中英混合的校准集,问题解决。这个坑的教训是:校准集要覆盖实际使用场景的数据分布,不能随便找一段文本凑数。

注意:量化后的模型一定要做端到端的质量评估,不能只看 perplexity。有些量化误差在 perplexity 上体现不明显,但在具体任务上会导致输出完全不可用。

5. 内存布局:把数据放在对的地方

5.1 片上 SRAM 和 PSRAM 的带宽差距

前面提过,P4 的片上 SRAM 和外部 PSRAM 在带宽和延迟上差距很大。具体数字因配置而异,但量级上的差异是:片上 SRAM 的访问延迟在几个周期,PSRAM 在几十个周期。对于 LLM 推理这种访存密集的负载,这个差距直接决定了性能上限。

我的做法是把权重按“热度”分层:attention 的 QKV 投影和 FFN 的第一层矩阵放在片上 SRAM,其余放 PSRAM。这个分层的依据是访问频率——QKV 投影在每个 token 生成时都要用,FFN 第一层次之,输出层再次之。

5.2 对齐:一个被忽视的性能杀手

数据对齐在 x86 上可能只是“最好做一下”的事情,在 P4 上是“必须做”的事情。PIE 的向量指令要求操作数按特定边界对齐,如果不对齐,要么触发异常,要么走慢速路径。我一开始没注意这个,矩阵乘的输入指针对齐是随机的,导致 PIE 指令有一半时间在走慢速路径。

修复方法很简单:所有权重数组用aligned_alloc分配,确保起始地址按 16 字节或 32 字节对齐。这个改动本身只花了几行代码,但带来的性能提升是实打实的。

5.3 数据布局:行优先还是列优先

矩阵乘法的数据布局对 cache 命中率影响很大。PyTorch 默认是行优先,但 LLM 推理里的矩阵乘往往是“矩阵乘向量”的形式,这时候列优先可能更友好。我试过两种布局,在 P4 上的实测结果是:对于 PIE 实现的矩阵乘,把权重转成列优先能减少一次转置操作,整体快约 10%。

这个改动需要在模型导出阶段就做好,推理时直接加载转置后的权重。如果你是从 PyTorch 导出,可以在导出脚本里加一步转置。

6. 算子实现:PIE 指令怎么用才不浪费

6.1 矩阵乘的 PIE 实现思路

矩阵乘是 LLM 推理里最耗时的算子,也是 PIE 加速收益最大的地方。基本思路是把矩阵分块,每块用 PIE 的向量乘加指令处理。关键参数是分块大小——块太小,PIE 指令的启动开销占比高;块太大,寄存器不够用,会溢出到栈上。

我试了几组分块大小,最终选的是 4x8 的块(4 行 8 列)。这个选择是基于 P4 的 PIE 寄存器数量和指令延迟实测出来的,不一定对所有模型都最优,但可以作为起点。

6.2 激活函数的向量化

LLM 里用到的激活函数主要是 GELU 和 SiLU。这两个函数都涉及指数运算,在通用核上很慢。PIE 没有直接的指数指令,但可以用多项式近似加向量指令实现。我用的近似方案是分段多项式,精度损失在 1e-3 量级,对最终输出质量的影响可以忽略。

6.3 哪些算子不值得优化

不是所有算子都值得花时间优化。我的经验是:只优化占总时间超过 5% 的算子。在 LLM 推理里,矩阵乘和 attention 占了 90% 以上的时间,其余像 LayerNorm、残差连接这些,优化收益很小。我一开始花了不少时间优化 LayerNorm,后来发现它只占总时间的 2%,优化到极致也就省 1%。

7. KV Cache 的管理:小改动大收益

7.1 为什么 KV Cache 是瓶颈

自回归生成时,每生成一个新 token,都需要用到之前所有 token 的 Key 和 Value。如果每次都重新计算,复杂度是 O(n²)。KV Cache 的作用是把之前算过的 K 和 V 存下来,新 token 只需要算自己的 K 和 V,然后和缓存里的拼接。这个机制本身没问题,问题在于缓存的分配和访问方式。

我基线版本的做法是每次生成新 token 都重新分配一块更大的内存,把旧的拷过去,再追加新的。这个做法在 PC 上可能感觉不到,在 P4 上就是灾难——每次分配和拷贝都要走内存总线,而且频繁的分配会导致内存碎片。

7.2 预分配加环形缓冲

改成预分配之后,速度立刻上了一个台阶。具体做法是:根据模型的最大上下文长度,一次性分配足够的 KV Cache 空间,然后用环形缓冲的方式管理。新 token 的 K 和 V 写到环形缓冲的当前位置,指针往前走,走到头就绕回来。

这个改动的收益主要来自两方面:一是消除了分配和拷贝的开销,二是环形缓冲的访问模式对 cache 更友好。

7.3 精度换空间的取舍

KV Cache 也可以用 INT8 存储,能省一半内存。但 K 和 V 的量化误差会累积,生成越长误差越大。我试过 INT8 KV Cache,短序列(<128 token)质量还行,长序列就明显退化。最终选择是 K 用 INT8、V 用 FP16,在内存和质量之间取了个平衡。

8. 编译与调度:最后那 25% 从哪来

8.1 编译选项的实测效果

编译选项这块,我试过的组合和效果:

选项效果备注
-O2基准默认
-O3+5%循环展开更激进
-O3 + -funroll-loops+8%手动指定展开
-O3 + -ffast-math+12%但影响浮点精度,慎用
上述 + LTO+15%链接时优化,跨文件内联

最终用的是 -O3 + -funroll-loops + LTO,没用 -ffast-math,因为精度损失在长序列生成时会累积。

8.2 双核分工

P4 是双核,但 LLM 推理的串行性很强,不容易并行。我的做法是把矩阵乘的不同行分给两个核,用信号量同步。这个改动的收益取决于矩阵大小,大矩阵能到 1.3x,小矩阵因为同步开销反而更慢。所以实际实现里是根据矩阵尺寸动态决定要不要并行。

8.3 中断和任务调度的影响

P4 上如果有其他任务在跑(比如网络栈、UI 刷新),会抢占推理任务的 CPU 时间。我的做法是把推理任务设成高优先级,并且把它的栈放在片上 SRAM 里,减少上下文切换的开销。这个改动对平均速度影响不大,但对尾延迟改善明显。

9. 实测数据与质量评估

9.1 不同配置下的速度对比

把各个优化阶段的数据汇总一下:

配置tok/s相对基线
基线0.611.0x
+ INT8 量化1.101.8x
+ 内存布局1.652.7x
+ PIE 算子2.644.3x
+ KV Cache3.435.6x
+ 编译调度4.317.1x

9.2 输出质量的主观评估

速度上去了,质量怎么样?我用了一组固定的 prompt 做对比,包括问答、续写、翻译三类任务。INT8 量化后的输出和 FP32 相比,在短序列上几乎看不出差别,长序列(>256 token)偶尔会出现重复或逻辑断裂。这个退化在可接受范围内,毕竟速度涨了 7 倍。

9.3 功耗和温度

P4 跑 LLM 时的功耗比空载高不少,具体数字取决于主频和外设状态。温度方面,持续推理时芯片会明显发热,需要做好散热。如果是在封闭环境里跑,建议加散热片或者降频使用。

10. 这个系列接下来会写什么

这篇总览把整个优化链路和关键决策点过了一遍,但每个部分都还有大量细节没展开。接下来的系列文章会按主题深入:

  • 量化格式的详细对比和校准方法
  • PIE 指令的实战用法和踩坑记录
  • 内存布局的具体实现和测量方法
  • KV Cache 的多种管理策略对比
  • 编译选项的逐项实测数据

每篇都会包含可复现的步骤和实测数据,不是泛泛而谈。如果你在 P4 或者类似的 RISC-V MCU 上做推理优化,希望这个系列能帮你少走一些弯路。

最后分享一个我在这个项目里体会最深的事情:在资源受限的平台上做优化,最重要的不是知道什么技术,而是知道瓶颈在哪。我见过太多人(包括我自己)一上来就想着用什么高级技术,结果花了很多时间优化了一个根本不是瓶颈的地方。正确的做法是先测量、再优化、再测量,让数据告诉你该做什么。这个原则在 P4 上适用,在任何平台上都适用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询