☰
ESP32-P4跑LLM:从0.61到4.31 tok/s的7轮优化路线
2026/10/6 1:02:20 网站建设 项目流程

先说结论:在 ESP32-P4 上把 LLM 推理真正跑起来,并且把生成速度从 0.61 tok/s 一路拉到 4.31 tok/s,整个过程不是靠某一招“神优化”一锤定音,而是 CPU 算子、内存搬运、量化格式、双核调度四个方向分别扣出来的结果。这颗芯片没有 NPU,所有计算都压在双核 RISC-V 上,能把速度做到接近 7 倍提升,我觉得已经能拿来日常对话了。这篇文章是系列总览,先把整体路线图和每轮优化的核心思路讲清楚,后面每一篇再对应展开具体实现。

很多人第一次听到 MCU 上跑 LLM 都觉得是噱头,但当你真正把模型塞进外部存储、把逐 token 生成循环跑起来以后会发现,MCU 上的推理瓶颈和服务器端其实是同一个根源:带宽和访存模式。算力不够可以靠裁剪和向量指令解决,访存不够高效就只能眼睁睁看着 CPU 空转。这篇文章适合手里有 ESP32-P4 开发板、想在边缘设备上跑生成式模型的开发者,也适合已经在跑但速度卡在 1 tok/s 以下、不知道从哪下手优化的朋友。

1. 整体设计与优化路线:这个项目到底在折腾什么

1.1 为什么用 ESP32-P4 跑 LLM 而不是再加一块 NPU

先说硬件选型。ESP32-P4 最吸引我的不是“双核 RISC-V”这个标签本身,而是它能外挂足够大的 DDR/PSRAM,同时又保留了 MCU 的低功耗、快速启动、无风扇、量产成本可控这些特性。嵌入式产品做 AI 功能,长期来看要的不是单点算力天花板,而是可维护、可量产、功耗预算可控的整机方案。外接 NPU 模块听上去很猛,但会引入额外驱动、额外电源域、额外的系统复杂度和成本,在很多实际产品里根本交不了差。

而且 p4 的场景并不是跑几十亿参数的大模型。它的目标是 0.5B 级别的量化小模型,这种体量下 CPU 算力并不是绝对天花板,外部存储带宽反而是关键。只要把计算循环、量化格式和数据搬运节奏调好,双核 RISC-V 完全能把小模型的生成速度推到可用水平。这个思路跟我早期在服务器上压推理延迟很像:先看评估是 compute-bound 还是 memory-bound,再决定优化方向,而不是无脑堆硬件。

1.2 模型选型与内存预算怎么配平

本次系列用的模型是 0.5B 参数级别的开源对话模型。为什么不选更大的?因为 LLM 逐 token 生成时,每生成一个新 token,理论上都要把模型所有参数从存储读到计算单元。模型参数越大,每个 token 需要搬运的字节数就越多,tok/s 就越低。

我按实际内存占用算过一笔账:0.5B 参数如果直接用 FP16 存储,大约是 1GB,超出多数 MCU 外存预算;换成 4-bit 量化后压到 300MB 上下,配合 KV cache、上下文缓冲区和运行时临时空间,才能稳稳放进 ESP32-P4 的外挂 DDR/PSRAM 里。这个容量配平是整个项目能跑通的前提。如果你一上来就塞 1.5B 模型,量化完仍然将近 1GB,每次生成 token 都要把 1GB 数据从头搬到尾,速度会直接掉到不可用区间。

1.3 从 0.61 到 4.31 的七阶段路线图

这里先给出整条优化链路的全貌,后续每一篇都会有对应章节展开。

阶段核心动作tok/s
1初始跑通:Q8 量化、单核、通用矩阵乘0.61
2编译器优化加 CPU 向量扩展0.98
3量化格式从 Q8 切到 Q4_K_S1.32
4重写专用 GEMV 算子1.86
5权重重排配合双缓冲 DMA 预取2.71
6双核并行分担采样与前置处理3.42
7任务调度微调、锁频避免抖动4.31

可以明显看到,前面三轮解决的是“每生成一个 token 少读一点数据”的问题,后面四轮解决的是“剩下的数据怎么读得更快、CPU 怎么不空等”的问题。这两类问题如果混在一起调,会非常难归因。

