ARTICLE DETAIL

资讯详情

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

STM32N6上Concat算子意外回退memcpy:原因与性能优化实战

STM32N6上Concat算子意外回退memcpy:原因与性能优化实战 上个月调一块基于 STM32N6 的视觉预处理板卡时被一个看起来毫不起眼的 concat 卡了整整两天。板卡上 M55 核要在一个 4 ms 的实时帧预算里完成相机数据接收、双路特征图拼接、NPU 推理前处理这一整条流水线。性能分析器一跑LL_ATON_LIB_Concat这个接口直接占掉 32% 的预算换算下来约 1.28 ms。最初我还以为是 NPU 算子出了问题后来把 trace 放大一看真正的大头根本不是 ATON 硬件搬运而是库内部回退到了软件memcpy路径并且一次性拆成了 4 次memcpy。也就是说当 concat 的轴不是最左边那一维时硬件加速单元基本帮不上忙全部由 CPU 硬扛。这篇文章就把这个问题拆开讲清楚为什么会发生 fallback4 次 memcpy 是从哪来的以及怎么在软件层面把这几毫秒抢回来。适合正在 STM32N6、N94、N96 等带 AI 加速单元的 MCU 上做实时推理、前处理优化的人参考。1. 先看懂问题LL_ATON_LIB_Concat 到底在干什么1.1 ATON 硬件加速单元与 LL 层库的定位STM32N6 和以前的 M4、M7 最本质的区别是它内部不再只有一颗 CPU 内核而是 M55 主核加 NPU 再加一组专门做张量运算的硬件单元。ST 在这颗芯片上给开发者提供了一套底层算子库接口名以LL_ATON_LIB_开头LL表示 Low-LayerATON是这组硬件单元的名字。LL_ATON_LIB_Concat就是这套库里的张量拼接接口负责把两个或更多 tensor 沿某个轴拼成一个新 tensor。ATON 这类硬件单元的设计初衷就是让你在数据搬运上尽量不消耗 CPU 周期。它内部有独立的 DMA 通道和地址生成逻辑可以按照你给它的描述符把一块内存里的数据按指定步长搬到另一个地址搬完后再通过中断或标志位通知 CPU。它很擅长那种“整块连续搬”、“按固定 stride 搬”的操作比如把一个 128×128 的 feature map 的中间某个通道区域抽出来或者在两个 buffer 之间搬一整块数据。需要注意的是LL_ATON_LIB_Concat并不是一个纯软实现它内部会先检查当前拼接请求能不能被硬件直接处理。这个检查的核心逻辑就是看拼接轴是不是最左边那一维以及两个输入的 shape 是否满足硬件地址连续性的要求。一旦不满足函数并不会报错而是默默走回退路径。这个“静默回退”是最坑的地方因为从 API 调用角度看结果完全正确但性能特性发生了数量级的变化。1.2 concat 轴非最左时发生了什么为了说清楚 fallback 的触发条件先回顾一下张量在内存里的排列方式。MCU 上跑视觉模型时最常见的布局是 NHWC也就是 Nbatch、Hheight、Wwidth、Cchannel其中 C 是内存里最连续的一维。如果把 concat 放在 C 轴上两个输入 tensor 各自内部的数据都是连续排列的拼接时只需要把 A 的整块数据搬到输出前段再把 B 的整块数据搬到输出后段这个操作非常“整齐”硬件很容易处理。但是当 concat 的轴变成 H 或者 W甚至 N 时情况就变了。以 NHWC 布局、H 轴拼接为例输入 A 是 [1, 2, 32, 64]输入 B 是 [1, 3, 32, 64]输出是 [1, 5, 32, 64]。由于内存里每一行是 W×C 连续排列的拼接后输出的第 0 到 1 行来自 A第 2 到 4 行来自 B。此时如果想把 A 的数据整块搬过去输出内存中对应区域是连续的A 本身也是连续的看起来似乎可以整块搬。但实际上库的通用实现对非最左轴拼接的处理方式是“逐 slice 拼接”而不是“整块搬”。这个差异源于一个细节对于任意轴拼接如果直接把 A 整块搬过去那 B 的放置位置是从 A 块之后开始的这要求输出区域中 A 和 B 的排布和输入顺序完全一致。在 H 轴拼接的例子里确实是这样但如果是 W 轴拼接A 的每个“行片段”后面都要紧跟 B 的对应“行片段”整块搬就完全行不通了。库内部为了统一逻辑不会为每个轴单独写一套特优实现而是直接退化成对每个 slice 单独memcpy。这也就是标题里说的 “falls back to 4× memcpy”——当它 fallback 时本来硬件一次能搞定的事变成了 CPU 进行多次内存拷贝。2. 为什么是 4 次 memcpy以及 32% 的预算怎么算出来的2.1 4 次 memcpy 是如何拆出来的“4 次 memcpy” 这个数字并不是固定的它取决于输入 tensor 的 shape 和拼接轴的位置。我现场跑的那个 case是两条分支的特征图在通道拼接之外又叠加了一个“按高度分块”的操作。当时两个输入 shape 分别是 [1, 64, 64, 32] 和 [1, 64, 64, 32]在 H 轴方向上拼成 [1, 128, 64, 32]。由于 H 轴不是最左轴库把输出按 H 方向切成了两段前 64 行从 A 拷贝后 64 行从 B 拷贝理论上是 2 次memcpy就能完成。但我实测到的是 4 次。进一步看 trace 才发现库在 fallback 时并没有做“把 A 的 64 行一次性拷过去”的优化而是沿着 W 方向又做了一次切片。换句话说它把整个输出区域按“块”划分每个块来自一个输入然后逐块memcpy。因为我的 W×C 区域比较大库把它拆成多个 cache-line 友好的块最终 A 和 B 各被拆成了 2 块总计 4 次。如果你的 shape 更大、维度更多这个次数还会更多而且呈乘积趋势增长。这里有一件值得留意的事fallback 路径的memcpy次数和 size 并不是线性对应的。有时候虽然总字节数一样但拆成几十次小拷贝后函数调用开销、cache miss、以及 memcpy 内部对短长度的分支判断都会让实际时间远超理论带宽计算值。所以如果你在 profile 里看到LL_ATON_LIB_Concat占用异常高第一步先看它到底走了硬件路径还是软件路径第二步看它内部memcpy的调用次数和平均长度这两个信息比看总耗时更有用。2.2 4 ms 预算下的开销估算4 ms 的实时预算在视觉类 MCU 应用里很常见通常一个帧周期的分配大概是camera 接收 0.5 ms前处理 0.8 msNPU 推理 1.5 ms后处理和显示/传输 1.2 ms。concat 这个操作本身只占前处理的一部分但它如果吃掉 1.28 ms就意味着前处理环节被它占用了大半其他逻辑只能拼命压缩。从带宽角度做一次粗略估算假设 concat 涉及的总数据量约 512 KB两个 256 KB 输入输出 512 KBSTM32N6 上 M55 核做普通memcpy的有效带宽大约在 300-500 MB/s 之间取决于是否对齐、是否能利用缓存。按 400 MB/s 算512 KB 需要约 1.28 ms。你看这个数字恰好和 32% 占比对上了。也就是说当 concat 走到软件 fallback 时你的 M55 基本上就是在以内存带宽上限做纯拷贝CPU 根本无法同时做其他有价值的计算。即使你尝试在拷贝间隙插入轻量任务也会因为 memcpy 占住总线带宽和缓存而收效甚微。这个估算还忽略了一个重要因素M55 在执行memcpy时是同步的CPU 要等所有字节搬完才会继续下一条指令。而硬件路径是异步的ATON 搬运数据时 CPU 可以同时去跑下一帧的预处理逻辑。同步和异步之间的差距在实际系统中可能不止 2 倍。所以 32% 这个数字背后真正的问题不是“多了几次拷贝”而是“CPU 被绑定在纯搬运上失去了与硬件单元并行的机会”。3. 从内存布局看 concat 的本质与优化窗口3.1 NCHW/NHWC 与拼接轴的亲和性前面提到NHWC 布局中 C 维连续因此 C 轴 concat 最容易被硬件处理。反过来如果是 NCHW 布局C 维变成了最内层第二维W 维连续此时 W 轴 concat 才是最亲和的。这个规律可以总结成一句话拼接轴应当是内存中最不连续的那一维或者至少保证每个输入在拼接方向上是整块连续的。实际工程中很多模型的标准输入布局是 NHWC。如果你需要在 H 轴做 concat比如双分支不同分辨率的特征融合数据布局天然对这个操作不友好。一个常见做法是先把其中一个输入的布局转成 NC_HW 的“块状”形式或者干脆在模型设计阶段就把 concat 操作放在 C 轴上完成。很多 NPU 工具链在编译量化模型时会在 graph 优化阶段自动把 concat 往 channel 方向挪并插入对应的 transpose 节点。但在 MCU 上跑裸算子时图优化没有这么激进你必须自己检查。另一种更实用的方式是利用“内存布局本身也是优化对象”这一思路。比如两个输入在 H 轴上本来就要拼成 [1, 128, 64, 32]如果你在设计上一层的输出时就预先分配一个 [1, 128, 64, 32] 的 buffer让网络的前半部分直接把 A 写到这个 buffer 的前半部分B 写到后半部分那么 concat 就完全不需要任何拷贝操作了。这种“零拷贝 concat”在纯手工 pipeline 中非常有效代价是前期内存规划和指针分配要更精细。3.2 连续拷贝与非连续拼接的本质差异连续拷贝是 memcpy 最理想的情况源地址、目的地址都是线性递增每次搬运可以尽量大地利用缓存行甚至可以触发硬件预取。非连续拼接的本质是输出内存中每个目标区段来源于不同输入每个区段的长度可能不一致区段之间还可能有偏移。这种情况下只调用一次 memcpy 是不可能完成的必须分成多次而每次 memcpy 的长度越短开销占比越高。举一个极端的例子如果你想在 W 维度上把两个 [1, 1, 64, 32] 的输入拼成 [1, 1, 64, 64]每个输入一行有 32 个元素输出一行 64 个元素。你没法直接把 A 整块搬到输出的前 32 列因为第二行 A 的起始地址和第一行 A 的起始地址之间隔着 32 个元素但输出第二行 A 的起始地址和第一行 A 的起始地址之间隔着 64 个元素。这意味着你只能一行一行地拷贝总共 64 次小 memcpy每次 128 字节。而 128 字节的 memcpy 在现代 MCU 上的效率远低于 16 KB 的大块拷贝函数调用开销和循环初始化占了很大比例。这个例子能帮你理解为什么 concat 轴位置如此重要。3.3 安全区与非安全区对 buffer 访问的影响STM32N6 上除了性能问题还有一层内存隔离的约束。ST 为这颗芯片提供了基于 TrustZone 的安全区和非安全区划分你的 tensor buffer 可能被分配到安全区也可能在非安全区。ATON 硬件单元在做搬运时是否能访问对应区域取决于该区域的 TrustZone 属性设置。如果 buffer 在非安全区而 ATON 被配置成只能访问安全区地址空间那么即使 concat 轴是最左轴硬件路径也无法直接访问最终还是会回退到 CPU 拷贝。这个坑不容易发现因为编译和链接都不报错只有在运行时有内存管理单元或总线错误或者像我们这样通过性能分析才意识到路径不对。排查方法比较直接查看 concat 输入/输出的内存地址确认它们是否在同一个安全域然后检查 ATON 描述符里的地址空间标记位。如果你在做双核异构部署M55 和 NPU 之间的共享 buffer 尤其容易出现这种问题。另外安全区与非安全区之间的切换本身也有额外延时即使数据不需要拷贝跨区域访问也最好通过显式定义的共享内存区域来做避免一切隐式跨域路径。4. 实操优化方案4.1 最简单高效调整内存布局让 concat 的轴变成最左轴这是所有方案里改动量最小、收益最明显的也是我最后实际采用的路线。具体做法在构建整个前处理流水线时不要把 concat 的输入当成两个独立的 buffer而是把两块内存直接分配到同一个父 buffer 的连续前后两段并且让 concat 轴沿内存连续方向展开。举个例子。原来的做法是输入 A 在地址 0x30000000shape [1, 64, 64, 32]输入 B 在地址 0x30020000shape [1, 64, 64, 32]调用LL_ATON_LIB_Concat(A, B, out, axis1)H 轴拼接这个调用会触发 fallback。改成这样分配一个连续 buffer大小是 [1, 128, 64, 32]上一层计算 A 时就把结果写到这个 buffer 的 [0:64] 区域上一层计算 B 时就把结果写到这个 buffer 的 [64:128] 区域concat 根本不需要调用很多情况下A 和 B 来自上两个不同的算子比如两个卷积层。如果这两个卷积层是用同一个 kernel 遍历不同 ROI你完全可以在写输出时用偏移量控制让它们各写各的区域。这样不仅省掉了 concat 的 1.28 ms还省掉了 concat 输出 buffer 的额外内存。我在实际项目中把原来的 3 个 bufferA、B、out合并成 1 个 buffer内存占用也下降了。当然不是所有 concat 都能这样改。比如两个输入来自不同时间点、不同来源一个来自 DMA 接收一个来自 NPU 输出你没法控制它们在产生阶段的地址。这种情况下退而求其次把 concat 移动到数据流的更早或更晚阶段让待拼接的数据在产生时就能满足整块连续的布局关系。4.2 利用 permute/transpose 融合打破轴限制如果调整内存布局不可行另一个思路是预先重排数据让拼接轴变成最左轴。比如在 H 轴拼接时可以先把两个输入都转成类似“H 为最外维”的布局拼接完成后再转回原布局。看起来多做了两次 transpose似乎更慢但在特定 shape 下它反而更快因为 transpose 本身可以用 ATON 硬件完成而 concat fallback 只能用 CPU。成本核算关键看数据量和耗时。比如输入 [1, 64, 64, 32]做一次 H→NCHW 式的转置需要把 512 KB 数据按步长搬移ATON 完成大约在几百微秒级别而 concat fallback 的 4 次 memcpy 是 1.28 ms。如果转置ATON concat转置的总耗时小于 1.28 ms那这个 swap 就值得做。我在另一个项目里实测过类似方案总耗时从 1.3 ms 降到 0.5 ms 左右。需要注意转置必须和整个流水线的 layout 策略统一不能只改 concat 前后两层。否则你在 concat 这里省了时间后续的卷积算子又因为 layout 变化多出大量的重排开销。最好的方式是在模型编译阶段就把 layout 偏好告诉工具链让工具链统一插入 transpose 节点而不是在运行时手工处理。4.3 用 MVE 向量指令优化剩余 memcpy如果 fallback 实在无法避免我们能做的就是把 fallback 里的 memcpy 性能提升到极致。Cortex-M55 核自带 MVEM-Profile Vector Extension向量扩展也就是常说的 Helium。它和 AArch64 架构里的 Neon 在概念上类似都是单指令多数据可以把 128 位甚至 256 位的数据在一个周期内搬移。很多人误以为 memcpy 已经被编译器优化得很好了实际上在嵌入式 GCC 上默认的memcpy实现未必启用了向量指令尤其是当长度不是固定值、目标地址没有显式对齐时编译器会退化为逐字节拷贝。一种手写 MVE 优化的 memcpy 思路如下void memcpy_mve_optimized(uint8_t *dst, const uint8_t *src, size_t len) { size_t i 0; // 先做 16 字节对齐的向量拷贝 for (; i 16 len; i 16) { uint8x16_t data vld1q_u8(src i); vst1q_u8(dst i, data); } // 剩下的字节逐个拷贝 for (; i len; i) { dst[i] src[i]; } }这个代码在理想情况下能比普通 memcpy 快 2 到 4 倍。实际优化时还要注意两个问题一是源地址和目标地址的 4 字节、8 字节、16 字节对齐情况如果地址不对齐向量加载是安全的但效率会大打折扣二是在 concat fallback 里很多小片段的长度不足 16 字节向量化意义不大这时应该把多个小片段合并成一个大片段拷贝而不是调用小 memcpy 的循环。另一个技巧是如果你可以预知 concat 的所有 slice 信息可以手动编写一个“批量 memcpy”函数把多个源地址和目的地址组成数组然后在一个循环里依次处理。这样做的好处是减少了函数调用次数也给了编译器更大的循环优化空间。实践中我见过把 4 次 memcpy 合并成 1 次批量拷贝后总耗时减少了 30% 左右原因是减少了分支判断和流水线清空。4.4 双缓冲与异步流水让 CPU 和 ATON 并行在很多流水线应用里concat 的输入并不是同时就绪的。比如 A 先准备好B 要等 DMA 从摄像头搬到内存后才准备好。如果同步地等 B 到了才做 concatCPU 在等待期间是空闲的。更好的方案是拆成两个阶段当 A 就绪时先用 ATON 把 A 搬运到输出 buffer 的前半段当 B 就绪时再用 ATON 把 B 搬运到输出 buffer 的后半段。这个方案的本质是把“一次 concat 调用”拆成“两个独立搬运任务”每个任务都能被硬件异步执行。CPU 在两次搬运之间可以处理其他事务比如启动下一帧的 DMA。虽然总的数据量没有变少但 CPU 的阻塞时间被大幅压缩实时预算看起来就从 1.28 ms 变成了几乎可以忽略的两个异步提交。双缓冲在这个基础上还能进一步优化。你分配两个输出 buffer当前帧用 buffer 0 做 concat下一帧的 A 同时往 buffer 1 里搬等下一帧开始时 buffer 1 已经准备好concat 就只需要补 B 的部分。这种流水线模式在处理连续帧时效果非常好把 concat 的搬运时间和前一帧的后处理重叠起来。4.5 利用 ATON 的 strided copy 功能如果库支持如果你的 STM32N6 的 ATON 硬件支持 strided copy带步长的搬运那很多非最左轴 concat 其实可以用硬件完成。所谓 strided copy是指源地址不是连续递增的而是每搬完一段后跳过固定长度。正好符合 H 轴或 W 轴 concat 的需求。比如 W 轴拼接A 的一行有 32 个元素输出一行有 64 个元素A 的行内拷贝需要从源地址连续搬 32 字节然后跳过 32 字节的间隔也就是 B 的位置再到下一行的 A 数据。这是典型的二维 strided copy。如果 ATON 描述符支持这种二维步长描述你可以构造一个描述符让硬件自己完成拼接完全不经过 CPU。这个功能和库 API 的关系需要注意有些版本的LL_ATON_LIB_Concat虽然 API 看起来是 concat但它内部不会自动生成带 stride 的描述符。你只能通过更底层的LL_ATON_LIB_DescInit、LL_ATON_LIB_AddCopyTask之类的接口自己构建。如果你的应用允许直接操作描述符这是我推荐的最高性能路线。我在这类底层接口上花了一周时间阅读参考手册但换来的是 concat 耗时从 1.28 ms 降到 200 µs 左右直接省出了 1 ms 给其他模块。5. 常见问题与排查实录5.1 如何快速判断 concat 是否走了 fallback判断方法有两种一种是在线看性能计数器另一种是离线看 trace。STM32N6 的 CoreSight 性能分析单元PMU可以统计 CPU 周期数、缓存命中率、总线访问次数等。在 concat 调用前后分别读取DWT-CYCCNT如果两者差值远大于同数据量硬件搬运的理论周期基本可确定走了软件路径。更直接的方法是在调试器里给memcpy下断点运行到 concat 调用时观察是否命中。如果命中了说明库内部确实调用了 memcpy。另一种情况是即使不调用库你自己的代码里也有隐式 memcpy比如结构体赋值、消息队列拷贝这时候需要过滤掉非 concat 路径的调用。我在实践中发现一个比较靠谱的 profile 方法把输入数据量调大 10 倍然后观察 concat 耗时的增长曲线。如果耗时随数据量线性增长且斜率接近 memcpy 带宽说明是 fallback如果耗时几乎不增长说明走的是硬件异步路径。这个方法不需要额外工具只需要修改一个 shape 参数即可。5.2 常见问题速查表问题现象可能原因排查方法解决方案concat 耗时占比异常高concat 轴非最左走了 memcpy fallback查看 concat 调用参数和性能 trace调整内存布局、使用 strided copy已改成最左轴仍然很慢buffer 跨安全区/非安全区ATON 无法访问检查 buffer 地址和 TrustZone 配置将 buffer 放到统一安全域共享区小片段 concat 特别慢多次短长度 memcpy调用开销大统计 memcpy 调用次数和平均长度批量化拷贝、合并 slizeconcat 输出与预期不符安全区访问异常或地址重叠检查地址映射和边界重新规划内存布局双缓冲后仍卡顿等待同步事件阻塞了 CPU查看 DMA 完成中断标志位使用中断回调替代轮询等待5.3 实测技巧与避坑心得第一不要在调试版里优化 concat。调试版编译默认-O0memcpy 会退化成逐字节拷贝性能数据完全失真。必须使用 Release 编译并开启-O3以及针对 Cortex-M55 的-mcpucortex-m55 -mthumb -mfloat-abihard -mve选项才能看到接近真实性能的指标。第二关注内存对齐。M55 的 MVE 向量加载对 16 字节对齐特别敏感。如果你不能在内存分配阶段保证 16 字节对齐至少也要保证 8 字节对齐。可以在链接脚本里为 tensor buffer 专门分配一个 64 字节对齐的段这样memcpy内部的所有优化分支都能走最高效路径。第三善用 cache 操作。STM32N6 内部有较大的 SRAM很多时候数据本身就在 TCM 或紧耦合内存里不需要经过 cache。但如果你的 buffer 在外部 PSRAMmemcpy的性能会严重受限于 PSRAM 带宽。这种情况下与其优化 memcpy 本身不如把频繁拼接的 tensor 搬到内部 SRAM。内存规划的重要性往往比指令优化更高。第四不要忽略编译器的__restrict和__builtin_memcpy。在关键路径上如果你想确保编译器生成最优秀的代码可以在 memcpy 调用前加上if (len threshold)的大块处理分支把大数据量拷贝和小数据量拷贝分开。我实测后发现12 字节以下的小拷贝用逐字节循环比调用库函数更快而 4 KB 以上的大拷贝用 MVE 向量化收益最明显。结尾踩过这次坑之后我最大的感受是在带硬件加速单元的 MCU 上做优化第一优先级的永远是“让硬件干它擅长的事”而不是“把软件写得更好”。LL_ATON_LIB_Concat的 fallback 不是 bug它是库在通用性与性能之间做的取舍但如果你不了解这个取舍点它就会在实时系统里变成隐形杀手。最后再分享一个我一直在用的小技巧每次做性能优化前先把所有算子按“硬件路径/软件路径”分类列一张表凡是标记为软件路径的算子都值得单独深挖一层。很多性能问题在表格列出来的那一刻就已经解决了一半。
返回列表