3. 静态内存分配策略

静态内存分配,说白了就是“先算好,再动手”。

在NPU上跑YOLO,我最怕的就是推理跑到一半,突然内存不够了。静态分配就是解决这个问题的“笨办法”,但往往最管用。你想想看,模型结构、输入尺寸、中间张量大小,这些在编译时都是确定的。那为什么不一次性把内存规划好呢?

3.1 编译时确定内存布局

静态分配的核心思路很简单:在模型编译阶段,就把所有张量的生命周期和大小算清楚,然后一次性分配好内存。运行时不再做任何动态申请。

我习惯把YOLO模型的内存布局分成三个区域:

  • 权重区:存放卷积核、BN参数等。只读,固定不变。
  • 输入/输出区:存放输入图像和最终检测结果。大小由输入分辨率决定。
  • 中间张量区:存放每一层的feature map。这是大头,也是优化的关键。

举个例子,YOLOv8s输入640x640,我们可以在编译时算出每一层的输出大小:

// 伪代码:静态内存布局规划
// 假设NPU内存起始地址 0x80000000

#define WEIGHT_BASE    0x80000000  // 权重区
#define INPUT_BASE     0x80100000  // 输入区 (640*640*3 ≈ 1.2MB)
#define OUTPUT_BASE    0x80200000  // 输出区 (检测结果)
#define TENSOR_BASE    0x80300000  // 中间张量区

// 编译时计算每层feature map偏移
// Layer 0: Conv, 输出 320x320x32 → 3.2MB
// Layer 1: Conv, 输出 160x160x64 → 6.4MB
// ...
// 通过内存复用,实际只需要最大层的空间
#define MAX_TENSOR_SIZE  (8 * 1024 * 1024)  // 8MB

关键点:中间张量区可以复用。同一时间只有一层在计算,所以只需要分配所有层中最大的那个张量大小。我在项目中见过有人给每层都单独分配空间,白白浪费了3倍内存。

3.2 固定地址映射

固定地址映射,就是把每个张量绑定到一个具体的物理地址上。NPU不像CPU有MMU做虚拟地址转换,它直接操作物理地址。

我建议这样做:

  1. 权重地址固定:权重在模型转换时就烧录到NPU的SRAM或DDR固定区域。运行时直接DMA加载,不需要查表。
  2. 输入地址固定:比如固定在0x80100000。每次推理前,CPU把图像数据拷贝到这个地址,NPU直接读取。
  3. 输出地址固定:NPU推理完成后,检测结果直接写到固定地址,CPU轮询读取。

这样做的好处是:零地址计算开销。NPU的DMA控制器只需要一个固定的源地址和目标地址,不需要每次计算偏移量。

我的经验:我曾经在一个项目中,把输入地址和输出地址放在同一个bank里,结果DMA读写冲突,推理速度直接掉了30%。后来我把它们分到不同的bank,问题就解决了。所以,地址映射时要注意bank冲突。

3.3 优缺点分析

静态分配不是银弹。它有明显的优点,也有让人头疼的缺点。

维度 优点 缺点
性能 零运行时分配开销,推理延迟稳定 无法应对动态输入尺寸,灵活性差
内存 内存碎片为零,利用率可精确计算 必须预留最大可能的内存,浪费空间
调试 地址固定,内存越界容易复现 一旦内存布局出错,需要重新编译
部署 适合量产,行为可预测 模型更新后需要重新规划内存

避坑指南:我曾经在一个项目中,为了省内存,把中间张量区的复用做得太激进。结果两个层的生命周期有重叠,导致数据被覆盖,推理结果全错。调试了整整两天才发现。所以,做内存复用时一定要画清楚生命周期图。

3.4 静态分配的核心流程

下面这张图是我在项目中常用的静态内存分配流程。它把编译时规划和运行时执行分得很清楚。

静态内存分配核心流程 编译时阶段 解析模型结构 计算张量大小 分析生命周期 内存复用 生成地址映射表 运行时阶段 加载权重到固定地址 拷贝输入到固定地址 启动NPU推理 从固定地址读取输出 核心思想:编译时算好一切,运行时只做数据搬运和计算,不做内存分配

3.5 什么时候该用静态分配?

根据我的经验,静态分配最适合以下场景:

  • 输入尺寸固定:比如YOLO模型只处理640x640的输入,不处理其他分辨率。
  • 实时性要求高:自动驾驶、工业检测等场景,不能容忍动态分配带来的延迟抖动。
  • 内存资源紧张:嵌入式NPU只有几十MB内存,经不起动态分配的内存碎片。
  • 量产部署:所有设备运行相同的模型,静态分配可以保证行为一致。

一句话总结:静态分配是用编译时的计算量,换取运行时的确定性和稳定性。如果你能接受模型固定、输入固定,那它就是最可靠的选择。