2. 基线性能剖析:0.61 到底差在哪

2.1 0.61 tok/s 是什么概念

0.61 tok/s 意味着生成一个 token 大约要 1.64 秒。一个 20 字的回答,需要 40 秒左右才能完整蹦出来。放在 LLM 对话场景里,这基本是灾难性的体验:用户早就失去耐心了。但另一方面,这个基线数字也说明推理链路本身是通的,模型能加载、前向能跑、采样能输出,只是效率太低。

我建议拿到板子第一天不要急着做产品体验,先把这个基线数据稳定复现出来。后续每做一次优化,都拿同一段测试 prompt 和同样的上下文长度去测,才能保证对比有效。测速时记得在 CPU 主频固定、外存控制器配置固定的条件下跑,不要一边测一边让系统动态调频,否则数字会在 0.5 到 0.8 之间反复横跳。

2.2 先用性能计数器定位瓶颈

第一轮优化前,我先做了一件事:给推理循环里的每个关键环节打时间戳。用esp_timer_get_time()或者 CPU 的 cycle counter,分别统计加载权重、反量化、矩阵向量乘、采样、tokenizer 这几个模块的耗时占比。

实测结果非常夸张:矩阵向量乘类操作占了接近 78% 的时间,采样和 tokenizer 加起来只有 4%,剩下的就是调度和同步开销。这个分布说明一个核心问题:LLM 解码阶段是内存密集型的,不是计算密集型的。代码里乘加运算看似很多,但 CPU 大部分周期其实都在等数据从外部存储搬进来。找到这个事实之后,我就不再纠结“要不要用汇编继续榨干乘法循环”这种方向了,而是把重点转向“如何让权重数据更快送到 CPU 面前”。

2.3 把 -O2 换成 -O3 没太大用,原因在这

我一开始也走过弯路,以为换个更强的编译优化等级就能解决问题。实际把 -O2 换成 -O3,再开 LTO,tok/s 只从 0.61 涨到 0.69,涨幅不到 15%。原因很简单:当 CPU 在等内存时,编译器再努力优化循环,也只是缩短计算阶段的时间,而内存等待的时钟周期仍然原样存在。就像生产线上的工人手脚再快,如果料车送不到工位前,产出依然上不去。

所以这次复盘的第一个经验是:遇到 LLM 推理慢,先分清楚是 compute-bound 还是 memory-bound。判断方法很简单,把同样的算子改成从内部 SRAM 读数据,如果速度立刻大幅提升,说明问题就在外存访问上。这一步搞错方向,后面会白做很多功。

3. 算子与量化:把计算密度抠上去

3.1 为 LLM 推理单独写一个专用 GEMV

通用矩阵乘是为训练和批量推理设计的,它最怕 M=1 的情况。但在 LLM 逐 token 生成时,每次前向几乎都是拿一个 1×hidden 的向量去乘整个权重矩阵,这在数学上叫 GEMV,就是矩阵向量乘。

用通用 matmul 跑 GEMV,会有大量不必要的循环开销和缓存策略冲突。所以我单独写了一个专用 GEMV:外层遍历权重矩阵的行,内层做向量点积,同时把反量化、缩放因子应用全部内联进去。

for (int r = 0; r < rows; r++) { int32_t acc = 0; for (int c = 0; c < cols; c++) { acc += input[c] * weight[r * cols + c]; } out[r] = scale[r] * acc; }

这段代码只是示意逻辑,实际实现里我不会让它在 MCU 上逐元素跑,而是会做循环展开和向量点积。核心思想是让每一次读到的权重字节都参与尽可能多的计算,而不是读过以后就被丢弃。

3.2 量化格式横向对比

量化格式直接影响每生成一个 token 要读取的字节数,我专门做了横向测试:

格式权重体积反量化开销实测 tok/s精度影响
Q8_0约 0.5GB低基线对比基本无损
Q4_0约 0.29GB中比 Q8 快 20% 左右明显可见
Q4_K_S约 0.30GB中高但可优化最终采用比 Q4_0 好很多
IQ2更小高不适合 MCU下降较多

