☰
MCU上跑LLM:从0.61到4.31 tok/s的7倍推理优化实战
2026/10/8 18:13:55 网站建设 项目流程

1. 项目缘起与整体思路拆解

1.1 为什么要在 MCU 上跑 LLM

把大语言模型塞进一颗微控制器,这件事放在两年前听起来像是段子。我第一次在 ESP32-P4 上跑通一个 260K 参数级别的 TinyLLM 时,推理速度只有 0.61 tok/s,输出一句话要等半分钟,体验相当糟糕。但这件事本身的意义不在于"能不能用",而在于它验证了一条路径:在百元级硬件、无操作系统、无外部加速器的条件下,端侧推理是可行的。

ESP32-P4 是乐鑫推出的一颗定位偏高的 MCU,双核 RISC-V,主频 400MHz,带 768KB 片上 SRAM 和可外挂 PSRAM 的接口,还集成了 PIE(Processor Instruction Extension)这类面向向量/矩阵运算的指令扩展。这些特性决定了它比传统的 Cortex-M 系列更适合做定点神经网络推理。而 LLM 推理的核心是大量的矩阵乘加运算,正好是 PIE 指令的用武之地。

这个系列要解决的问题很具体:如何把 0.61 tok/s 的原始推理速度,通过一系列工程手段优化到 4.31 tok/s,实现约 7 倍的提升。这不是靠换硬件,而是靠软件层面的深度优化。适合的读者是:有嵌入式开发基础、对端侧 AI 感兴趣、愿意啃底层优化的工程师。如果你只是想调个 API 用大模型,这篇内容对你价值不大;但如果你想搞清楚"为什么慢"和"怎么变快",那接下来的内容应该能给你不少参考。

1.2 7 倍优化到底优化了什么

很多人看到"7 倍"第一反应是"是不是换了个更小的模型"。恰恰相反,模型结构基本没动,参数量也没砍。这 7 倍全部来自推理引擎层面的优化。我把它拆成几个维度:

  • 算子层面:把标量乘加替换成 PIE 向量指令,单条指令处理多个数据
  • 内存层面:减少 PSRAM 和 SRAM 之间的数据搬运,把热点数据常驻片上
  • 调度层面:双核分工,一个核做矩阵运算,一个核做采样和输出
  • 量化层面:从 FP32 降到 INT8,权重和激活都做定点化
  • 算法层面:KV Cache 复用、注意力计算的剪枝与近似

这几个维度不是孤立的,它们之间有耦合。比如你做了 INT8 量化,那 PIE 指令的利用率会更高,因为数据位宽变窄了,一次能塞进寄存器的元素更多。再比如你把热点数据常驻 SRAM,那内存搬运的延迟就降下来了,双核调度的收益才明显。

我个人的经验是:优化要按"收益/成本比"排序来做。先做量化,因为改动小、收益大;再做 PIE 指令替换,这个需要读指令手册、写内联汇编,成本高但收益也高;最后做双核调度,这个最容易出 bug,放在最后做。

1.3 整体架构与技术选型

整个推理引擎的架构可以分成四层:

层级职责关键技术
应用层接收输入、输出结果UART/USB 串口通信
调度层任务分发、双核协同FreeRTOS 任务 + 核间通信
算子层矩阵乘、激活、归一化PIE 向量指令 + 定点运算
存储层权重、KV Cache、中间结果PSRAM + SRAM 分级缓存

选型上,我没有用现成的 TFLite Micro 或 CMSIS-NN,原因是这两个框架对 LLM 这种以矩阵乘为主、且需要动态 KV Cache 的场景支持不够灵活。自己写算子虽然工作量大,但每一行代码都知道在干什么,优化的时候心里有底。

模型格式上,我用的是 GGUF 的简化版——只保留权重和必要的元数据,去掉了大量对 MCU 无用的字段。加载的时候直接 mmap 到 PSRAM,避免一次性读入 SRAM 导致内存不够。

提示:ESP32-P4 的 PSRAM 访问延迟比 SRAM 高一个数量级,所以任何频繁访问的数据都必须想办法搬到 SRAM。这是整个优化里最容易被忽视、但影响最大的一点。

2. 核心细节解析与实操要点

2.1 PIE 指令集到底能加速什么

