ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MCU上NPU部署:真正难管的是模型与内存的映射决策

MCU上NPU部署:真正难管的是模型与内存的映射决策 前阵子帮一个做工业手持终端的团队看板子主控是 Cortex-M55 加一颗 128 MAC 的 NPU模型是 96×96 输入的轻量分类网络权重换算下来大概 240KB塞进片上 2MB Flash 绰绰有余。结果固件跑起来第一次调用推理就进了 HardFault。查了两天不是指针越界也不是 NPU 配置寄存器写错而是张量竞技场tensor arena按 300KB 开实际峰值要 480KB恰好把紧挨着的 RTOS 堆踩了。把 arena 改大之后能跑了但 Flash 占了一大半、SRAM 剩不下多少紧接着串口 DMA 就开始丢包。这件事之后我一直在琢磨标题里那个问题NPU 进到 MCU 里以后真正难管的是模型还是内存我自己的答案是——两个都不是难管的是它们中间那层映射决策。模型是软的、可迭代的、长得飞快的变量内存是硬的、焊死的、一颗字节都不能多的约束。单独看模型你能压单独看内存你能列预算表。可一旦把某一版模型落到某一块具体板子上你就得同时回答哪些权重常驻、哪些分块搬运、哪些中间张量能复用、哪些层必须掉到 CPU 上跑。这些问题在模型文件里没有答案在芯片手册里也没有答案只能靠人对两边都熟。往下我把这套东西拆开讲从存储的物理差异讲到映射策略再到上板之后的调试手段中间会给出可以直接抄的预算表和裁剪路径。1. 先把账算清MCU 上的 NPU 到底被什么卡住在讨论模型难管还是内存难管之前得先明确一件事MCU 里的 NPU 不是缩小版的 GPU。GPU 有一大块高带宽显存和宽总线NPU 在 MCU 上能用的只有几百 KB 到几 MB 的片上 SRAM外加一个经常被忽视的、访问代价极不均匀的存储体系。如果不先把这个物理事实接受下来后面所有的模型选型都会建立在错误假设上。1.1 Flash、SRAM、TCM三种存储三种完全不同的代价很多人第一次做 MCU 侧推理会下意识地把权重放 Flash激活放 SRAM当成一个天然正确的结论。这句话方向没错但代价差别巨大值得掰开。片上 Flash 一般是 XIP就地执行访问前面挂一层很小的指令/数据缓存。缓存命中时取数延迟接近内核频率下的几个周期一旦未命中一次填充可能要几十个周期实际等效带宽掉到几十到一百多 MB/s。对于权重这种顺序读、几乎不复用的访问模式缓存效率并不高——每一层卷积的权重只读一遍就过去了缓存基本帮不上忙。片上 SRAM 才是真正的主战场。它通常分成多个 bank不同 bank 可以并行访问单周期能取 4 字节甚至 8 字节。但它贵而且和栈、堆、通信缓冲、RTOS 控制块抢同一块地。TCM紧耦合内存是第三种东西很多人忽略它。它容量小但延迟确定、不经过总线矩阵仲裁最适合放那些每帧都要跑、对抖动敏感的小张量比如激活函数前后的临时缓冲。我把这三者的典型特征整理成了一张表实际项目里我经常拿它跟硬件同事对齐认知存储位置典型容量典型等效带宽适合放什么片上 FlashXIP cache512KB – 4MB未命中时约 50 – 150MB/s权重、常量、查找表片上 SRAM多 bank256KB – 4MB约 200 – 800MB/s激活、arena、DMA 缓冲TCM / DTCM64 – 512KB单周期、确定高频小张量、ISR 栈外部 PSRAMOctal/Hex SPI4 – 64MB约 80 – 200MB/s大模型权重、音频帧缓冲看这张表要抓住一个结论从 SRAM 到外部 PSRAM带宽可能掉四到十倍从 SRAM 到 Flash未命中时的延迟更是数量级的差别。模型的每一层只要跨越一次这个边界性能就会被打回原形。1.2 算力是纸面数字带宽才是真天花板厂商给的 NPU 参数通常是 GOPS 或者 TOPS比如 600 GOPS int8、128 MAC/cycle 之类。这个数字只说明每周期能并行做多少次乘加完全不说明每周期能喂进去多少字节。我做过一次粗略估算可以帮大家建立直觉。假设一个 NPU 标称 600 GOPS int8如果某一层的每个 MAC 平均需要 1 字节权重加 1 字节激活并且完全没有数据复用那么对应的访存需求是 1.2 TB/s 量级。而片上 SRAM 的实际带宽是什么水平200 到 800 MB/s。差了三个数量级。这说明什么说明现代 NPU 能跑起来靠的完全不是带宽而是数据复用——权重复用同一个权重服务多个输出位置、激活复用行缓冲/滑窗、以及片上缓冲。一旦某一层的复用率不够比如深度可分离卷积的第一个 1×1 层、或者逐元素运算NPU 的利用率会瞬间掉到个位数百分比。注意看到 NPU 参数表时先别急着算理论延迟。问清楚三件事——片上 SRAM 带宽是多少、NPU 有没有专用缓冲比如 Ethos-U 那 16/32KB 的 weight/activation buffer、权重从 Flash 到 NPU 的搬运是硬件自动做还是要 CPU/DMA 参与。1.3 为什么模型只有 300KB这个数字会骗人回到我开头那个案例。为什么 240KB 的模型会吃出 480KB 的运行时内存因为模型大小和运行时内存是两个完全不同的量。模型文件里装的是权重和算子描述是静态的。运行时内存的峰值由同时存活的张量决定某一层的输入、输出、im2col 缓冲、以及还没被复用的上一层输出全都得同时待在内存里。中间层的输出往往比权重还大——比如 96×96×16 的 int8 特征图就是 147KB比很多层全部权重加起来都多。更麻烦的是峰值出现在哪一层并不直观。一个直觉上最后一层通道最多的网络峰值很可能出现在第一个 stride1 的卷积层因为那一层的输出分辨率最高。我见过不少模型把最后一层通道数从 256 砍到 128内存几乎没变把第一层 stride 从 1 改成 2内存直接掉三分之一。所以真正该看的指标不是模型体积而是逐层激活张量的驻留曲线。2. 模型侧的暗礁不是压不动而是压完之后映射不进去现在轮到模型这边了。绝大多数人处理 MCU 侧模型的思路是压缩——量化、剪枝、去掉花哨的结构。这套思路本身没错但它解决的是一个连续的问题精度换大小而 MCU 上的映射问题本质是离散的某一层要么放得下要么放不下中间没有过渡地带。这就是模型侧真正让人头疼的地方。2.1 量化改变的不只是精度还有内存布局与对齐int8 量化是最基本的一步但很多人只关心量化后的精度掉了多少不关心它把内存布局改成了什么样。几个必须知道的细节。第一感知量化per-channel比逐张量量化per-tensor精度好但每个输出通道都要额外存一个 fp32 的 scale通道数多的时候这笔开销不能忽略——一个 512 通道的卷积层就是 2KB十几个这样的层轻轻松松吃掉几十 KB。第二bias 一般保留 int32卷积层的 bias 数组是通道数乘 4 字节同样别漏算。第三也是最容易翻车的内存对齐。NPU 和 DMA 都要求缓冲区 16 字节甚至 32 字节对齐如果按逐元素尺寸精确分配最后一个张量可能只差 3 个字节就跨行导致需要对三个额外的张量重新做 padding。我自己的习惯是在内存预算表里永远给每个张量加一行对齐后尺寸按 32 字节向上取整再算。这样做出来的表比精确算的表更接近真实占用。按 96×96×16 147456 字节来算本来就是 32 的整数倍没事但 12×12×10 1440向上取整到 1472多出 32 字节。层数一多这些零头加起来是实打实的。2.2 算子融合本质上是一次内存规划很多人把算子融合conv bias activation、conv add relu 之类当成纯粹的性能优化以为它只影响速度。其实融合对内存的影响往往更大。道理很简单没融合的时候中间张量必须完整写回内存再读出来融合之后它只活在 NPU 的流水线里根本不落地。举个例子。一个残差块主干是 conv-bn-relu分支是 identity最后 add-relu。如果不融合你得分配三个张量conv 的输出、identity 的输入可能就是上一层输出、以及 add 的输出。如果主干和分支的输出都要完整驻留峰值就是两份特征图。而如果编译器把它识别成一个融合算子NPU 在写输出的同时就把残差加进去了只需要一份输出缓冲。这就是为什么模型结构设计在 MCU 场景下实际上就是在设计内存布局。同样是 ResNet 风格的残差连接主干通道数和分支通道数相等时可以让编译器做原地in-place复用不等时就必须额外分配。你在网络设计阶段做的每一个通道数选择最后都会变成内存预算表上的一行。2.3 一个不被支持的算子能把你整个内存预算掀翻这是我在实际项目里踩过最狠的一个坑值得单独说。NPU 的算子支持是有限的。以 Ethos-U 系列为例卷积、深度可分离卷积、全连接、池化、加法这些是原生支持的但像一些不常见的激活、复杂的 reshape、带特殊语义的 transpose、以及某些自定义 op编译器会把它们下放到 CPU 上跑fallback。问题是一旦发生 fallback情况就变了。你的固件里不仅要有 NPU 的驱动和缓冲区还得链接 TFLite Micro 的 CPU 算子内核以及它需要的那一整套张量分配逻辑。也就是说同一个模型会同时需要 NPU 侧和 CPU 侧两套内存规划。我在一个音频关键词唤醒项目里遇到过整个模型 180KB因为中间有一个不被支持的 reshapeCPU 侧的 arena 需求从 60KB 直接涨到 210KB。更隐蔽的是这类 fallback 在编译日志里往往只是一行提示很容易被忽略。所以我的做法是每次模型迭代后第一件事不是看精度而是去看编译器有没有报告operator ... not supported或者fallback to CPU第二件事是看它报告的 SRAM 峰值。这两项不达标精度再高也不往板子上烧。3. 内存侧的硬骨头峰值、碎片、以及没人算的搬运开销模型侧的问题偏设计内存侧的问题偏工程但后者的隐蔽性更强。因为内存这个东西报错的时候你不会知道是哪一步出的问题——通常是别的模块先崩然后你顺藤摸瓜才找到推理这一块。3.1 内存峰值预算怎么算从拍脑袋 200KB到可复核的表格先给一个能直接用的计算方法。核心思路不是把所有权重和张量加起来而是逐层推演张量存活状态取最大值。我把这个方法固化成了一个表结构每一层一行层输入张量输出张量权重临时缓冲im2col 等该层峰值input—96×96×3 27.6KB——27.6KBconv1 (3×3, s2, 16ch)27.6KB48×48×16 36.9KB432B约 2.6KB约 67.1KBconv2 (3×3, s2, 32ch)36.9KB24×24×32 18.4KB4.6KB约 6.9KB约 62.2KBconv3 (3×3, s2, 64ch)18.4KB12×12×64 9.2KB18.4KB约 6.9KB约 34.5KBGAP FC9.2KB10B640B—约 9.8KB关键在于输入张量这一列不是所有层的输入都要一直留着只有在它还被后面的层引用时才占用。上表里每层的输入就是上一层的输出逐层释放所以峰值只取单层最大值约 67KB。im2col 缓冲的粗略估算方式是输入通道数 × 卷积核高 × 卷积核宽 × 输出宽度 × 元素字节数如果实现里做了双缓冲再乘 2。这个值在浅层小、深层大所以经常在中间层成为隐性峰值来源。算完之后加上这些看不见的开销TFLite Micro 的解释器对象和算子内核通常 10–25KB、抢占栈和 ISR 嵌套预留至少 4–8KB、DMA 描述符与环形缓冲视通信量 2–16KB、以及 RTOS 自己的堆别和 arena 混在一起。这几项加起来小项目里 30KB 是常态。3.2 张量竞技场不是堆但碎片化照样发生TFLite Micro 用一块大数组tensor arena来装所有张量分配策略是按生命周期做平面规划比通用 malloc 高效得多。但它的规划是基于编译期推断的生命周期一旦你的模型里有动态 shape 或者条件分支规划就会保守导致复用率下降。我遇到过一种典型情况模型里有两个分支比如多任务输出一个做分类、一个做回归每个分支各有一个大部分时间空闲的张量。理论上它们可以复用同一块内存但因为编译器不确定两者是否同时活跃就给它们各分了一块。解决办法是把分支结构改成串行或者显式给出生命周期提示。另一个经常被忽略的点是对齐导致的碎片。arena 里的每个张量都要求 16 字节对齐如果规划器频繁地在 100 字节和 300 字节的张量之间插空会出现大量几十字节的空洞。实测下来这类空洞能吃掉 5% 到 15% 的 arena 空间。我通常的做法是预留 15% 的余量而不是算出来多少就开多少。代码上这块很好检查constexpr int kTensorArenaSize 320 * 1024; alignas(16) static uint8_t tensor_arena[kTensorArenaSize]; MicroInterpreter interpreter(model, resolver, tensor_arena, kTensorArenaSize); interpreter.AllocateTensors(); printf(arena used: %u / %d bytes\n, (unsigned)interpreter.arena_used_bytes(), kTensorArenaSize);把arena_used_bytes()打到日志里跑几十次不同输入看看波动比自己算靠谱得多。同时给 arena 加一个编译期断言让它和链接脚本里的段大小绑定谁改漏了都会在编译阶段炸出来。3.3 DMA、NPU、CPU 抢同一条总线时的隐性等待这部分是内存难管里最不直观的一环。MCU 的总线矩阵虽然能让多个主设备并发访问不同 bank但仲裁是有代价的。当 NPU 在连续读权重、CPU 在跑其他中断服务、串口 DMA 在搬数据三者同时抢同一条路径时NPU 的有效带宽会明显下降。表现出来就是推理时间不稳定——同一帧输入有时 8ms有时 18ms。我的经验做法有三条。第一把 NPU 的权重区和串口/以太网的 DMA 缓冲放到不同的 SRAM bank这在支持多 bank 的芯片上比如很多 Cortex-M55 平台是纯收益的配置代价只是链接脚本多写几段。第二推理期间把非关键外设的 DMA 降速或者暂停尤其是那些周期性小包传输。第三把 arena 放到 NPU 可以直接访问的共享 SRAM 区域而不是经过缓存的那一块。说到缓存这里有个高发问题如果芯片有数据缓存D-Cache而 NPU 访问的是物理内存、不走缓存那么 CPU 写完的数据必须先做 cache clean否则 NPU 读到的是旧值。反过来 NPU 写完、CPU 要读就得做 invalidate。很多人第一次遇到结果偶尔对偶尔错就是栽在这上面。稳妥的做法是把 NPU 共享区域配成 non-cacheable牺牲一点 CPU 性能换取确定性。4. 真正难管的那一层模型与内存之间的映射决策前面三章分开讲了模型和内存各自的坑但标题里的问题还没有正面回答。我的判断是单独管理模型是软件工程问题单独管理内存是配置问题两者一旦耦合就变成了资源调度问题而资源调度的复杂度是随层数超线性增长的。这才是真正难的地方。4.1 常驻、分块、双缓冲三种驻留策略的取舍对于权重只有三种基本策略选错了后面全乱。全常驻所有权重一次性放进片上 SRAM。延迟最低、最稳定但容量有限。适合权重总量在 200KB 以内、SRAM 宽裕的场景。全分块streaming权重留在 Flash每层推理前用 DMA 搬到一块小缓冲里。省 SRAM但引入搬运时间。关键是双缓冲——在 NPU 处理第 N 块权重时DMA 已经在搬第 N1 块这样搬运延迟被隐藏。不做双缓冲的话性能损失能到 40% 以上。混合高频调用的层常驻 SRAM低频的放 Flash。我做过一个方案把前两层占权重 60%但调用最密集常驻后面几层流式加载整体 SRAM 占用从 480KB 降到 210KB推理时间只涨了 8%。选择依据可以简单量化设权重总量 W、可用 SRAM 余量 M、搬运带宽 B、单次推理周期 T。如果W / B远小于T就大胆流式加载如果两者可比就混合如果W / B已经接近T的一半就必须常驻。4.2 从 480KB 到 210KB我实际用过的六步压缩路径回到开头那个手持终端的项目我把峰值压下来的顺序是这样的按投入产出比排序第一步改 stride。把第一层卷积的 stride 从 1 改成 2输入分辨率不变的前提下第二层特征图面积直接变四分之一。这一步通常精度损失在 1 个百分点以内内存收益最大。第二步显式降低输入分辨率。从 160×160 降到 128×128所有激活张量面积按 0.64 倍缩放。这一步要和数据增强一起调不然精度掉得厉害。第三步让编译器做融合。确认编译器把 convaddrelu 识别成了融合算子中间张量不落地。检查方式是看它报告的层数和峰值——如果峰值出现在残差块而不是第一层卷积说明融合没生效。第四步重新审视算子支持。把那个不支持、需要 fallback 的 reshape 换成了等价的、能被原生支持的实现CPU 侧 arena 直接消失省了 150KB。第五步用编译器的 Size 优化模式。以 Vela 为例可以用--optimise Size让它在性能和内存之间偏向前者同时选择匹配你实际硬件配置的 system-config 和 memory-mode 文件这两个文件描述的是你的 SRAM 大小和缓存配置写错了编译器会按错的模型做规划。vela --accelerator-config ethos-u55-128 \ --optimise Size \ --system-config MySysConfig.ini \ --memory-mode MyMemMode.ini \ --output-dir ./out \ model_int8.tflite具体参数名以你手上版本的--help为准重点是别用默认配置——默认配置往往假设一个比你的板子更宽裕的内存环境。第六步把 arena 从普通 SRAM 挪到 TCM 旁边的段里并和链接脚本里的段大小做编译期绑定。这一步不省总量但能让规划更确定避免了跑一会儿崩一次的随机故障。六步做完峰值从 480KB 掉到 210KB精度从 94.2% 掉到 92.8%。这个交换比我认为是划算的。4.3 把映射决策固化下来一份可复用的评审清单这套东西我不建议每次都靠脑子推容易漏。我现在固定用一个清单在模型评审时逐项过检查项判据不通过的后果编译器报告 SRAM 峰值小于可用 SRAM 的 75%没有余量后续加功能必崩是否存在 CPU fallback 算子必须为零同时需要两套内存规划最大单层输出张量尺寸小于 arena 的 40%峰值不可控优化空间被锁死权重是否全部可流式加载是或可混合只能全常驻SRAM 需求暴涨张量对齐后的总尺寸与规划值偏差小于 15%频繁出现差一点点的失败推理期间是否有竞争性 DMA已排到不同 bank推理时间抖动严重这张表我一般会在模型定稿之前过一遍凡是有一项打问号就先不往下走。经验上在模型阶段花两小时过这张表比在上板之后花两天调内存要划算得多。5. 上板之后才知道的事调试手段与选型判断前面讲的是怎么想、怎么算。但 MCU 侧的事情纸面上算得再清楚上板还是会遇到新问题。这一章讲我实际用的观测手段以及选型阶段就该问清楚的事。5.1 编译通过但行为异常的几个高频原因按我遇到的频次排个序。最高频的是栈溢出。推理过程中如果用了递归的算子实现、或者用了变长数组做大缓冲栈会悄悄越界然后破坏相邻的静态变量。表现是结果偶尔错、偶尔对非常难查。解决办法是给栈加哨兵canary并且把大缓冲全部改成静态分配。第二高频是cache 一致性。前面提过不再展开只说一句如果 NPU 和 CPU 共享同一块内存先把那一块配成 non-cacheable能省掉你一整天的排查。第三是对齐问题。NPU 或 DMA 遇到非对齐地址时有的芯片会静默截断低几位有的会触发总线错误。前者更可怕因为结果是错的、但不报错。第四是中断优先级反转。推理如果放在任务里跑同时有高优先级中断频繁抢占会导致 NPU 的缓冲区被部分覆盖。稳妥做法是推理期间屏蔽非关键中断或者给推理加一个不可重入的锁。5.2 我用得最多的四个观测手段链接期 map 文件。这是最便宜也最有效的手段。用-Wl,--print-memory-usage让链接器直接打印各段占用再用arm-none-eabi-size看总体。每次构建都看一眼比任何事后分析都省事。NPU 自带的性能计数器。多数带 NPU 的 MCU 都有一组 PMU 寄存器能读出周期数、活跃周期、等待周期。这三个数的比例直接告诉你瓶颈在哪活跃周期占比低就是带宽不够或者数据没喂饱等待周期高就是总线竞争。DWT 周期计数器。测推理总耗时最方便的方式不需要额外外设。注意要在前后各读一次并处理溢出。SWO / ITM 输出。比串口快、不占外设适合在推理过程中打点画出一层一层的时间轴。我第一次用它的时候才发现有一层卷积的耗时是它邻居的十倍原因就是那层的权重在 Flash 里没被预取。5.3 选型阶段就该问清楚的几个问题最后聊点前置的事情。很多内存不够的困境其实是选型阶段就埋下的。第一片上 SRAM 到底有多少是连续可用的有的芯片标称 4MB但被切成好几块不连续的 bankNPU 需要一个连续的大缓冲时会拼不出来。第二NPU 有没有专用缓冲有的话容量多少、能不能被权重复用这直接决定了流式加载的可行性。第三模型工具链能不能报告内存占用如果一个工具链只告诉你编译成功不告诉你峰值 SRAM 是多少那么整个项目的内存风险是不可评估的。第四Flash 到 SRAM 的搬运通道是硬件全自动还是要 CPU 参与如果每层都要 CPU 干预那推理期间 CPU 基本被占死多任务设计就很受限。这四个问题的答案往往比 NPU 的 GOPS 数字更能决定项目能不能落地。回到最开始那个问题——真正难管的是模型还是内存。我现在的答案是内存给出的是硬边界模型给出的是可调变量而项目的成败取决于你能不能在两者之间找到一条走通的路。模型可以一版一版迭代但板子已经焊好了内存可以精打细算但模型的算子覆盖和结构约束是你没法单方面改变的。所以每次有人问我该先优化模型还是先优化内存我的建议都是同一个**先在纸上把两边的账都算一遍找出那个真正卡住你的约束然后再动手。**大多数时候你会发现卡住你的既不是模型也不是内存而是某一条你没注意到的映射规则。
返回列表