MCU上跑NPU:模型与内存预算的端侧推理避坑指南 📅 发布时间:2026/9/18 18:02:11 👁 浏览次数: 去年我把一个八万参数的异常检测模型往一颗带 NPU 的 Cortex-M 上搬前后折腾了三周。真正卡住我的既不是模型结构也不是驱动而是一张怎么都填不平的内存表Flash 里要装固件、要留双区 OTA、还要塞模型SRAM 里要养协议栈、要养采样缓冲最后还得给 NPU 留一块激活区。那三周我反复在改模型和改内存布局之间横跳改一次模型就要重算一次内存挪一次内存又要重跑一次精度最后才想明白一件事——NPU 进 MCU 之后模型和内存根本不是两件事它们是同一张预算表的两列。这几年 NPU 的落点明显分成了三层笔记本和 PC 上的 NPU 动辄几十 TOPS手机上的 NPU 在几 TOPS 量级而 MCU 上的 NPU算力常常只有 0.1 到 1 TOPS本地 SRAM 从几 KB 到 1 MB 出头。算力差了三个数量级遇到的问题也完全不同——PC 上大家愁的是怎么把算子喂满MCU 上愁的是数据根本搬不过来。所以这篇文章不聊大模型推理只聊一件很具体的事在一个 Flash 一两兆、SRAM 一两百 K 的 MCU 上把 NPU 用起来模型侧和内存侧各自会踩到什么坑两者又怎么互相牵制。内容适合三类人看正在评估带 NPU 的 MCU 做端侧推理的产品和固件工程师已经把模型训好、准备往板子上搬但被内存卡住的算法同学以及还在纠结要不要上 NPU的硬件选型的人。我会把量化、算子、权重存放、激活复用、DMA 与 cache 这些环节按我实际踩坑的顺序讲一遍中间的计算过程都写出来你可以直接拿自己的模型套进去算。1. 先把账算清楚MCU 加 NPU 之后的内存模型变了什么1.1 MCU 原本的三级存储账本传统 MCU 的内存账其实很清爽就三级。最上面是寄存器堆和紧耦合内存TCM一两个时钟周期就能访问中间是片上 SRAM通常跑在同频总线上零等待或少等待最下面是 Flash容量大但慢而且慢得很有讲究。这里要专门说一下MCU 内部的 Flash 用什么接口访问这件事因为它直接决定了模型能不能放在 Flash 里跑。片上 Flash 属于嵌入式闪存读写特性跟外挂 NOR 完全不同擦写粒度大、寿命有限读虽然比写快得多但仍有几十纳秒级的访问延迟。芯片内部通常有一个 Flash 控制器挂在 AHB 或 AXI 总线上把 Flash 空间映射到统一地址空间里CPU 取指时走的是 XIP 那条路。为了不让取指把流水线拖死控制器一般带预取缓冲和 cache line比如 32 字节一行、预取 2 到 4 行。命中时几乎无感一旦跳转或者随机访问一次 miss 就是十几个到几十个周期。如果你把模型权重当成只读数据直接留在 Flash 里让 NPU 去取那 NPU 面对的其实是同一套控制器。它比 CPU 更吃亏CPU 取指至少有空间局部性而 NPU 读权重虽然也是顺序的但它的读速率要求高得多很容易把预取缓冲和 cache 全部冲垮。记住一个判断原则片内 Flash 的瓶颈从来不是容量而是随机访问 高带宽并发这两件事凑在一起。要么让访问变得极其规律要么干脆把数据搬进 SRAM。1.2 NPU 带来的三类新数据流NPU 一进来内存表就多出了三行而且这三行的性格完全不同。第一类是权重只读、体量大、访问模式极其规律。一个 8 位量化的卷积网络权重从几十 KB 到几百 KB 不等。第二类是激活读写都有、体量中等、生命周期短某一层的输出往往是下一层的输入用完就该回收。第三类是指令和控制描述符体量很小但必须在 NPU 能快速取到的地方否则 NPU 就处在等指令的状态算力再高也白搭。这三类数据对带宽的敏感度差别巨大。我做过一个很粗的估算一个小型卷积模型权重 300 KB如果每秒推理 25 次光是权重搬运就是 300 KB × 25 ≈ 7.5 MB/s。听起来不大但 MCU 上外挂 QSPI Flash 在小粒度、非连续访问下的有效带宽实测往往只有十几到二十几 MB/s。也就是说光把权重搬一遍就吃掉了一大半可用带宽而这时候 NPU 的 MAC 阵列可能只忙了不到两成时间。把这两个数字放在一起看更明显假设 NPU 是 128 个 MAC 单元、主频 200 MHz理论峰值就是 128 × 200M ≈ 25.6 GMAC/s折合约 51 GOP/s。一个 6 MMAC 的模型纯计算时间是 6M / 25.6G ≈ 0.23 ms。而 300 KB 权重以 20 MB/s 搬进来要 15 ms。计算和搬运差了六十多倍这就是算力过剩、带宽饥饿的典型画面。1.3 为什么说模型和内存是同一枚硬币看到上面那组数字答案其实已经出来了真正难管的是两者之间的匹配关系。模型决定了内存的形状——你有多少权重、峰值激活多大、算子之间怎么串。内存决定了模型的上限——Flash 放不放得下、SRAM 够不够双缓冲、总线带宽撑不撑得住目标帧率。而量化这个动作同时改写两边它把权重体积压到四分之一把激活也压到四分之一但代价是引入了定点的数值语义和饱和风险。所以我后来不再问是模型难还是内存难而是改问三个更具体的问题这个模型的权重能不能常驻在 NPU 自己的 SRAM 里激活的峰值能不能压到剩下的空间以内权重的搬运路径是 Flash 直取、DMA 分块还是常驻本地这三个问题里只要有一个答不上来方案就还不成立。2. 模型侧的硬骨头量化语义、算子支持度和结构选型2.1 量化改的不是文件大小是数值语义很多人第一次做 int8 量化心态是压一压文件反正精度掉一点能接受。真正上板之后才发现量化改动的是每一个乘加的计算方式出问题的地方跟精度掉了几个点完全不在一个维度上。一个标准的 int8 卷积层里权重是 int8激活是 int8乘法结果是 int32 累加偏置也用 int32 存。算完之后要重新量化回 int8靠的是一个定点乘数加右移M (Sx × Sw) / Sy其中 Sx 是输入 scaleSw 是权重 scaleSy 是输出 scale。M 会被工具链拆成一个 int32 的乘数和一个移位量在推理时做乘法 舍入 移位 饱和。这套流程本身没问题问题出在三个地方。第一是校准集选得不对。校准集的目的是估计每一层激活的动态范围如果校准数据全是正常工况那一上板遇到边界数据激活就会大面积饱和表现出来不是精度缓慢下降而是输出直接卡在极值上不动。我一般会让校准集里至少放 10% 到 20% 的边界样本哪怕这些样本在训练集里很稀少。第二是 per-tensor 和 per-channel 的选择。对普通卷积per-tensor 勉强能用但对深度可分离卷积per-channel 几乎是必需的。原因很直白depthwise 卷积的每个通道都是独立卷积核通道之间的权重动态范围可以差几十倍用一个共享 scale 去覆盖小通道直接被压成噪声。第三是首尾两层的特殊照顾。第一层直接吃原始输入比如音频 MFCC 或者传感器采样值输入 scale 如果估得太粗后面所有层都在为它擦屁股最后一层输出 logits经常是几十几百的动态范围int8 塞不下稳妥的做法是最后一层留在 int32 或者干脆放回 CPU 算。2.2 算子支持度决定模型能不能落地这一点是我认为比量化更致命的问题。NPU 不是通用计算单元它支持的算子集是有限的而且每家都不一样。典型支持的是 1×1 和 3×3 卷积、stride 1 和 2、深度可分离卷积、最大池化和平均池化、全连接、逐元素加法、拼接、以及 ReLU 和 ReLU6 这类简单激活。至于 GELU、SiLU、LayerNorm、Softmax 这类带超越函数的算子很多 MCU 级 NPU 干脆不支持或者只支持一个查表近似版本。不支持会怎么样工具链会把它切回 CPU也就是 fallback。这一刀下去代价常常比整个 NPU 推理还高数据要先从 NPU 的本地缓冲搬回主 SRAMCPU 算完再搬回去中间还夹着 cache 维护和同步。我在一块板子上见过一个模型NPU 算子覆盖率 92%剩下 8% 的算子 fallback 带来的耗时占了总推理时间的六成以上。我的做法是从NPU 支持的积木出发设计模型而不是先训好再想办法适配。具体操作是先把工具链的算子支持表打印出来然后按支持的算子搭骨架训练精度不够就加宽而不是加花样。用 GELU 换 ReLU6、用全局平均池化换大 FC这些改动对精度的影响通常在一两个点以内但对能否部署是决定性的。2.3 结构选型Transformer 和 TCN 在 MCU 上的真实成本热词里 Transformer、TCN 都被提到了我来说说它们在 MCU 上的实际处境。Transformer 的核心是自注意力它的中间张量规模是序列长度的平方。序列长度 64 的时候还好长度 256 的时候中间张量就是 64 倍的增长而且 Softmax 需要指数运算NPU 一般只能查表近似。更麻烦的是推理时的 KV 缓存它要求把历史状态一直保留在内存里这对一个只有一百多 KB 可用 SRAM 的 MCU 来说基本是灾难。所以我现在的判断是MCU 上做序列建模优先用一维卷积堆叠或者轻量 GRU注意力机制只在序列极短、且工具链明确支持的情况下才考虑。TCN 的问题不太一样。它靠膨胀卷积扩大感受野膨胀系数翻倍增长意味着你要保留越来越长的历史缓冲。比如膨胀系数 1、2、4、8 四层最后一层的输入窗口就是前 15 个时间步每一层的激活都得留着内存占用随层数线性往上走。如果工具链能把中间张量及时回收问题不大如果它是全图分配策略那 TCN 的激活峰值会明显高于同等参数量的普通卷积网络。关于模型深度和宽度的平衡我的经验是这样在参数预算固定的前提下MCU 上宁可深一点窄一点也不要浅而宽。因为宽度直接决定单层激活张量的大小和峰值内存而深度带来的激活增长可以通过层间复用压下去。同样是 50 万参数一个 6 层 32 通道的网络比一个 3 层 96 通道的网络峰值激活常常小一半以上。3. 内存侧的暗礁权重放哪、激活怎么复用、DMA 和 cache 怎么相处3.1 权重存放的三种策略与取舍权重放哪里是我认为整件事里最需要提前定下来的决策因为它牵动 Flash 预算、SRAM 预算和推理耗时三个指标。第一种是 Flash 直取权重留在片内或片外 FlashNPU 通过 DMA 按需读取。优点是零 SRAM 占用模型能做得很大缺点是依赖 Flash 控制器的预取能力带宽受限而且如果权重被反复读取每次推理都要重读一遍功耗也会明显上升。第二种是常驻 SRAM把全部权重在初始化时搬进 NPU 本地或者主 SRAM。优点是推理时带宽压力骤降NPU 可以跑到接近峰值缺点是要占用一大块 SRAM而且这块内存从此不能被别的模块用。第三种是分块双缓冲把权重按层或者按块切分DMA 后台预取下一块NPU 计算当前块。这是最常见的折中方案代价是需要写比较细的调度代码还要处理对齐和依赖关系。具体怎么选我给你一个可以直接套的判断流程先算出模型权重总量 W再算出 SRAM 里除权重之外必须留出的空间 R协议栈、采样缓冲、显示缓冲、堆栈等再算出芯片可用 SRAM 总量 S。如果 W R 激活峰值 × 1.5 S那就大胆常驻这是最省心的方案。如果差得不多考虑只把访问最频繁的那几层通常是深层的 1×1 卷积和全连接常驻其余走 Flash。如果 W 本身就超过 S 的三分之一那基本只能走分块流式方案。还有一个容易被忽略的算术强度问题。全连接层是典型的每字节权重只换来一次乘加属于带宽杀手而 3×3 卷积的权重复用率是输出空间尺寸的平方动辄几百倍对带宽非常不敏感。所以如果你的模型里塞了几个大 FC 层把它们砍掉换成全局平均池化 小 FC往往是同时改善精度、体积和带宽的一刀。3.2 激活内存做一张张量生命周期表再谈优化激活内存的优化不能靠感觉必须做张量生命周期分析。方法很土但非常有效把模型的每一层输入输出列成一张表标出每个张量的出生层和死亡层然后找出在任意时刻同时存活的张量集合其中占用之和最大的那一刻就是峰值激活。举个具体的例子。假设一个四层卷积网络每层的输出张量分别是 A、B、C、D再加最后的输出 E并且有两条跨层连接比如残差那么 A 的存活期会被拉长到 D 之后。这时候如果工具链按顺序分配内存就会白白浪费前面已经可以回收的空间。手工调整的方法是把跨层连接的两端尽量安排得近一些或者在残差分支上做通道压缩。我实际用过的一个优化手段是把输入缓冲和中间缓冲复用。输入张量在第一次卷积之后就没用了而它的尺寸又比较大比如 49×40 的 MFCC 特征如果内存分配器能识别出输入张量已死、第一个中间张量可以覆盖它就能省下几 KB 到几十 KB。很多工具链默认不做这个优化需要你在模型转换时开启内存复用选项或者手动指定。还有一个细节是对齐。DMA 一般要求缓冲区按 4、16 或者 32 字节对齐cache line 通常是 32 字节。如果你把张量紧密排布每个张量的起始地址都不对齐DMA 就要做拆包处理效率掉得很厉害。稳妥做法是所有张量按 32 字节对齐虽然浪费了一点空间但换来的是可预测的性能。3.3 DMA、cache 与总线争用三个最容易翻车的地方第一是 cache 一致性。如果 NPU 通过 DMA 往一块 CPU 也访问的 SRAM 写数据而那块地址区间又是可缓存的那么 CPU 读到的可能是 cache 里的旧值。正确做法是DMA 写之前 clean 掉 CPU 可能留下的脏行DMA 写完之后 invalidate 掉对应的 cache 行让 CPU 下次读的时候真的去内存取。/* NPU 通过 DMA 往 act_buf 写结果前后的标准动作 */ SCB_CleanDCache_by_Addr((uint32_t *)act_buf, ACT_BUF_BYTES); /* 清脏行防止 DMA 覆盖后被 cache 回写冲掉 */ NPU_SubmitJob(job); NPU_WaitDone(job); SCB_InvalidateDCache_by_Addr((uint32_t *)act_buf, ACT_BUF_BYTES); /* 作废旧行强制 CPU 从 SRAM 重读 */这段代码看着很简单但实际项目里我见过太多次数据偶尔错一次最后都是漏了这两行或者地址没对齐。第二是总线争用。NPU 和 CPU 通常共享一条 SRAM 总线NPU 一旦开始大流量搬运CPU 的取指和数据访问就会被挤。表现出来就是NPU 跑起来了但主循环变慢了。解决办法是把 CPU 的关键代码和数据放进 TCM紧耦合内存那是独立的通道不受 NPU 影响。如果芯片支持把 SRAM 分 bank 并做交织也尽量让 NPU 和 CPU 落在不同的 bank 上。第三是中断与同步开销。如果推理结果通过中断通知 CPU每帧一次中断还好如果按层触发中断让 CPU 介入这个上下文切换的开销在几百 MHz 的 MCU 上是相当可观的。我的建议是把整个推理做成一次提交、一次等待中间不要插 CPU 干预。3.4 双区 OTA最容易被忽略的那一刀这一刀我单独拎出来说因为它砍掉的往往是整个方案。很多产品要求支持固件回滚也就是 A/B 双分区 OTA。这意味着 Flash 要按两份固件来算。一颗 2 MB Flash 的 MCU固件本身 700 KB双区就是 1.4 MB剩下 600 KB 要放模型、配置、日志和文件系统。而你那个 900 KB 的模型根本没地方放。低估这个约束的人非常多我当年就是其中之一。后来总结了几个应对思路一是把模型从固件区独立出来模型分区只保留一份OTA 时只更新固件不动模型二是模型也走增量更新只下发变化的那部分权重三是大模型放外挂 Flash用 QSPI 挂一颗 8 MB 或 16 MB 的 NOR虽然带宽受限但至少放得下四是接受单区 OTA 外部看门狗兜底的方案用产品流程而不是存储冗余来保证可靠性。4. 一套可复现的部署流程从训练到上板的内存预算方法4.1 训练到导出的关键参数量化感知训练不是必须的但对小模型来说收益明显。我的流程一般是先用浮点训练到收敛然后开量化感知训练微调 10% 到 20% 的步数导出 int8 模型最后用一套独立的验证集在 PC 上和板子上各跑一遍比对输出误差。import tensorflow as tf # 校准集200~500 条样本足够但必须包含边界工况否则激活动态范围估不准 def rep_dataset(): for x in calib_samples: yield [x.astype(float32)] conv tf.lite.TFLiteConverter.from_saved_model(saved_model) conv.optimizations [tf.lite.Optimize.DEFAULT] conv.representative_dataset rep_dataset conv.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] conv.inference_input_type tf.int8 # 输入也走 int8避免板子上再做一次浮点转换 conv.inference_output_type tf.int8 tflite_int8 conv.convert() open(model_int8.tflite, wb).write(tflite_int8) print(flatbuffer bytes:, len(tflite_int8)) # 先看这个数再决定内存方案导出之后的第一件事不是上板而是用工具链的离线分析报告看清楚三件事权重总量、峰值激活、以及不支持的算子清单。这三项直接决定了后面的内存布局。4.2 内存预算表怎么做我的做法是列一张表每一项都填具体的字节数绝不写大概。下面是我最近一个项目的真实预算你可以照着这个结构套。项目大小位置说明应用固件760 KBFlash A 区含协议栈、驱动、业务逻辑双区 OTA 冗余760 KBFlash B 区若砍掉此项模型空间直接翻倍模型权重340 KB外部 QSPI Flashint8含平铺对齐填充NPU 指令与描述符8 KBNPU 本地 SRAM常驻不参与换出激活缓冲峰值46 KBSRAM_NPU已按 32 字节对齐含 ping-pong输入张量3 KBSRAM_NPU与第一个中间张量复用同一块协议栈与堆栈42 KBSRAM_CPU含采样缓冲预留余量 20%约 22 KBSRAM调试和后期加功能用这张表里有几个数字值得展开说。权重 340 KB 是 int8 之后的大小原始浮点模型是 1.3 MB 左右压到了四分之一出头。激活峰值 46 KB 是按张量生命周期分析算出来的最大并发值如果没有做复用优化实际峰值会到 90 KB 以上直接超预算。预留 20% 是硬性要求我吃过一次亏上线前加了个日志功能堆栈多了 8 KB结果激活缓冲被挤到边界外表现是偶发输出全零查了两天才定位到。链接脚本上也要把分区写死别指望编译器自动摆放MEMORY { FLASH_APP (rx) : ORIGIN 0x08020000, LENGTH 760K FLASH_OTA (rx) : ORIGIN 0x080E0000, LENGTH 760K SRAM_CPU (rwx) : ORIGIN 0x20000000, LENGTH 320K SRAM_NPU (rwx) : ORIGIN 0x20050000, LENGTH 160K } SECTIONS { .npu_activations (NOLOAD) : ALIGN(32) { *(.npu_act) } SRAM_NPU }把 NPU 的激活缓冲单独放在一个 NOLOAD 段里好处是它不参与固件镜像的生成也不怕被初始化数据污染同时能通过链接脚本强制 32 字节对齐。4.3 上板之后怎么判断是模型问题还是内存问题这一步是很多人卡住的地方因为两类问题的表象很像——都是结果不对或者跑不到帧率。我一般用三个对照实验来切分。第一个实验是把权重全部复制到 SRAM 再跑一遍。如果推理时间大幅下降说明瓶颈在权重带宽方案要往常驻或者分块预取方向改。如果时间几乎不变说明瓶颈在计算或者激活搬运。第二个实验是把推理频率降到十分之一。如果单次耗时没变但系统整体变流畅了说明是总线争用或者功耗限制在起作用而不是单次推理本身慢。第三个实验是只跑 NPU 部分把前后处理全部去掉用 NPU 的 cycle counter 计时。这个数字和 PC 上工具链报告的估计值对比能直接看出是 NPU 利用率不足还是前后处理的搬运把时间吃掉了。现象大概率原因优先排查方向单次推理远慢于理论值权重带宽不足或算子 fallback权重来源、算子覆盖率报告输出偶发全零或固定值激活缓冲越界或 cache 未作废内存分区边界、cache 维护代码输出整体偏移但形态正常量化 scale 估偏或首层饱和校准集覆盖度、首尾层定点策略主循环明显变慢总线争用或中断过于频繁TCM 使用、中断触发频率跑几分钟后精度下降内存踩踏或堆栈溢出预留余量、栈使用峰值统计5. 常见问题与排查技巧实录5.1 我踩过的几个具体坑第一个坑是模型能跑但偶尔出错。现象是连续跑几千次推理会出现一两次输出异常。查了很久发现是 DMA 缓冲区和堆栈共用了同一段地址空间链接脚本里两个段有重叠而编译器没有报错。教训是所有涉及 DMA 的缓冲区必须显式指定地址段并且用编译期断言检查段边界不重叠。第二个坑是量化后精度掉得离谱。原因是我用了训练集的最后 200 条做校准而这 200 条恰好都是同一种工况激活动态范围严重低估。换成跨工况随机抽样的校准集之后精度从掉 8 个点变成掉 1.2 个点。第三个坑是NPU 用上了反而更慢。原因是模型里有三个不小心的全连接层参数占了总量的六成而这三层的算术强度极低全是带宽在拖。把其中一个 FC 换成全局平均池化参数量掉了一半推理时间掉了四成。第四个坑是内存压缩相关的思路用错地方。有人看到 PC 上关闭内存压缩能降占用就以为 MCU 上也该这么想。MCU 没有虚拟内存也没有压缩那一套它的优化空间只有两个让数据别同时存在让搬运别重复发生。一条我反复验证过的经验量化之后的模型第一件事是看权重总量的绝对值第二件事是看峰值激活的绝对值第三件事才是看精度。顺序反过来的项目基本都会返工。5.2 关于选型的几句实话如果你的模型权重在 200 KB 以内、激活峰值在 50 KB 以内那么带 NPU 的中高端 MCU 已经足够甚至不需要 NPU堆一点 DSP 指令或者向量扩展也能跑。这个量级下上 NPU 的收益主要是功耗和 CPU 占用率而不是绝对速度。如果权重在 200 KB 到 1 MB 之间NPU 的价值就明显了但你会立刻撞上 Flash 预算和 OTA 的墙。这时候我建议优先考虑带外部 QSPI 存储、并且 NPU 支持直接从外部存储流式读权重的方案。如果权重超过 1 MB说实话 MCU 这条路就开始别扭了。你要么做更激进的量化4 位权重、稀疏化要么把模型切成多段分时加载要么承认这件事应该交给带 DDR 的 SoC 去做。硬上不是不行但每一分优化都要靠工程师手工堆出来维护成本会很高。最后分享一个我一直在用的习惯在写第一行固件代码之前先把那张内存预算表填满把每一个数字都算出来哪怕估算得不准也要写。项目做到一半再去算内存代价通常是要推翻已经完成的模型设计或者内存布局。这张表不花什么时间但它能提前告诉你这个方案到底成不成立。