PIE 是 ESP32-P4 上的一套向量扩展指令,支持 128 位宽的 SIMD 操作。什么意思呢?假设你要做 INT8 的向量点积,标量指令一次算一个乘加,PIE 指令一次能算 16 个。理论上 16 倍加速,实际因为内存带宽和指令流水线的限制,能拿到 4 到 8 倍就不错了。

但 PIE 不是万能的。它有几个限制你必须清楚:

  • 数据必须对齐:128 位操作要求地址 16 字节对齐,不对齐会触发异常或者性能骤降
  • 指令延迟不固定:有些 PIE 指令是多周期的,流水线排布不好会有气泡
  • 寄存器数量有限:PIE 有专门的向量寄存器组,用超了会溢出到栈上,反而更慢

我实测下来,矩阵乘里最值得用 PIE 优化的是权重和激活的内积计算。这部分占了整个推理 70% 以上的计算量。把这一块用 PIE 重写之后,单这一项就带来了约 2.5 倍的端到端加速。

具体做法是:把权重按 16 字节对齐分块,激活也做同样的分块,然后用pie_dotp_s8这类指令做块内点积,最后累加。代码大概长这样:

// 简化示意,实际需要处理边界和对齐 int32_t pie_matvec_block(const int8_t *w, const int8_t *a, int len) { int32_t acc = 0; for (int i = 0; i < len; i += 16) { acc += pie_dotp_s8(w + i, a + i); // 一次算16个乘加 } return acc; }

2.2 量化:从 FP32 到 INT8 的取舍

量化是性价比最高的优化。FP32 的权重占 4 字节,INT8 只占 1 字节,内存占用直接降到 1/4,内存带宽压力也降到 1/4。对于内存带宽受限的 MCU 来说,这几乎是立竿见影的。

但量化有代价。LLM 里的激活值分布很不均匀,有些层动态范围很大,直接线性量化会丢精度。我用的是对称量化 + per-channel scale:每个输出通道单独算一个 scale,而不是整个张量共用一个。这样精度损失小很多。

量化的公式是:

q = round(x / scale) x_dequant = q * scale scale = max(abs(x)) / 127

实操中要注意几点:

  • 校准集要选好:用一批有代表性的输入跑一遍,统计每层的激活分布,再定 scale。校准集太小或者太偏,量化后的模型会明显变傻。
  • 敏感层保留高精度:第一层和最后一层对精度最敏感,我实测下来这两层用 INT16 或者保留 FP32,整体精度能提升不少,而性能损失很小。
  • 溢出保护:INT8 累加很容易溢出,矩阵乘的累加器要用 INT32,最后再饱和回 INT8。

注意:量化不是一劳永逸的。你换了模型结构或者输入分布,scale 就得重新校准。我踩过的坑是拿 A 模型的 scale 去量化 B 模型,结果输出全是乱码。

2.3 内存分级:把热点数据焊在 SRAM 上

ESP32-P4 有 768KB 片上 SRAM,外挂 PSRAM 可以到 16MB 甚至更多。但 PSRAM 的访问延迟大概是 SRAM 的 8 到 10 倍。如果你的推理引擎每次都从 PSRAM 读权重,那 PIE 再快也没用,瓶颈全在内存上。

我的做法是分层缓存:

  • 常驻 SRAM:当前正在计算的层的权重、KV Cache 的当前窗口、激活缓冲区
  • 放 PSRAM:不活跃的层权重、历史 KV Cache、模型元数据

关键是要做预取。在计算第 N 层的时候,后台 DMA 把第 N+1 层的权重从 PSRAM 搬到 SRAM。这样计算和搬运重叠,延迟就被藏起来了。

实测下来,光这一项优化就带来了约 1.8 倍的加速。而且它和 PIE 优化是正交的,两者叠加效果更好。

2.4 双核调度:让两个核都忙起来

ESP32-P4 是双核 RISC-V,默认情况下 FreeRTOS 会把任务分到两个核上,但如果你不显式指定,很可能两个核在抢同一个任务,或者一个核忙死一个核闲着。

我的调度方案是:

  • Core 0:专职做矩阵运算,跑 PIE 指令
  • Core 1:做采样、token 输出、串口通信、以及下一层的预取调度

两个核之间用队列通信。Core 0 算完一层,把结果丢到队列里,Core 1 取出来做后续处理,同时触发下一层的预取。

这里有个坑:核间通信本身有开销。如果每算一个 token 就通信一次,开销可能吃掉收益。我的做法是批量通信:Core 0 连续算完几层再通知 Core 1,减少通信次数。