最终选 Q4_K_S 的考量是:它用分组量化的方式保留了更多精度信息,反量化成本虽然高一点,但在向量扩展的加持下可以压到很低。相比之下 Q4_0 虽然体积差不多,但精度损失导致输出偶尔出现明显逻辑断裂,对对话场景来说代价太大。

3.3 用向量扩展做反量化后的效果

ESP32-P4 的 RISC-V 核支持向量扩展,这部分是真刀真枪的性能来源。启用之后,GEMV 内层循环一次可以处理多个权重值,配合反量化时的乘加操作,计算密度明显提升。我在工具链里启用了对应指令集的编译参数,但这里必须提醒一句:不同版本的编译器对 RISC-V 向量扩展的支持程度不一样,不要直接照抄别人的 march 字符串。

提示:改完指令集参数后,先用一两段纯向量累加的小程序跑一下,确认工具链和目标芯片都能正常支持,再大规模替换推理算子。否则运行时出现非法指令,排查成本会非常高。

4. 内存带宽优化:让外存读取不再是卡点

4.1 从 PSRAM 读模型和从 SRAM 读,差距到底多大

这一轮是整个项目里涨幅最大的一轮。前面提到,CPU 在推理时大量时间花在等待权重从外存读出。PSRAM/DDR 虽然容量大,但随机访问效率远不如芯片内部 SRAM,而且每次访问还有额外的延迟和刷新开销。

我把模型权重读取路径梳了一遍,发现代码里有很多零散的随机读取,比如每一层去读量化 scale、去读某种元信息,这些零碎访问在外部存储上代价极高。解决思路就是四个字:尽量顺序。把跨步访问改成整块连续访问,配合缓存行的大小去搬数据,性能立刻提升一个台阶。类比一下:从大仓库取货,一次推一整车出来,比多次跑进仓库拿几件小零件要高效得多。LLM 的权重矩阵天然就是大块连续的数据,问题只是我们怎么把它组织成适合搬运的块。

4.2 双缓冲与 DMA 预取是怎么回事

双缓冲是嵌入式高性能计算里的经典套路。我把权重分成若干个数据块,一边让 CPU 计算当前已经到手的块,一边用 DMA 去预取下一个块,两块缓冲区交替使用。这样 CPU 计算和数据搬运重叠在一起,外部存储延迟就被隐藏起来了。

dma_load(buf[0], weight_addr[0]); for (int i = 0; i < block_count; i++) { dma_wait(i % 2); if (i + 1 < block_count) { dma_load(buf[(i+1) % 2], weight_addr[i+1]); } compute_block(buf[i % 2]); }

实际操作中块大小不是越大越好。块太大,单次 DMA 等待时间过长;块太小,DMA 中断和启动开销占比又太高。我实测下来,块大小在 4KB 到 16KB 之间比较合适,具体数值取决于你用的外部存储类型和数据宽度。这个参数值得多试几轮,它往往比调算法更容易带来 tok/s 提升。

4.3 权重重排:把“电平跳动”变成“连续搬运”

GGUF 格式本身是按模型结构组织好的,但它在存储层上并不一定是最适合 MCU 顺序读取的布局。比如某些层的 scale、某些量化分组数据,容易被分散在不同位置。我在模型加载阶段做了一次权重重排,把推理时真正连续读取的权重按访问顺序重新规整,让 DMA 每次都能搬运一整段连续地址。

这个操作听起来很不起眼,但效果非常显著。原因在于外部存储的顺序读带宽可能是随机读带宽的几倍甚至十几倍。把“走走停停”的随机访问变成“一口气读完”的顺序访问,等于变相凭空多了很多带宽。我后来查了一下,很多嵌入式推理框架都会在模型加载后做类似的 tensors 重映射,这不是我独创的技巧,但确实是必须做的一步。

5. 双核流水线与调度优化:压榨最后一个百分点的算力

5.1 大核和小核怎么分工

ESP32-P4 有双核高性能 CPU 和低功耗协处理核。我最初只把推理任务放在一个核上,另一个核几乎空闲,有点浪费。后来我把推理主循环绑在 CPU0,把 tokenizer、采样器、logits 后处理等周边任务放到 CPU1,两者通过队列传递数据。

