ESP32-P4跑大模型:0.61到4.31 tok/s的7倍优化全路线
ESP32-P4 跑 LLM 这件事我想了很久到底要不要写成系列。一手数据显示手里这块开发板上的大模型推理从最开始的 0.61 tok/s 一步一步优化到 4.31 tok/s换算下来刚好是 7.07 倍。别急着拿这个数字和云服务器比放在 MCU 上这个涨幅背后踩过的坑比数字本身值钱得多。这个系列打算从 00 篇开始先把整条优化路径的地图摊开为什么要在一颗没有内置无线、主打算力外设的单片机上跑大模型0.61 这个低得离谱的基线是怎么测出来的中间到底动了哪些刀子最终为什么停在 4.31 而不是更高。顺便把这过程中所有值得留档的经验教训一起沉淀下来。适合正在做端侧 AI、或者在 MCU 上折腾本地 LLM 的嵌入式开发者参考。哪怕你手头不是 ESP32-P4是 ESP32-S3、是某个 RISC-V 开发板这套“量化—算子—内存—并行—系统”的优化顺序也完全能平移过去。1. 为什么要在 MCU 上跑 LLM这个系列到底解决什么问题1.1 从 0.61 到 4.31 tok/s 这条曲线意味着什么先算一笔账。0.61 tok/s平均生成一个 token 要 1.64 秒输出一句话大约 30 个 token 就得等差不多 50 秒体验属于“可以演示不能交互”。4.31 tok/s 时单 token 只要 0.23 秒同样 30 个 token 输出约 7 秒。对于单片机系统这已经从一个玩具变成了能承载轻度交互的原型。很多人第一次接触 token 这个概念会卡住。通俗说token 是模型处理文本的最小单位一个 token 大约对应一个汉字或者 0.6 个英文单词。所以“这杯咖啡真不错”这句话大概会切成 10 到 15 个 token。聊天时的速度感完全取决于每秒钟能生成多少个这样的单位。0.61 到 4.31 的数字差看起来不大人在屏幕前的等待体感却是“刷个朋友圈回来它还没说完”和“能比较自然地对答几句”的区别。还要说清楚一点LLM 的推理不是一次性吐出整段话而是先“阅读理解”你给的 prompt这个过程叫 prefill再一个 token 一个 token 地往后接这个过程叫 decode。0.61 和 4.31 这两个数指的是 decode 阶段的稳定速度。prefill 阶段因为要并行处理整段输入通常只发生一次对连续对话的流畅度影响主要取决于 prompt 长度跟这里的优化主线不是一回事。后续系列文章里我会单独把 prefill 的耗时也拆出来讲这里先记住“生成速度看 decode”就够了。1.2 什么场景真的需要“单片机里的聊天机器人”有人会问好好的云端接口不用非得在 MCU 上跑大模型图什么真实场景其实不少。第一类是离线隐私需求音频、文本、对话记录全部留在本地设备里不上云这在玩具、家电、医疗辅助设备里特别敏感。第二类是断网可用设备可能在野外、厂房、地下空间根本没有稳定的网络环境。第三类是成本问题一个云端对话接口按调用次数计费跑一台设备一小时可能没多少钱但跑一万台设备费用就非常可观。还有一类更贴合 ESP32-P4 定位的场景设备本身有屏幕、有摄像头、有 USB它要做的不是“聊天机器人”而是“本地智能交互壳”。比如相机拍完照后在设备上直接生成一句话描述玩具根据孩子说的话做出剧情回应工业仪表根据操作员的口头指令给出操作建议。这些任务不需要几百亿参数的强大模型一个一两亿参数的小模型经过量化已经能给出质量尚可的结果。LLM 本地化的价值不在“最强”而在“够用、可控、私有”。之所以选择 ESP32-P4而不是更常见的 ESP32-S3核心在于 P4 的定位更激进。它是一颗没有内置无线的“算力型” MCU双核 RISC-V 架构带 PSRAM 甚至 LPDDR4 的支持还有 MIPI CSI/DSI 和 USB 这类面向多媒体交互的外设接口。P4 给人的感觉就是厂家明确告诉你这颗芯片不算通信专心做本地计算和交互。这也让它天然适合当“MCU 上的 LLM 试验场”。2. 基线构建先把 0.61 tok/s 老老实实跑出来2.1 硬件底座我手里的 ESP32-P4 开发板长什么样P4 不是普通 MCU。它的双核 RISC-V 跑在 400MHz 附近支持从外部 PSRAM 扩展内存带显示和摄像头接口明显对标“带算力的交互设备”。但它没有内置 WiFi 和蓝牙所以开发板上通常会预留外挂射频模组的接口。本文所有速度数据都基于我手里这块搭配 16MB OSPI PSRAM 的开发板。不同厂商的板子PSRAM 型号、供电方案、PCB 布局差异很大最终能跑到的速度会有出入但优化方法是一致的。这里多说一句跑 LLM 对内存容量的要求远高于对 Flash 的要求。模型权重、KV cache、中间激活值全都要常驻 RAM。ESP32-P4 的优势就在于能外扩大容量 PSRAM让放不下 SRAM 的模型有了容身之处。代价是 PSRAM 的带宽和延迟都不如片内 SRAM这也是后面所有性能优化的主要矛盾来源。如果你手头的板子只有 8MB PSRAM那模型选型就要更保守优先考虑 100M 参数以下的模型。2.2 模型选择与第一版移植我选模型的原则有三条参数量控制在 0.1B 到 0.2B 之间量化后权重能在 PSRAM 里放下输出质量对演示任务够用。最后选了一类 125M 左右的开源小模型类似 TinyStories 或 SmolLM 的基础版本。这类模型词表小、层数少量化成 INT4 后权重体积大约 60 到 70MB正好卡在这块板子的 PSRAM 容量内。0.5B 级别的模型就算量化到 INT4也要 250MB 以上现阶段放不下成本也扛不住。第一版移植过程很粗糙。我把模型从训练框架里导出成原始权重数组在 MCU 上写了一个最直接的逐层循环矩阵乘先保证输出结果和 PC 端参考实现一致再谈优化。这个步骤看起来笨但我强烈建议所有想在 MCU 上跑 LLM 的人先这样干一遍。因为一上来就直接套开源推理框架遇到问题根本分不清是模型本身的问题、量化引起的偏差还是板子的内存配置不对。从零写一个“能跑但极慢”的版本等于给自己建立了一套可控的参照系。很多教程喜欢把一个能跑的工程直接丢给你然后告诉你“只需要改几个宏定义”。这种路径在 PC 上可行在 MCU 上会非常痛苦。MCU 开发必须对底层内存、链接脚本、启动流程有掌控而这些只有在你亲手把模型“搬”进板子时才能真正理解。0.61 这个成绩就是在这么个“能跑、没优化、到处是重复计算”的状态下测出来的。它足够难堪但也足够真实。2.3 0.61 tok/s 是怎么测出来的测性能不是对着秒表数数字。我先把同样的问题在 PC 上跑一遍记录参考输出上板后喂固定 prompt先预热两三次让 Flash 缓存、PSRAM 访问都进入稳定状态然后正式测试。记录总时间 T剔除 prefill 阶段的耗时 T_prefill只统计 decode 阶段生成的 N 个 token最终速度就是 N 除以 (T 减 T_prefill)。这里有两个细节对结果影响很大。第一我会去掉前两个 token 再统计因为刚进入 decode 时 CPU 还要做采样、更新缓存等初始化工作速度不稳。第二用中位数而不是平均值。满载运行时偶尔一次中断或者串口打印会把平均值拉高不少中位数对这种毛刺不敏感。当时第一次测出 0.61我就发现中间有几个 token 掉到 0.4 以下查下去是定时器中断抢占了 CPU这就是中位数比平均值好用的地方。后续所有优化阶段的对比都沿用这套测量方法保证数字可比。3. 7 倍优化路线图全拆解3.1 第一刀把模型量化到 8bit内存占用减半机器学习里的量化说白了就是用更少的比特数表示同一个数值。FP32 权重每个数占 4 字节INT8 只占 1 字节。Transformer 生成每个 token 时都要把绝大部分权重从内存搬到计算单元相当于每输出一个字就要完整翻一遍整本模型“书”。这本书越薄每页翻起来越快所以压缩权重字节数对推理提速是立竿见影的。我在这一步先切 INT8。具体做法是用校准集统计权重分布给每个 tensor 或每行分配一个 scale 和 zero point把 FP32 权重映射到 -128 到 127 的整数范围。一开始图省事用了 per-tensor 量化整层共用一个 scale结果模型明显变笨后来改成 per-channel按输出通道给 scale质量才稳住。这一步实测从 0.61 提到 1.12接近翻倍。可以顺便解释为什么不是四倍虽然权重读取量降到了四分之一但 INT8 权重在计算前要做反量化增加了额外指令而且 decode 阶段还有很多非矩阵乘操作比如 LayerNorm、激活函数、采样这些并没有明显变快。任何优化措施的收益都会被木桶效应拉低所以别期待理论值直接看实测曲线才是正道。3.2 第二刀算子重写别让矩阵乘拖后腿INT8 量化后朴素矩阵乘成了下一个瓶颈。我第一版代码用三重循环做矩阵乘内层每次访问内存都是随机取完全没有利用连续读取的优势。更糟糕的是MCU 编译器默认生成的代码根本没有用到向量指令CPU 大量时间在空转等内存。这一步我把计算核心彻底重写了一轮重点做了三件事。第一把解码阶段的矩阵乘明确按 GEMV也就是矩阵乘以向量来实现。LLM 逐 token 生成时 batch 通常是 1没必要写通用 GEMM。GEMV 的访存模式是连续线性扫描每次可以连续搬运一整行权重配合 PSRAM 的 burst 读取特性效率高得多。第二循环展开加定点内积内部用 32 位累加器做整数乘加减少浮点转换和 FPU 压力。第三算子融合把残差连接、LayerNorm、激活函数的多次遍历合并成一次减少写中间结果的次数。这一步让速度从 1.12 涨到 1.91。体感变化很明显第一个 token 的响应时间几乎没变但后续每个 token 的间隔肉眼可见地缩短了。这说明此前算子里的浪费非常大甚至比量化本身更值得先处理。经验是先确认哪部分代码浪费最多再决定先量化还是先重写算子不要盲目照搬别人的顺序。3.3 第三刀内存布局与缓存策略到了这个阶段CPU 和算子已经不是主要矛盾PSRAM 的带宽开始成为瓶颈。P4 的片内 SRAM 速度比外挂 PSRAM 快很多但容量很小所以核心策略是“把最常访问的数据放到最快的内存里让其余数据通过 DMA 批量搬运”。我在这一步做了几个调整。首先是权重常驻 PSRAM启动阶段从 Flash 一次性拷贝过去decode 阶段不再碰 Flash。Flash 的随机读取延迟很高哪怕有 cache频繁访问也会拖慢速度。其次是热数据放 SRAM比如 embedding 表、最近几层的权重、KV cache 的高频访问部分。SRAM 容量有限放不下全部权重但放这几个小块是划算的。再次是静态分配程序启动时把所有 tensor 的位置一次性定好推理路径上坚决不用 malloc 和 free。MCU 上的动态内存分配不仅慢还容易产生碎片一次推理要运行几十上百次迟早出问题。DMA 也在这里派上用场。计算下一层之前先用 DMA 把下一层权重从 PSRAM 拉到 SRAM 缓冲区让搬运和当前层的计算重叠起来。这一步做完速度从 1.91 到 2.63。让我比较意外的是DMA 的收益没有想象中大因为 PSRAM 的总带宽就那么多计算和搬运共用一条路只是把“等数据”的时间藏到了“算数据”的时间里净效果取决于它们的重叠程度。3.4 第四刀双核并行与异步流水ESP32-P4 有双核理论上可以把计算拆给两个核一起干。但 LLM 解码是严格串行的第 N 个 token 没算完第 N1 个 token 就没法开始不能简单地把不同 token 丢给两个核。我采用的方式是“任务分工、流水重叠”Core 0 负责调度、DMA 搬运、采样和串口输出Core 1 专注最重的矩阵乘和向量计算。两者之间用一个无锁环形队列传递任务尽量减少互相等待。实际调下来这个阶段只提升了 1.38 倍从 2.63 到 3.36。原因很直白两个核共享同一份 PSRAM 带宽Core 0 在搬权重时Core 1 的矩阵乘会被带宽竞争拖慢。如果某个操作对内存带宽的占用特别大两核并行甚至会负优化。后来我做了个折中矩阵乘期间Core 0 不搬大批数据只做轻量级的采样和状态管理矩阵乘结束后的间隙里Core 0 再预取下一层权重把带宽竞争的窗口尽量错开。关于双核我有几句实在话想讲。嵌入式 MCU 上的多核编程难点往往不在并发逻辑本身而在调试。两个核同时访问同一块 PSRAM、同一个外设寄存器出了问题极难复现。我建议先在单核版本上把功能完全稳定再做并行化并行时给每个核分配明确的内存区域别搞大范围共享数据。共享得越多锁就越多性能反而越差。3.5 系统级细节编译参数、时钟与 Flash 缓存到 3.36 之后再改算法已经啃不动了剩下的一点提升空间全在系统参数里。这一步比较琐碎但每一步都在堆数字。首先是编译选项把优化等级开到最高打开 LTO 链接时优化给编译器指定符合 P4 内核的 mcpu 参数让它有机会生成 SIMD 向量指令。这个操作对机器码的影响非常大同样一份 C 代码针对性编译出来的二进制可能快 20% 以上。其次是时钟。把 CPU 频率拉到标称最高值PSRAM 时钟也适当调高。这两个参数最容易出稳定性问题供电不足时直接死机或者随机复位。我的经验是频率每次只调一档跑满 20 分钟压力测试再继续千万别一次性拉满省事。最后是系统减法关掉所有调试打印把无关外设的中断全部屏蔽定时器中断频率降到满足需求的最低值。有一次我只是把日志等级从 verbose 改成 nonetok/s 就涨了 0.15属于白捡的收益。做完这些速度来到 4.31。我一度想继续往 5 以上冲但用带宽模型算了一笔账发现已经接近这块板子的有效 PSRAM 带宽上限。再往上硬挤收益会非常小不如停下来把方法论沉淀好。这个认识很关键知道什么时候该收手也是性能优化的一部分。3.6 优化效果总表一步一步从 0.61 涨到 4.31阶段主要改动达到 tok/s本次提升累计倍率基线朴素浮点逐层推理0.61-1.00xINT8 量化per-channel INT8 权重1.121.84x1.84xINT4 GEMV 重写4bit 打包权重、定点内积、算子融合1.911.71x3.13x内存与缓存策略权重常驻 PSRAM、DMA 搬运、静态分配2.631.38x4.31x双核并行计算与搬运分工异步流水3.361.28x5.51x系统级参数编译选项、CPU/PSRAM 频率、中断与日志裁剪4.311.28x7.07x这张表里的每个数值只对当前这块板子和这个 125M 级模型成立。换一个更大的模型或者换一块 PSRAM 带宽不同的板子每步的提升比例都会变。但顺序很有参考价值先解决“数据体积”再解决“计算效率”接着解决“数据搬运”最后才考虑“多核”和“系统参数”。这个顺序的核心逻辑是每一步都要先消除最大的短板而不是从最容易的部分入手。4. 优化中踩过的坑和排查技巧4.1 瓶颈判断算力不足还是带宽不足一个实测方法我在优化途中经常需要回答一个问题现在到底是 CPU 算不过来还是内存搬运不过来如果判断错就可能白费力气去优化一个根本不是瓶颈的模块。有一个简单的估算方法。decode 阶段每个 token 都要把模型权重完整读一遍假设量化后权重是 60MB目标是 4 tok/s那么每秒至少要搬运 240MB 数据加上中间激活和 KV cache 的读写实际需求会超过 300MB/s。如果这块板子的有效 PSRAM 带宽也就是 300MB/s 左右那不管算子写得多漂亮速度都上不去带宽就是天花板。实际验证方法有两个。第一个是把权重临时全放到 SRAM如果模型很小或者只测单层速度大幅提升说明 PSRAM 带宽是瓶颈提升不明显说明瓶颈在算力。第二个是只降 CPU 频率比如降 20%如果推理速度几乎不变说明 CPU 算力有大量余量真正限制速度的是内存。我在 4.31 这个节点做了这个实验确认算力已经富余继续优化算子没有意义于是停止。4.2 量化模型“变笨”了怎么办量化最怕的不是速度没上来而是模型缩水太严重回答质量掉到不可用。我自己踩过的坑有三个。第一个是量化粒度太粗per-tensor 对整个层用一个 scale碰到激活值分布不均匀的层误差会被放大。解决方案是换 per-channel 或 per-group按行或按块单独给 scale。第二个是校准集和实际使用场景不匹配我用纯英文语料做校准结果主要跑中文任务时输出明显变差换成结合真实对话样本的校准集后才好转。第三个坑是敏感层不该无脑量化。attention 模块和输出层的数值变化会被后续层累积放大对质量影响特别大。我后来在 PC 上做逐层敏感性分析把量化后 perplexity 变化最明显的层保留为 INT8 甚至 FP16其余层用 INT4。这样折中下来模型体积没增加太多回答质量回到几乎可用的水平。如果遇到量化后模型崩坏先从这三条线排查比反复调 scale 公式有效得多。4.3 浮点与整数混用时的精度陷阱把模型从纯浮点改成整数混合计算最隐蔽的问题不是“慢”而是“数值悄悄出错”。我遇到过一个经典问题矩阵乘用 32 位整数累加矩阵维度超过 1024 后部分中间结果溢出生成的内容开始随机乱码。排查时差点怀疑是模型权重拷错后来逐层对比发现是累加溢出的位置正好对应某个大数值通道。解决办法是反量化前先把 scale 归一化到合适的幂次确保累加结果落在 int32 范围内。另一个容易出问题的地方是残差连接。出自两个不同分支的 tensor如果各自 scale 不同直接相加就相当于关公战秦琼必须先把它们的量化参数对齐到同一尺度再做加法。我当时在残差连接上忽略了这一点输出完全不可用花了一个晚上才定位到。还有一个原则RMSNorm 和 LayerNorm 这类要做均值、方差计算的操作一定要保留浮点精度别为了省事硬塞进定点逻辑。方差计算对精度极其敏感一旦定点化训练时的统计特性全乱了模型表现会变得无法预测。调试这类问题时我的习惯是给定一个随机种子在 PC 上以相同的输入跑一遍参考实现把每一层的输出导出来和板子上的逐层结果做对比第一个数值不一致的位置就是问题所在。4.4 调试工具与排查实录MCU 上的调试手段比 PC 少很多但也不是没有套路。我整理了一个速查表很多问题在初期就能对上号现象可能原因处理方式首 token 很慢后续稳定prefill 未优化或 Flash 加载慢优化 prefill 向量化权重常驻 PSRAM中途卡顿、偶发停顿中断、日志打印、DMA 冲突裁剪中断关闭日志检查 DMA 通道分配输出乱码、数值爆炸反量化溢出、scale 未对齐检查 int32 累加溢出对齐残差 scale双核并行后更慢了PSRAM 带宽竞争、锁开销大减少跨核共享数据用无锁队列必要时回单核高温、随机复位频率过高、供电不足降低 CPU/PSRAM 频率检查供电改善散热如果用的是 llama.cpp 这类现成框架它们自带的日志和 profiling 接口会省不少事。但 MCU 上的 C 库裁剪很厉害有些 hook 不一定能用需要自己适配内存分配器。这个主题比较长我会在系列后续单独写一篇框架适配相关的文章这里先提个醒不要以为 PC 上能编译通过的框架代码在 MCU 上就能直接跑实际情况往往是这种小节里的小坑最多。5. 后续文章地图从总览到实操5.1 系列目录规划这个 00 篇只负责把全貌讲清楚。为了让每个优化步骤都可复现后面的文章我按这条线拆开写01ESP32-P4 开发环境搭建与第一版推理移植02模型选择、导出、校准与 INT8/INT4 量化实操03GEMV 与核心算子重写附关键代码片段04内存布局、DMA 搬运与静态分配策略05双核并行与任务拆分含同步细节06性能测试方法、瓶颈定位与调参清单每一篇都会包含完整的操作步骤、实测数据和对应的避坑记录尽量做到能照着做而不是“讲了道理但复现不出来”。如果你只关心某个环节比如量化可以直接跳到对应篇目建议还是按顺序看因为后边的优化点往往依赖前边的基础。5.2 你需要什么基础、建议的动手顺序想实操的话建议有嵌入式 C 语言基础和基本的 Transformer 结构概念知道 token、层、注意力这些词在说什么。如果第一次接触请先把手头的开发板点灯和串口跑通再回来看 01 篇。不建议一上来就做量化那部分模型导出和校准集准备牵扯的工具链细节很多新手容易卡在环境上反而消磨信心。我个人实际操作的体会是把“PC 端推理输出”当成唯一参照标准每一步改动都做相同输入下的输出比对确保没有引入数值偏差再继续。这个方法论比单看 tok/s 数字变化重要得多因为很多坑在数字上根本看不出来只能在内容质量上暴露。先把这张地图放在心里再一篇文章一个坑去踩你会明白 7 倍提升不是玄学而是每个环节多榨一点出来的叠加。下一步就是我们真正开始动手搭环境的时候了。