3. 实操过程与核心环节实现

3.1 环境搭建与工具链配置

先说工具链。我用的是乐鑫官方的 ESP-IDF,版本选的是支持 P4 的最新稳定版。安装过程不复杂,但有几个点要注意:

# 克隆 ESP-IDF git clone --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32p4 . ./export.sh

安装完之后,用idf.py set-target esp32p4设置目标芯片。然后你需要确认 PIE 指令的支持情况——有些 PIE 指令需要特定的编译选项才能启用。我在CMakeLists.txt里加了:

target_compile_options(${COMPONENT_LIB} PRIVATE -mpie)

如果编译报错说找不到 PIE 指令,那可能是你的 IDF 版本太老,或者工具链没更新。我建议直接用官方推荐的工具链版本,别自己折腾。

3.2 模型转换与量化流程

模型转换分三步:

  1. 导出:从训练框架导出成 ONNX 或者 PyTorch 的 state_dict
  2. 量化:用校准集跑一遍,生成量化参数
  3. 打包:转成 MCU 能直接读的二进制格式

量化这一步我用的是自己写的脚本,核心逻辑是:

def calibrate(model, calib_loader): scales = {} hooks = [] def hook_fn(name): def fn(module, input, output): x = input[0].detach() scales[name] = x.abs().max() / 127.0 return fn for name, module in model.named_modules(): if isinstance(module, nn.Linear): hooks.append(module.register_forward_hook(hook_fn(name))) with torch.no_grad(): for batch in calib_loader: model(batch) for h in hooks: h.remove() return scales

校准集我用了 128 条样本,覆盖了常见的输入模式。实测下来,校准集从 64 增加到 128,精度提升明显;从 128 增加到 256,提升就很小了。所以 128 是个性价比不错的点。

3.3 PIE 算子的手写与调优

这是整个项目里最硬核的部分。我以矩阵乘为例,讲讲怎么从标量版本一步步优化到 PIE 版本。

标量版本:

void matvec_scalar(const int8_t *w, const int8_t *a, int32_t *out, int rows, int cols) { for (int r = 0; r < rows; r++) { int32_t acc = 0; for (int c = 0; c < cols; c++) { acc += w[r * cols + c] * a[c]; } out[r] = acc; } }

PIE 版本:

void matvec_pie(const int8_t *w, const int8_t *a, int32_t *out, int rows, int cols) { for (int r = 0; r < rows; r++) { int32_t acc = 0; int c = 0; for (; c + 16 <= cols; c += 16) { acc += pie_dotp_s8(w + r * cols + c, a + c); } for (; c < cols; c++) { acc += w[r * cols + c] * a[c]; } out[r] = acc; } }

关键优化点:

  • 对齐:w + r * cols + c必须 16 字节对齐。我在模型打包阶段就把每行权重的起始地址对齐到 16 字节,避免运行时判断。
  • 边界处理:cols不一定是 16 的倍数,剩下的用标量补。这部分占比很小,不用太纠结。
  • 循环展开:手动展开 2 到 4 次,减少循环开销。我实测展开 4 次收益最好,再多了指令缓存会不够用。

调优的时候我用的是 GPIO 翻转 + 逻辑分析仪测时间,比软件计时准。每次改完测一遍,记录数据,最后画成曲线找最优参数。

3.4 双核协同的代码实现

双核调度的核心是 FreeRTOS 的任务绑定:

xTaskCreatePinnedToCore(compute_task, "compute", 4096, NULL, 5, NULL, 0); xTaskCreatePinnedToCore(io_task, "io", 4096, NULL, 4, NULL, 1);

compute_task跑在 Core 0,io_task跑在 Core 1。两者通过队列通信:

QueueHandle_t result_queue = xQueueCreate(4, sizeof(layer_result_t)); // Core 0 void compute_task(void *arg) { while (1) { layer_result_t res = compute_layer(); xQueueSend(result_queue, &res, portMAX_DELAY); } } // Core 1 void io_task(void *arg) { layer_result_t res; while (1) { if (xQueueReceive(result_queue, &res, portMAX_DELAY)) { process_and_output(&res); prefetch_next_layer(); } } }

这里有个细节:队列长度要够。如果队列太短,Core 0 算完一层发现队列满了,就得等,白白浪费算力。我设成 4,实测下来基本不会阻塞。

