
1. 项目缘起与整体思路拆解1.1 为什么要在 MCU 上跑大模型先说结论在 ESP32-P4 上跑 LLM不是为了替代手机或 PC 上的推理而是为了验证一条“端侧极低功耗、离线可用、数据不出设备”的技术路线。我最初动这个念头是因为手头有一批工业现场的数据采集节点用的是 ESP32 系列平时只做传感器读取和上报。但现场经常没有稳定网络有些场景甚至要求数据绝对不出本地这时候如果能让设备自己“看懂”几句自然语言指令或者对采集到的日志做一次本地摘要价值就完全不一样了。ESP32-P4 是乐鑫新出的高性能 MCU双核 RISC-V主频跑到 400 MHz带 SIMD 和 PIE 指令扩展片上 SRAM 有 768 KB还支持外挂 PSRAM。这个配置放在 MCU 里算是相当能打的了。但“能打”是相对的——一个 0.5B 参数的小模型量化到 int8 之后权重也要 500 MB 左右就算量化到 int4也得 250 MB 上下。P4 的片上内存远远不够必须外挂 PSRAM而且推理过程要精打细算每一步都在跟内存带宽和算力较劲。我选这个项目核心目标很明确在 ESP32-P4 上把一个小参数量 LLM 的推理跑通并且把吞吐从最初的 0.61 tok/s 优化到 4.31 tok/s。0.61 tok/s 是什么概念大概一秒蹦不出一个完整的词交互体验基本为零。4.31 tok/s 虽然也不算快但已经能支撑一些简单的指令解析和短文本生成了。这 7 倍的提升不是靠换硬件而是靠一整套软硬件协同的优化内存布局、指令集加速、算子融合、量化策略、缓存调度每一块都抠出了不少性能。这个系列我会拆成若干篇来讲本篇是总览先把整体思路、关键决策点和优化路线图讲清楚后面再逐篇深入每个模块的实操细节。适合谁看如果你在做 MCU 上的边缘 AI 推理或者对 RISC-V 平台上的模型部署感兴趣又或者你只是想了解“在资源极度受限的环境下怎么把性能榨出来”那这个系列应该能给你一些可直接抄作业的思路。1.2 整体方案选型与架构设计在动手之前我花了大概两周时间做方案调研和选型。核心问题有三个选什么模型、用什么推理框架、怎么利用 P4 的硬件特性。模型选型这块我试过几个方向。最开始想跑 TinyLlama-1.1B但量化到 int4 之后权重仍有约 600 MB外挂 PSRAM 的带宽和容量都吃紧实测加载都费劲。后来转向更小的模型最终锁定在一个 0.5B 级别的紧凑模型上层数少、隐藏维度小量化到 int8 后权重约 500 MBint4 后约 250 MB。实际部署时我用了混合量化权重 int4激活值 int8这样在精度和内存之间取了个平衡。模型结构是标准的 decoder-only Transformer但我去掉了不必要的分支只保留核心的 attention 和 FFN 结构。推理框架方面我没有直接用 TFLite Micro 或 ONNX Runtime原因很简单这些通用框架对 RISC-V 的 PIE 指令支持不够细而且内存管理粒度太粗在 P4 这种内存紧张的平台上很难做到极致优化。我选择自己写一个轻量级的推理引擎核心算子手写实现直接调用 P4 的 PIE 加速指令。这样做工作量大但每一层的内存分配、每一次矩阵乘的循环展开都能自己控制优化空间大得多。硬件特性利用是这次优化的重头戏。ESP32-P4 的 RISC-V 核心支持 PIEPacked SIMD指令扩展可以一次处理多个 8 位或 16 位数据。矩阵乘法是 LLM 推理的绝对大头我针对 int8 和 int4 的矩阵乘分别写了 PIE 加速版本把内层循环展开用向量指令一次算 8 个或 16 个乘加。另外P4 的缓存结构是分层的L1 和 L2 的容量有限我把频繁访问的权重块做了分块加载尽量让数据在缓存里多待一会儿减少 PSRAM 的访问次数。整体架构上我把推理流程拆成几个阶段模型加载与内存布局、tokenizer 预处理、逐层推理、采样与输出。每个阶段都有独立的优化点后面会逐一展开。这里先给一个全局的优化路线图让你心里有个数优化阶段主要手段预期收益内存布局权重分块、PSRAM 对齐、缓存预取减少访存延迟算子加速PIE 向量化、循环展开、算子融合提升计算吞吐量化策略混合精度、权重重排、激活值动态缩放降低内存占用调度优化双核并行、流水线重叠、中断绑定提升利用率这个路线图不是拍脑袋定的而是我在实测中逐步摸索出来的。最开始跑通的时候只有 0.61 tok/s后来每做一个优化就测一次记录下每个阶段的提升幅度最终累积到 4.31 tok/s。后面我会把每个阶段的实测数据和踩坑经验都分享出来。2. 核心细节解析与实操要点2.1 内存布局把 PSRAM 用出 SRAM 的感觉ESP32-P4 的片上 SRAM 只有 768 KB而模型权重动辄几百 MB必须放在外挂 PSRAM 里。PSRAM 的带宽和延迟跟 SRAM 差了一个数量级如果每次矩阵乘都直接从 PSRAM 读权重性能会被访存拖死。我的做法是分块加载 缓存复用把权重矩阵按行或按列切成小块每次只把当前计算需要的那一块加载到片上 SRAM 或缓存里算完再换下一块。具体分块大小怎么定这需要算一笔账。假设 PSRAM 的随机读取延迟约 100 ns突发传输带宽约 200 MB/s而片上 SRAM 的访问延迟只有几个时钟周期。如果块太小频繁的加载开销会吃掉计算时间如果块太大缓存放不下又会频繁换出。我实测下来对于 int8 权重的矩阵乘块大小设在 4 KB 到 8 KB 之间比较合适刚好能放进 L1 缓存同时加载开销占比不到 10%。另一个关键是内存对齐。PIE 指令对数据对齐有要求如果权重块的起始地址没有按 16 字节或 32 字节对齐向量加载指令会触发异常或者降级成非对齐访问性能直接打对折。我在模型加载阶段就把所有权重按 32 字节对齐重新排布虽然多占了一点内存但换来的性能提升非常值。注意PSRAM 的访问要尽量用突发模式连续读取比随机读取快得多。我在权重排布时特意把同一行或同一列的数据放在连续地址上这样 PIE 加载指令可以一次拉一整块效率最高。还有一个容易被忽略的点栈和堆的分配。推理过程中会频繁创建临时缓冲区如果这些缓冲区放在 PSRAM 里每次访问都要走外部总线延迟很高。我把所有临时缓冲区都放在片上 SRAM 里虽然容量紧张但通过精细的生命周期管理复用同一块内存实际占用并不大。具体做法是给每个算子分配固定的 scratch buffer算完就标记为空闲下一个算子接着用。2.2 PIE 指令加速让矩阵乘跑满向量单元矩阵乘法是 LLM 推理里最耗时的部分占了总计算量的 80% 以上。ESP32-P4 的 PIE 指令集支持 8 位和 16 位的 SIMD 操作我针对 int8 矩阵乘写了专门的向量化实现。核心思路是把权重和激活值都按向量宽度打包用一条指令完成多个乘加运算。以 int8 矩阵乘为例PIE 可以一次处理 16 个 8 位数据。我把内层循环按 16 展开每次加载 16 个权重和 16 个激活值做点积后累加到 32 位的累加器里。这里有个细节int8 乘法的结果可能溢出所以累加器要用 32 位而且每算完一批要及时把累加结果存回内存避免累加器溢出。循环展开的层数也有讲究。我试过展开 4、8、16 三种实测展开 8 的时候性价比最高。展开太少指令流水线填不满展开太多寄存器不够用反而要频繁 spill 到栈上性能下降。展开 8 的时候正好能把 P4 的通用寄存器和向量寄存器都用上指令级并行度也够。还有一个优化点是算子融合。标准的 Transformer 里有 LayerNorm、Attention、FFN 等多个算子如果每个算子都单独读写一遍内存访存开销会很大。我把 LayerNorm 和后面的矩阵乘融合在一起LayerNorm 算完的结果直接留在寄存器里紧接着做矩阵乘省掉了一次内存往返。类似地Attention 里的 QK^T 和 softmax 也做了融合softmax 的归一化因子在计算过程中动态更新不用等所有分数算完再统一处理。实操心得PIE 指令的吞吐很高但前提是数据要喂得上。我在写矩阵乘的时候特意把权重加载和计算做了流水线重叠当前块在计算的时候下一块已经在后台加载了。这样计算单元基本不会空转实测吞吐提升了约 30%。2.3 量化策略精度和内存的平衡术量化是让 LLM 能在 MCU 上跑起来的关键。我最初试过全 int8 量化模型大小约 500 MBPSRAM 勉强能放下但推理速度不理想因为 int8 的矩阵乘虽然比浮点快但权重还是太大访存瓶颈明显。后来转向 int4 量化权重直接减半到 250 MB 左右访存压力小了很多但精度损失也比较明显生成的内容经常出现重复或乱码。最终我采用了混合量化方案权重用 int4激活值用 int8。这样权重占用小激活值的精度也够用。具体实现上权重在加载时做一次反量化把 int4 还原成 int8 再参与计算虽然多了一步转换但转换后的数据可以缓存在片上实际开销不大。激活值则保持 int8每层的输入输出都做动态缩放缩放因子根据当前层的数值范围实时计算。权重的排布方式也影响量化效果。我试过按行量化和按列量化最后发现按通道量化效果最好。每个输出通道有独立的缩放因子这样不同通道之间的数值差异不会互相干扰精度损失更小。代价是需要额外存储缩放因子但每个通道一个 float总开销可以忽略。还有一个坑是反量化时的舍入误差。int4 只有 16 个离散值反量化到 int8 的时候如果直接截断误差会累积。我用的是四舍五入加饱和处理虽然多花了几条指令但生成质量明显更稳定。实测下来混合量化后的模型在短文本生成任务上困惑度只比全精度模型高了不到 5%完全可接受。量化方案权重占用激活值占用推理速度生成质量全 int8500 MB1 MB基准好全 int4250 MB0.5 MB快 1.8 倍一般混合量化250 MB1 MB快 1.6 倍较好这个表格是我实测数据的汇总可以看到混合量化在速度和质量的平衡上是最优的。全 int4 虽然更快但生成质量下降太多实际不可用。3. 实操过程与核心环节实现3.1 模型加载与内存初始化模型加载是整个推理流程的第一步也是最容易出问题的一步。ESP32-P4 的 PSRAM 容量有限如果加载方式不对要么加载失败要么加载后内存碎片严重后续推理跑不起来。我的做法是一次性分配 分区管理。启动时先向系统申请一大块连续的 PSRAM大小根据模型权重的实际占用来定留出约 10% 的余量。然后把这块内存分成几个区域权重区、激活值区、临时缓冲区。权重区按层顺序排列每层的权重块按 32 字节对齐激活值区按最大序列长度分配复用同一块内存临时缓冲区给矩阵乘和 softmax 用也是固定分配、循环复用。加载权重的时候我从 Flash 里按块读取每读一块就做一次反量化然后写到 PSRAM 的对应位置。这里有个细节Flash 的读取速度比 PSRAM 慢如果边读边算推理会被 Flash 拖累。所以我选择在初始化阶段就把所有权重加载到 PSRAM推理时只从 PSRAM 读虽然启动慢了几秒但推理阶段的访存延迟可控。注意PSRAM 的初始化需要配置正确的时序参数不同厂家的 PSRAM 芯片参数不一样。我用的是一款常见的 8 MB PSRAM时序配置错了会导致数据读写不稳定表现为推理结果随机出错。建议先用内存测试程序跑一遍全片读写校验确认无误后再加载模型。内存初始化完成后我会打印一份内存布局图标出每个区域的起始地址和大小。这个习惯帮我省了很多调试时间后面遇到内存越界或者数据错乱直接对照布局图就能定位问题。3.2 逐层推理的流水线实现推理流程按层推进每一层包含 Attention 和 FFN 两个主要模块。我把每一层的计算拆成几个阶段输入归一化、QKV 投影、注意力计算、输出投影、FFN 上升投影、激活函数、FFN 下降投影。每个阶段都有对应的算子和内存缓冲区。Attention 计算是重头戏。QK^T 矩阵乘的规模是序列长度乘以隐藏维度序列长度我限制在 128 以内隐藏维度是 1024这样 QK^T 的结果矩阵是 128x128刚好能放进片上 SRAM。softmax 在 SRAM 里原地计算算完直接和 V 矩阵乘结果写回激活值缓冲区。整个过程没有大的内存搬运延迟主要花在矩阵乘的计算上。FFN 部分的矩阵乘规模更大上升投影是 1024x4096下降投影是 4096x1024。这两个矩阵乘是推理里最耗时的部分我用了 PIE 向量化加循环展开实测单层 FFN 的计算时间从最初的 12 ms 降到了 3.5 ms 左右。优化前后的对比很明显优化前每生成一个 token 要等将近 1.6 秒优化后降到 230 ms 左右。流水线调度上我尝试过双核并行一个核负责 Attention另一个核负责 FFN中间用信号量同步。理论上能提升接近一倍但实际受限于内存带宽和缓存一致性提升只有约 40%。后来我改成更细粒度的并行把矩阵乘的行分块两个核各算一半同步开销更小实测提升约 55%。这个方案对算子的实现有要求需要把矩阵乘写成可分割的形式但收益很可观。3.3 采样与输出优化推理跑完之后最后一层输出的是 logits 向量需要经过采样才能得到下一个 token。采样本身计算量不大但如果不注意也会成为瓶颈。我最初用简单的 argmax 采样速度很快但生成的内容很死板经常重复。后来换成温度采样加了 top-k 和 top-p 过滤生成质量好了很多但采样时间从几乎为零增加到了约 15 ms。15 ms 听起来不多但在 4.31 tok/s 的吞吐下每个 token 的总时间是 232 ms采样占了 6% 左右。我做了两个优化一是把 top-k 的排序改成部分选择算法不用全排序只需要找出前 k 个最大值时间复杂度从 O(n log n) 降到 O(n)二是把温度缩放和 softmax 融合在一起省掉一次遍历。这两项优化把采样时间压到了 8 ms 以内。输出环节还有一个容易忽略的点tokenizer 的反向解码。模型输出的是 token id需要查表转换成文本。如果词表很大查表本身也有开销。我把词表按频率排序高频词放在前面用二分查找加速。另外对于多字节的 UTF-8 字符我做了缓存避免重复解码。实操心得采样阶段的随机数生成器也有讲究。我用的是 xorshift 算法比标准库的 rand() 快很多而且周期足够长不会出现明显的重复模式。种子用硬件随机数生成器初始化保证每次生成的多样性。4. 常见问题与排查技巧实录4.1 推理结果异常排查在调试过程中我遇到最多的问题就是推理结果异常要么输出乱码要么重复同一个 token要么直接崩溃。排查这类问题我总结了一套流程按顺序检查基本能覆盖 90% 的情况。第一步检查内存对齐。PIE 指令对对齐要求很严格如果权重或激活值的地址没有按 16 字节对齐向量加载会出错但错误可能不会立即显现而是表现为结果逐渐偏离。我的做法是在每个算子入口加一个对齐断言一旦发现不对齐就打印地址和调用栈快速定位。第二步检查量化缩放因子。混合量化里权重和激活值的缩放因子是分开的如果某一层的缩放因子算错了这一层的输出就会整体偏大或偏小经过几层累积后就会溢出或归零。我在每层输出后加了一个数值范围检查如果超出预期范围就报警这样能快速定位到出问题的层。第三步检查序列长度和位置编码。LLM 的位置编码对序列长度敏感如果推理时序列长度超过了训练时的最大长度位置编码会外推结果可能完全错误。我把最大序列长度硬编码在配置里推理时如果超过就直接截断避免外推。问题现象可能原因排查方法输出乱码内存对齐错误检查权重地址对齐重复 token采样参数不当调整温度和 top-k推理崩溃内存越界检查缓冲区大小结果逐渐偏离量化缩放错误逐层检查数值范围速度突然变慢缓存命中率下降检查分块大小这个表格是我在实际调试中整理的每次遇到问题先对照表格能省不少时间。4.2 性能瓶颈定位与优化性能优化不是一蹴而就的我用了分段计时的方法来定位瓶颈。在推理流程的每个阶段前后加时间戳记录每个阶段的耗时然后找出占比最大的部分重点优化。最初跑通的时候FFN 矩阵乘占了总时间的 65%Attention 占 20%其他占 15%。所以我的优化重点放在 FFN 上做了 PIE 向量化和循环展开把 FFN 的耗时降了一半多。分段计时有个坑如果时间戳的读取本身开销太大会影响测量结果。我用的是 CPU 周期计数器读取一次只要几个时钟周期对整体影响可以忽略。另外计时点不要加得太密否则日志输出本身也会拖慢推理。我一般只在每个大阶段前后加计时点细节部分用性能计数器辅助分析。还有一个优化点是中断绑定。ESP32-P4 的中断控制器支持把中断绑定到特定核心我把推理相关的定时器和 DMA 中断都绑定到同一个核心减少核心间的干扰。这个优化看起来不起眼但实测下来对稳定性帮助很大推理时间的抖动从 ±15% 降到了 ±5% 以内。注意优化的时候要一次只改一个变量改完立即测试记录数据。我试过同时改好几个地方结果性能反而下降了花了很久才定位到是其中一个改动引入了额外的内存拷贝。后来我养成了习惯每次只动一个点用 git 管理版本随时可以回滚。4.3 稳定性与长时运行测试推理跑通只是第一步能不能稳定运行才是关键。我做了一轮 24 小时的长时运行测试每 10 秒生成一段文本记录成功率和耗时。测试中发现两个问题一是内存泄漏跑几个小时后可用内存逐渐减少最后分配失败二是温度升高后 PSRAM 的时序出现偏差偶尔读到错误数据。内存泄漏的根源是临时缓冲区没有正确释放。我在每个算子结束时都加了释放逻辑但有一个分支路径漏掉了导致每次经过那个分支就泄漏一点。修复后24 小时运行的内存占用曲线基本平稳。PSRAM 的时序问题通过降低访问频率和增加刷新周期解决了代价是带宽略有下降但稳定性大幅提升。长时运行还有一个隐患是看门狗复位。推理过程中如果某个算子耗时过长看门狗会触发复位。我把看门狗的喂狗周期调短并在每个算子内部加了喂狗点确保不会误触发。这个改动对性能几乎没有影响但避免了随机复位的问题。5. 优化效果复盘与后续扩展方向5.1 从 0.61 到 4.31 tok/s 的实测数据把整个优化过程的数据整理出来能清楚看到每一步的收益。最初跑通的时候没有任何优化纯解释执行吞吐是 0.61 tok/s。然后我按顺序做了以下优化每做一项测一次优化步骤累计吞吐提升幅度基线无优化0.61 tok/s-内存对齐 分块加载1.12 tok/s84%PIE 向量化矩阵乘2.05 tok/s83%混合量化2.68 tok/s31%算子融合3.22 tok/s20%双核并行3.89 tok/s21%采样与输出优化4.31 tok/s11%这个表格里的数据是多次测试的平均值每次测试跑 100 个 token取稳定后的吞吐。可以看到最大的提升来自内存对齐和 PIE 向量化这两项加起来贡献了超过 3 倍的提升。量化虽然提升了速度但幅度没有想象中大主要是因为访存瓶颈缓解后计算瓶颈变得更突出。实操心得优化要有优先级先解决最大的瓶颈。我一开始想先做量化觉得权重小了速度自然快但实测发现内存对齐没做好的话量化带来的收益很有限。后来调整顺序先把访存路径理顺再做量化效果就好多了。5.2 后续可以继续挖的方向4.31 tok/s 不是终点还有不少可以继续优化的空间。我目前正在尝试的几个方向一是更激进的量化。int4 权重加 int4 激活值理论上能把内存占用再降一半但精度损失需要靠更精细的缩放策略来补偿。我在试验按组量化和离群值保留初步结果还不错但还需要更多测试。二是稀疏化。LLM 的权重里有很多接近零的值如果能把它们剪掉矩阵乘的计算量能大幅下降。但稀疏矩阵乘在 PIE 上的实现比较复杂需要重新设计数据布局和索引结构我还在摸索阶段。三是更细粒度的并行。目前双核并行是按行分块同步开销还有压缩空间。我在考虑用流水线的方式一个核算当前层另一个核预取下一层的权重进一步重叠计算和访存。四是模型结构搜索。针对 MCU 的硬件特性设计更友好的模型结构比如减少注意力头数、用深度可分离卷积替代部分全连接层等。这个方向需要训练侧的配合周期比较长但潜力很大。这个系列后续会逐篇展开每个优化点的具体实现细节包括代码片段、配置参数和实测数据。如果你也在做类似的事情欢迎交流踩过的坑和找到的技巧都可以互相分享。