☰
STM32N6上Concat算子意外回退memcpy:原因与性能优化实战
2026/10/13 8:34:01 网站建设 项目流程

上个月调一块基于 STM32N6 的视觉预处理板卡时,被一个看起来毫不起眼的 concat 卡了整整两天。板卡上 M55 核要在一个 4 ms 的实时帧预算里完成相机数据接收、双路特征图拼接、NPU 推理前处理这一整条流水线。性能分析器一跑,LL_ATON_LIB_Concat这个接口直接占掉 32% 的预算,换算下来约 1.28 ms。最初我还以为是 NPU 算子出了问题,后来把 trace 放大一看,真正的大头根本不是 ATON 硬件搬运,而是库内部回退到了软件memcpy路径,并且一次性拆成了 4 次memcpy。也就是说,当 concat 的轴不是最左边那一维时,硬件加速单元基本帮不上忙,全部由 CPU 硬扛。这篇文章就把这个问题拆开讲清楚:为什么会发生 fallback,4 次 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-Layer,ATON是这组硬件单元的名字。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,也就是 N(batch)、H(height)、W(width)、C(channel),其中 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 ms,NPU 推理 1.5 ms,后处理和显示/传输 1.2 ms。concat 这个操作本身只占前处理的一部分,但它如果吃掉 1.28 ms,就意味着前处理环节被它占用了大半,其他逻辑只能拼命压缩。

从带宽角度做一次粗略估算:假设 concat 涉及的总数据量约 512 KB(两个 256 KB 输入,输出 512 KB),STM32N6 上 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 在地址 0x30000000,shape [1, 64, 64, 32]
  • 输入 B 在地址 0x30020000,shape [1, 64, 64, 32]
  • 调用LL_ATON_LIB_Concat(A, B, out, axis=1),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 个 buffer(A、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 核自带 MVE(M-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 到了才做 concat,CPU 在等待期间是空闲的。更好的方案是拆成两个阶段:

  1. 当 A 就绪时,先用 ATON 把 A 搬运到输出 buffer 的前半段;
  2. 当 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 调用次数和平均长度批量化拷贝、合并 slize
concat 输出与预期不符安全区访问异常或地址重叠检查地址映射和边界重新规划内存布局
双缓冲后仍卡顿等待同步事件阻塞了 CPU查看 DMA 完成中断标志位使用中断回调替代轮询等待

5.3 实测技巧与避坑心得

第一,不要在调试版里优化 concat。调试版编译默认-O0,memcpy 会退化成逐字节拷贝,性能数据完全失真。必须使用 Release 编译,并开启-O3以及针对 Cortex-M55 的-mcpu=cortex-m55 -mthumb -mfloat-abi=hard -mve选项,才能看到接近真实性能的指标。

第二,关注内存对齐。M55 的 MVE 向量加载对 16 字节对齐特别敏感。如果你不能在内存分配阶段保证 16 字节对齐,至少也要保证 8 字节对齐。可以在链接脚本里为 tensor buffer 专门分配一个 64 字节对齐的段,这样memcpy内部的所有优化分支都能走最高效路径。

第三,善用 cache 操作。STM32N6 内部有较大的 SRAM,很多时候数据本身就在 TCM 或紧耦合内存里,不需要经过 cache。但如果你的 buffer 在外部 PSRAM,memcpy的性能会严重受限于 PSRAM 带宽。这种情况下,与其优化 memcpy 本身,不如把频繁拼接的 tensor 搬到内部 SRAM。内存规划的重要性,往往比指令优化更高。

第四,不要忽略编译器的__restrict和__builtin_memcpy。在关键路径上,如果你想确保编译器生成最优秀的代码,可以在 memcpy 调用前加上if (len > threshold)的大块处理分支,把大数据量拷贝和小数据量拷贝分开。我实测后发现,12 字节以下的小拷贝用逐字节循环比调用库函数更快,而 4 KB 以上的大拷贝用 MVE 向量化收益最明显。

结尾

踩过这次坑之后,我最大的感受是:在带硬件加速单元的 MCU 上做优化,第一优先级的永远是“让硬件干它擅长的事”,而不是“把软件写得更好”。LL_ATON_LIB_Concat的 fallback 不是 bug,它是库在通用性与性能之间做的取舍,但如果你不了解这个取舍点,它就会在实时系统里变成隐形杀手。最后再分享一个我一直在用的小技巧:每次做性能优化前,先把所有算子按“硬件路径/软件路径”分类列一张表,凡是标记为软件路径的算子,都值得单独深挖一层。很多性能问题在表格列出来的那一刻就已经解决了一半。

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

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

立即咨询