4. 常见问题与排查技巧实录

4.1 推理结果乱码或重复

这是最常见的问题,原因通常有三个:

现象可能原因排查方法
输出全是乱码量化 scale 不对检查校准集和 scale 计算
输出重复循环KV Cache 索引越界打印 KV Cache 的读写指针
输出截断内存不足导致分配失败检查 PSRAM 剩余空间

我遇到过一次输出重复循环,查了半天发现是 KV Cache 的环形缓冲区索引算错了,写指针绕回的时候没处理好边界。后来加了个断言,每次写之前检查索引范围,问题就暴露出来了。

4.2 速度不达预期

优化做完发现速度没提升,或者提升很小,通常是这几个原因:

  • 瓶颈不在计算上:先用 profiling 确认时间花在哪。如果大部分时间在等内存,那优化计算没用。
  • PIE 指令没生效:检查编译选项,反汇编看看生成的指令里有没有 PIE 指令。
  • 双核没真正并行:用 FreeRTOS 的运行时统计看两个核的占用率,如果 Core 1 一直闲着,说明任务分配有问题。

我踩过最大的坑是编译器把 PIE 指令优化掉了。因为我写的内联汇编没有加volatile,编译器认为没有副作用,直接删了。加上volatile之后问题解决。

4.3 内存不够用

ESP32-P4 的 SRAM 只有 768KB,跑 LLM 很容易不够。我的经验是:

  • 权重放 PSRAM:权重是只读的,放 PSRAM 不影响正确性,只是慢一点
  • KV Cache 分页:不要一次性分配整个 KV Cache,按需分配,用完的页回收
  • 激活缓冲区复用:不同层的激活缓冲区可以复用同一块内存,因为层与层之间是串行的

提示:PSRAM 的分配要用heap_caps_malloc(size, MALLOC_CAP_SPIRAM),别用普通的malloc,否则会分配到 SRAM 上,很快就不够了。

4.4 常见问题速查表

问题排查方向解决思路
编译报错找不到 PIE 指令工具链版本更新 IDF 和工具链
运行时触发异常内存对齐检查 PIE 操作数地址
推理速度波动大任务调度检查双核负载和队列长度
精度下降明显量化参数重新校准,敏感层保留高精度
长时间运行死机内存泄漏检查 KV Cache 和临时缓冲区的释放

5. 优化效果复盘与后续扩展方向

5.1 各项优化的收益拆解

把整个优化过程的数据整理出来,能清楚看到每一项的贡献:

优化项优化前 tok/s优化后 tok/s累计提升
基线0.610.611.0x
INT8 量化0.611.422.3x
PIE 算子1.422.874.7x
内存分级2.873.656.0x
双核调度3.654.317.1x

可以看到,量化和 PIE 是两大主力,贡献了大部分收益。内存分级和双核调度是锦上添花,但如果没有前两步,后两步的收益也出不来。

5.2 还能怎么继续优化

4.31 tok/s 不是终点。我目前看到还有几个方向可以挖:

  • 更激进的量化:INT4 甚至二值化,理论上能把内存带宽再降一半,但精度损失需要仔细评估
  • 算子融合:把矩阵乘和激活函数融合成一个算子,减少中间结果的读写
  • KV Cache 压缩:用低秩近似或者量化压缩 KV Cache,减少内存占用和访问量
  • 指令级并行:手动排布 PIE 指令的流水线,减少气泡

我个人最看好的是算子融合,因为它不损失精度,纯粹是工程优化。但实现起来需要对整个计算图做分析,工作量不小。

5.3 给后来者的几点建议

如果你也想在 MCU 上跑 LLM,我的建议是:

  1. 先跑通再优化:别一上来就想着 PIE 和双核,先用最朴素的实现跑通,确认模型和量化没问题,再逐步优化。
  2. profiling 先行:每次优化前先测,确认瓶颈在哪。凭感觉优化往往会做无用功。
  3. 保留可回退的版本:每做一项优化就打个 tag,出问题了能快速回退对比。
  4. 别忽视内存:MCU 上内存比算力更稀缺,任何优化都要考虑内存占用。

这个系列后续我会把每个优化项单独展开,包括完整的代码、测试数据和踩坑记录。如果你对某个部分特别感兴趣,可以重点关注对应的章节。

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

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

立即咨询