这个分工的关键是:不要在数据依赖点上强行并行。比如采样器需要前向计算完成后的 logits,这部分没法完全与 GEMV 并行。但 tokenizer 可以在上一轮输出后立刻开始编码下一段文本,logits 的 argmax 和温度采样也可以在 GEMV 计算下一层时同步做一部分。通过把这类“小众但占时间”的操作挪到另一个核,主核的推理循环变得更加连续。

5.2 不让系统任务抢走推理周期

嵌入式系统里各种后台任务非常多,Wi-Fi 协议栈、硬件驱动、日志输出等都可能随时抢占 CPU。我在测试时发现,如果有别的任务频繁打断推理线程,tok/s 会波动得非常厉害。解决办法是把推理任务钉在指定核心,并且把优先级拉到最高;同时在推理主循环里避免动态内存申请,禁止在关键路径上调用可能触发调度的系统 API。

ESP-IDF 里用xTaskCreatePinnedToCore就可以完成绑核操作,配合合适的任务优先级,基本能保证推理期间 CPU 资源专属于模型计算。还要注意关掉不必要的日志打印,串口输出在低端 MCU 上真的会吃掉不少周期。

5.3 token 生成循环的实时性处理

做到这里之后,我遇到一个很隐性的问题:tok/s 平均值好看,但每个 token 之间的生成时间不均匀,有时候一个 token 只要 180ms,下一个却要 300ms。后来发现是动态调频策略导致的,系统会根据负载改变 CPU 主频,结果主频波动直接反映到了生成延迟上。

我干脆在做测速和体验时把主频固定到最高档,让每个 token 的时间更稳定。虽然功耗会高一点,但电量和速度在嵌入式产品里往往是可权衡的。如果你的产品对功耗敏感,我建议照样推理过程中锁频,空闲时再降频,这比让 token 时间忽快忽慢体验上好很多。

6. 七轮优化贡献拆解与系列文章计划

6.1 每轮优化带来的 tok/s 提升

这里再回头把七轮优化放回同一张表,方便你快速对齐。

阶段主要动作tok/s相对提升
1初始基线,Q8 量化,未做针对优化0.61-
2开启编译器高级优化与向量扩展0.98+60.7%
3量化切到 Q4_K_S1.32+34.7%
4重写 GEMV 专用算子1.86+40.9%
5内存重排加双缓冲 DMA 预取2.71+45.7%
6双核并行分担前置与采样3.42+26.2%
7调度精细调整、锁频稳定4.31+26.0%

从 0.61 到 4.31,正好是 7.07 倍。如果你也想复现这个提升,我建议一定按这个顺序走:先减少读取的数据量,再加快读取速度,最后处理并行和系统干扰。跳过前几步直接做双核优化,会发现其他瓶颈会把收益直接吃掉。

6.2 还可以继续深挖的方向

做到 4.31 tok/s 之后我还留了几个优化方向没上,留给后续文章。

第一,投机解码在小模型上未必不可行,但 MCU 算力有限,收益可能被额外开销抵消。第二,KV cache 的管理策略还可以继续精细化,对话变长后 cache 的访问会逐渐变成新瓶颈。第三,模型结构本身也可以做剪枝,把一些冗余层在编译阶段拿掉,进一步压缩每 token 读取量。第四,如果只跑固定任务而不是通用对话,可以用蒸馏后的小模型替换原始模型,体积和速度差距会非常明显。

6.3 系列文章发布计划

这个系列后续会按下面几篇展开:

  • 01:ESP32-P4 上搭建 LLM 推理环境,从零跑通第一个 token
  • 02:baseline 测速方法与性能计数器使用细节
  • 03:专用 GEMV 算子手写与量化反量化融合
  • 04:PSRAM 带宽优化,双缓冲与 DMA 预取的完整实现
  • 05:双核并行调度与实时性控制
  • 06:精度评估与速度提升的权衡,量化前后输出质量对比

最后一句话给想复现的朋友:这套优化顺序非常重要,不要一上来就改汇编或者抄网上的零散 trick。先把模型加载后的一次完整前向拆开,统计好每一段耗时,再按“少读、快搬、并行”的顺序去做。实际踩过几次坑之后,你会发现 MCU 推理优化的每一步都能用数据说话,这也是这个系列想一直坚持的风格。

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

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

立即咨询